T2026-08-047 feat(fin): catálogos SAT, conceptos de facturación y datos fiscales del emisor #5

Open
jcedilloAS wants to merge 10 commits from feature/AS-catalogos-sat-facturacion into feature/crm-cumplimiento-pdf
4 changed files with 457 additions and 295 deletions
Showing only changes of commit ae0664e987 - Show all commits

View File

@@ -0,0 +1,185 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import {
satCatalogsAPI,
type ConceptInput,
type SatCatalogItem,
type SatUnitOfMeasure
} from '$lib/api/fin';
import { toast } from 'svelte-sonner';
let {
form = $bindable(),
companyId,
/** Clave ProdServ ya elegida; se muestra resuelta en vez del buscador. */
productService = $bindable(),
/** Error del 409 del backend, mostrado junto al campo de clave ProdServ. */
productServiceError = $bindable()
}: {
form: ConceptInput;
companyId: number | null;
productService: SatCatalogItem | null;
productServiceError: string;
} = $props();
let unitsOfMeasure = $state<SatUnitOfMeasure[]>([]);
let taxObjects = $state<SatCatalogItem[]>([]);
let productServiceQuery = $state('');
let productServiceOptions = $state<SatCatalogItem[]>([]);
let searchingProductService = $state(false);
$effect(() => {
const cid = companyId;
if (!cid) return;
void loadCatalogs(cid);
});
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 pick(option: SatCatalogItem) {
productService = option;
form.product_service_id = option.id;
productServiceQuery = '';
productServiceOptions = [];
productServiceError = '';
}
function clearProductService() {
productService = null;
form.product_service_id = 0;
productServiceOptions = [];
}
const inputCls =
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
</script>
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Clave *</span>
<input class="font-mono {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>
<p class="text-xs text-muted-foreground">
Una clave del SAT solo puede estar asignada a un concepto de la empresa.
</p>
{#if productService}
<div class="flex items-center justify-between gap-2 rounded-md border px-3 py-2">
<span class="text-sm">
<span class="font-mono">{productService.code}</span>
<span class="text-muted-foreground">{productService.description}</span>
</span>
<Button type="button" variant="ghost" size="sm" onclick={clearProductService}
>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={() => pick(option)}
>
<span class="font-mono">{option.code}</span>
<span class="text-muted-foreground">{option.description}</span>
</button>
</li>
{/each}
</ul>
{:else if productServiceQuery.trim().length >= 2}
<p class="text-xs text-muted-foreground">Sin coincidencias en el catálogo.</p>
{/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" class="h-4 w-4 rounded border" 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="3" class={inputCls} bind:value={form.notes}></textarea>
</label>
</div>

View File

@@ -1,60 +1,23 @@
<script lang="ts">
import { Tags, Plus, Pencil, Trash2, Search } from '@lucide/svelte';
import { Tags, Plus, Trash2, Search, ChevronRight } 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 { conceptsAPI, type Concept } 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) {
@@ -71,120 +34,6 @@
}
}
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;
@@ -223,7 +72,7 @@
empresa.
</p>
</div>
<Button onclick={openCreate} disabled={!companyId}>
<Button href="/dashboard/fin/conceptos/nuevo" disabled={!companyId}>
<Plus class="mr-1 h-4 w-4" /> Nuevo concepto
</Button>
</div>
@@ -274,21 +123,26 @@
<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 class="font-mono text-xs font-medium">
<a class="hover:underline" href={`/dashboard/fin/conceptos/${concept.id}`}>
{concept.code}
</a>
</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
>
<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 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
@@ -302,10 +156,10 @@
<Button
variant="ghost"
size="sm"
onclick={() => openEdit(concept)}
aria-label="Editar"
href={`/dashboard/fin/conceptos/${concept.id}`}
aria-label="Abrir"
>
<Pencil class="h-4 w-4" />
<ChevronRight class="h-4 w-4" />
</Button>
<Button
variant="ghost"
@@ -325,134 +179,3 @@
</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,155 @@
<script lang="ts">
import { ArrowLeft, Tags, Trash2 } from '@lucide/svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import ConceptFields from '$lib/components/fin/ConceptFields.svelte';
import { companyStore } from '$lib/stores/company.svelte';
import { conceptsAPI, type Concept, type ConceptInput, type SatCatalogItem } from '$lib/api/fin';
import { toast } from 'svelte-sonner';
const conceptId = $derived(Number(page.params.id));
const companyId = $derived(companyStore.activeCompany?.id ?? null);
let concept = $state<Concept | null>(null);
let form = $state<ConceptInput>({ code: '', description: '', product_service_id: 0 });
let productService = $state<SatCatalogItem | null>(null);
let productServiceError = $state('');
let loading = $state(false);
let saving = $state(false);
$effect(() => {
const cid = companyId;
const id = conceptId;
if (!cid || !id) return;
void load(cid, id);
});
function hydrate(c: Concept) {
form = {
code: c.code,
description: c.description,
product_service_id: c.product_service_id,
unit_of_measure_id: c.unit_of_measure_id,
tax_object_id: c.tax_object_id,
unit_price: c.unit_price,
currency: c.currency,
is_active: c.is_active,
notes: c.notes ?? ''
};
productService = c.product_service;
productServiceError = '';
}
async function load(cid: number, id: number) {
loading = true;
try {
concept = await conceptsAPI.get(id, cid);
hydrate(concept);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo cargar el concepto');
} finally {
loading = false;
}
}
async function save(event: SubmitEvent) {
event.preventDefault();
const cid = companyId;
if (!cid || !concept) 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 {
concept = await conceptsAPI.update(
concept.id,
{
...form,
unit_price:
form.unit_price === null || form.unit_price === undefined
? null
: Number(form.unit_price),
notes: form.notes?.trim() ? form.notes : null
},
cid
);
hydrate(concept);
toast.success('Cambios guardados');
} catch (e) {
const message = e instanceof Error ? e.message : 'No se pudieron guardar los cambios';
// El 409 del backend por clave ProdServ ya asignada se muestra junto al campo.
if (message.toLowerCase().includes('producto/servicio')) productServiceError = message;
else toast.error(message);
} finally {
saving = false;
}
}
async function remove() {
const cid = companyId;
if (!cid || !concept) 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 goto('/dashboard/fin/conceptos');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo dar de baja el concepto');
}
}
</script>
<svelte:head>
<title>{concept ? `Concepto ${concept.code}` : 'Concepto de facturación'}</title>
</svelte:head>
<div class="space-y-6">
<Button variant="ghost" size="sm" href="/dashboard/fin/conceptos">
<ArrowLeft class="mr-1 h-4 w-4" /> Conceptos
</Button>
{#if loading && !concept}
<p class="text-sm text-muted-foreground">Cargando…</p>
{:else if concept}
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Tags class="h-6 w-6" />
{concept.code}
</h1>
<p class="mt-1 text-sm text-muted-foreground">
{concept.description}
{#if concept.product_service}
· <span class="font-mono">{concept.product_service.code}</span>
{/if}
· {concept.is_active ? 'Activo' : 'Inactivo'}
</p>
</div>
<Button variant="outline" onclick={remove}>
<Trash2 class="mr-1 h-4 w-4 text-destructive" /> Dar de baja
</Button>
</div>
<Card.Root>
<Card.Content class="pt-6">
<form onsubmit={save}>
<ConceptFields bind:form bind:productService bind:productServiceError {companyId} />
<div class="mt-6 flex justify-end border-t pt-4">
<Button type="submit" disabled={saving}
>{saving ? 'Guardando…' : 'Guardar cambios'}</Button
>
</div>
</form>
</Card.Content>
</Card.Root>
{/if}
</div>

View File

@@ -0,0 +1,99 @@
<script lang="ts">
import { ArrowLeft, Tags } from '@lucide/svelte';
import { goto } from '$app/navigation';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import ConceptFields from '$lib/components/fin/ConceptFields.svelte';
import { companyStore } from '$lib/stores/company.svelte';
import { conceptsAPI, type ConceptInput, type SatCatalogItem } from '$lib/api/fin';
import { toast } from 'svelte-sonner';
let form = $state<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 productService = $state<SatCatalogItem | null>(null);
let productServiceError = $state('');
let saving = $state(false);
const companyId = $derived(companyStore.activeCompany?.id ?? null);
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 created = await conceptsAPI.create(
{
...form,
unit_price:
form.unit_price === null || form.unit_price === undefined
? null
: Number(form.unit_price),
notes: form.notes?.trim() ? form.notes : null
},
cid
);
toast.success('Concepto creado');
await goto(`/dashboard/fin/conceptos/${created.id}`);
} catch (e) {
const message = e instanceof Error ? e.message : 'No se pudo crear el concepto';
// El 409 del backend por clave ProdServ ya asignada se muestra junto al campo.
if (message.toLowerCase().includes('producto/servicio')) productServiceError = message;
else toast.error(message);
} finally {
saving = false;
}
}
</script>
<svelte:head>
<title>Nuevo concepto de facturación</title>
</svelte:head>
<div class="space-y-6">
<Button variant="ghost" size="sm" href="/dashboard/fin/conceptos">
<ArrowLeft class="mr-1 h-4 w-4" /> Conceptos
</Button>
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Tags class="h-6 w-6" /> Nuevo concepto
</h1>
<p class="mt-1 text-sm text-muted-foreground">
Cada concepto se liga a una clave de producto/servicio del SAT.
</p>
</div>
<Card.Root>
<Card.Content class="pt-6">
<form onsubmit={save}>
<ConceptFields bind:form bind:productService bind:productServiceError {companyId} />
<div class="mt-6 flex justify-end gap-2 border-t pt-4">
<Button type="button" variant="outline" href="/dashboard/fin/conceptos">Cancelar</Button>
<Button type="submit" disabled={saving || !companyId}
>{saving ? 'Guardando…' : 'Crear'}</Button
>
</div>
</form>
</Card.Content>
</Card.Root>
</div>