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:
2026-08-07 16:58:09 -05:00
parent 8d9db3505d
commit 5a112a0171
9 changed files with 918 additions and 1 deletions

View 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)
};

View 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);
}
};

View File

@@ -3,6 +3,10 @@
*/ */
import { api } from '$lib/api'; 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 type InvoiceStatus = 'borrador' | 'emitida' | 'enviada' | 'en_revision_cliente' | 'pagada' | 'cancelada';
export interface Invoice { export interface Invoice {

View 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!;
}
};

View File

@@ -67,6 +67,7 @@ export function getNavMain(): NavMainItem[] {
icon: Receipt, icon: Receipt,
items: [ items: [
{ title: 'Facturas y cobranza', url: '/dashboard/fin/facturas' }, { 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', title: 'Configuración',
url: '/dashboard/settings/general', url: '/dashboard/settings/general',
icon: Settings2, icon: Settings2,
items: [
{ title: 'General', url: '/dashboard/settings/general' },
{ title: 'Facturación', url: '/dashboard/settings/facturacion', permission: 'fin.settings.view' },
],
}, },
]; ];
} }

View 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}

View 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>

View File

@@ -0,0 +1 @@
export const ssr = false;

View File

@@ -1,6 +1,10 @@
<script lang="ts"> <script lang="ts">
import { Settings2 } from 'lucide-svelte'; import { Settings2, Receipt, ChevronRight } from 'lucide-svelte';
import * as Card from '$lib/components/ui/card'; 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> </script>
<svelte:head> <svelte:head>
@@ -18,6 +22,25 @@
</p> </p>
</div> </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.Root>
<Card.Header> <Card.Header>
<Card.Title>Configuración del sistema</Card.Title> <Card.Title>Configuración del sistema</Card.Title>