refactor(fin): conceptos con páginas dedicadas en vez de modal
Sustituye el diálogo de alta/edición por el patrón que ya usa el CRM para proveedores y cuentas: la lista solo lista, y el alta y la edición viven en /dashboard/fin/conceptos/nuevo y /dashboard/fin/conceptos/[id]. Los campos del formulario se extraen a $lib/components/fin/ConceptFields.svelte para que ambas pantallas compartan el combobox de clave ProdServ y los selects de unidad y objeto de impuesto. El 409 del backend por clave ya asignada se sigue mostrando junto al campo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
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">
|
<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 Card from '$lib/components/ui/card';
|
||||||
import * as Table from '$lib/components/ui/table';
|
import * as Table from '$lib/components/ui/table';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { companyStore } from '$lib/stores/company.svelte';
|
import { companyStore } from '$lib/stores/company.svelte';
|
||||||
import {
|
import { conceptsAPI, type Concept } from '$lib/api/fin';
|
||||||
conceptsAPI,
|
|
||||||
satCatalogsAPI,
|
|
||||||
type Concept,
|
|
||||||
type ConceptInput,
|
|
||||||
type SatCatalogItem,
|
|
||||||
type SatUnitOfMeasure
|
|
||||||
} from '$lib/api/fin';
|
|
||||||
import { toast } from 'svelte-sonner';
|
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 items = $state<Concept[]>([]);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let search = $state('');
|
let search = $state('');
|
||||||
let activeFilter = $state<'todos' | 'activos' | 'inactivos'>('todos');
|
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);
|
const companyId = $derived(companyStore.activeCompany?.id ?? null);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const cid = companyId;
|
const cid = companyId;
|
||||||
if (!cid) return;
|
if (!cid) return;
|
||||||
void load(cid);
|
void load(cid);
|
||||||
void loadCatalogs(cid);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
async function load(cid: number) {
|
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) {
|
async function remove(concept: Concept) {
|
||||||
const cid = companyId;
|
const cid = companyId;
|
||||||
if (!cid) return;
|
if (!cid) return;
|
||||||
@@ -223,7 +72,7 @@
|
|||||||
empresa.
|
empresa.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onclick={openCreate} disabled={!companyId}>
|
<Button href="/dashboard/fin/conceptos/nuevo" disabled={!companyId}>
|
||||||
<Plus class="mr-1 h-4 w-4" /> Nuevo concepto
|
<Plus class="mr-1 h-4 w-4" /> Nuevo concepto
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -274,21 +123,26 @@
|
|||||||
<Table.Body>
|
<Table.Body>
|
||||||
{#each items as concept (concept.id)}
|
{#each items as concept (concept.id)}
|
||||||
<Table.Row>
|
<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>{concept.description}</Table.Cell>
|
||||||
<Table.Cell class="text-xs">
|
<Table.Cell class="text-xs">
|
||||||
<span class="font-mono">{concept.product_service?.code ?? '—'}</span>
|
<span class="font-mono">{concept.product_service?.code ?? '—'}</span>
|
||||||
{#if concept.product_service}
|
{#if concept.product_service}
|
||||||
<span class="block text-muted-foreground"
|
<span class="block text-muted-foreground">
|
||||||
>{concept.product_service.description}</span
|
{concept.product_service.description}
|
||||||
>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell class="text-xs">{concept.unit_of_measure?.name ?? '—'}</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-xs">{concept.tax_object?.code ?? '—'}</Table.Cell>
|
||||||
<Table.Cell class="text-right"
|
<Table.Cell class="text-right">
|
||||||
>{money(concept.unit_price)} {concept.currency}</Table.Cell
|
{money(concept.unit_price)}
|
||||||
>
|
{concept.currency}
|
||||||
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<span
|
<span
|
||||||
class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {concept.is_active
|
class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {concept.is_active
|
||||||
@@ -302,10 +156,10 @@
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onclick={() => openEdit(concept)}
|
href={`/dashboard/fin/conceptos/${concept.id}`}
|
||||||
aria-label="Editar"
|
aria-label="Abrir"
|
||||||
>
|
>
|
||||||
<Pencil class="h-4 w-4" />
|
<ChevronRight class="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -325,134 +179,3 @@
|
|||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
</div>
|
</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