feat(fin): pantallas de conceptos y datos fiscales del emisor
Clientes API por dominio para los catálogos del SAT, conceptos y emisor. Los catálogos se cachean en un Map del módulo tras la primera carga: son fijos y no cambian durante la sesión. Pantalla de conceptos (/dashboard/fin/conceptos) con tabla, buscador, filtro de activos y alta/edición en diálogo. La clave de producto/servicio se elige con un combobox que consulta el catálogo a partir de 2 caracteres, y el 409 del backend por clave ya asignada se muestra junto al campo. Sección de configuración fiscal (/dashboard/settings/facturacion) con razón social, RFC (misma validación que el backend), régimen fiscal y CP. Si el GET responde 404 se abre en modo alta, no como error; el guardar se deshabilita sin fin.settings.edit. Todo en Svelte 5 con runes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
94
frontend/src/lib/api/fin/catalogs.ts
Normal file
94
frontend/src/lib/api/fin/catalogs.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Cliente API — Catálogos del SAT (solo lectura).
|
||||
*
|
||||
* Son catálogos fijos que publica el SAT: una vez cargados no cambian durante la
|
||||
* sesión, así que se guardan en un `Map` del módulo para no repetir la petición en
|
||||
* cada selector. No hay POST/PUT/PATCH/DELETE: el backend tampoco los expone.
|
||||
*/
|
||||
import { api } from '$lib/api';
|
||||
|
||||
export interface SatCatalogItem {
|
||||
id: number;
|
||||
code: string;
|
||||
description: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface SatTaxRegime extends SatCatalogItem {
|
||||
applies_to_individual: boolean; // persona física
|
||||
applies_to_legal_entity: boolean; // persona moral
|
||||
}
|
||||
|
||||
export interface SatTax extends SatCatalogItem {
|
||||
is_withholding: boolean;
|
||||
is_transferred: boolean;
|
||||
is_local: boolean;
|
||||
}
|
||||
|
||||
/** `description` es la nota larga del SAT y puede venir vacía; el nombre corto va en `name`. */
|
||||
export interface SatUnitOfMeasure extends Omit<SatCatalogItem, 'description'> {
|
||||
description: string | null;
|
||||
name: string;
|
||||
symbol: string | null;
|
||||
}
|
||||
|
||||
export type PersonType = 'fisica' | 'moral';
|
||||
|
||||
type CatalogParams = Record<string, string | number | boolean | undefined>;
|
||||
|
||||
/** Cache en memoria del módulo, con la query string completa como llave. */
|
||||
const cache = new Map<string, unknown>();
|
||||
|
||||
function buildQuery(companyId: number, params?: CatalogParams): string {
|
||||
const qs = new URLSearchParams({ company_id: String(companyId) });
|
||||
for (const [key, value] of Object.entries(params ?? {})) {
|
||||
if (value !== undefined && value !== '') qs.set(key, String(value));
|
||||
}
|
||||
qs.sort(); // llave de cache estable sin importar el orden de los parámetros
|
||||
return qs.toString();
|
||||
}
|
||||
|
||||
async function fetchCatalog<T>(
|
||||
path: string,
|
||||
companyId: number,
|
||||
params?: CatalogParams
|
||||
): Promise<T[]> {
|
||||
const query = buildQuery(companyId, params);
|
||||
const key = `${path}?${query}`;
|
||||
const cached = cache.get(key);
|
||||
if (cached) return cached as T[];
|
||||
|
||||
const res = await api.get<T[]>(`/v1/fin/catalogs/${path}?${query}`);
|
||||
if (res.error) throw new Error(res.error);
|
||||
const rows = res.data ?? [];
|
||||
cache.set(key, rows);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Vacía el cache; útil tras actualizar los catálogos con `sync_catalogs`. */
|
||||
export function clearCatalogCache(): void {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
export const satCatalogsAPI = {
|
||||
taxRegimes: (
|
||||
companyId: number,
|
||||
params?: { search?: string; person_type?: PersonType; active_only?: boolean }
|
||||
) => fetchCatalog<SatTaxRegime>('tax-regimes', companyId, params),
|
||||
taxes: (companyId: number, params?: { search?: string; active_only?: boolean }) =>
|
||||
fetchCatalog<SatTax>('taxes', companyId, params),
|
||||
paymentForms: (companyId: number, params?: { search?: string; active_only?: boolean }) =>
|
||||
fetchCatalog<SatCatalogItem>('payment-forms', companyId, params),
|
||||
unitsOfMeasure: (companyId: number, params?: { search?: string; active_only?: boolean }) =>
|
||||
fetchCatalog<SatUnitOfMeasure>('units-of-measure', companyId, params),
|
||||
productsServices: (
|
||||
companyId: number,
|
||||
params?: { search?: string; limit?: number; active_only?: boolean }
|
||||
) => fetchCatalog<SatCatalogItem>('products-services', companyId, params),
|
||||
voucherTypes: (companyId: number, params?: { search?: string; active_only?: boolean }) =>
|
||||
fetchCatalog<SatCatalogItem>('voucher-types', companyId, params),
|
||||
paymentMethods: (companyId: number, params?: { search?: string; active_only?: boolean }) =>
|
||||
fetchCatalog<SatCatalogItem>('payment-methods', companyId, params),
|
||||
taxObjects: (companyId: number, params?: { search?: string; active_only?: boolean }) =>
|
||||
fetchCatalog<SatCatalogItem>('tax-objects', companyId, params)
|
||||
};
|
||||
81
frontend/src/lib/api/fin/concepts.ts
Normal file
81
frontend/src/lib/api/fin/concepts.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Cliente API — Catálogo de conceptos de facturación.
|
||||
*
|
||||
* Cada concepto está ligado 1:1 a una clave de producto/servicio del SAT dentro de la
|
||||
* empresa; el backend responde 409 si la clave ya está tomada.
|
||||
*/
|
||||
import { api } from '$lib/api';
|
||||
import type { SatCatalogItem, SatUnitOfMeasure } from './catalogs';
|
||||
|
||||
export interface Concept {
|
||||
id: number;
|
||||
code: string;
|
||||
description: string;
|
||||
product_service_id: number;
|
||||
unit_of_measure_id: number | null;
|
||||
tax_object_id: number | null;
|
||||
unit_price: number | null;
|
||||
currency: string;
|
||||
is_active: boolean;
|
||||
notes: string | null;
|
||||
product_service: SatCatalogItem | null;
|
||||
unit_of_measure: SatUnitOfMeasure | null;
|
||||
tax_object: SatCatalogItem | null;
|
||||
tenant_id: number;
|
||||
company_id: number;
|
||||
created_by: string | null;
|
||||
updated_by: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ConceptInput {
|
||||
code: string;
|
||||
description: string;
|
||||
product_service_id: number;
|
||||
unit_of_measure_id?: number | null;
|
||||
tax_object_id?: number | null;
|
||||
unit_price?: number | null;
|
||||
currency?: string;
|
||||
is_active?: boolean;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
export const conceptsAPI = {
|
||||
async list(
|
||||
companyId: number,
|
||||
params?: { search?: string; active_only?: boolean; product_service_id?: number }
|
||||
): Promise<Concept[]> {
|
||||
const qs = new URLSearchParams({ company_id: String(companyId) });
|
||||
if (params?.search) qs.set('search', params.search);
|
||||
if (params?.active_only !== undefined) qs.set('active_only', String(params.active_only));
|
||||
if (params?.product_service_id !== undefined)
|
||||
qs.set('product_service_id', String(params.product_service_id));
|
||||
const res = await api.get<Concept[]>(`/v1/fin/concepts?${qs}`);
|
||||
if (res.error) throw new Error(res.error);
|
||||
return res.data!;
|
||||
},
|
||||
|
||||
async get(id: number, companyId: number): Promise<Concept> {
|
||||
const res = await api.get<Concept>(`/v1/fin/concepts/${id}?company_id=${companyId}`);
|
||||
if (res.error) throw new Error(res.error);
|
||||
return res.data!;
|
||||
},
|
||||
|
||||
async create(data: ConceptInput, companyId: number): Promise<Concept> {
|
||||
const res = await api.post<Concept>(`/v1/fin/concepts?company_id=${companyId}`, data);
|
||||
if (res.error) throw new Error(res.error);
|
||||
return res.data!;
|
||||
},
|
||||
|
||||
async update(id: number, data: Partial<ConceptInput>, companyId: number): Promise<Concept> {
|
||||
const res = await api.patch<Concept>(`/v1/fin/concepts/${id}?company_id=${companyId}`, data);
|
||||
if (res.error) throw new Error(res.error);
|
||||
return res.data!;
|
||||
},
|
||||
|
||||
async remove(id: number, companyId: number): Promise<void> {
|
||||
const res = await api.delete(`/v1/fin/concepts/${id}?company_id=${companyId}`);
|
||||
if (res.error) throw new Error(res.error);
|
||||
}
|
||||
};
|
||||
@@ -3,6 +3,10 @@
|
||||
*/
|
||||
import { api } from '$lib/api';
|
||||
|
||||
export * from './catalogs';
|
||||
export * from './concepts';
|
||||
export * from './issuer';
|
||||
|
||||
export type InvoiceStatus = 'borrador' | 'emitida' | 'enviada' | 'en_revision_cliente' | 'pagada' | 'cancelada';
|
||||
|
||||
export interface Invoice {
|
||||
|
||||
51
frontend/src/lib/api/fin/issuer.ts
Normal file
51
frontend/src/lib/api/fin/issuer.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Cliente API — Datos fiscales del emisor (una configuración por empresa).
|
||||
*/
|
||||
import { api } from '$lib/api';
|
||||
import type { SatTaxRegime } from './catalogs';
|
||||
|
||||
/** RFC de persona moral (3 letras) o física (4 letras) + fecha + homoclave. */
|
||||
export const RFC_REGEX = /^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$/;
|
||||
|
||||
export interface IssuerSettings {
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
company_id: number;
|
||||
legal_name: string;
|
||||
rfc: string;
|
||||
tax_regime_id: number;
|
||||
tax_regime: SatTaxRegime | null;
|
||||
zip_code: string | null;
|
||||
updated_by: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface IssuerSettingsInput {
|
||||
legal_name: string;
|
||||
rfc: string;
|
||||
tax_regime_id: number;
|
||||
zip_code?: string | null;
|
||||
}
|
||||
|
||||
export const issuerAPI = {
|
||||
/**
|
||||
* Devuelve `null` cuando la empresa todavía no captura sus datos fiscales: el
|
||||
* backend responde 404 y la pantalla debe abrirse en modo alta, no en error.
|
||||
*/
|
||||
async get(companyId: number): Promise<IssuerSettings | null> {
|
||||
const res = await api.get<IssuerSettings>(`/v1/fin/settings/issuer?company_id=${companyId}`);
|
||||
if (res.status === 404) return null;
|
||||
if (res.error) throw new Error(res.error);
|
||||
return res.data!;
|
||||
},
|
||||
|
||||
async save(data: IssuerSettingsInput, companyId: number): Promise<IssuerSettings> {
|
||||
const res = await api.put<IssuerSettings>(
|
||||
`/v1/fin/settings/issuer?company_id=${companyId}`,
|
||||
data
|
||||
);
|
||||
if (res.error) throw new Error(res.error);
|
||||
return res.data!;
|
||||
}
|
||||
};
|
||||
@@ -67,6 +67,7 @@ export function getNavMain(): NavMainItem[] {
|
||||
icon: Receipt,
|
||||
items: [
|
||||
{ title: 'Facturas y cobranza', url: '/dashboard/fin/facturas' },
|
||||
{ title: 'Conceptos', url: '/dashboard/fin/conceptos', permission: 'fin.concept.view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -83,6 +84,10 @@ export function getNavMain(): NavMainItem[] {
|
||||
title: 'Configuración',
|
||||
url: '/dashboard/settings/general',
|
||||
icon: Settings2,
|
||||
items: [
|
||||
{ title: 'General', url: '/dashboard/settings/general' },
|
||||
{ title: 'Facturación', url: '/dashboard/settings/facturacion', permission: 'fin.settings.view' },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
458
frontend/src/routes/dashboard/fin/conceptos/+page.svelte
Normal file
458
frontend/src/routes/dashboard/fin/conceptos/+page.svelte
Normal file
@@ -0,0 +1,458 @@
|
||||
<script lang="ts">
|
||||
import { Tags, Plus, Pencil, Trash2, Search } from '@lucide/svelte';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import {
|
||||
conceptsAPI,
|
||||
satCatalogsAPI,
|
||||
type Concept,
|
||||
type ConceptInput,
|
||||
type SatCatalogItem,
|
||||
type SatUnitOfMeasure
|
||||
} from '$lib/api/fin';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const EMPTY_FORM: ConceptInput = {
|
||||
code: '',
|
||||
description: '',
|
||||
product_service_id: 0,
|
||||
unit_of_measure_id: null,
|
||||
tax_object_id: null,
|
||||
unit_price: null,
|
||||
currency: 'MXN',
|
||||
is_active: true,
|
||||
notes: ''
|
||||
};
|
||||
|
||||
let items = $state<Concept[]>([]);
|
||||
let loading = $state(false);
|
||||
let search = $state('');
|
||||
let activeFilter = $state<'todos' | 'activos' | 'inactivos'>('todos');
|
||||
|
||||
let modalOpen = $state(false);
|
||||
let saving = $state(false);
|
||||
let editingId = $state<number | null>(null);
|
||||
let form = $state<ConceptInput>({ ...EMPTY_FORM });
|
||||
/** Error del 409 del backend, mostrado junto al campo de clave ProdServ. */
|
||||
let productServiceError = $state('');
|
||||
|
||||
// Catálogos del SAT para los selectores.
|
||||
let unitsOfMeasure = $state<SatUnitOfMeasure[]>([]);
|
||||
let taxObjects = $state<SatCatalogItem[]>([]);
|
||||
|
||||
// Combobox de clave de producto/servicio.
|
||||
let productServiceQuery = $state('');
|
||||
let productServiceOptions = $state<SatCatalogItem[]>([]);
|
||||
let productServiceSelected = $state<SatCatalogItem | null>(null);
|
||||
let searchingProductService = $state(false);
|
||||
|
||||
const companyId = $derived(companyStore.activeCompany?.id ?? null);
|
||||
|
||||
$effect(() => {
|
||||
const cid = companyId;
|
||||
if (!cid) return;
|
||||
void load(cid);
|
||||
void loadCatalogs(cid);
|
||||
});
|
||||
|
||||
async function load(cid: number) {
|
||||
loading = true;
|
||||
try {
|
||||
items = await conceptsAPI.list(cid, {
|
||||
search: search.trim() || undefined,
|
||||
active_only: activeFilter === 'todos' ? undefined : activeFilter === 'activos'
|
||||
});
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar los conceptos');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCatalogs(cid: number) {
|
||||
try {
|
||||
[unitsOfMeasure, taxObjects] = await Promise.all([
|
||||
satCatalogsAPI.unitsOfMeasure(cid),
|
||||
satCatalogsAPI.taxObjects(cid)
|
||||
]);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar los catálogos del SAT');
|
||||
}
|
||||
}
|
||||
|
||||
/** Busca claves ProdServ; a partir de 2 caracteres para no traer el catálogo completo. */
|
||||
async function searchProductServices() {
|
||||
const cid = companyId;
|
||||
const term = productServiceQuery.trim();
|
||||
if (!cid || term.length < 2) {
|
||||
productServiceOptions = [];
|
||||
return;
|
||||
}
|
||||
searchingProductService = true;
|
||||
try {
|
||||
productServiceOptions = await satCatalogsAPI.productsServices(cid, {
|
||||
search: term,
|
||||
limit: 20
|
||||
});
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : 'No se pudo buscar la clave de producto/servicio'
|
||||
);
|
||||
} finally {
|
||||
searchingProductService = false;
|
||||
}
|
||||
}
|
||||
|
||||
function pickProductService(option: SatCatalogItem) {
|
||||
productServiceSelected = option;
|
||||
form.product_service_id = option.id;
|
||||
productServiceQuery = '';
|
||||
productServiceOptions = [];
|
||||
productServiceError = '';
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingId = null;
|
||||
form = { ...EMPTY_FORM };
|
||||
productServiceSelected = null;
|
||||
productServiceQuery = '';
|
||||
productServiceOptions = [];
|
||||
productServiceError = '';
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
function openEdit(concept: Concept) {
|
||||
editingId = concept.id;
|
||||
form = {
|
||||
code: concept.code,
|
||||
description: concept.description,
|
||||
product_service_id: concept.product_service_id,
|
||||
unit_of_measure_id: concept.unit_of_measure_id,
|
||||
tax_object_id: concept.tax_object_id,
|
||||
unit_price: concept.unit_price,
|
||||
currency: concept.currency,
|
||||
is_active: concept.is_active,
|
||||
notes: concept.notes ?? ''
|
||||
};
|
||||
productServiceSelected = concept.product_service;
|
||||
productServiceQuery = '';
|
||||
productServiceOptions = [];
|
||||
productServiceError = '';
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
async function save(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const cid = companyId;
|
||||
if (!cid) return;
|
||||
if (!form.code.trim() || !form.description.trim()) {
|
||||
toast.error('La clave y la descripción son obligatorias');
|
||||
return;
|
||||
}
|
||||
if (!form.product_service_id) {
|
||||
productServiceError = 'Selecciona la clave de producto/servicio del SAT';
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
productServiceError = '';
|
||||
try {
|
||||
const payload: ConceptInput = {
|
||||
...form,
|
||||
unit_price:
|
||||
form.unit_price === null || form.unit_price === undefined
|
||||
? null
|
||||
: Number(form.unit_price),
|
||||
notes: form.notes?.trim() ? form.notes : null
|
||||
};
|
||||
if (editingId) {
|
||||
await conceptsAPI.update(editingId, payload, cid);
|
||||
toast.success('Concepto actualizado');
|
||||
} else {
|
||||
await conceptsAPI.create(payload, cid);
|
||||
toast.success('Concepto creado');
|
||||
}
|
||||
modalOpen = false;
|
||||
await load(cid);
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : 'No se pudo guardar el concepto';
|
||||
// El 409 del backend por clave ProdServ ya tomada se muestra junto al campo.
|
||||
if (message.toLowerCase().includes('producto/servicio')) productServiceError = message;
|
||||
else toast.error(message);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(concept: Concept) {
|
||||
const cid = companyId;
|
||||
if (!cid) return;
|
||||
if (!confirm(`¿Dar de baja el concepto "${concept.code}"?`)) return;
|
||||
try {
|
||||
await conceptsAPI.remove(concept.id, cid);
|
||||
toast.success('Concepto dado de baja');
|
||||
await load(cid);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudo dar de baja el concepto');
|
||||
}
|
||||
}
|
||||
|
||||
function money(value: number | null): string {
|
||||
if (value === null || value === undefined) return '—';
|
||||
return new Intl.NumberFormat('es-MX', { minimumFractionDigits: 2 }).format(Number(value));
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Conceptos de facturación</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||
<Tags class="h-6 w-6" />
|
||||
Conceptos de facturación
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Cada concepto se liga a una clave de producto/servicio del SAT, que no puede repetirse en la
|
||||
empresa.
|
||||
</p>
|
||||
</div>
|
||||
<Button onclick={openCreate} disabled={!companyId}>
|
||||
<Plus class="mr-1 h-4 w-4" /> Nuevo concepto
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="relative max-w-sm flex-1">
|
||||
<Search class="absolute top-2.5 left-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
class="w-full py-2 pr-3 pl-8 {inputCls}"
|
||||
placeholder="Buscar por clave o descripción…"
|
||||
bind:value={search}
|
||||
onchange={() => companyId && load(companyId)}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
class="{inputCls} max-w-xs"
|
||||
bind:value={activeFilter}
|
||||
onchange={() => companyId && load(companyId)}
|
||||
>
|
||||
<option value="todos">Todos</option>
|
||||
<option value="activos">Solo activos</option>
|
||||
<option value="inactivos">Solo inactivos</option>
|
||||
</select>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if loading}
|
||||
<p class="py-6 text-center text-sm text-muted-foreground">Cargando…</p>
|
||||
{:else if items.length === 0}
|
||||
<p class="py-6 text-center text-sm text-muted-foreground">Sin conceptos registrados.</p>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Clave</Table.Head>
|
||||
<Table.Head>Descripción</Table.Head>
|
||||
<Table.Head>Clave ProdServ</Table.Head>
|
||||
<Table.Head>Unidad</Table.Head>
|
||||
<Table.Head>Objeto de impuesto</Table.Head>
|
||||
<Table.Head class="text-right">Precio unitario</Table.Head>
|
||||
<Table.Head>Estado</Table.Head>
|
||||
<Table.Head class="text-right">Acciones</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each items as concept (concept.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-mono text-xs font-medium">{concept.code}</Table.Cell>
|
||||
<Table.Cell>{concept.description}</Table.Cell>
|
||||
<Table.Cell class="text-xs">
|
||||
<span class="font-mono">{concept.product_service?.code ?? '—'}</span>
|
||||
{#if concept.product_service}
|
||||
<span class="block text-muted-foreground"
|
||||
>{concept.product_service.description}</span
|
||||
>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-xs">{concept.unit_of_measure?.name ?? '—'}</Table.Cell>
|
||||
<Table.Cell class="text-xs">{concept.tax_object?.code ?? '—'}</Table.Cell>
|
||||
<Table.Cell class="text-right"
|
||||
>{money(concept.unit_price)} {concept.currency}</Table.Cell
|
||||
>
|
||||
<Table.Cell>
|
||||
<span
|
||||
class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {concept.is_active
|
||||
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400'
|
||||
: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400'}"
|
||||
>
|
||||
{concept.is_active ? 'Activo' : 'Inactivo'}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => openEdit(concept)}
|
||||
aria-label="Editar"
|
||||
>
|
||||
<Pencil class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => remove(concept)}
|
||||
aria-label="Dar de baja"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
<svelte:window
|
||||
onkeydown={(e) => {
|
||||
if (modalOpen && e.key === 'Escape') modalOpen = false;
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if modalOpen}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
role="presentation"
|
||||
onclick={() => (modalOpen = false)}
|
||||
>
|
||||
<div
|
||||
class="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-lg border bg-card p-6 shadow-lg"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={editingId ? 'Editar concepto' : 'Nuevo concepto'}
|
||||
tabindex="-1"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 class="mb-4 text-lg font-semibold">{editingId ? 'Editar concepto' : 'Nuevo concepto'}</h2>
|
||||
<form class="grid gap-4 sm:grid-cols-2" onsubmit={save}>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Clave *</span>
|
||||
<input class={inputCls} bind:value={form.code} maxlength="40" required />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Precio unitario</span>
|
||||
<input type="number" step="0.01" min="0" class={inputCls} bind:value={form.unit_price} />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
|
||||
<span class="font-medium">Descripción *</span>
|
||||
<input class={inputCls} bind:value={form.description} maxlength="500" required />
|
||||
</label>
|
||||
|
||||
<div class="flex flex-col gap-1 text-sm sm:col-span-2">
|
||||
<span class="font-medium">Clave de producto/servicio del SAT *</span>
|
||||
{#if productServiceSelected}
|
||||
<div class="flex items-center justify-between gap-2 rounded-md border px-3 py-2">
|
||||
<span class="text-sm">
|
||||
<span class="font-mono">{productServiceSelected.code}</span>
|
||||
<span class="text-muted-foreground"> — {productServiceSelected.description}</span>
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
productServiceSelected = null;
|
||||
form.product_service_id = 0;
|
||||
}}
|
||||
>
|
||||
Cambiar
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<input
|
||||
class={inputCls}
|
||||
placeholder="Escribe al menos 2 caracteres (clave o descripción)…"
|
||||
bind:value={productServiceQuery}
|
||||
oninput={searchProductServices}
|
||||
/>
|
||||
{#if searchingProductService}
|
||||
<p class="text-xs text-muted-foreground">Buscando…</p>
|
||||
{:else if productServiceOptions.length > 0}
|
||||
<ul class="max-h-48 overflow-y-auto rounded-md border">
|
||||
{#each productServiceOptions as option (option.id)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-3 py-2 text-left text-sm hover:bg-muted"
|
||||
onclick={() => pickProductService(option)}
|
||||
>
|
||||
<span class="font-mono">{option.code}</span>
|
||||
<span class="text-muted-foreground"> — {option.description}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if productServiceError}
|
||||
<p class="text-xs text-destructive">{productServiceError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Unidad de medida</span>
|
||||
<select class={inputCls} bind:value={form.unit_of_measure_id}>
|
||||
<option value={null}>Sin especificar</option>
|
||||
{#each unitsOfMeasure as unit (unit.id)}
|
||||
<option value={unit.id}>{unit.code} — {unit.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Objeto de impuesto</span>
|
||||
<select class={inputCls} bind:value={form.tax_object_id}>
|
||||
<option value={null}>Sin especificar</option>
|
||||
{#each taxObjects as taxObject (taxObject.id)}
|
||||
<option value={taxObject.id}>{taxObject.code} — {taxObject.description}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Moneda</span>
|
||||
<input class={inputCls} bind:value={form.currency} maxlength="3" />
|
||||
</label>
|
||||
<label class="flex items-center gap-2 self-end text-sm">
|
||||
<input type="checkbox" bind:checked={form.is_active} />
|
||||
<span class="font-medium">Activo</span>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
|
||||
<span class="font-medium">Notas</span>
|
||||
<textarea rows="2" class={inputCls} bind:value={form.notes}></textarea>
|
||||
</label>
|
||||
|
||||
<div class="flex justify-end gap-2 sm:col-span-2">
|
||||
<Button type="button" variant="outline" onclick={() => (modalOpen = false)}
|
||||
>Cancelar</Button
|
||||
>
|
||||
<Button type="submit" disabled={saving}>{saving ? 'Guardando…' : 'Guardar'}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
200
frontend/src/routes/dashboard/settings/facturacion/+page.svelte
Normal file
200
frontend/src/routes/dashboard/settings/facturacion/+page.svelte
Normal file
@@ -0,0 +1,200 @@
|
||||
<script lang="ts">
|
||||
import { Receipt } from '@lucide/svelte';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { authStore, userHasPermission } from '$lib/auth';
|
||||
import {
|
||||
issuerAPI,
|
||||
satCatalogsAPI,
|
||||
RFC_REGEX,
|
||||
type IssuerSettingsInput,
|
||||
type SatTaxRegime
|
||||
} from '$lib/api/fin';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
let form = $state<IssuerSettingsInput>({
|
||||
legal_name: '',
|
||||
rfc: '',
|
||||
tax_regime_id: 0,
|
||||
zip_code: ''
|
||||
});
|
||||
let taxRegimes = $state<SatTaxRegime[]>([]);
|
||||
let loading = $state(false);
|
||||
let saving = $state(false);
|
||||
/** true mientras la empresa no tenga datos capturados (el GET respondió 404). */
|
||||
let isNew = $state(true);
|
||||
let rfcError = $state('');
|
||||
|
||||
const companyId = $derived(companyStore.activeCompany?.id ?? null);
|
||||
const canView = $derived(userHasPermission($authStore.user, 'fin.settings.view'));
|
||||
const canEdit = $derived(userHasPermission($authStore.user, 'fin.settings.edit'));
|
||||
|
||||
$effect(() => {
|
||||
const cid = companyId;
|
||||
if (!cid || !canView) return;
|
||||
void load(cid);
|
||||
});
|
||||
|
||||
async function load(cid: number) {
|
||||
loading = true;
|
||||
try {
|
||||
const [settings, regimes] = await Promise.all([
|
||||
issuerAPI.get(cid),
|
||||
satCatalogsAPI.taxRegimes(cid)
|
||||
]);
|
||||
taxRegimes = regimes;
|
||||
isNew = settings === null;
|
||||
if (settings) {
|
||||
form = {
|
||||
legal_name: settings.legal_name,
|
||||
rfc: settings.rfc,
|
||||
tax_regime_id: settings.tax_regime_id,
|
||||
zip_code: settings.zip_code ?? ''
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar los datos fiscales');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizedRfc(): string {
|
||||
return (form.rfc ?? '').replace(/[\s-]/g, '').toUpperCase();
|
||||
}
|
||||
|
||||
async function save(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const cid = companyId;
|
||||
if (!cid) return;
|
||||
|
||||
const rfc = normalizedRfc();
|
||||
if (!RFC_REGEX.test(rfc)) {
|
||||
rfcError = 'El RFC no tiene un formato válido (ej. XAXX010101000)';
|
||||
return;
|
||||
}
|
||||
rfcError = '';
|
||||
if (!form.tax_regime_id) {
|
||||
toast.error('Selecciona el régimen fiscal');
|
||||
return;
|
||||
}
|
||||
|
||||
saving = true;
|
||||
try {
|
||||
await issuerAPI.save(
|
||||
{ ...form, rfc, zip_code: form.zip_code?.trim() ? form.zip_code.trim() : null },
|
||||
cid
|
||||
);
|
||||
isNew = false;
|
||||
toast.success('Datos fiscales guardados');
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudieron guardar los datos fiscales');
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Configuración de Facturación</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||
<Receipt class="h-6 w-6" />
|
||||
Datos fiscales del emisor
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Identidad fiscal con la que la empresa emite sus comprobantes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if !canView}
|
||||
<Card.Root>
|
||||
<Card.Content>
|
||||
<p class="py-6 text-center text-sm text-muted-foreground">
|
||||
No tienes permiso para consultar los datos fiscales del emisor.
|
||||
</p>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{:else}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{isNew ? 'Capturar datos fiscales' : 'Datos fiscales registrados'}</Card.Title>
|
||||
<Card.Description>
|
||||
{isNew
|
||||
? 'Esta empresa aún no tiene datos fiscales configurados.'
|
||||
: 'Actualiza la información con la que se emiten los comprobantes.'}
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if loading}
|
||||
<p class="py-6 text-center text-sm text-muted-foreground">Cargando…</p>
|
||||
{:else}
|
||||
<form class="grid max-w-2xl gap-4 sm:grid-cols-2" onsubmit={save}>
|
||||
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
|
||||
<span class="font-medium">Razón social *</span>
|
||||
<input
|
||||
class={inputCls}
|
||||
bind:value={form.legal_name}
|
||||
maxlength="255"
|
||||
required
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">RFC *</span>
|
||||
<input
|
||||
class="{inputCls} font-mono uppercase"
|
||||
bind:value={form.rfc}
|
||||
maxlength="13"
|
||||
required
|
||||
disabled={!canEdit}
|
||||
oninput={() => (rfcError = '')}
|
||||
/>
|
||||
{#if rfcError}<span class="text-xs text-destructive">{rfcError}</span>{/if}
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Código postal del lugar de expedición</span>
|
||||
<input
|
||||
class={inputCls}
|
||||
bind:value={form.zip_code}
|
||||
maxlength="5"
|
||||
inputmode="numeric"
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
|
||||
<span class="font-medium">Régimen fiscal *</span>
|
||||
<select class={inputCls} bind:value={form.tax_regime_id} required disabled={!canEdit}>
|
||||
<option value={0}>Selecciona un régimen…</option>
|
||||
{#each taxRegimes as regime (regime.id)}
|
||||
<option value={regime.id}>{regime.code} — {regime.description}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="flex justify-end sm:col-span-2">
|
||||
<Button type="submit" disabled={saving || !canEdit || !companyId}>
|
||||
{saving ? 'Guardando…' : 'Guardar'}
|
||||
</Button>
|
||||
</div>
|
||||
{#if !canEdit}
|
||||
<p class="text-xs text-muted-foreground sm:col-span-2">
|
||||
Solo puedes consultar: se requiere el permiso de edición de datos fiscales.
|
||||
</p>
|
||||
{/if}
|
||||
</form>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1 @@
|
||||
export const ssr = false;
|
||||
@@ -1,6 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { Settings2 } from 'lucide-svelte';
|
||||
import { Settings2, Receipt, ChevronRight } from 'lucide-svelte';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { authStore, userHasPermission } from '$lib/auth';
|
||||
|
||||
const canViewIssuerSettings = $derived(userHasPermission($authStore.user, 'fin.settings.view'));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -18,6 +22,25 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if canViewIssuerSettings}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="flex items-center gap-2">
|
||||
<Receipt class="h-5 w-5" />
|
||||
Facturación
|
||||
</Card.Title>
|
||||
<Card.Description>
|
||||
Datos fiscales del emisor: razón social, RFC, régimen fiscal y lugar de expedición.
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<Button variant="outline" href="/dashboard/settings/facturacion">
|
||||
Abrir datos fiscales <ChevronRight class="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Configuración del sistema</Card.Title>
|
||||
|
||||
Reference in New Issue
Block a user