T2026-08-047 feat(fin): catálogos SAT, conceptos de facturación y datos fiscales del emisor #5
185
frontend/src/lib/components/fin/ConceptFields.svelte
Normal file
185
frontend/src/lib/components/fin/ConceptFields.svelte
Normal 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>
|
||||
@@ -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}
|
||||
|
||||
155
frontend/src/routes/dashboard/fin/conceptos/[id]/+page.svelte
Normal file
155
frontend/src/routes/dashboard/fin/conceptos/[id]/+page.svelte
Normal 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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user