Merge branch 'features/Creacion-formularios_catalogos_generales' into development
This commit is contained in:
@@ -1,127 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { createCompany, updateCompany, type Company } from "$lib/api/dashboard/a76/general_catalogs/company";
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
item = null,
|
||||
onSuccess
|
||||
}: {
|
||||
open: boolean;
|
||||
item?: Company | null;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
const isEdit = $derived(!!item);
|
||||
const title = $derived(isEdit ? "Editar Empresa" : "Nueva Empresa");
|
||||
|
||||
let formData = $state({
|
||||
name: item?.name || '',
|
||||
rfc: item?.rfc || '',
|
||||
main_activity: item?.main_activity || '',
|
||||
program: item?.program || '',
|
||||
program_number: item?.program_number || ''
|
||||
});
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (item) {
|
||||
formData = {
|
||||
name: item.name || '',
|
||||
rfc: item.rfc || '',
|
||||
main_activity: item.main_activity || '',
|
||||
program: item.program || '',
|
||||
program_number: item.program_number || ''
|
||||
};
|
||||
} else {
|
||||
formData = {
|
||||
name: '',
|
||||
rfc: '',
|
||||
main_activity: '',
|
||||
program: '',
|
||||
program_number: ''
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
error = null;
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
if (!formData.name.trim()) throw new Error('El nombre es requerido');
|
||||
|
||||
const dataToSend = {
|
||||
name: formData.name.trim(),
|
||||
rfc: formData.rfc.trim() || null,
|
||||
main_activity: formData.main_activity.trim() || null,
|
||||
program: formData.program.trim() || null,
|
||||
program_number: formData.program_number.trim() || null
|
||||
};
|
||||
|
||||
let response;
|
||||
if (isEdit && item) {
|
||||
response = await updateCompany(item.id, dataToSend);
|
||||
} else {
|
||||
response = await createCompany(dataToSend);
|
||||
}
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
|
||||
open = false;
|
||||
if (onSuccess) onSuccess();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Error al guardar';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{title}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-4">
|
||||
{#if error}
|
||||
<div class="text-destructive text-sm">{error}</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="name">Nombre <span class="text-destructive">*</span></Label>
|
||||
<Input id="name" bind:value={formData.name} />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="rfc">RFC</Label>
|
||||
<Input id="rfc" bind:value={formData.rfc} />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="program">Programa</Label>
|
||||
<Input id="program" bind:value={formData.program} />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="program_number">No. Programa</Label>
|
||||
<Input id="program_number" bind:value={formData.program_number} />
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="outline" onclick={() => open = false}>Cancelar</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Guardando...' : 'Guardar'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -1,381 +1,221 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { customsBrokersApi, type CreateCustomsBrokerData } from "$lib/api/dashboard/a76/customs-brokers";
|
||||
import { companyStore } from "$lib/stores/company.svelte";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { Separator } from "$lib/components/ui/separator";
|
||||
import { Loader2 } from "lucide-svelte";
|
||||
import type { CreateCustomsBrokerData, CustomsBroker } from "$lib/api/dashboard/a76/customs-brokers"; // Ajusta la ruta
|
||||
import { toast } from "svelte-sonner";
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
onSuccess
|
||||
}: {
|
||||
open: boolean;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
// --- Props ---
|
||||
export let open = false;
|
||||
export let mode: "create" | "edit" = "create";
|
||||
export let initialData: CustomsBroker | null = null;
|
||||
export let companyId: string; // Necesario según tu API
|
||||
|
||||
let formData = $state({
|
||||
broker_key: "",
|
||||
name: "",
|
||||
type: "",
|
||||
address: "",
|
||||
postal_code: "",
|
||||
city: "",
|
||||
state: "",
|
||||
phone: "",
|
||||
fax: "",
|
||||
email: "",
|
||||
country: "",
|
||||
tax_id: "",
|
||||
personal_id: "",
|
||||
position: "",
|
||||
license: "",
|
||||
company: "",
|
||||
contact: ""
|
||||
});
|
||||
// La función onSave ahora devuelve una promesa para manejar el loading aquí
|
||||
export let onSave: (data: CreateCustomsBrokerData) => Promise<void>;
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
// --- Estado ---
|
||||
let loading = false;
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!companyStore.activeCompany) {
|
||||
error = "No hay compañía seleccionada";
|
||||
return;
|
||||
}
|
||||
// Estado del formulario
|
||||
let formData: CreateCustomsBrokerData = {
|
||||
broker_key: "",
|
||||
license: "",
|
||||
name: "",
|
||||
tax_id: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
fax: "",
|
||||
contact: "",
|
||||
address: "",
|
||||
postal_code: "",
|
||||
city: "",
|
||||
state: "",
|
||||
country: "",
|
||||
tenant_id: "", // Se llenará en el submit o por defecto
|
||||
company_id: ""
|
||||
};
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
// --- Reactividad ---
|
||||
$: if (open) {
|
||||
if (mode === "edit" && initialData) {
|
||||
// Cargar datos existentes
|
||||
formData = {
|
||||
...initialData,
|
||||
// Aseguramos que no sean null/undefined para los inputs
|
||||
name: initialData.name || "",
|
||||
tax_id: initialData.tax_id || "",
|
||||
email: initialData.email || "",
|
||||
phone: initialData.phone || "",
|
||||
fax: initialData.fax || "",
|
||||
contact: initialData.contact || "",
|
||||
address: initialData.address || "",
|
||||
postal_code: initialData.postal_code || "",
|
||||
city: initialData.city || "",
|
||||
state: initialData.state || "",
|
||||
country: initialData.country || "",
|
||||
license: initialData.license || ""
|
||||
};
|
||||
} else {
|
||||
// Reset para crear
|
||||
formData = {
|
||||
broker_key: "",
|
||||
license: "",
|
||||
name: "",
|
||||
tax_id: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
fax: "",
|
||||
contact: "",
|
||||
address: "",
|
||||
postal_code: "",
|
||||
city: "",
|
||||
state: "",
|
||||
country: "MEX", // Valor por defecto sugerido
|
||||
tenant_id: "default", // Ajustar según lógica de tu app
|
||||
company_id: companyId
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const payload: CreateCustomsBrokerData = {
|
||||
broker_key: formData.broker_key,
|
||||
name: formData.name || null,
|
||||
type: formData.type || null,
|
||||
address: formData.address || null,
|
||||
postal_code: formData.postal_code || null,
|
||||
city: formData.city || null,
|
||||
state: formData.state || null,
|
||||
phone: formData.phone || null,
|
||||
fax: formData.fax || null,
|
||||
email: formData.email || null,
|
||||
country: formData.country || null,
|
||||
tax_id: formData.tax_id || null,
|
||||
personal_id: formData.personal_id || null,
|
||||
position: formData.position || null,
|
||||
license: formData.license || null,
|
||||
company: formData.company || null,
|
||||
contact: formData.contact || null,
|
||||
tenant_id: "1", // TODO: Get from user context
|
||||
company_id: companyStore.activeCompany.id.toString()
|
||||
};
|
||||
// --- Handlers ---
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
loading = true;
|
||||
|
||||
const response = await customsBrokersApi.create(payload);
|
||||
// Validaciones básicas
|
||||
if (!formData.broker_key) {
|
||||
toast.error("La Clave del Agente es obligatoria");
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
if (!formData.license) {
|
||||
toast.error("La Patente es obligatoria");
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.error) {
|
||||
if (response.status === 401) {
|
||||
error = 'Sesión expirada. Recargando página...';
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
} else {
|
||||
error = response.error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Inyectar company_id si no viene
|
||||
const payload = { ...formData, company_id: companyId };
|
||||
|
||||
// Éxito
|
||||
open = false;
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : "Error al guardar";
|
||||
console.error("Error saving:", e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenChange(newOpen: boolean) {
|
||||
if (!newOpen) {
|
||||
// Limpiar form al cerrar
|
||||
formData = {
|
||||
broker_key: "",
|
||||
name: "",
|
||||
type: "",
|
||||
address: "",
|
||||
postal_code: "",
|
||||
city: "",
|
||||
state: "",
|
||||
phone: "",
|
||||
fax: "",
|
||||
email: "",
|
||||
country: "",
|
||||
tax_id: "",
|
||||
personal_id: "",
|
||||
position: "",
|
||||
license: "",
|
||||
company: "",
|
||||
contact: ""
|
||||
};
|
||||
error = null;
|
||||
}
|
||||
open = newOpen;
|
||||
}
|
||||
await onSave(payload);
|
||||
open = false;
|
||||
toast.success(mode === 'create' ? "Agente creado correctamente" : "Agente actualizado correctamente");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Error al guardar el agente aduanal");
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={handleOpenChange}>
|
||||
<Dialog.Content class="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Nuevo Agente Aduanal</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Completa los datos para crear un nuevo agente aduanal.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>
|
||||
{mode === "create" ? "Nuevo Agente Aduanal" : "Editar Agente Aduanal"}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Ingresa los datos generales del agente. La configuración de VU y Personal se gestiona aparte.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<form onsubmit={handleSubmit} class="space-y-6">
|
||||
{#if error}
|
||||
<div class="rounded-lg border border-destructive bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grid gap-6 py-4">
|
||||
|
||||
<div class="space-y-4">
|
||||
<h4 class="text-sm font-medium leading-none text-muted-foreground">Identificación</h4>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="broker_key">Clave Agente *</Label>
|
||||
<Input id="broker_key" bind:value={formData.broker_key} placeholder="Ej. 550" disabled={mode === 'edit' || loading} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="license">Patente *</Label>
|
||||
<Input id="license" bind:value={formData.license} placeholder="Ej. 3421" disabled={loading} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2 col-span-2">
|
||||
<Label for="name">Nombre / Razón Social</Label>
|
||||
<Input id="name" bind:value={formData.name} placeholder="Nombre del Agente o Agencia" disabled={loading} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="tax_id">RFC</Label>
|
||||
<Input id="tax_id" bind:value={formData.tax_id} placeholder="RFC de la agencia" disabled={loading} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="contact">Nombre Contacto</Label>
|
||||
<Input id="contact" bind:value={formData.contact} placeholder="Persona de contacto" disabled={loading} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Información básica -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-semibold">Información Básica</h3>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="broker_key">Clave *</Label>
|
||||
<Input
|
||||
id="broker_key"
|
||||
bind:value={formData.broker_key}
|
||||
placeholder="Ej: 12345"
|
||||
maxlength={5}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="type">Tipo</Label>
|
||||
<Input
|
||||
id="type"
|
||||
bind:value={formData.type}
|
||||
placeholder="Tipo de agente"
|
||||
maxlength={9}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<h4 class="text-sm font-medium leading-none text-muted-foreground">Contacto</h4>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="space-y-2 col-span-1">
|
||||
<Label for="phone">Teléfono</Label>
|
||||
<Input id="phone" bind:value={formData.phone} disabled={loading} />
|
||||
</div>
|
||||
<div class="space-y-2 col-span-2">
|
||||
<Label for="email">Correo Electrónico</Label>
|
||||
<Input id="email" type="email" bind:value={formData.email} disabled={loading} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="name">Nombre</Label>
|
||||
<Input
|
||||
id="name"
|
||||
bind:value={formData.name}
|
||||
placeholder="Nombre del agente aduanal"
|
||||
maxlength={80}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="license">Patente</Label>
|
||||
<Input
|
||||
id="license"
|
||||
bind:value={formData.license}
|
||||
placeholder="Número de patente"
|
||||
maxlength={4}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<h4 class="text-sm font-medium leading-none text-muted-foreground">Dirección Fiscal</h4>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="address">Calle y Número</Label>
|
||||
<Input id="address" bind:value={formData.address} disabled={loading} />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="company">Empresa</Label>
|
||||
<Input
|
||||
id="company"
|
||||
bind:value={formData.company}
|
||||
placeholder="Empresa del agente"
|
||||
maxlength={200}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="postal_code">C.P.</Label>
|
||||
<Input id="postal_code" bind:value={formData.postal_code} disabled={loading} />
|
||||
</div>
|
||||
<div class="space-y-2 col-span-2">
|
||||
<Label for="city">Ciudad</Label>
|
||||
<Input id="city" bind:value={formData.city} disabled={loading} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="state">Estado</Label>
|
||||
<Input id="state" bind:value={formData.state} disabled={loading} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="country">País</Label>
|
||||
<Input id="country" bind:value={formData.country} disabled={loading} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Información de contacto -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-semibold">Información de Contacto</h3>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="phone">Teléfono</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
bind:value={formData.phone}
|
||||
placeholder="Número telefónico"
|
||||
maxlength={30}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="fax">Fax</Label>
|
||||
<Input
|
||||
id="fax"
|
||||
bind:value={formData.fax}
|
||||
placeholder="Número de fax"
|
||||
maxlength={30}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
bind:value={formData.email}
|
||||
placeholder="correo@ejemplo.com"
|
||||
maxlength={100}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="contact">Contacto</Label>
|
||||
<Input
|
||||
id="contact"
|
||||
bind:value={formData.contact}
|
||||
placeholder="Nombre del contacto"
|
||||
maxlength={80}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dirección -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-semibold">Dirección</h3>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="address">Dirección</Label>
|
||||
<Input
|
||||
id="address"
|
||||
bind:value={formData.address}
|
||||
placeholder="Calle y número"
|
||||
maxlength={1500}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="postal_code">Código Postal</Label>
|
||||
<Input
|
||||
id="postal_code"
|
||||
bind:value={formData.postal_code}
|
||||
placeholder="C.P."
|
||||
maxlength={15}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="city">Ciudad</Label>
|
||||
<Input
|
||||
id="city"
|
||||
bind:value={formData.city}
|
||||
placeholder="Ciudad"
|
||||
maxlength={30}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="state">Estado</Label>
|
||||
<Input
|
||||
id="state"
|
||||
bind:value={formData.state}
|
||||
placeholder="Estado"
|
||||
maxlength={30}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="country">País</Label>
|
||||
<Input
|
||||
id="country"
|
||||
bind:value={formData.country}
|
||||
placeholder="País"
|
||||
maxlength={3}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Información fiscal -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-semibold">Información Fiscal</h3>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="tax_id">RFC</Label>
|
||||
<Input
|
||||
id="tax_id"
|
||||
bind:value={formData.tax_id}
|
||||
placeholder="RFC"
|
||||
maxlength={30}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="personal_id">CURP</Label>
|
||||
<Input
|
||||
id="personal_id"
|
||||
bind:value={formData.personal_id}
|
||||
placeholder="CURP"
|
||||
maxlength={20}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="position">Posición</Label>
|
||||
<Input
|
||||
id="position"
|
||||
bind:value={formData.position}
|
||||
placeholder="Cargo o posición"
|
||||
maxlength={30}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="outline" onclick={() => (open = false)} disabled={loading}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{#if loading}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent"></div>
|
||||
Guardando...
|
||||
</div>
|
||||
{:else}
|
||||
Crear Agente Aduanal
|
||||
{/if}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" on:click={() => (open = false)} disabled={loading}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button on:click={handleSubmit} disabled={loading}>
|
||||
{#if loading}
|
||||
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
|
||||
Guardando
|
||||
{:else}
|
||||
Guardar Agente
|
||||
{/if}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -5,44 +5,44 @@ import DataTableActions from './data-table-actions.svelte';
|
||||
|
||||
export function createColumns(onSuccess?: () => void): ColumnDef<ExchangeRate>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'date',
|
||||
header: 'Fecha',
|
||||
cell: ({ row }) => {
|
||||
const dateStr = row.original.date;
|
||||
if (!dateStr) return 'N/A';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('es-MX');
|
||||
{
|
||||
accessorKey: 'date',
|
||||
header: 'Fecha',
|
||||
cell: ({ row }) => {
|
||||
const dateStr = row.original.date;
|
||||
if (!dateStr) return 'N/A';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('es-MX');
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'value',
|
||||
header: 'Valor',
|
||||
cell: ({ row }) => {
|
||||
const value = row.original.value;
|
||||
if (value === null || value === undefined) return 'N/A';
|
||||
return value.toFixed(6);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'local_currency',
|
||||
header: 'Moneda Local',
|
||||
cell: ({ row }) => row.original.local_currency ?? 'N/A'
|
||||
},
|
||||
{
|
||||
accessorKey: 'foreign_currency',
|
||||
header: 'Moneda Extranjera',
|
||||
cell: ({ row }) => row.original.foreign_currency ?? 'N/A'
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Acciones',
|
||||
cell: ({ row }) => {
|
||||
return renderComponent(DataTableActions, {
|
||||
item: row.original,
|
||||
onSuccess
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'value',
|
||||
header: 'Tipo de Cambio',
|
||||
cell: ({ row }) => {
|
||||
const value = row.original.value;
|
||||
if (value === null || value === undefined) return 'N/A';
|
||||
return value.toFixed(6);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'local_currency',
|
||||
header: 'Moneda Local',
|
||||
cell: ({ row }) => row.original.local_currency ?? 'N/A'
|
||||
},
|
||||
{
|
||||
accessorKey: 'foreign_currency',
|
||||
header: 'Moneda Extranjera',
|
||||
cell: ({ row }) => row.original.foreign_currency ?? 'N/A'
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Acciones',
|
||||
cell: ({ row }) => {
|
||||
return renderComponent(DataTableActions, {
|
||||
item: row.original,
|
||||
onSuccess
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -1,164 +1,151 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { LoaderCircle } from 'lucide-svelte';
|
||||
import type {
|
||||
ExchangeRate,
|
||||
ExchangeRateCreate,
|
||||
ExchangeRateUpdate
|
||||
} from '$lib/api/dashboard/a76/general_catalogs/exchange-rate';
|
||||
import { createExchangeRate, updateExchangeRate } from '$lib/api/dashboard/a76/general_catalogs/exchange-rate';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import {
|
||||
createExchangeRate,
|
||||
updateExchangeRate,
|
||||
type ExchangeRate
|
||||
} from "$lib/api/dashboard/a76/general_catalogs/exchange-rate";
|
||||
import { companyStore } from "$lib/stores/company.svelte";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
item?: ExchangeRate | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess?: (item: ExchangeRate) => void;
|
||||
}
|
||||
let {
|
||||
open = $bindable(false),
|
||||
item = null,
|
||||
onSuccess
|
||||
}: {
|
||||
open: boolean;
|
||||
item?: ExchangeRate | null;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
let { open = $bindable(false), item = null, onOpenChange, onSuccess }: Props = $props();
|
||||
const isEdit = $derived(!!item);
|
||||
const title = $derived(isEdit ? "Editar Tipo de Cambio" : "Nuevo Tipo de Cambio");
|
||||
|
||||
let formData = $state<ExchangeRateCreate | ExchangeRateUpdate>({
|
||||
date: '',
|
||||
value: null,
|
||||
local_currency: null,
|
||||
foreign_currency: null
|
||||
});
|
||||
let formData = $state({
|
||||
date: '',
|
||||
value: null as number | null,
|
||||
local_currency: '',
|
||||
foreign_currency: ''
|
||||
});
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let isEdit = $derived(!!item);
|
||||
// Cargar datos al abrir
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
if (item && item.date) {
|
||||
const formattedDate = item.date.includes('T') ? item.date.split('T')[0] : item.date;
|
||||
formData = {
|
||||
date: formattedDate,
|
||||
value: item.value,
|
||||
local_currency: item.local_currency || '',
|
||||
foreign_currency: item.foreign_currency || ''
|
||||
};
|
||||
} else {
|
||||
formData = {
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
value: null,
|
||||
local_currency: 'MXN',
|
||||
foreign_currency: 'USD'
|
||||
};
|
||||
}
|
||||
error = null;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (item) {
|
||||
const date = new Date(item.date);
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
|
||||
formData = {
|
||||
date: dateStr,
|
||||
value: item.value,
|
||||
local_currency: item.local_currency,
|
||||
foreign_currency: item.foreign_currency
|
||||
};
|
||||
} else {
|
||||
const today = new Date();
|
||||
const dateStr = today.toISOString().split('T')[0];
|
||||
formData = {
|
||||
date: dateStr,
|
||||
value: null,
|
||||
local_currency: null,
|
||||
foreign_currency: null
|
||||
};
|
||||
}
|
||||
error = null;
|
||||
});
|
||||
async function handleSubmit() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) throw new Error('No hay una compañía seleccionada');
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
loading = true;
|
||||
error = null;
|
||||
if (!formData.date) throw new Error('La fecha es requerida');
|
||||
if (formData.value === null) throw new Error('El valor es requerido');
|
||||
|
||||
const dataToSend = {
|
||||
date: formData.date,
|
||||
value: Number(formData.value),
|
||||
local_currency: formData.local_currency?.trim().toUpperCase() || null,
|
||||
foreign_currency: formData.foreign_currency?.trim().toUpperCase() || null
|
||||
};
|
||||
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) {
|
||||
error = 'No hay una empresa seleccionada';
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
if (isEdit && item) {
|
||||
await updateExchangeRate(item.id, dataToSend, companyId);
|
||||
alert(`✅ Tipo de cambio actualizado correctamente`);
|
||||
} else {
|
||||
await createExchangeRate(dataToSend, companyId);
|
||||
alert(`✅ Tipo de cambio creado correctamente`);
|
||||
}
|
||||
|
||||
try {
|
||||
let result: ExchangeRate;
|
||||
if (isEdit && item) {
|
||||
result = await updateExchangeRate(item.id, formData as ExchangeRateUpdate, companyId);
|
||||
} else {
|
||||
result = await createExchangeRate(formData as ExchangeRateCreate, companyId);
|
||||
}
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess(result);
|
||||
}
|
||||
onOpenChange(false);
|
||||
} catch (err: any) {
|
||||
error = err.message || `Error al ${isEdit ? 'actualizar' : 'crear'} el tipo de cambio`;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
open = false;
|
||||
if (onSuccess) onSuccess();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Error al guardar';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root {open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Content class="sm:max-w-[500px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{isEdit ? 'Editar' : 'Crear'} Tipo de Cambio</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="fixed inset-0 z-[9999] bg-black/80 backdrop-blur-sm" />
|
||||
|
||||
<Dialog.Content class="fixed left-[50%] top-[50%] z-[10000] w-full max-w-[500px] translate-x-[-50%] translate-y-[-50%] border bg-background p-6 shadow-lg sm:rounded-lg">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{title}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<form onsubmit={handleSubmit} class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="date">Fecha *</Label>
|
||||
<Input
|
||||
id="date"
|
||||
type="date"
|
||||
bind:value={formData.date}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-4 py-4">
|
||||
{#if error}
|
||||
<div class="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="value">Tipo de Cambio *</Label>
|
||||
<Input
|
||||
id="value"
|
||||
type="number"
|
||||
step="0.000001"
|
||||
bind:value={formData.value}
|
||||
placeholder="0.000000"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid gap-4">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="date" class="text-right">Fecha *</Label>
|
||||
<div class="col-span-3">
|
||||
<Input id="date" type="date" bind:value={formData.date} disabled={loading} required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="local_currency">Moneda Local</Label>
|
||||
<Input
|
||||
id="local_currency"
|
||||
type="text"
|
||||
maxlength="7"
|
||||
bind:value={formData.local_currency}
|
||||
placeholder="MXN"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="value" class="text-right">Valor *</Label>
|
||||
<div class="col-span-3">
|
||||
<Input id="value" type="number" step="0.000001" bind:value={formData.value} disabled={loading} required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="foreign_currency">Moneda Extranjera</Label>
|
||||
<Input
|
||||
id="foreign_currency"
|
||||
type="text"
|
||||
maxlength="7"
|
||||
bind:value={formData.foreign_currency}
|
||||
placeholder="USD"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="local_currency" class="text-right">Local</Label>
|
||||
<div class="col-span-3">
|
||||
<Input id="local_currency" bind:value={formData.local_currency} maxlength={3} disabled={loading} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="text-sm text-destructive">{error}</p>
|
||||
{/if}
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="foreign_currency" class="text-right">Extranjera</Label>
|
||||
<div class="col-span-3">
|
||||
<Input id="foreign_currency" bind:value={formData.foreign_currency} maxlength={3} disabled={loading} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onclick={() => onOpenChange(false)} disabled={loading}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{#if loading}
|
||||
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
|
||||
{/if}
|
||||
{isEdit ? 'Actualizar' : 'Crear'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="outline" onclick={() => open = false} disabled={loading}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Guardando...' : isEdit ? 'Actualizar' : 'Crear'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
@@ -27,23 +27,21 @@
|
||||
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) {
|
||||
alert('No hay compañía seleccionada');
|
||||
alert('❌ Error: No hay compañía seleccionada');
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
await deleteExchangeRate(item.id, companyId);
|
||||
|
||||
// Éxito
|
||||
alert(`✅ Tipo de cambio del ${new Date(item.date).toLocaleDateString('es-MX')} eliminado correctamente`);
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Error al eliminar el tipo de cambio';
|
||||
alert(`Error: ${error}`);
|
||||
alert(`❌ Error: ${error}`);
|
||||
console.error('Error deleting:', err);
|
||||
} finally {
|
||||
loading = false;
|
||||
|
||||
@@ -1,123 +1,106 @@
|
||||
<script lang="ts" generics="TData, TValue">
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel
|
||||
} from "@tanstack/table-core";
|
||||
import { createSvelteTable, FlexRender } from "$lib/components/ui/data-table/index.js";
|
||||
import * as Table from "$lib/components/ui/table/index.js";
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
} from "@tanstack/table-core";
|
||||
import { createSvelteTable, FlexRender } from "$lib/components/ui/data-table/index.js";
|
||||
import * as Table from "$lib/components/ui/table/index.js";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
|
||||
type DataTableProps<TData, TValue> = {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
loading: boolean;
|
||||
hasMore: boolean;
|
||||
loadMore: () => void;
|
||||
};
|
||||
type DataTableProps<TData, TValue> = {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
pageCount: number;
|
||||
totalItems: number;
|
||||
};
|
||||
|
||||
let {
|
||||
data,
|
||||
columns,
|
||||
loading,
|
||||
hasMore,
|
||||
loadMore
|
||||
}: DataTableProps<TData, TValue> = $props();
|
||||
let {
|
||||
data,
|
||||
columns,
|
||||
pageCount,
|
||||
totalItems
|
||||
}: DataTableProps<TData, TValue> = $props();
|
||||
|
||||
const table = createSvelteTable({
|
||||
get data() {
|
||||
return data;
|
||||
},
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel()
|
||||
});
|
||||
const table = createSvelteTable({
|
||||
get data() { return data; },
|
||||
get columns() { return columns; },
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
get pageCount() { return pageCount; },
|
||||
});
|
||||
|
||||
let scrollContainer = $state<HTMLDivElement>();
|
||||
let loadingTrigger = $state<HTMLDivElement>();
|
||||
function handlePageChange(newPage: number) {
|
||||
const url = new URL($page.url);
|
||||
url.searchParams.set('page', newPage.toString());
|
||||
goto(url, { keepFocus: true, noScroll: true });
|
||||
}
|
||||
|
||||
// Intersection Observer para detectar cuando el usuario llega al final
|
||||
onMount(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const [entry] = entries;
|
||||
if (entry.isIntersecting && hasMore && !loading) {
|
||||
loadMore();
|
||||
}
|
||||
},
|
||||
{
|
||||
root: scrollContainer,
|
||||
threshold: 0.1
|
||||
}
|
||||
);
|
||||
|
||||
if (loadingTrigger) {
|
||||
observer.observe(loadingTrigger);
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
});
|
||||
const currentPage = $derived(Number($page.url.searchParams.get('page') || 1));
|
||||
</script>
|
||||
|
||||
<div class="w-full">
|
||||
<div class="rounded-md border max-h-[600px] overflow-y-auto" bind:this={scrollContainer}>
|
||||
<Table.Root>
|
||||
<Table.Header class="sticky top-0 bg-background z-10">
|
||||
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
|
||||
<Table.Row>
|
||||
{#each headerGroup.headers as header (header.id)}
|
||||
<Table.Head>
|
||||
{#if !header.isPlaceholder}
|
||||
<FlexRender
|
||||
content={header.column.columnDef.header}
|
||||
context={header.getContext()}
|
||||
/>
|
||||
{/if}
|
||||
</Table.Head>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each table.getRowModel().rows as row (row.id)}
|
||||
<Table.Row data-state={row.getIsSelected() && "selected"}>
|
||||
{#each row.getVisibleCells() as cell (cell.id)}
|
||||
<Table.Cell>
|
||||
<FlexRender
|
||||
content={cell.column.columnDef.cell}
|
||||
context={cell.getContext()}
|
||||
/>
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={columns.length} class="h-24 text-center">
|
||||
No hay resultados.
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
|
||||
<!-- Loading Trigger - Se activa cuando es visible -->
|
||||
{#if hasMore}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={columns.length} class="h-20 text-center">
|
||||
<div bind:this={loadingTrigger}>
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<div class="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
|
||||
<span class="text-muted-foreground text-sm">Cargando más...</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-muted-foreground text-sm">
|
||||
Desplázate para cargar más
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
|
||||
<Table.Row>
|
||||
{#each headerGroup.headers as header (header.id)}
|
||||
<Table.Head>
|
||||
{#if !header.isPlaceholder}
|
||||
<FlexRender
|
||||
content={header.column.columnDef.header}
|
||||
context={header.getContext()}
|
||||
/>
|
||||
{/if}
|
||||
</Table.Head>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each table.getRowModel().rows as row (row.id)}
|
||||
<Table.Row data-state={row.getIsSelected() && "selected"}>
|
||||
{#each row.getVisibleCells() as cell (cell.id)}
|
||||
<Table.Cell>
|
||||
<FlexRender
|
||||
content={cell.column.columnDef.cell}
|
||||
context={cell.getContext()}
|
||||
/>
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={columns.length} class="h-24 text-center">
|
||||
No hay resultados.
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end space-x-2 py-4 px-2">
|
||||
<div class="flex-1 text-sm text-muted-foreground">
|
||||
Total: {totalItems} registros
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage >= pageCount}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
// 👇 1. Importamos la API correcta
|
||||
import {
|
||||
updateClassificationConcept,
|
||||
createClassificationConcept,
|
||||
type ClassificationConcept
|
||||
} from "$lib/api/dashboard/a76/general_catalogs/classification-concepts";
|
||||
// 👇 2. Importamos el Store para el ID de la empresa
|
||||
import { companyStore } from "$lib/stores/company.svelte";
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
item = null,
|
||||
onSuccess
|
||||
}: {
|
||||
open: boolean;
|
||||
item?: ClassificationConcept | null;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
const isEdit = $derived(!!item);
|
||||
const title = $derived(isEdit ? "Editar Clasificación" : "Nueva Clasificación");
|
||||
|
||||
// 👇 3. Estado limpio: Solo lo que existe en la BD
|
||||
let formData = $state({
|
||||
classification: ''
|
||||
});
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Cargar datos al abrir
|
||||
$effect(() => {
|
||||
if (item) {
|
||||
formData = {
|
||||
classification: item.classification || ''
|
||||
};
|
||||
} else {
|
||||
formData = {
|
||||
classification: ''
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
error = null;
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
// 👇 4. Validar Company ID
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) throw new Error('No hay una compañía seleccionada');
|
||||
|
||||
// Validar campos
|
||||
if (!formData.classification.trim()) throw new Error('El nombre de la clasificación es requerido');
|
||||
|
||||
const dataToSend = {
|
||||
classification: formData.classification.trim()
|
||||
};
|
||||
|
||||
// 👇 5. Llamar a la API pasando el companyId
|
||||
if (isEdit && item) {
|
||||
await updateClassificationConcept(item.id, dataToSend, companyId);
|
||||
} else {
|
||||
await createClassificationConcept(dataToSend, companyId);
|
||||
}
|
||||
|
||||
open = false;
|
||||
if (onSuccess) onSuccess();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Error al guardar';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{title}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-4">
|
||||
{#if error}
|
||||
<div class="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-4 py-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="classification">Clasificación <span class="text-destructive">*</span></Label>
|
||||
<Input
|
||||
id="classification"
|
||||
bind:value={formData.classification}
|
||||
placeholder="Ej: GENERAL"
|
||||
maxlength={30}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="outline" onclick={() => open = false}>Cancelar</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Guardando...' : 'Guardar'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ClassificationConcept } from '$lib/api/dashboard/a76/general_catalogs/classification-concepts';
|
||||
import type { ColumnDef } from '@tanstack/table-core';
|
||||
import { renderComponent } from '$lib/components/ui/data-table';
|
||||
import DataTableActions from './data-table-actions.svelte';
|
||||
|
||||
export function createColumns(onSuccess?: () => void): ColumnDef<ClassificationConcept>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'classification',
|
||||
header: 'Clasificación',
|
||||
cell: ({ row }) => row.original.classification || '-'
|
||||
},
|
||||
{
|
||||
accessorKey: 'description',
|
||||
header: 'Descripción',
|
||||
cell: ({ row }) => row.original.description || '-'
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Acciones',
|
||||
cell: ({ row }) => {
|
||||
return renderComponent(DataTableActions, {
|
||||
item: row.original,
|
||||
onSuccess
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
|
||||
import { deleteClassificationConcept, type ClassificationConcept } from "$lib/api/dashboard/a76/general_catalogs/classification-concepts";
|
||||
import { companyStore } from "$lib/stores/company.svelte";
|
||||
import { EllipsisVertical, Pencil, LoaderCircle, Trash2 } from 'lucide-svelte';
|
||||
import CreateEditDialog from "$lib/components/dashboard/general_catalogs/classification/create-edit-dialog.svelte";
|
||||
|
||||
let {
|
||||
item,
|
||||
onSuccess
|
||||
}: {
|
||||
item: ClassificationConcept;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let dialogOpen = $state(false);
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm(`¿Estás seguro de eliminar la clasificación "${item.classification}"?\n\nNota: No se puede eliminar si tiene registros relacionados.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!companyStore.activeCompany) {
|
||||
alert('❌ Error: No hay una compañía seleccionada');
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const response = await deleteClassificationConcept(item.id, companyStore.activeCompany.id);
|
||||
|
||||
if (response.error) {
|
||||
alert(`❌ Error al eliminar:\n\n${response.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Éxito
|
||||
if (response.status === 204 || response.status === 200 || !response.error) {
|
||||
alert(`✅ Clasificación "${item.classification}" eliminada correctamente`);
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
const errorMsg = e instanceof Error ? e.message : 'Error desconocido al eliminar el registro';
|
||||
alert(`❌ Error al eliminar:\n\n${errorMsg}`);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="h-8 w-8 p-0">
|
||||
<span class="sr-only">Abrir menú</span>
|
||||
<EllipsisVertical class="h-4 w-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
|
||||
<DropdownMenu.Item onclick={() => dialogOpen = true}>
|
||||
<Pencil class="mr-2 h-4 w-4" />
|
||||
Editar
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item class="text-destructive focus:text-destructive" onclick={handleDelete} disabled={loading}>
|
||||
{#if loading}
|
||||
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
|
||||
{:else}
|
||||
<Trash2 class="mr-2 h-4 w-4" />
|
||||
{/if}
|
||||
Eliminar
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<CreateEditDialog
|
||||
bind:open={dialogOpen}
|
||||
item={item}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script lang="ts" generics="TData, TValue">
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
} from "@tanstack/table-core";
|
||||
import { createSvelteTable, FlexRender } from "$lib/components/ui/data-table/index.js";
|
||||
import * as Table from "$lib/components/ui/table/index.js";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
|
||||
type DataTableProps<TData, TValue> = {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
pageCount: number;
|
||||
totalItems: number;
|
||||
};
|
||||
|
||||
let {
|
||||
data,
|
||||
columns,
|
||||
pageCount,
|
||||
totalItems
|
||||
}: DataTableProps<TData, TValue> = $props();
|
||||
|
||||
const table = createSvelteTable({
|
||||
get data() { return data; },
|
||||
get columns() { return columns; },
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
get pageCount() { return pageCount; },
|
||||
});
|
||||
|
||||
function handlePageChange(newPage: number) {
|
||||
const url = new URL($page.url);
|
||||
url.searchParams.set('page', newPage.toString());
|
||||
goto(url, { keepFocus: true, noScroll: true });
|
||||
}
|
||||
|
||||
const currentPage = $derived(Number($page.url.searchParams.get('page') || 1));
|
||||
</script>
|
||||
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
|
||||
<Table.Row>
|
||||
{#each headerGroup.headers as header (header.id)}
|
||||
<Table.Head>
|
||||
{#if !header.isPlaceholder}
|
||||
<FlexRender
|
||||
content={header.column.columnDef.header}
|
||||
context={header.getContext()}
|
||||
/>
|
||||
{/if}
|
||||
</Table.Head>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each table.getRowModel().rows as row (row.id)}
|
||||
<Table.Row data-state={row.getIsSelected() && "selected"}>
|
||||
{#each row.getVisibleCells() as cell (cell.id)}
|
||||
<Table.Cell>
|
||||
<FlexRender
|
||||
content={cell.column.columnDef.cell}
|
||||
context={cell.getContext()}
|
||||
/>
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={columns.length} class="h-24 text-center">
|
||||
No hay resultados.
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end space-x-2 py-4 px-2">
|
||||
<div class="flex-1 text-sm text-muted-foreground">
|
||||
Total: {totalItems} registros
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage >= pageCount}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,296 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import * as Tabs from "$lib/components/ui/tabs";
|
||||
import { Switch } from "$lib/components/ui/switch";
|
||||
import { createCompany, updateCompany, type Company } from "$lib/api/dashboard/a76/general_catalogs/company";
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
item = null,
|
||||
onSuccess
|
||||
}: {
|
||||
open: boolean;
|
||||
item?: Company | null;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
const isEdit = $derived(!!item);
|
||||
const title = $derived(isEdit ? "Editar Empresa" : "Nueva Empresa");
|
||||
|
||||
// Inicialización completa con todos los campos del modelo
|
||||
let formData = $state({
|
||||
// General
|
||||
name: '',
|
||||
rfc: '',
|
||||
curp: '',
|
||||
main_activity: '',
|
||||
|
||||
// Programas
|
||||
program: '',
|
||||
program_number: '',
|
||||
prosec: 0,
|
||||
prosec_authorization: '',
|
||||
|
||||
// Responsable
|
||||
responsible_name: '',
|
||||
responsible_last_name: '',
|
||||
responsible_mother_last_name: '',
|
||||
responsible_rfc: '',
|
||||
position: '',
|
||||
|
||||
// Configuración / Operativo
|
||||
manufacturer_id: '',
|
||||
has_express_line: false,
|
||||
is_service_company: false,
|
||||
order_format_type: '',
|
||||
|
||||
// Certificaciones
|
||||
ctpat_svi: '',
|
||||
trusted_exporter_number: ''
|
||||
});
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Cargar datos al abrir para editar
|
||||
$effect(() => {
|
||||
if (item) {
|
||||
formData = {
|
||||
name: item.name || '',
|
||||
rfc: item.rfc || '',
|
||||
curp: item.curp || '',
|
||||
main_activity: item.main_activity || '',
|
||||
|
||||
program: item.program || '',
|
||||
program_number: item.program_number || '',
|
||||
prosec: item.prosec || 0,
|
||||
prosec_authorization: item.prosec_authorization || '',
|
||||
|
||||
responsible_name: item.responsible_name || '',
|
||||
responsible_last_name: item.responsible_last_name || '',
|
||||
responsible_mother_last_name: item.responsible_mother_last_name || '',
|
||||
responsible_rfc: item.responsible_rfc || '',
|
||||
position: item.position || '',
|
||||
|
||||
manufacturer_id: item.manufacturer_id || '',
|
||||
has_express_line: item.has_express_line || false,
|
||||
is_service_company: item.is_service_company || false,
|
||||
order_format_type: item.order_format_type || '',
|
||||
|
||||
ctpat_svi: item.ctpat_svi || '',
|
||||
trusted_exporter_number: item.trusted_exporter_number || ''
|
||||
};
|
||||
} else {
|
||||
// Reset manual si es nuevo
|
||||
formData = {
|
||||
name: '', rfc: '', curp: '', main_activity: '',
|
||||
program: '', program_number: '', prosec: 0, prosec_authorization: '',
|
||||
responsible_name: '', responsible_last_name: '', responsible_mother_last_name: '', responsible_rfc: '', position: '',
|
||||
manufacturer_id: '', has_express_line: false, is_service_company: false, order_format_type: '',
|
||||
ctpat_svi: '', trusted_exporter_number: ''
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
error = null;
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
if (!formData.name.trim()) throw new Error('El nombre es requerido');
|
||||
if (!formData.rfc.trim()) throw new Error('El RFC es requerido');
|
||||
|
||||
// Construir payload limpiando strings vacíos a null
|
||||
const dataToSend = {
|
||||
name: formData.name.trim(),
|
||||
rfc: formData.rfc.trim(),
|
||||
curp: formData.curp.trim() || null,
|
||||
main_activity: formData.main_activity.trim() || null,
|
||||
|
||||
program: formData.program.trim() || null,
|
||||
program_number: formData.program_number.trim() || null,
|
||||
prosec: formData.prosec || null,
|
||||
prosec_authorization: formData.prosec_authorization.trim() || null,
|
||||
|
||||
// Concatenar nombre completo del responsable si se desea guardar en 'responsible' también
|
||||
responsible: `${formData.responsible_name} ${formData.responsible_last_name}`.trim() || null,
|
||||
responsible_name: formData.responsible_name.trim() || null,
|
||||
responsible_last_name: formData.responsible_last_name.trim() || null,
|
||||
responsible_mother_last_name: formData.responsible_mother_last_name.trim() || null,
|
||||
responsible_rfc: formData.responsible_rfc.trim() || null,
|
||||
position: formData.position.trim() || null,
|
||||
|
||||
manufacturer_id: formData.manufacturer_id.trim() || null,
|
||||
has_express_line: formData.has_express_line,
|
||||
is_service_company: formData.is_service_company,
|
||||
order_format_type: formData.order_format_type.trim() || null,
|
||||
|
||||
ctpat_svi: formData.ctpat_svi.trim() || null,
|
||||
trusted_exporter_number: formData.trusted_exporter_number.trim() || null
|
||||
};
|
||||
|
||||
let response;
|
||||
if (isEdit && item) {
|
||||
response = await updateCompany(item.id, dataToSend);
|
||||
} else {
|
||||
response = await createCompany(dataToSend);
|
||||
}
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
|
||||
open = false;
|
||||
if (onSuccess) onSuccess();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Error al guardar';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{title}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-4">
|
||||
{#if error}
|
||||
<div class="p-3 text-sm text-red-600 bg-red-50 rounded-md border border-red-200">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Tabs.Root value="general" class="w-full">
|
||||
<Tabs.List class="grid w-full grid-cols-4">
|
||||
<Tabs.Trigger value="general">General</Tabs.Trigger>
|
||||
<Tabs.Trigger value="programas">Programas</Tabs.Trigger>
|
||||
<Tabs.Trigger value="responsable">Responsable</Tabs.Trigger>
|
||||
<Tabs.Trigger value="config">Config</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="general" class="space-y-4 py-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="name">Razón Social <span class="text-destructive">*</span></Label>
|
||||
<Input id="name" bind:value={formData.name} placeholder="Nombre de la empresa" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="rfc">RFC <span class="text-destructive">*</span></Label>
|
||||
<Input id="rfc" bind:value={formData.rfc} maxlength={13} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="curp">CURP</Label>
|
||||
<Input id="curp" bind:value={formData.curp} maxlength={18} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="main_activity">Actividad Principal</Label>
|
||||
<Input id="main_activity" bind:value={formData.main_activity} />
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="programas" class="space-y-4 py-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="program">Programa (IMMEX)</Label>
|
||||
<Input id="program" bind:value={formData.program} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="program_number">No. Programa</Label>
|
||||
<Input id="program_number" bind:value={formData.program_number} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="prosec">Sector PROSEC (ID)</Label>
|
||||
<Input id="prosec" type="number" bind:value={formData.prosec} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="prosec_auth">Autorización PROSEC</Label>
|
||||
<Input id="prosec_auth" bind:value={formData.prosec_authorization} />
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="responsable" class="space-y-4 py-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="resp_name">Nombre</Label>
|
||||
<Input id="resp_name" bind:value={formData.responsible_name} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="resp_last">Apellido Paterno</Label>
|
||||
<Input id="resp_last" bind:value={formData.responsible_last_name} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="resp_mother">Apellido Materno</Label>
|
||||
<Input id="resp_mother" bind:value={formData.responsible_mother_last_name} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="resp_rfc">RFC Responsable</Label>
|
||||
<Input id="resp_rfc" bind:value={formData.responsible_rfc} maxlength={13} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="position">Puesto / Cargo</Label>
|
||||
<Input id="position" bind:value={formData.position} />
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="config" class="space-y-4 py-4">
|
||||
<div class="grid grid-cols-2 gap-6">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Switch id="express" bind:checked={formData.has_express_line} />
|
||||
<Label for="express">Carril Exprés (OEA/NEEC)</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Switch id="service" bind:checked={formData.is_service_company} />
|
||||
<Label for="service">Es Empresa de Servicios</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t my-2"></div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="man_id">Manufacturer ID (MID)</Label>
|
||||
<Input id="man_id" bind:value={formData.manufacturer_id} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="order_format">Formato de Pedido</Label>
|
||||
<Input id="order_format" bind:value={formData.order_format_type} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="ctpat">C-TPAT / SVI</Label>
|
||||
<Input id="ctpat" bind:value={formData.ctpat_svi} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="exporter">No. Exportador Confiable</Label>
|
||||
<Input id="exporter" bind:value={formData.trusted_exporter_number} />
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="outline" onclick={() => open = false}>Cancelar</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Guardando...' : 'Guardar Empresa'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -18,7 +18,7 @@
|
||||
let dialogOpen = $state(false);
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm(`¿Estás seguro de eliminar la empresa "${item.name}"?`)) {
|
||||
if (!confirm(`¿Estás seguro de eliminar la empresa "${item.name}"?\n\nNota: No se puede eliminar si tiene registros relacionados.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -28,16 +28,23 @@
|
||||
try {
|
||||
const response = await deleteCompany(item.id);
|
||||
|
||||
// Si hay error en la respuesta
|
||||
if (response.error) {
|
||||
alert(`Error al eliminar: ${response.error}`);
|
||||
alert(`❌ Error al eliminar:\n\n${response.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
// Éxito (status 204 o 200)
|
||||
if (response.status === 204 || response.status === 200 || !response.error) {
|
||||
alert(`✅ Empresa "${item.name}" eliminada correctamente`);
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Error al eliminar el registro');
|
||||
const errorMsg = e instanceof Error ? e.message : 'Error desconocido al eliminar el registro';
|
||||
alert(`❌ Error al eliminar:\n\n${errorMsg}`);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
@@ -55,7 +62,7 @@
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
|
||||
<DropdownMenu.Item onclick={() => dialogOpen = true}>
|
||||
<DropdownMenu.Item onclick={() => dialogOpen = true}>
|
||||
<Pencil class="mr-2 h-4 w-4" />
|
||||
Editar
|
||||
</DropdownMenu.Item>
|
||||
@@ -0,0 +1,109 @@
|
||||
<script lang="ts" generics="TData, TValue">
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
} from "@tanstack/table-core";
|
||||
import { createSvelteTable, FlexRender } from "$lib/components/ui/data-table/index.js";
|
||||
import * as Table from "$lib/components/ui/table/index.js";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
|
||||
// Props exactos que manda tu página de Companies
|
||||
type DataTableProps<TData, TValue> = {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
pageCount: number;
|
||||
totalItems: number;
|
||||
};
|
||||
|
||||
let {
|
||||
data,
|
||||
columns,
|
||||
pageCount,
|
||||
totalItems
|
||||
}: DataTableProps<TData, TValue> = $props();
|
||||
|
||||
const table = createSvelteTable({
|
||||
get data() { return data; },
|
||||
get columns() { return columns; },
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
get pageCount() { return pageCount; },
|
||||
});
|
||||
|
||||
// Función para navegar cambiando la URL ?page=X
|
||||
function handlePageChange(newPage: number) {
|
||||
const url = new URL($page.url);
|
||||
url.searchParams.set('page', newPage.toString());
|
||||
goto(url, { keepFocus: true, noScroll: true }); // Truco: noScroll evita saltos feos
|
||||
}
|
||||
|
||||
// Helper para saber la página actual
|
||||
const currentPage = $derived(Number($page.url.searchParams.get('page') || 1));
|
||||
</script>
|
||||
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
|
||||
<Table.Row>
|
||||
{#each headerGroup.headers as header (header.id)}
|
||||
<Table.Head>
|
||||
{#if !header.isPlaceholder}
|
||||
<FlexRender
|
||||
content={header.column.columnDef.header}
|
||||
context={header.getContext()}
|
||||
/>
|
||||
{/if}
|
||||
</Table.Head>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each table.getRowModel().rows as row (row.id)}
|
||||
<Table.Row data-state={row.getIsSelected() && "selected"}>
|
||||
{#each row.getVisibleCells() as cell (cell.id)}
|
||||
<Table.Cell>
|
||||
<FlexRender
|
||||
content={cell.column.columnDef.cell}
|
||||
context={cell.getContext()}
|
||||
/>
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={columns.length} class="h-24 text-center">
|
||||
No hay resultados.
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end space-x-2 py-4 px-2">
|
||||
<div class="flex-1 text-sm text-muted-foreground">
|
||||
Total: {totalItems} registros
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage >= pageCount}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Concept } from '$lib/api/dashboard/a76/general_catalogs/concepts';
|
||||
import type { ColumnDef } from '@tanstack/table-core';
|
||||
import { renderComponent } from '$lib/components/ui/data-table';
|
||||
import DataTableActions from './data-table-actions.svelte';
|
||||
|
||||
export function createColumns(onSuccess?: () => void): ColumnDef<Concept>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'code',
|
||||
header: 'Código',
|
||||
cell: ({ row }) => row.original.code || '-'
|
||||
},
|
||||
{
|
||||
accessorKey: 'description',
|
||||
header: 'Descripción',
|
||||
cell: ({ row }) => row.original.description || '-'
|
||||
},
|
||||
{
|
||||
accessorKey: 'type',
|
||||
header: 'Tipo',
|
||||
cell: ({ row }) => row.original.type || '-'
|
||||
},
|
||||
{
|
||||
accessorKey: 'section',
|
||||
header: 'Sección',
|
||||
cell: ({ row }) => row.original.section?.toString() || '-'
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Acciones',
|
||||
cell: ({ row }) => {
|
||||
return renderComponent(DataTableActions, {
|
||||
item: row.original,
|
||||
onSuccess
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import * as Tabs from "$lib/components/ui/tabs";
|
||||
import { Switch } from "$lib/components/ui/switch";
|
||||
import { createConcept, updateConcept, type Concept } from "$lib/api/dashboard/a76/general_catalogs/concepts";
|
||||
import { companyStore } from "$lib/stores/company.svelte";
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
item = null,
|
||||
onSuccess
|
||||
}: {
|
||||
open: boolean;
|
||||
item?: Concept | null;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
const isEdit = $derived(!!item);
|
||||
const title = $derived(isEdit ? "Editar Concepto" : "Nuevo Concepto");
|
||||
|
||||
let formData = $state({
|
||||
code: '',
|
||||
description: '',
|
||||
description_en: '',
|
||||
detailed_description: '',
|
||||
priority: '',
|
||||
priority_ame: '',
|
||||
first_total: '',
|
||||
type: '',
|
||||
is_printed: false,
|
||||
section: '',
|
||||
classification: '',
|
||||
});
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Cargar datos al abrir
|
||||
$effect(() => {
|
||||
if (item) {
|
||||
formData = {
|
||||
code: item.code || '',
|
||||
description: item.description || '',
|
||||
description_en: item.description_en || '',
|
||||
detailed_description: item.detailed_description || '',
|
||||
priority: item.priority ? String(item.priority) : '', // Asegurar string
|
||||
priority_ame: item.priority_ame ? String(item.priority_ame) : '',
|
||||
first_total: item.first_total ? String(item.first_total) : '',
|
||||
type: item.type || '',
|
||||
is_printed: item.is_printed || false,
|
||||
section: item.section || '',
|
||||
classification: item.classification || '',
|
||||
};
|
||||
} else {
|
||||
// Limpiar formulario
|
||||
formData = {
|
||||
code: '',
|
||||
description: '',
|
||||
description_en: '',
|
||||
detailed_description: '',
|
||||
priority: '',
|
||||
priority_ame: '',
|
||||
first_total: '',
|
||||
type: '',
|
||||
is_printed: false,
|
||||
section: '',
|
||||
classification: ''
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
error = null;
|
||||
loading = true;
|
||||
|
||||
|
||||
if (!companyStore.activeCompany) {
|
||||
error = "No hay una empresa seleccionada. Recarga la página.";
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
const companyId = companyStore.activeCompany.id;
|
||||
|
||||
try {
|
||||
if (!formData.code.trim()) throw new Error('El código es requerido');
|
||||
|
||||
const dataToSend = {
|
||||
code: formData.code.trim(),
|
||||
company_id: companyId,
|
||||
description: formData.description.trim(),
|
||||
description_en: formData.description_en.trim() || null,
|
||||
detailed_description: formData.detailed_description.trim() || null,
|
||||
priority: formData.priority ? parseInt(formData.priority) : null,
|
||||
priority_ame: formData.priority_ame ? parseInt(formData.priority_ame) : null,
|
||||
section: formData.section ? parseInt(formData.section) : null,
|
||||
first_total: !!formData.first_total,
|
||||
type: formData.type.trim() || null,
|
||||
is_printed: Boolean(formData.is_printed),
|
||||
classification: formData.classification.trim() || null,
|
||||
};
|
||||
|
||||
let response;
|
||||
|
||||
if (isEdit && item) {
|
||||
|
||||
response = await updateConcept(item.id, dataToSend, companyId);
|
||||
} else {
|
||||
|
||||
response = await createConcept(dataToSend, companyId);
|
||||
}
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error); // Axios a veces devuelve error en el body
|
||||
}
|
||||
|
||||
open = false;
|
||||
if (onSuccess) onSuccess();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Error al guardar';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{title}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-4">
|
||||
{#if error}
|
||||
<div class="p-3 text-sm text-red-600 bg-red-50 rounded-md border border-red-200">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Tabs.Root value="general" class="w-full">
|
||||
<Tabs.Content value="general" class="space-y-4 py-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="code">Código <span class="text-destructive">*</span></Label>
|
||||
<Input id="code" bind:value={formData.code} maxlength={10} />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="priority">Prioridad</Label>
|
||||
<Input id="priority" bind:value={formData.priority} type="number" />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="priority_ame">Prioridad AME</Label>
|
||||
<Input id="priority_ame" bind:value={formData.priority_ame} type="number"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="description">Descripción <span class="text-destructive">*</span></Label>
|
||||
<Input id="description" bind:value={formData.description} maxlength={100} />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="description_en">Descripción (Inglés)</Label>
|
||||
<Input id="description_en" bind:value={formData.description_en} maxlength={100} />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="detailed_description">Descripción Detallada</Label>
|
||||
<Input id="detailed_description" bind:value={formData.detailed_description} />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="first_total">Primer Total</Label>
|
||||
<Input id="first_total" bind:value={formData.first_total} type="number" />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="type">Tipo</Label>
|
||||
<Input id="type" bind:value={formData.type} maxlength={5} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="section">Sección</Label>
|
||||
<Input id="section" bind:value={formData.section} maxlength={10} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="classification">Clasificación</Label>
|
||||
<Input id="classification" bind:value={formData.classification} maxlength={10} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2 pt-2">
|
||||
<Switch id="is_printed" bind:checked={formData.is_printed} />
|
||||
<Label for="is_printed">¿Se Imprime?</Label>
|
||||
</div>
|
||||
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="outline" onclick={() => open = false}>Cancelar</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Guardando...' : 'Guardar Concepto'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
|
||||
import { deleteConcept, type Concept } from "$lib/api/dashboard/a76/general_catalogs/concepts";
|
||||
import { companyStore } from "$lib/stores/company.svelte";
|
||||
import { EllipsisVertical, Pencil, LoaderCircle, Trash2 } from 'lucide-svelte';
|
||||
import CreateEditDialog from "./create-edit-dialog.svelte";
|
||||
|
||||
let {
|
||||
item,
|
||||
onSuccess
|
||||
}: {
|
||||
item: Concept;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let dialogOpen = $state(false);
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm(`¿Estás seguro de eliminar el concepto "${item.code}"?\n\nNota: No se puede eliminar si tiene registros relacionados.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!companyStore.activeCompany) {
|
||||
alert('❌ Error: No hay una compañía seleccionada');
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const response = await deleteConcept(item.id, companyStore.activeCompany.id);
|
||||
|
||||
if (response.error) {
|
||||
alert(`❌ Error al eliminar:\n\n${response.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Éxito
|
||||
if (response.status === 204 || response.status === 200 || !response.error) {
|
||||
alert(`✅ Concepto "${item.code}" eliminado correctamente`);
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
const errorMsg = e instanceof Error ? e.message : 'Error desconocido al eliminar el registro';
|
||||
alert(`❌ Error al eliminar:\n\n${errorMsg}`);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="h-8 w-8 p-0">
|
||||
<span class="sr-only">Abrir menú</span>
|
||||
<EllipsisVertical class="h-4 w-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
|
||||
<DropdownMenu.Item onclick={() => dialogOpen = true}>
|
||||
<Pencil class="mr-2 h-4 w-4" />
|
||||
Editar
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item class="text-destructive focus:text-destructive" onclick={handleDelete} disabled={loading}>
|
||||
{#if loading}
|
||||
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
|
||||
{:else}
|
||||
<Trash2 class="mr-2 h-4 w-4" />
|
||||
{/if}
|
||||
Eliminar
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<CreateEditDialog
|
||||
bind:open={dialogOpen}
|
||||
item={item}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script lang="ts" generics="TData, TValue">
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
} from "@tanstack/table-core";
|
||||
import { createSvelteTable, FlexRender } from "$lib/components/ui/data-table/index.js";
|
||||
import * as Table from "$lib/components/ui/table/index.js";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
|
||||
type DataTableProps<TData, TValue> = {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
pageCount: number;
|
||||
totalItems: number;
|
||||
};
|
||||
|
||||
let {
|
||||
data,
|
||||
columns,
|
||||
pageCount,
|
||||
totalItems
|
||||
}: DataTableProps<TData, TValue> = $props();
|
||||
|
||||
const table = createSvelteTable({
|
||||
get data() { return data; },
|
||||
get columns() { return columns; },
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
get pageCount() { return pageCount; },
|
||||
});
|
||||
|
||||
function handlePageChange(newPage: number) {
|
||||
const url = new URL($page.url);
|
||||
url.searchParams.set('page', newPage.toString());
|
||||
goto(url, { keepFocus: true, noScroll: true });
|
||||
}
|
||||
|
||||
const currentPage = $derived(Number($page.url.searchParams.get('page') || 1));
|
||||
</script>
|
||||
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
|
||||
<Table.Row>
|
||||
{#each headerGroup.headers as header (header.id)}
|
||||
<Table.Head>
|
||||
{#if !header.isPlaceholder}
|
||||
<FlexRender
|
||||
content={header.column.columnDef.header}
|
||||
context={header.getContext()}
|
||||
/>
|
||||
{/if}
|
||||
</Table.Head>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each table.getRowModel().rows as row (row.id)}
|
||||
<Table.Row data-state={row.getIsSelected() && "selected"}>
|
||||
{#each row.getVisibleCells() as cell (cell.id)}
|
||||
<Table.Cell>
|
||||
<FlexRender
|
||||
content={cell.column.columnDef.cell}
|
||||
context={cell.getContext()}
|
||||
/>
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={columns.length} class="h-24 text-center">
|
||||
No hay resultados.
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end space-x-2 py-4 px-2">
|
||||
<div class="flex-1 text-sm text-muted-foreground">
|
||||
Total: {totalItems} registros
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage >= pageCount}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { CustomsBrokerConcept } from '$lib/api/dashboard/a76/general_catalogs/customs-broker-concepts';
|
||||
import type { ColumnDef } from '@tanstack/table-core';
|
||||
import { renderComponent } from '$lib/components/ui/data-table';
|
||||
import DataTableActions from './data-table-actions.svelte';
|
||||
|
||||
export function createColumns(onSuccess?: () => void): ColumnDef<CustomsBrokerConcept>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'code',
|
||||
header: 'Código',
|
||||
cell: ({ row }) => row.original.code || '-'
|
||||
},
|
||||
{
|
||||
accessorKey: 'description',
|
||||
header: 'Descripción',
|
||||
cell: ({ row }) => row.original.description || '-'
|
||||
},
|
||||
{
|
||||
accessorKey: 'type',
|
||||
header: 'Tipo',
|
||||
cell: ({ row }) => row.original.type || '-'
|
||||
},
|
||||
{
|
||||
accessorKey: 'section',
|
||||
header: 'Sección',
|
||||
cell: ({ row }) => row.original.section?.toString() || '-'
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Acciones',
|
||||
cell: ({ row }) => {
|
||||
return renderComponent(DataTableActions, {
|
||||
item: row.original,
|
||||
onSuccess
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import {
|
||||
createCustomsBrokerConcept,
|
||||
updateCustomsBrokerConcept,
|
||||
type CustomsBrokerConcept
|
||||
} from "$lib/api/dashboard/a76/general_catalogs/customs-broker-concepts";
|
||||
import { companyStore } from "$lib/stores/company.svelte";
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
item = null,
|
||||
onSuccess
|
||||
}: {
|
||||
open: boolean;
|
||||
item?: CustomsBrokerConcept | null;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
const isEdit = $derived(!!item);
|
||||
const title = $derived(isEdit ? "Editar Concepto AA" : "Nuevo Concepto AA");
|
||||
|
||||
// 3. Estado alineado al modelo de BD
|
||||
let formData = $state({
|
||||
broker_key: '',
|
||||
concept: '',
|
||||
amount: null as number | null,
|
||||
priority: null as number | null
|
||||
});
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// 4. Cargar datos al editar
|
||||
$effect(() => {
|
||||
if (item) {
|
||||
formData = {
|
||||
broker_key: item.broker_key || '',
|
||||
concept: item.concept || '',
|
||||
amount: item.amount || null,
|
||||
priority: item.priority || null
|
||||
};
|
||||
} else {
|
||||
formData = {
|
||||
broker_key: '',
|
||||
concept: '',
|
||||
amount: null,
|
||||
priority: null
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
error = null;
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) throw new Error('No hay una compañía seleccionada');
|
||||
|
||||
// Validaciones
|
||||
if (!formData.broker_key.trim()) throw new Error('La Clave AA es requerida');
|
||||
if (!formData.concept.trim()) throw new Error('El Concepto es requerido');
|
||||
|
||||
// 5. Preparar datos con los tipos correctos (Números)
|
||||
const dataToSend = {
|
||||
broker_key: formData.broker_key.trim(),
|
||||
concept: formData.concept.trim(),
|
||||
amount: formData.amount ? Number(formData.amount) : undefined,
|
||||
priority: formData.priority ? Number(formData.priority) : undefined
|
||||
};
|
||||
|
||||
// 6. Corregida la sintaxis de llamada a la API
|
||||
if (isEdit && item) {
|
||||
// UPDATE: (id, data, companyId)
|
||||
await updateCustomsBrokerConcept(item.id, dataToSend, companyId);
|
||||
} else {
|
||||
// CREATE: (data, companyId)
|
||||
await createCustomsBrokerConcept(dataToSend, companyId);
|
||||
}
|
||||
|
||||
open = false;
|
||||
if (onSuccess) onSuccess();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Error al guardar';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-w-md max-h-[90vh] overflow-y-auto">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{title}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{isEdit ? 'Modifica el concepto del agente aduanal' : 'Crea un nuevo concepto'}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-4">
|
||||
{#if error}
|
||||
<div class="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="broker_key">Clave AA <span class="text-destructive">*</span></Label>
|
||||
<Input
|
||||
id="broker_key"
|
||||
bind:value={formData.broker_key}
|
||||
placeholder="Ej: 550"
|
||||
maxlength={5}
|
||||
disabled={isEdit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="concept">Concepto <span class="text-destructive">*</span></Label>
|
||||
<Input
|
||||
id="concept"
|
||||
bind:value={formData.concept}
|
||||
placeholder="Ej: FLETE"
|
||||
maxlength={15}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="amount">Importe</Label>
|
||||
<Input
|
||||
id="amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
bind:value={formData.amount}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="priority">Prioridad</Label>
|
||||
<Input
|
||||
id="priority"
|
||||
type="number"
|
||||
bind:value={formData.priority}
|
||||
placeholder="Ej: 1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="outline" onclick={() => open = false} disabled={loading}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Guardando...' : isEdit ? 'Actualizar' : 'Crear'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
|
||||
import { deleteCustomsBrokerConcept, type CustomsBrokerConcept } from "$lib/api/dashboard/a76/general_catalogs/customs-broker-concepts";
|
||||
import { companyStore } from "$lib/stores/company.svelte";
|
||||
import { EllipsisVertical, Pencil, LoaderCircle, Trash2 } from 'lucide-svelte';
|
||||
import CreateEditDialog from "./create-edite-dialoge.svelte";
|
||||
|
||||
let {
|
||||
item,
|
||||
onSuccess
|
||||
}: {
|
||||
item: CustomsBrokerConcept;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let dialogOpen = $state(false);
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm(`¿Estás seguro de eliminar el concepto "${item.code}"?\n\nNota: No se puede eliminar si tiene registros relacionados.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!companyStore.activeCompany) {
|
||||
alert('❌ Error: No hay una compañía seleccionada');
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const response = await deleteCustomsBrokerConcept(item.id, companyStore.activeCompany.id);
|
||||
|
||||
if (response.error) {
|
||||
alert(`❌ Error al eliminar:\n\n${response.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Éxito
|
||||
if (response.status === 204 || response.status === 200 || !response.error) {
|
||||
alert(`✅ Concepto "${item.code}" eliminado correctamente`);
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
const errorMsg = e instanceof Error ? e.message : 'Error desconocido al eliminar el registro';
|
||||
alert(`❌ Error al eliminar:\n\n${errorMsg}`);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="h-8 w-8 p-0">
|
||||
<span class="sr-only">Abrir menú</span>
|
||||
<EllipsisVertical class="h-4 w-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
|
||||
<DropdownMenu.Item onclick={() => dialogOpen = true}>
|
||||
<Pencil class="mr-2 h-4 w-4" />
|
||||
Editar
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item class="text-destructive focus:text-destructive" onclick={handleDelete} disabled={loading}>
|
||||
{#if loading}
|
||||
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
|
||||
{:else}
|
||||
<Trash2 class="mr-2 h-4 w-4" />
|
||||
{/if}
|
||||
Eliminar
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<CreateEditDialog
|
||||
bind:open={dialogOpen}
|
||||
item={item}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script lang="ts" generics="TData, TValue">
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
} from "@tanstack/table-core";
|
||||
import { createSvelteTable, FlexRender } from "$lib/components/ui/data-table/index.js";
|
||||
import * as Table from "$lib/components/ui/table/index.js";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
|
||||
type DataTableProps<TData, TValue> = {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
pageCount: number;
|
||||
totalItems: number;
|
||||
};
|
||||
|
||||
let {
|
||||
data,
|
||||
columns,
|
||||
pageCount,
|
||||
totalItems
|
||||
}: DataTableProps<TData, TValue> = $props();
|
||||
|
||||
const table = createSvelteTable({
|
||||
get data() { return data; },
|
||||
get columns() { return columns; },
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
get pageCount() { return pageCount; },
|
||||
});
|
||||
|
||||
function handlePageChange(newPage: number) {
|
||||
const url = new URL($page.url);
|
||||
url.searchParams.set('page', newPage.toString());
|
||||
goto(url, { keepFocus: true, noScroll: true });
|
||||
}
|
||||
|
||||
const currentPage = $derived(Number($page.url.searchParams.get('page') || 1));
|
||||
</script>
|
||||
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
|
||||
<Table.Row>
|
||||
{#each headerGroup.headers as header (header.id)}
|
||||
<Table.Head>
|
||||
{#if !header.isPlaceholder}
|
||||
<FlexRender
|
||||
content={header.column.columnDef.header}
|
||||
context={header.getContext()}
|
||||
/>
|
||||
{/if}
|
||||
</Table.Head>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each table.getRowModel().rows as row (row.id)}
|
||||
<Table.Row data-state={row.getIsSelected() && "selected"}>
|
||||
{#each row.getVisibleCells() as cell (cell.id)}
|
||||
<Table.Cell>
|
||||
<FlexRender
|
||||
content={cell.column.columnDef.cell}
|
||||
context={cell.getContext()}
|
||||
/>
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={columns.length} class="h-24 text-center">
|
||||
No hay resultados.
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end space-x-2 py-4 px-2">
|
||||
<div class="flex-1 text-sm text-muted-foreground">
|
||||
Total: {totalItems} registros
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage >= pageCount}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ColumnDef } from '@tanstack/table-core';
|
||||
import type { Doda } from '$lib/api/dashboard/a76/general_catalogs/doda';
|
||||
import { renderComponent } from '$lib/components/ui/data-table';
|
||||
import DataTableActions from './data-table-actions.svelte';
|
||||
|
||||
export function createColumns(onSuccess?: () => void): ColumnDef<Doda>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'integration_number',
|
||||
header: 'No. Integración',
|
||||
cell: ({ row }) => row.original.integration_number || 'N/A'
|
||||
},
|
||||
{
|
||||
accessorKey: 'patent',
|
||||
header: 'Patente',
|
||||
cell: ({ row }) => row.original.patent || 'N/A'
|
||||
},
|
||||
{
|
||||
accessorKey: 'pedimentos',
|
||||
header: 'Pedimentos',
|
||||
cell: ({ row }) => row.original.pedimentos || 'N/A'
|
||||
},
|
||||
{
|
||||
accessorKey: 'doda_date',
|
||||
header: 'Fecha',
|
||||
cell: ({ row }) => row.original.doda_date || 'N/A'
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: 'Estatus',
|
||||
cell: ({ row }) => row.original.status || 'N/A'
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: ({ row }) => {
|
||||
return renderComponent(DataTableActions, {
|
||||
item: row.original,
|
||||
onSuccess
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { Textarea } from "$lib/components/ui/textarea";
|
||||
import { Switch } from "$lib/components/ui/switch";
|
||||
import * as Tabs from "$lib/components/ui/tabs";
|
||||
import { companyStore } from "$lib/stores/company.svelte";
|
||||
import {
|
||||
createDoda,
|
||||
updateDoda,
|
||||
type Doda
|
||||
} from '$lib/api/dashboard/a76/general_catalogs/doda';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
item = null,
|
||||
onSuccess
|
||||
}: {
|
||||
open: boolean;
|
||||
item?: Doda | null;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
const isEdit = $derived(!!item);
|
||||
const title = $derived(isEdit ? `Editar DODA ${item?.integration_number || ''}` : "Nuevo DODA");
|
||||
|
||||
// Estado del formulario
|
||||
let formData = $state({
|
||||
integration_number: '',
|
||||
doda_date: undefined as number | undefined,
|
||||
doda_time: undefined as number | undefined,
|
||||
dispatch_customs: '',
|
||||
customs_sections: '',
|
||||
patent: '',
|
||||
pedimentos: '',
|
||||
caat: '',
|
||||
transport_identification: '',
|
||||
fast_id: '',
|
||||
operation_type: '',
|
||||
selected: false,
|
||||
user_selected: '',
|
||||
last_user: '',
|
||||
responsible: '',
|
||||
carrier: '',
|
||||
shipments: '',
|
||||
pedimento_type: '',
|
||||
original_chain: '',
|
||||
serial_number: '',
|
||||
electronic_signature: '',
|
||||
transaction_number: '',
|
||||
status: '',
|
||||
linq_sat_qr: '',
|
||||
sat_certificate: '',
|
||||
sat_digital_seal: '',
|
||||
xml_doda_sent_path: '',
|
||||
xml_doda_response_path: '',
|
||||
sat_original_chain: '',
|
||||
customs_clearance: undefined as number | undefined,
|
||||
unique_badge_number: ''
|
||||
});
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Cargar datos
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
if (item) {
|
||||
formData = {
|
||||
integration_number: item.integration_number || '',
|
||||
doda_date: item.doda_date,
|
||||
doda_time: item.doda_time,
|
||||
dispatch_customs: item.dispatch_customs || '',
|
||||
customs_sections: item.customs_sections || '',
|
||||
patent: item.patent || '',
|
||||
pedimentos: item.pedimentos || '',
|
||||
caat: item.caat || '',
|
||||
transport_identification: item.transport_identification || '',
|
||||
fast_id: item.fast_id || '',
|
||||
operation_type: item.operation_type || '',
|
||||
selected: item.selected || false,
|
||||
user_selected: item.user_selected || '',
|
||||
last_user: item.last_user || '',
|
||||
responsible: item.responsible || '',
|
||||
carrier: item.carrier || '',
|
||||
shipments: item.shipments || '',
|
||||
pedimento_type: item.pedimento_type || '',
|
||||
original_chain: item.original_chain || '',
|
||||
serial_number: item.serial_number || '',
|
||||
electronic_signature: item.electronic_signature || '',
|
||||
transaction_number: item.transaction_number || '',
|
||||
status: item.status || '',
|
||||
linq_sat_qr: item.linq_sat_qr || '',
|
||||
sat_certificate: item.sat_certificate || '',
|
||||
sat_digital_seal: item.sat_digital_seal || '',
|
||||
xml_doda_sent_path: item.xml_doda_sent_path || '',
|
||||
xml_doda_response_path: item.xml_doda_response_path || '',
|
||||
sat_original_chain: item.sat_original_chain || '',
|
||||
customs_clearance: item.customs_clearance,
|
||||
unique_badge_number: item.unique_badge_number || ''
|
||||
};
|
||||
} else {
|
||||
// Reset
|
||||
formData = {
|
||||
integration_number: '',
|
||||
doda_date: undefined,
|
||||
doda_time: undefined,
|
||||
dispatch_customs: '',
|
||||
customs_sections: '',
|
||||
patent: '',
|
||||
pedimentos: '',
|
||||
caat: '',
|
||||
transport_identification: '',
|
||||
fast_id: '',
|
||||
operation_type: '',
|
||||
selected: false,
|
||||
user_selected: '',
|
||||
last_user: '',
|
||||
responsible: '',
|
||||
carrier: '',
|
||||
shipments: '',
|
||||
pedimento_type: '',
|
||||
original_chain: '',
|
||||
serial_number: '',
|
||||
electronic_signature: '',
|
||||
transaction_number: '',
|
||||
status: '',
|
||||
linq_sat_qr: '',
|
||||
sat_certificate: '',
|
||||
sat_digital_seal: '',
|
||||
xml_doda_sent_path: '',
|
||||
xml_doda_response_path: '',
|
||||
sat_original_chain: '',
|
||||
customs_clearance: undefined,
|
||||
unique_badge_number: ''
|
||||
};
|
||||
}
|
||||
error = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) {
|
||||
error = 'No hay compañía seleccionada';
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const idToUpdate = item?.id;
|
||||
|
||||
if (isEdit && idToUpdate) {
|
||||
await updateDoda(idToUpdate, formData, companyId);
|
||||
} else {
|
||||
await createDoda(formData, companyId);
|
||||
}
|
||||
|
||||
open = false;
|
||||
if (onSuccess) onSuccess();
|
||||
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
error = e instanceof Error ? e.message : 'Error al guardar DODA';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="sm:max-w-[900px] max-h-[90vh] overflow-y-auto">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{title}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="py-4">
|
||||
{#if error}
|
||||
<div class="mb-4 rounded-md bg-destructive/15 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Tabs.Root value="general" class="w-full">
|
||||
<Tabs.List class="grid w-full grid-cols-4">
|
||||
<Tabs.Trigger value="general">General</Tabs.Trigger>
|
||||
<Tabs.Trigger value="transport">Aduana/Transp.</Tabs.Trigger>
|
||||
<Tabs.Trigger value="sat">SAT / Digital</Tabs.Trigger>
|
||||
<Tabs.Trigger value="other">Otros</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<!-- TAB: GENERAL -->
|
||||
<Tabs.Content value="general" class="space-y-4 py-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="integration_number">No. Integración</Label>
|
||||
<Input id="integration_number" bind:value={formData.integration_number} maxlength={30} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="status">Estatus</Label>
|
||||
<Input id="status" bind:value={formData.status} maxlength={30} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="doda_date">Fecha (YYYYMMDD)</Label>
|
||||
<Input type="number" id="doda_date" bind:value={formData.doda_date} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="doda_time">Hora (HHMMSS)</Label>
|
||||
<Input type="number" id="doda_time" bind:value={formData.doda_time} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="operation_type">Tipo Operación</Label>
|
||||
<Input id="operation_type" bind:value={formData.operation_type} maxlength={1} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="pedimentos">Pedimentos</Label>
|
||||
<Input id="pedimentos" bind:value={formData.pedimentos} maxlength={80} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="pedimento_type">Tipo Pedimento</Label>
|
||||
<Input id="pedimento_type" bind:value={formData.pedimento_type} maxlength={30} />
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- TAB: ADUANA / TRANSPORTE -->
|
||||
<Tabs.Content value="transport" class="space-y-4 py-4">
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="patent">Patente</Label>
|
||||
<Input id="patent" bind:value={formData.patent} maxlength={4} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="dispatch_customs">Aduana Despacho</Label>
|
||||
<Input id="dispatch_customs" bind:value={formData.dispatch_customs} maxlength={3} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="customs_sections">Sección Aduanera</Label>
|
||||
<Input id="customs_sections" bind:value={formData.customs_sections} maxlength={3} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="caat">CAAT</Label>
|
||||
<Input id="caat" bind:value={formData.caat} maxlength={10} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="carrier">Transportista (Carrier)</Label>
|
||||
<Input id="carrier" bind:value={formData.carrier} maxlength={8} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="transport_identification">Ident. Transporte</Label>
|
||||
<Input id="transport_identification" bind:value={formData.transport_identification} maxlength={20} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="fast_id">FAST ID</Label>
|
||||
<Input id="fast_id" bind:value={formData.fast_id} maxlength={20} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="shipments">Embarques (Shipments)</Label>
|
||||
<Input id="shipments" bind:value={formData.shipments} maxlength={80} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="customs_clearance">Despacho Aduanero (ID)</Label>
|
||||
<Input type="number" id="customs_clearance" bind:value={formData.customs_clearance} />
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- TAB: SAT / DIGITAL -->
|
||||
<Tabs.Content value="sat" class="space-y-4 py-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="serial_number">Número de Serie</Label>
|
||||
<Input id="serial_number" bind:value={formData.serial_number} maxlength={21} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="transaction_number">No. Transacción</Label>
|
||||
<Input id="transaction_number" bind:value={formData.transaction_number} maxlength={30} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="unique_badge_number">Número Único de Gafete</Label>
|
||||
<Input id="unique_badge_number" bind:value={formData.unique_badge_number} maxlength={250} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="original_chain">Cadena Original</Label>
|
||||
<Textarea id="original_chain" bind:value={formData.original_chain} class="h-20" />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="electronic_signature">Firma Electrónica</Label>
|
||||
<Textarea id="electronic_signature" bind:value={formData.electronic_signature} class="h-20" />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="sat_digital_seal">Sello Digital SAT</Label>
|
||||
<Textarea id="sat_digital_seal" bind:value={formData.sat_digital_seal} class="h-20" />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="sat_original_chain">Cadena Original SAT</Label>
|
||||
<Textarea id="sat_original_chain" bind:value={formData.sat_original_chain} class="h-20" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="xml_doda_sent_path">Ruta XML Enviado</Label>
|
||||
<Input id="xml_doda_sent_path" bind:value={formData.xml_doda_sent_path} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="xml_doda_response_path">Ruta XML Respuesta</Label>
|
||||
<Input id="xml_doda_response_path" bind:value={formData.xml_doda_response_path} />
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- TAB: OTROS -->
|
||||
<Tabs.Content value="other" class="space-y-4 py-4">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Switch id="selected" bind:checked={formData.selected} />
|
||||
<Label for="selected">Seleccionado</Label>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="user_selected">Usuario Selección</Label>
|
||||
<Input id="user_selected" bind:value={formData.user_selected} maxlength={30} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="last_user">Último Usuario</Label>
|
||||
<Input id="last_user" bind:value={formData.last_user} maxlength={30} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="responsible">Responsable</Label>
|
||||
<Input id="responsible" bind:value={formData.responsible} maxlength={14} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="linq_sat_qr">LINQ SAT QR</Label>
|
||||
<Input id="linq_sat_qr" bind:value={formData.linq_sat_qr} maxlength={1000} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="sat_certificate">Certificado SAT</Label>
|
||||
<Input id="sat_certificate" bind:value={formData.sat_certificate} maxlength={2001} />
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
|
||||
<Dialog.Footer class="mt-6">
|
||||
<Button type="button" variant="outline" onclick={() => open = false}>Cancelar</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Guardando...' : 'Guardar'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { EllipsisVertical, Pencil, Trash2, LoaderCircle } from 'lucide-svelte';
|
||||
import type { Doda } from '$lib/api/dashboard/a76/general_catalogs/doda';
|
||||
import { deleteDoda } from '$lib/api/dashboard/a76/general_catalogs/doda';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import CreateEditDialog from './create-edit-dialog.svelte';
|
||||
|
||||
let {
|
||||
item,
|
||||
onSuccess
|
||||
}: {
|
||||
item: Doda;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let dialogOpen = $state(false);
|
||||
let selectedItem = $state<Doda | null>(null);
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm('¿Está seguro de eliminar este registro DODA?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) {
|
||||
alert('❌ Error: No hay compañía seleccionada');
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
await deleteDoda(item.id, companyId);
|
||||
alert('✅ Registro eliminado correctamente');
|
||||
if (onSuccess) onSuccess();
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Error al eliminar el registro';
|
||||
alert(`❌ Error: ${error}`);
|
||||
console.error('Error deleting doda:', err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
selectedItem = item;
|
||||
dialogOpen = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="h-8 w-8 p-0">
|
||||
<span class="sr-only">Abrir menú</span>
|
||||
<EllipsisVertical class="h-4 w-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
|
||||
<DropdownMenu.Item onclick={handleEdit}>
|
||||
<Pencil class="mr-2 h-4 w-4" />
|
||||
Editar
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={handleDelete} class="text-red-600">
|
||||
{#if loading}
|
||||
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
|
||||
{:else}
|
||||
<Trash2 class="mr-2 h-4 w-4" />
|
||||
{/if}
|
||||
Eliminar
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<CreateEditDialog
|
||||
bind:open={dialogOpen}
|
||||
item={selectedItem}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
@@ -0,0 +1,102 @@
|
||||
<script lang="ts" generics="TData, TValue">
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
} from "@tanstack/table-core";
|
||||
import { createSvelteTable, FlexRender } from "$lib/components/ui/data-table/index.js";
|
||||
import * as Table from "$lib/components/ui/table/index.js";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
|
||||
type DataTableProps<TData, TValue> = {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
pageCount: number;
|
||||
totalItems: number;
|
||||
};
|
||||
|
||||
let {
|
||||
data,
|
||||
columns,
|
||||
pageCount,
|
||||
totalItems
|
||||
}: DataTableProps<TData, TValue> = $props();
|
||||
|
||||
const table = createSvelteTable({
|
||||
get data() { return data; },
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
pageCount: pageCount,
|
||||
});
|
||||
|
||||
function handlePageChange(newPage: number) {
|
||||
const url = new URL($page.url);
|
||||
url.searchParams.set('page', newPage.toString());
|
||||
goto(url);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
|
||||
<Table.Row>
|
||||
{#each headerGroup.headers as header (header.id)}
|
||||
<Table.Head>
|
||||
{#if !header.isPlaceholder}
|
||||
<FlexRender
|
||||
content={header.column.columnDef.header}
|
||||
context={header.getContext()}
|
||||
/>
|
||||
{/if}
|
||||
</Table.Head>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each table.getRowModel().rows as row (row.id)}
|
||||
<Table.Row data-state={row.getIsSelected() && "selected"}>
|
||||
{#each row.getVisibleCells() as cell (cell.id)}
|
||||
<Table.Cell>
|
||||
<FlexRender
|
||||
content={cell.column.columnDef.cell}
|
||||
context={cell.getContext()}
|
||||
/>
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={columns.length} class="h-24 text-center">
|
||||
No hay resultados.
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end space-x-2 py-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(Number($page.url.searchParams.get('page') || 1) - 1)}
|
||||
disabled={Number($page.url.searchParams.get('page') || 1) <= 1}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
Página {Number($page.url.searchParams.get('page') || 1)} de {pageCount}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(Number($page.url.searchParams.get('page') || 1) + 1)}
|
||||
disabled={Number($page.url.searchParams.get('page') || 1) >= pageCount}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ColumnDef } from '@tanstack/table-core';
|
||||
import type { ElectronicNotice } from '$lib/api/dashboard/a76/general_catalogs/electronic-notices';
|
||||
import { renderComponent } from '$lib/components/ui/data-table';
|
||||
import DataTableActions from './data-table-actions.svelte';
|
||||
|
||||
export function createColumns(onSuccess?: () => void): ColumnDef<ElectronicNotice>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'notice_number',
|
||||
header: 'No. Aviso',
|
||||
cell: ({ row }) => row.original.notice_number || 'N/A'
|
||||
},
|
||||
{
|
||||
accessorKey: 'year',
|
||||
header: 'Año',
|
||||
cell: ({ row }) => row.original.year || 'N/A'
|
||||
},
|
||||
{
|
||||
accessorKey: 'patent',
|
||||
header: 'Patente',
|
||||
cell: ({ row }) => row.original.patent || 'N/A'
|
||||
},
|
||||
{
|
||||
accessorKey: 'pedimento',
|
||||
header: 'Pedimento',
|
||||
cell: ({ row }) => row.original.pedimento || 'N/A'
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: 'Estatus',
|
||||
cell: ({ row }) => row.original.status || 'N/A'
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: ({ row }) => {
|
||||
return renderComponent(DataTableActions, {
|
||||
item: row.original,
|
||||
onSuccess
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { companyStore } from "$lib/stores/company.svelte";
|
||||
import {
|
||||
createElectronicNotice,
|
||||
updateElectronicNotice,
|
||||
type ElectronicNotice
|
||||
} from '$lib/api/dashboard/a76/general_catalogs/electronic-notices';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
item = null,
|
||||
onSuccess
|
||||
}: {
|
||||
open: boolean;
|
||||
item?: ElectronicNotice | null;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
const isEdit = $derived(!!item);
|
||||
const title = $derived(isEdit ? `Editar Aviso ${item?.notice_number || ''}` : "Nuevo Aviso Electrónico");
|
||||
|
||||
// Estado del formulario
|
||||
let formData = $state({
|
||||
notice_number: '',
|
||||
year: '',
|
||||
patent: '',
|
||||
pedimento: '',
|
||||
invoice: '',
|
||||
status: '',
|
||||
validation_acknowledgment: '',
|
||||
certificate_number: '',
|
||||
file_sent: '',
|
||||
file_response: '',
|
||||
fea: ''
|
||||
});
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Cargar datos
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
if (item) {
|
||||
formData = {
|
||||
notice_number: item.notice_number || '',
|
||||
year: item.year || '',
|
||||
patent: item.patent || '',
|
||||
pedimento: item.pedimento || '',
|
||||
invoice: item.invoice || '',
|
||||
status: item.status || '',
|
||||
validation_acknowledgment: item.validation_acknowledgment || '',
|
||||
certificate_number: item.certificate_number || '',
|
||||
file_sent: item.file_sent || '',
|
||||
file_response: item.file_response || '',
|
||||
fea: item.fea || ''
|
||||
};
|
||||
} else {
|
||||
// Reset
|
||||
formData = {
|
||||
notice_number: '',
|
||||
year: '',
|
||||
patent: '',
|
||||
pedimento: '',
|
||||
invoice: '',
|
||||
status: '',
|
||||
validation_acknowledgment: '',
|
||||
certificate_number: '',
|
||||
file_sent: '',
|
||||
file_response: '',
|
||||
fea: ''
|
||||
};
|
||||
}
|
||||
error = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) {
|
||||
error = 'No hay compañía seleccionada';
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
if (isEdit && item) {
|
||||
await updateElectronicNotice(item.id, formData, companyId);
|
||||
} else {
|
||||
await createElectronicNotice(formData, companyId);
|
||||
}
|
||||
|
||||
open = false;
|
||||
if (onSuccess) onSuccess();
|
||||
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
error = e instanceof Error ? e.message : 'Error al guardar aviso electrónico';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{title}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="grid gap-4 py-4">
|
||||
{#if error}
|
||||
<div class="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="notice_number">No. Aviso</Label>
|
||||
<Input id="notice_number" bind:value={formData.notice_number} placeholder="Ej. 12345" maxlength={500} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="year">Año</Label>
|
||||
<Input id="year" bind:value={formData.year} placeholder="Ej. 2024" maxlength={20} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="patent">Patente</Label>
|
||||
<Input id="patent" bind:value={formData.patent} placeholder="Ej. 1234" maxlength={4} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="pedimento">Pedimento</Label>
|
||||
<Input id="pedimento" bind:value={formData.pedimento} placeholder="Ej. 1234567" maxlength={15} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="invoice">Factura</Label>
|
||||
<Input id="invoice" bind:value={formData.invoice} placeholder="Ej. F-123" maxlength={50} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="status">Estatus</Label>
|
||||
<Input id="status" bind:value={formData.status} placeholder="Ej. Validado" maxlength={100} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="validation_acknowledgment">Acuse Validación</Label>
|
||||
<Input id="validation_acknowledgment" bind:value={formData.validation_acknowledgment} placeholder="Ej. AC-123" maxlength={20} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="certificate_number">No. Certificado</Label>
|
||||
<Input id="certificate_number" bind:value={formData.certificate_number} placeholder="Ej. CERT-123" maxlength={50} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="fea">FEA</Label>
|
||||
<Input id="fea" bind:value={formData.fea} placeholder="Firma Electrónica Avanzada" maxlength={1000} />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="file_sent">Archivo Enviado</Label>
|
||||
<Input id="file_sent" bind:value={formData.file_sent} placeholder="Nombre del archivo enviado" maxlength={1000} />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="file_response">Archivo Respuesta</Label>
|
||||
<Input id="file_response" bind:value={formData.file_response} placeholder="Nombre del archivo respuesta" maxlength={1000} />
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="outline" onclick={() => open = false}>Cancelar</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Guardando...' : 'Guardar'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { EllipsisVertical, Pencil, Trash2, LoaderCircle } from 'lucide-svelte';
|
||||
import type { ElectronicNotice } from '$lib/api/dashboard/a76/general_catalogs/electronic-notices';
|
||||
import { deleteElectronicNotice } from '$lib/api/dashboard/a76/general_catalogs/electronic-notices';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import CreateEditDialog from './create-edit-dialog.svelte';
|
||||
|
||||
let {
|
||||
item,
|
||||
onSuccess
|
||||
}: {
|
||||
item: ElectronicNotice;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let dialogOpen = $state(false);
|
||||
let selectedItem = $state<ElectronicNotice | null>(null);
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm('¿Está seguro de eliminar este aviso electrónico?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) {
|
||||
alert('❌ Error: No hay compañía seleccionada');
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
await deleteElectronicNotice(item.id, companyId);
|
||||
alert('✅ Aviso electrónico eliminado correctamente');
|
||||
if (onSuccess) onSuccess();
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Error al eliminar el aviso electrónico';
|
||||
alert(`❌ Error: ${error}`);
|
||||
console.error('Error deleting electronic notice:', err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
selectedItem = item;
|
||||
dialogOpen = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="h-8 w-8 p-0">
|
||||
<span class="sr-only">Abrir menú</span>
|
||||
<EllipsisVertical class="h-4 w-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
|
||||
<DropdownMenu.Item onclick={handleEdit}>
|
||||
<Pencil class="mr-2 h-4 w-4" />
|
||||
Editar
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={handleDelete} class="text-red-600">
|
||||
{#if loading}
|
||||
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
|
||||
{:else}
|
||||
<Trash2 class="mr-2 h-4 w-4" />
|
||||
{/if}
|
||||
Eliminar
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<CreateEditDialog
|
||||
bind:open={dialogOpen}
|
||||
item={selectedItem}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script lang="ts" generics="TData, TValue">
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
} from "@tanstack/table-core";
|
||||
import { createSvelteTable, FlexRender } from "$lib/components/ui/data-table/index.js";
|
||||
import * as Table from "$lib/components/ui/table/index.js";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
|
||||
type DataTableProps<TData, TValue> = {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
pageCount: number;
|
||||
totalItems: number;
|
||||
};
|
||||
|
||||
let {
|
||||
data,
|
||||
columns,
|
||||
pageCount,
|
||||
totalItems
|
||||
}: DataTableProps<TData, TValue> = $props();
|
||||
|
||||
const table = createSvelteTable({
|
||||
get data() { return data; },
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
pageCount: pageCount,
|
||||
});
|
||||
|
||||
function handlePageChange(newPage: number) {
|
||||
const url = new URL($page.url);
|
||||
url.searchParams.set('page', newPage.toString());
|
||||
goto(url);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
|
||||
<Table.Row>
|
||||
{#each headerGroup.headers as header (header.id)}
|
||||
<Table.Head>
|
||||
{#if !header.isPlaceholder}
|
||||
<FlexRender
|
||||
content={header.column.columnDef.header}
|
||||
context={header.getContext()}
|
||||
/>
|
||||
{/if}
|
||||
</Table.Head>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each table.getRowModel().rows as row (row.id)}
|
||||
<Table.Row data-state={row.getIsSelected() && "selected"}>
|
||||
{#each row.getVisibleCells() as cell (cell.id)}
|
||||
<Table.Cell>
|
||||
<FlexRender
|
||||
content={cell.column.columnDef.cell}
|
||||
context={cell.getContext()}
|
||||
/>
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={columns.length} class="h-24 text-center">
|
||||
No hay resultados.
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end space-x-2 py-4">
|
||||
<div class="flex-1 text-sm text-muted-foreground">
|
||||
Total: {totalItems} registros
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(Number($page.url.searchParams.get('page') || 1) - 1)}
|
||||
disabled={Number($page.url.searchParams.get('page') || 1) <= 1}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(Number($page.url.searchParams.get('page') || 1) + 1)}
|
||||
disabled={Number($page.url.searchParams.get('page') || 1) >= pageCount}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { ColumnDef } from '@tanstack/table-core';
|
||||
import type { Equivalency } from '$lib/api/dashboard/a76/general_catalogs/equivalencies';
|
||||
import { renderComponent } from '$lib/components/ui/data-table';
|
||||
import DataTableActions from './data-table-actions.svelte';
|
||||
|
||||
export function createColumns(onSuccess?: () => void): ColumnDef<Equivalency>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'fraccion_mex',
|
||||
header: 'Fracción MX',
|
||||
cell: ({ row }) => row.original.fraccion_mex || 'N/A'
|
||||
},
|
||||
{
|
||||
accessorKey: 'fraccion_us',
|
||||
header: 'Fracción US',
|
||||
cell: ({ row }) => row.original.fraccion_us || 'N/A'
|
||||
},
|
||||
{
|
||||
accessorKey: 'description',
|
||||
header: 'Descripción',
|
||||
cell: ({ row }) => row.original.description || 'N/A'
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: ({ row }) => {
|
||||
return renderComponent(DataTableActions, {
|
||||
item: row.original,
|
||||
onSuccess
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { companyStore } from "$lib/stores/company.svelte";
|
||||
import {
|
||||
createEquivalency,
|
||||
updateEquivalency,
|
||||
type Equivalency
|
||||
} from '$lib/api/dashboard/a76/general_catalogs/equivalencies';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
item = null,
|
||||
onSuccess
|
||||
}: {
|
||||
open: boolean;
|
||||
item?: Equivalency | null;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
const isEdit = $derived(!!item);
|
||||
const title = $derived(isEdit ? `Editar Equivalencia ${item?.fraccion_mex || ''}` : "Nueva Equivalencia");
|
||||
|
||||
// Estado del formulario
|
||||
let formData = $state({
|
||||
fraccion_mex: '',
|
||||
fraccion_us: '',
|
||||
description: ''
|
||||
});
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Cargar datos
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
if (item) {
|
||||
formData = {
|
||||
fraccion_mex: item.fraccion_mex || '',
|
||||
fraccion_us: item.fraccion_us || '',
|
||||
description: item.description || ''
|
||||
};
|
||||
} else {
|
||||
// Reset
|
||||
formData = {
|
||||
fraccion_mex: '',
|
||||
fraccion_us: '',
|
||||
description: ''
|
||||
};
|
||||
}
|
||||
error = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) {
|
||||
error = 'No hay compañía seleccionada';
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (isEdit && item) {
|
||||
response = await updateEquivalency(item.id, formData, companyId);
|
||||
} else {
|
||||
response = await createEquivalency(formData, companyId);
|
||||
}
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
|
||||
open = false;
|
||||
if (onSuccess) onSuccess();
|
||||
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
error = e instanceof Error ? e.message : 'Error al guardar equivalencia';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="sm:max-w-[500px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{title}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="grid gap-4 py-4">
|
||||
{#if error}
|
||||
<div class="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="fraccion_mex" class="text-right">Fracción MX</Label>
|
||||
<Input id="fraccion_mex" bind:value={formData.fraccion_mex} class="col-span-3" maxlength={10} required placeholder="Ej. 8544.11.01" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="fraccion_us" class="text-right">Fracción US</Label>
|
||||
<Input id="fraccion_us" bind:value={formData.fraccion_us} class="col-span-3" maxlength={100} required placeholder="Ej. 8544.11.00" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="description" class="text-right">Descripción</Label>
|
||||
<Input id="description" bind:value={formData.description} class="col-span-3" maxlength={200} />
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="outline" onclick={() => open = false}>Cancelar</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Guardando...' : 'Guardar'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { EllipsisVertical, Pencil, Trash2, LoaderCircle } from 'lucide-svelte';
|
||||
import type { Equivalency } from '$lib/api/dashboard/a76/general_catalogs/equivalencies';
|
||||
import { deleteEquivalency } from '$lib/api/dashboard/a76/general_catalogs/equivalencies';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import CreateEditDialog from './create-edit-dialog.svelte';
|
||||
|
||||
let {
|
||||
item,
|
||||
onSuccess
|
||||
}: {
|
||||
item: Equivalency;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let dialogOpen = $state(false);
|
||||
let selectedItem = $state<Equivalency | null>(null);
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm('¿Está seguro de eliminar esta equivalencia?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) {
|
||||
alert('❌ Error: No hay compañía seleccionada');
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
const response = await deleteEquivalency(item.id, companyId);
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
|
||||
alert('✅ Equivalencia eliminada correctamente');
|
||||
if (onSuccess) onSuccess();
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Error al eliminar la equivalencia';
|
||||
alert(`❌ Error: ${error}`);
|
||||
console.error('Error deleting equivalency:', err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
selectedItem = item;
|
||||
dialogOpen = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="h-8 w-8 p-0">
|
||||
<span class="sr-only">Abrir menú</span>
|
||||
<EllipsisVertical class="h-4 w-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
|
||||
<DropdownMenu.Item onclick={handleEdit}>
|
||||
<Pencil class="mr-2 h-4 w-4" />
|
||||
Editar
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={handleDelete} class="text-red-600">
|
||||
{#if loading}
|
||||
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
|
||||
{:else}
|
||||
<Trash2 class="mr-2 h-4 w-4" />
|
||||
{/if}
|
||||
Eliminar
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<CreateEditDialog
|
||||
bind:open={dialogOpen}
|
||||
item={selectedItem}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script lang="ts" generics="TData, TValue">
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
} from "@tanstack/table-core";
|
||||
import { createSvelteTable, FlexRender } from "$lib/components/ui/data-table/index.js";
|
||||
import * as Table from "$lib/components/ui/table/index.js";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
|
||||
type DataTableProps<TData, TValue> = {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
pageCount: number;
|
||||
totalItems: number;
|
||||
};
|
||||
|
||||
let {
|
||||
data,
|
||||
columns,
|
||||
pageCount,
|
||||
totalItems
|
||||
}: DataTableProps<TData, TValue> = $props();
|
||||
|
||||
const table = createSvelteTable({
|
||||
get data() { return data; },
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
pageCount: pageCount,
|
||||
});
|
||||
|
||||
function handlePageChange(newPage: number) {
|
||||
const url = new URL($page.url);
|
||||
url.searchParams.set('page', newPage.toString());
|
||||
goto(url);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
|
||||
<Table.Row>
|
||||
{#each headerGroup.headers as header (header.id)}
|
||||
<Table.Head>
|
||||
{#if !header.isPlaceholder}
|
||||
<FlexRender
|
||||
content={header.column.columnDef.header}
|
||||
context={header.getContext()}
|
||||
/>
|
||||
{/if}
|
||||
</Table.Head>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if table.getRowModel().rows.length}
|
||||
{#each table.getRowModel().rows as row (row.id)}
|
||||
<Table.Row data-state={row.getIsSelected() && "selected"}>
|
||||
{#each row.getVisibleCells() as cell (cell.id)}
|
||||
<Table.Cell>
|
||||
<FlexRender
|
||||
content={cell.column.columnDef.cell}
|
||||
context={cell.getContext()}
|
||||
/>
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={columns.length} class="h-24 text-center">
|
||||
No hay resultados.
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end space-x-2 py-4">
|
||||
<div class="flex-1 text-sm text-muted-foreground">
|
||||
Total: {totalItems} registros
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(Number($page.url.searchParams.get('page') || 1) - 1)}
|
||||
disabled={Number($page.url.searchParams.get('page') || 1) <= 1}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(Number($page.url.searchParams.get('page') || 1) + 1)}
|
||||
disabled={Number($page.url.searchParams.get('page') || 1) >= pageCount}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ColumnDef } from '@tanstack/table-core';
|
||||
import type { ErrorCatalog } from '$lib/api/dashboard/a76/general_catalogs/error-catalogs';
|
||||
import { renderComponent } from '$lib/components/ui/data-table';
|
||||
import DataTableActions from './data-table-actions.svelte';
|
||||
|
||||
export function createColumns(onSuccess?: () => void): ColumnDef<ErrorCatalog>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'code',
|
||||
header: 'Código'
|
||||
},
|
||||
{
|
||||
accessorKey: 'description',
|
||||
header: 'Descripción',
|
||||
cell: ({ row }) => row.original.description ?? '—'
|
||||
},
|
||||
{
|
||||
accessorKey: 'classification_id',
|
||||
header: 'Clasificación',
|
||||
cell: ({ row }) => row.original.classification_id ?? '—'
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Acciones',
|
||||
cell: ({ row }) => renderComponent(DataTableActions, {
|
||||
item: row.original,
|
||||
onSuccess
|
||||
})
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import * as Select from "$lib/components/ui/select";
|
||||
import {
|
||||
createErrorCatalog,
|
||||
updateErrorCatalog,
|
||||
getErrorClassifications,
|
||||
type ErrorCatalog,
|
||||
type ErrorClassification
|
||||
} from "$lib/api/dashboard/a76/general_catalogs/error-catalogs";
|
||||
import { companyStore } from "$lib/stores/company.svelte";
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
item = null,
|
||||
onSuccess
|
||||
}: {
|
||||
open: boolean;
|
||||
item?: ErrorCatalog | null;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
const isEdit = $derived(!!item);
|
||||
const title = $derived(isEdit ? "Editar Error" : "Nuevo Error");
|
||||
|
||||
let formData = $state({
|
||||
code: "",
|
||||
description: "",
|
||||
classification_id: ""
|
||||
});
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let classifications = $state<ErrorClassification[]>([]);
|
||||
let loadingClassifications = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
if (item) {
|
||||
formData = {
|
||||
code: item.code || "",
|
||||
description: item.description || "",
|
||||
classification_id: item.classification_id ? String(item.classification_id) : ""
|
||||
};
|
||||
} else {
|
||||
formData = { code: "", description: "", classification_id: "" };
|
||||
}
|
||||
error = null;
|
||||
loadClassifications();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadClassifications() {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) return;
|
||||
loadingClassifications = true;
|
||||
try {
|
||||
const response = await getErrorClassifications(companyId, 1, 100);
|
||||
classifications = response.items || [];
|
||||
} catch (err) {
|
||||
console.error("Error loading classifications", err);
|
||||
} finally {
|
||||
loadingClassifications = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) throw new Error("No hay una compañía seleccionada");
|
||||
|
||||
if (!formData.code.trim()) throw new Error("El código es requerido");
|
||||
|
||||
const basePayload = {
|
||||
description: formData.description?.trim() || null,
|
||||
classification_id: formData.classification_id ? Number(formData.classification_id) : null
|
||||
};
|
||||
|
||||
if (isEdit && item) {
|
||||
await updateErrorCatalog(item.id, basePayload, companyId);
|
||||
alert("✅ Error actualizado correctamente");
|
||||
} else {
|
||||
const createPayload = {
|
||||
code: formData.code.trim(),
|
||||
...basePayload
|
||||
};
|
||||
await createErrorCatalog(createPayload, companyId);
|
||||
alert("✅ Error creado correctamente");
|
||||
}
|
||||
|
||||
open = false;
|
||||
if (onSuccess) onSuccess();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : "Error al guardar";
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="fixed inset-0 z-[9999] bg-black/80 backdrop-blur-sm" />
|
||||
|
||||
<Dialog.Content class="fixed left-[50%] top-[50%] z-[10000] w-full max-w-[520px] translate-x-[-50%] translate-y-[-50%] border bg-background p-6 shadow-lg sm:rounded-lg">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{title}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-4 py-4">
|
||||
{#if error}
|
||||
<div class="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-4">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="code" class="text-right">Código *</Label>
|
||||
<div class="col-span-3">
|
||||
<Input id="code" bind:value={formData.code} maxlength={15} disabled={loading || isEdit} required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="description" class="text-right">Descripción</Label>
|
||||
<div class="col-span-3">
|
||||
<Input id="description" bind:value={formData.description} maxlength={255} disabled={loading} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="classification" class="text-right">Clasificación</Label>
|
||||
<div class="col-span-3">
|
||||
<Select.Root bind:value={formData.classification_id} disabled={loading || loadingClassifications}>
|
||||
<Select.Trigger>
|
||||
{#if formData.classification_id}
|
||||
{#each classifications as classification (classification.id)}
|
||||
{#if String(classification.id) === formData.classification_id}
|
||||
{classification.code}{#if classification.level} - {classification.level}{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
{:else}
|
||||
<span class="text-muted-foreground">
|
||||
{loadingClassifications ? "Cargando..." : "Seleccione una clasificación"}
|
||||
</span>
|
||||
{/if}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="">Sin clasificación</Select.Item>
|
||||
{#each classifications as classification (classification.id)}
|
||||
<Select.Item value={String(classification.id)}>
|
||||
{classification.code}{#if classification.level} - {classification.level}{/if}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="outline" onclick={() => open = false} disabled={loading}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Guardando...' : isEdit ? 'Actualizar' : 'Crear'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,97 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { EllipsisVertical, Pencil, Trash2, LoaderCircle } from 'lucide-svelte';
|
||||
import type { ErrorCatalog } from '$lib/api/dashboard/a76/general_catalogs/error-catalogs';
|
||||
import { deleteErrorCatalog } from '$lib/api/dashboard/a76/general_catalogs/error-catalogs';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import CreateEditDialog from './create-edit-dialog.svelte';
|
||||
|
||||
let {
|
||||
item,
|
||||
onSuccess
|
||||
}: {
|
||||
item: ErrorCatalog;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let dialogOpen = $state(false);
|
||||
let selectedItem = $state<ErrorCatalog | null>(null);
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm('¿Está seguro de que desea eliminar este error?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) {
|
||||
alert('❌ Error: No hay compañía seleccionada');
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
await deleteErrorCatalog(item.id, companyId);
|
||||
alert('✅ Error eliminado correctamente');
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Error al eliminar el error';
|
||||
alert(`❌ Error: ${error}`);
|
||||
console.error('Error deleting:', err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
selectedItem = item;
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
function handleDialogSuccess() {
|
||||
dialogOpen = false;
|
||||
selectedItem = null;
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="relative size-8 p-0">
|
||||
<span class="sr-only">Abrir menú</span>
|
||||
<EllipsisVertical size={16} />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end" class="w-[160px]">
|
||||
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onclick={handleEdit}>
|
||||
<Pencil size={16} class="mr-2" />
|
||||
Editar
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onclick={handleDelete} class="text-destructive" disabled={loading}>
|
||||
{#if loading}
|
||||
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
|
||||
{:else}
|
||||
<Trash2 size={16} class="mr-2" />
|
||||
{/if}
|
||||
Eliminar
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<CreateEditDialog
|
||||
bind:open={dialogOpen}
|
||||
item={selectedItem}
|
||||
onSuccess={handleDialogSuccess}
|
||||
/>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script lang="ts" generics="TData, TValue">
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
} from "@tanstack/table-core";
|
||||
import { createSvelteTable, FlexRender } from "$lib/components/ui/data-table/index.js";
|
||||
import * as Table from "$lib/components/ui/table/index.js";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
|
||||
type DataTableProps<TData, TValue> = {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
pageCount: number;
|
||||
totalItems: number;
|
||||
};
|
||||
|
||||
let {
|
||||
data,
|
||||
columns,
|
||||
pageCount,
|
||||
totalItems
|
||||
}: DataTableProps<TData, TValue> = $props();
|
||||
|
||||
const table = createSvelteTable({
|
||||
get data() { return data; },
|
||||
get columns() { return columns; },
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
get pageCount() { return pageCount; },
|
||||
});
|
||||
|
||||
function handlePageChange(newPage: number) {
|
||||
const url = new URL($page.url);
|
||||
url.searchParams.set('page', newPage.toString());
|
||||
goto(url, { keepFocus: true, noScroll: true });
|
||||
}
|
||||
|
||||
const currentPage = $derived(Number($page.url.searchParams.get('page') || 1));
|
||||
</script>
|
||||
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
|
||||
<Table.Row>
|
||||
{#each headerGroup.headers as header (header.id)}
|
||||
<Table.Head>
|
||||
{#if !header.isPlaceholder}
|
||||
<FlexRender
|
||||
content={header.column.columnDef.header}
|
||||
context={header.getContext()}
|
||||
/>
|
||||
{/if}
|
||||
</Table.Head>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each table.getRowModel().rows as row (row.id)}
|
||||
<Table.Row data-state={row.getIsSelected() && "selected"}>
|
||||
{#each row.getVisibleCells() as cell (cell.id)}
|
||||
<Table.Cell>
|
||||
<FlexRender
|
||||
content={cell.column.columnDef.cell}
|
||||
context={cell.getContext()}
|
||||
/>
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={columns.length} class="h-24 text-center">
|
||||
No hay resultados.
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end space-x-2 py-4 px-2">
|
||||
<div class="flex-1 text-sm text-muted-foreground">
|
||||
Total: {totalItems} registros
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage >= pageCount}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ColumnDef } from '@tanstack/table-core';
|
||||
import type { Identifier } from '$lib/api/dashboard/a76/general_catalogs/identifiers';
|
||||
import { renderComponent } from '$lib/components/ui/data-table';
|
||||
import DataTableActions from './data-table-actions.svelte';
|
||||
|
||||
export function createColumns(onSuccess?: () => void): ColumnDef<Identifier>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'code',
|
||||
header: 'Clave',
|
||||
cell: ({ row }) => row.original.code || 'N/A'
|
||||
},
|
||||
{
|
||||
accessorKey: 'description',
|
||||
header: 'Descripción',
|
||||
cell: ({ row }) => row.original.description || 'N/A'
|
||||
},
|
||||
{
|
||||
accessorKey: 'level',
|
||||
header: 'Nivel',
|
||||
cell: ({ row }) => row.original.level || 'N/A'
|
||||
},
|
||||
{
|
||||
accessorKey: 'complement',
|
||||
header: 'Complemento',
|
||||
cell: ({ row }) => row.original.complement || 'N/A'
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: ({ row }) => {
|
||||
return renderComponent(DataTableActions, {
|
||||
item: row.original,
|
||||
onSuccess
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { companyStore } from "$lib/stores/company.svelte";
|
||||
import {
|
||||
createIdentifier,
|
||||
updateIdentifier,
|
||||
type Identifier
|
||||
} from '$lib/api/dashboard/a76/general_catalogs/identifiers';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
item = null,
|
||||
onSuccess
|
||||
}: {
|
||||
open: boolean;
|
||||
item?: Identifier | null;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
const isEdit = $derived(!!item);
|
||||
const title = $derived(isEdit ? `Editar Identificador ${item?.code || ''}` : "Nuevo Identificador");
|
||||
|
||||
// Estado del formulario
|
||||
let formData = $state({
|
||||
code: '',
|
||||
description: '',
|
||||
level: '',
|
||||
complement: ''
|
||||
});
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Cargar datos
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
if (item) {
|
||||
formData = {
|
||||
code: item.code || '',
|
||||
description: item.description || '',
|
||||
level: item.level || '',
|
||||
complement: item.complement || ''
|
||||
};
|
||||
} else {
|
||||
// Reset
|
||||
formData = {
|
||||
code: '',
|
||||
description: '',
|
||||
level: '',
|
||||
complement: ''
|
||||
};
|
||||
}
|
||||
error = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) {
|
||||
error = 'No hay compañía seleccionada';
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
if (isEdit && item) {
|
||||
await updateIdentifier(item.id, formData, companyId);
|
||||
} else {
|
||||
await createIdentifier(formData, companyId);
|
||||
}
|
||||
|
||||
open = false;
|
||||
if (onSuccess) onSuccess();
|
||||
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
error = e instanceof Error ? e.message : 'Error al guardar identificador';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="sm:max-w-[500px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{title}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="grid gap-4 py-4">
|
||||
{#if error}
|
||||
<div class="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="code">Clave</Label>
|
||||
<Input id="code" bind:value={formData.code} placeholder="Ej. AI" maxlength={2} required />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="description">Descripción</Label>
|
||||
<Input id="description" bind:value={formData.description} placeholder="Descripción del identificador" maxlength={1000} />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="level">Nivel</Label>
|
||||
<Input id="level" bind:value={formData.level} placeholder="Ej. G" maxlength={1} />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="complement">Complemento</Label>
|
||||
<Input id="complement" bind:value={formData.complement} placeholder="Información complementaria" maxlength={5000} />
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="outline" onclick={() => open = false}>Cancelar</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Guardando...' : 'Guardar'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { EllipsisVertical, Pencil, Trash2, LoaderCircle } from 'lucide-svelte';
|
||||
import type { Identifier } from '$lib/api/dashboard/a76/general_catalogs/identifiers';
|
||||
import { deleteIdentifier } from '$lib/api/dashboard/a76/general_catalogs/identifiers';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import CreateEditDialog from './create-edit-dialog.svelte';
|
||||
|
||||
let {
|
||||
item,
|
||||
onSuccess
|
||||
}: {
|
||||
item: Identifier;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let dialogOpen = $state(false);
|
||||
let selectedItem = $state<Identifier | null>(null);
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm('¿Está seguro de eliminar este identificador?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) {
|
||||
alert('❌ Error: No hay compañía seleccionada');
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
await deleteIdentifier(item.id, companyId);
|
||||
alert('✅ Identificador eliminado correctamente');
|
||||
if (onSuccess) onSuccess();
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Error al eliminar el identificador';
|
||||
alert(`❌ Error: ${error}`);
|
||||
console.error('Error deleting identifier:', err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
selectedItem = item;
|
||||
dialogOpen = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="h-8 w-8 p-0">
|
||||
<span class="sr-only">Abrir menú</span>
|
||||
<EllipsisVertical class="h-4 w-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
|
||||
<DropdownMenu.Item onclick={handleEdit}>
|
||||
<Pencil class="mr-2 h-4 w-4" />
|
||||
Editar
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={handleDelete} class="text-red-600">
|
||||
{#if loading}
|
||||
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
|
||||
{:else}
|
||||
<Trash2 class="mr-2 h-4 w-4" />
|
||||
{/if}
|
||||
Eliminar
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<CreateEditDialog
|
||||
bind:open={dialogOpen}
|
||||
item={selectedItem}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script lang="ts" generics="TData, TValue">
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
} from "@tanstack/table-core";
|
||||
import { createSvelteTable, FlexRender } from "$lib/components/ui/data-table/index.js";
|
||||
import * as Table from "$lib/components/ui/table/index.js";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
|
||||
type DataTableProps<TData, TValue> = {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
pageCount: number;
|
||||
totalItems: number;
|
||||
};
|
||||
|
||||
let {
|
||||
data,
|
||||
columns,
|
||||
pageCount,
|
||||
totalItems
|
||||
}: DataTableProps<TData, TValue> = $props();
|
||||
|
||||
const table = createSvelteTable({
|
||||
get data() { return data; },
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
pageCount: pageCount,
|
||||
});
|
||||
|
||||
function handlePageChange(newPage: number) {
|
||||
const url = new URL($page.url);
|
||||
url.searchParams.set('page', newPage.toString());
|
||||
goto(url);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
|
||||
<Table.Row>
|
||||
{#each headerGroup.headers as header (header.id)}
|
||||
<Table.Head>
|
||||
{#if !header.isPlaceholder}
|
||||
<FlexRender
|
||||
content={header.column.columnDef.header}
|
||||
context={header.getContext()}
|
||||
/>
|
||||
{/if}
|
||||
</Table.Head>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each table.getRowModel().rows as row (row.id)}
|
||||
<Table.Row data-state={row.getIsSelected() && "selected"}>
|
||||
{#each row.getVisibleCells() as cell (cell.id)}
|
||||
<Table.Cell>
|
||||
<FlexRender
|
||||
content={cell.column.columnDef.cell}
|
||||
context={cell.getContext()}
|
||||
/>
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={columns.length} class="h-24 text-center">
|
||||
No hay resultados.
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end space-x-2 py-4">
|
||||
<div class="flex-1 text-sm text-muted-foreground">
|
||||
Total: {totalItems} registros
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(Number($page.url.searchParams.get('page') || 1) - 1)}
|
||||
disabled={Number($page.url.searchParams.get('page') || 1) <= 1}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(Number($page.url.searchParams.get('page') || 1) + 1)}
|
||||
disabled={Number($page.url.searchParams.get('page') || 1) >= pageCount}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { ColumnDef } from '@tanstack/table-core';
|
||||
import type { INPC } from '$lib/api/dashboard/a76/general_catalogs/inpc';
|
||||
import { renderComponent } from '$lib/components/ui/data-table';
|
||||
import DataTableActions from './data-table-actions.svelte';
|
||||
|
||||
export function createColumns(onSuccess?: () => void): ColumnDef<INPC>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'year',
|
||||
header: 'Año',
|
||||
cell: ({ row }) => row.original.year || 'N/A'
|
||||
},
|
||||
{
|
||||
accessorKey: 'month',
|
||||
header: 'Mes',
|
||||
cell: ({ row }) => row.original.month || 'N/A'
|
||||
},
|
||||
{
|
||||
accessorKey: 'value',
|
||||
header: 'Valor',
|
||||
cell: ({ row }) => row.original.value?.toString() || 'N/A'
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: ({ row }) => {
|
||||
return renderComponent(DataTableActions, {
|
||||
item: row.original,
|
||||
onSuccess
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { companyStore } from "$lib/stores/company.svelte";
|
||||
import {
|
||||
createINPC,
|
||||
updateINPC,
|
||||
type INPC
|
||||
} from '$lib/api/dashboard/a76/general_catalogs/inpc';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
item = null,
|
||||
onSuccess
|
||||
}: {
|
||||
open: boolean;
|
||||
item?: INPC | null;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
const isEdit = $derived(!!item);
|
||||
const title = $derived(isEdit ? `Editar INPC ${item?.year}-${item?.month}` : "Nuevo INPC");
|
||||
|
||||
// Estado del formulario
|
||||
let formData = $state({
|
||||
year: '',
|
||||
month: '',
|
||||
value: ''
|
||||
});
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Cargar datos
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
if (item) {
|
||||
formData = {
|
||||
year: item.year || '',
|
||||
month: item.month || '',
|
||||
value: item.value?.toString() || ''
|
||||
};
|
||||
} else {
|
||||
// Reset
|
||||
formData = {
|
||||
year: '',
|
||||
month: '',
|
||||
value: ''
|
||||
};
|
||||
}
|
||||
error = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) {
|
||||
error = 'No hay compañía seleccionada';
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
year: formData.year,
|
||||
month: formData.month,
|
||||
value: formData.value ? parseFloat(formData.value) : undefined
|
||||
};
|
||||
|
||||
if (isEdit && item) {
|
||||
await updateINPC(item.id, payload, companyId);
|
||||
} else {
|
||||
await createINPC(payload, companyId);
|
||||
}
|
||||
|
||||
open = false;
|
||||
if (onSuccess) onSuccess();
|
||||
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
error = e instanceof Error ? e.message : 'Error al guardar INPC';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="sm:max-w-[500px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{title}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="grid gap-4 py-4">
|
||||
{#if error}
|
||||
<div class="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="year" class="text-right">Año</Label>
|
||||
<Input id="year" bind:value={formData.year} class="col-span-3" maxlength={4} required />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="month" class="text-right">Mes</Label>
|
||||
<Input id="month" bind:value={formData.month} class="col-span-3" maxlength={2} required placeholder="01-12" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="value" class="text-right">Valor</Label>
|
||||
<Input id="value" type="number" step="0.00000001" bind:value={formData.value} class="col-span-3" />
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="outline" onclick={() => open = false}>Cancelar</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Guardando...' : 'Guardar'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,165 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import {
|
||||
createINPC,
|
||||
updateINPC,
|
||||
type INPC
|
||||
} from "$lib/api/dashboard/a76/general_catalogs/inpc";
|
||||
import { companyStore } from "$lib/stores/company.svelte";
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
item = null,
|
||||
onSuccess
|
||||
}: {
|
||||
open: boolean;
|
||||
item?: INPC | null;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
const isEdit = $derived(!!item);
|
||||
const title = $derived(isEdit ? "Editar INPC" : "Nuevo INPC");
|
||||
|
||||
let formData = $state({
|
||||
year: '',
|
||||
month: '',
|
||||
value: null as number | null
|
||||
});
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Cargar datos al abrir
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
if (item) {
|
||||
formData = {
|
||||
year: item.year,
|
||||
month: item.month,
|
||||
value: item.value || null
|
||||
};
|
||||
} else {
|
||||
formData = {
|
||||
year: '',
|
||||
month: '',
|
||||
value: null
|
||||
};
|
||||
}
|
||||
error = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) throw new Error('No hay una compañía seleccionada');
|
||||
|
||||
// Validaciones
|
||||
if (!formData.year.trim()) throw new Error('El año es requerido');
|
||||
if (formData.year.length !== 4) throw new Error('El año debe tener 4 dígitos');
|
||||
if (!formData.month.trim()) throw new Error('El mes es requerido');
|
||||
|
||||
// Preparar datos (limpios)
|
||||
const dataToSend = {
|
||||
year: formData.year.trim(),
|
||||
month: formData.month.trim(),
|
||||
value: formData.value // Ya es número o null
|
||||
};
|
||||
|
||||
let response;
|
||||
|
||||
|
||||
if (isEdit && item) {
|
||||
response = await updateINPC(item.id, dataToSend, companyId);
|
||||
} else {
|
||||
|
||||
response = await createINPC(dataToSend, companyId);
|
||||
}
|
||||
|
||||
open = false;
|
||||
if (onSuccess) onSuccess();
|
||||
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Error al guardar el INPC';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="sm:max-w-[400px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{title}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-4 py-4">
|
||||
{#if error}
|
||||
<div class="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-4">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="year" class="text-right">Año <span class="text-destructive">*</span></Label>
|
||||
<div class="col-span-3">
|
||||
<Input
|
||||
id="year"
|
||||
bind:value={formData.year}
|
||||
placeholder="Ej: 2025"
|
||||
maxlength={4}
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="month" class="text-right">Mes <span class="text-destructive">*</span></Label>
|
||||
<div class="col-span-3">
|
||||
<Input
|
||||
id="month"
|
||||
bind:value={formData.month}
|
||||
placeholder="Ej: 01"
|
||||
maxlength={2}
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
<p class="text-[10px] text-muted-foreground mt-1">Formato MM (Ej: 01, 12)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="value" class="text-right">Valor</Label>
|
||||
<div class="col-span-3">
|
||||
<Input
|
||||
id="value"
|
||||
type="number"
|
||||
step="0.00000001"
|
||||
bind:value={formData.value}
|
||||
placeholder="0.0000"
|
||||
disabled={loading}
|
||||
/>
|
||||
<p class="text-[10px] text-muted-foreground mt-1">Hasta 8 decimales</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="outline" onclick={() => open = false} disabled={loading}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Guardando...' : isEdit ? 'Actualizar' : 'Crear'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { EllipsisVertical, Pencil, Trash2, LoaderCircle } from 'lucide-svelte';
|
||||
import type { INPC } from '$lib/api/dashboard/a76/general_catalogs/inpc';
|
||||
import { deleteINPC } from '$lib/api/dashboard/a76/general_catalogs/inpc';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import CreateEditDialog from './create-edit-dialog.svelte';
|
||||
|
||||
let {
|
||||
item,
|
||||
onSuccess
|
||||
}: {
|
||||
item: INPC;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let dialogOpen = $state(false);
|
||||
let selectedItem = $state<INPC | null>(null);
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm('¿Está seguro de eliminar este registro de INPC?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) {
|
||||
alert('❌ Error: No hay compañía seleccionada');
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
await deleteINPC(item.id, companyId);
|
||||
alert('✅ Registro eliminado correctamente');
|
||||
if (onSuccess) onSuccess();
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Error al eliminar el registro';
|
||||
alert(`❌ Error: ${error}`);
|
||||
console.error('Error deleting INPC:', err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
selectedItem = item;
|
||||
dialogOpen = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="h-8 w-8 p-0">
|
||||
<span class="sr-only">Abrir menú</span>
|
||||
<EllipsisVertical class="h-4 w-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
|
||||
<DropdownMenu.Item onclick={handleEdit}>
|
||||
<Pencil class="mr-2 h-4 w-4" />
|
||||
Editar
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={handleDelete} class="text-red-600">
|
||||
{#if loading}
|
||||
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
|
||||
{:else}
|
||||
<Trash2 class="mr-2 h-4 w-4" />
|
||||
{/if}
|
||||
Eliminar
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<CreateEditDialog
|
||||
bind:open={dialogOpen}
|
||||
item={selectedItem}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script lang="ts" generics="TData, TValue">
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
} from "@tanstack/table-core";
|
||||
import { createSvelteTable, FlexRender } from "$lib/components/ui/data-table/index.js";
|
||||
import * as Table from "$lib/components/ui/table/index.js";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
|
||||
type DataTableProps<TData, TValue> = {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
pageCount: number;
|
||||
totalItems: number;
|
||||
};
|
||||
|
||||
let {
|
||||
data,
|
||||
columns,
|
||||
pageCount,
|
||||
totalItems
|
||||
}: DataTableProps<TData, TValue> = $props();
|
||||
|
||||
const table = createSvelteTable({
|
||||
get data() { return data; },
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
pageCount: pageCount,
|
||||
});
|
||||
|
||||
function handlePageChange(newPage: number) {
|
||||
const url = new URL($page.url);
|
||||
url.searchParams.set('page', newPage.toString());
|
||||
goto(url);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
|
||||
<Table.Row>
|
||||
{#each headerGroup.headers as header (header.id)}
|
||||
<Table.Head>
|
||||
{#if !header.isPlaceholder}
|
||||
<FlexRender
|
||||
content={header.column.columnDef.header}
|
||||
context={header.getContext()}
|
||||
/>
|
||||
{/if}
|
||||
</Table.Head>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if table.getRowModel().rows.length}
|
||||
{#each table.getRowModel().rows as row (row.id)}
|
||||
<Table.Row data-state={row.getIsSelected() && "selected"}>
|
||||
{#each row.getVisibleCells() as cell (cell.id)}
|
||||
<Table.Cell>
|
||||
<FlexRender
|
||||
content={cell.column.columnDef.cell}
|
||||
context={cell.getContext()}
|
||||
/>
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={columns.length} class="h-24 text-center">
|
||||
No hay resultados.
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end space-x-2 py-4">
|
||||
<div class="flex-1 text-sm text-muted-foreground">
|
||||
Total: {totalItems} registros
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(Number($page.url.searchParams.get('page') || 1) - 1)}
|
||||
disabled={Number($page.url.searchParams.get('page') || 1) <= 1}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(Number($page.url.searchParams.get('page') || 1) + 1)}
|
||||
disabled={Number($page.url.searchParams.get('page') || 1) >= pageCount}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
@@ -0,0 +1,153 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { Textarea } from "$lib/components/ui/textarea";
|
||||
import {
|
||||
createLegend,
|
||||
updateLegend,
|
||||
type Legend
|
||||
} from "$lib/api/dashboard/a76/general_catalogs/legends";
|
||||
import { companyStore } from "$lib/stores/company.svelte";
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
item = null,
|
||||
onSuccess
|
||||
}: {
|
||||
open: boolean;
|
||||
item?: Legend | null;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
const isEdit = $derived(!!item);
|
||||
const title = $derived(isEdit ? "Editar Leyenda" : "Nueva Leyenda");
|
||||
|
||||
// Estado del formulario
|
||||
// code es number | null para manejar el input type="number"
|
||||
let formData = $state({
|
||||
code: null as number | null,
|
||||
description: ''
|
||||
});
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Cargar datos al abrir
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
if (item) {
|
||||
formData = {
|
||||
code: item.code, // Es number
|
||||
description: item.description || ''
|
||||
};
|
||||
} else {
|
||||
formData = {
|
||||
code: null,
|
||||
description: ''
|
||||
};
|
||||
}
|
||||
error = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) throw new Error('No hay una compañía seleccionada');
|
||||
|
||||
// Validaciones
|
||||
if (formData.code === null) throw new Error('La Clave (Código) es requerida');
|
||||
|
||||
// Preparar datos
|
||||
const dataToSend = {
|
||||
code: Number(formData.code), // Aseguramos que sea número
|
||||
description: formData.description.trim() || undefined
|
||||
};
|
||||
|
||||
let response;
|
||||
|
||||
if (isEdit && item) {
|
||||
response = await updateLegend(item.id, dataToSend, companyId);
|
||||
} else {
|
||||
response = await createLegend(dataToSend, companyId);
|
||||
}
|
||||
|
||||
// Verificar si hubo error
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
|
||||
open = false;
|
||||
if (onSuccess) onSuccess();
|
||||
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Error al guardar la leyenda';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="sm:max-w-[500px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{title}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-4 py-4">
|
||||
{#if error}
|
||||
<div class="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-4">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="code" class="text-right">Clave <span class="text-destructive">*</span></Label>
|
||||
<div class="col-span-3">
|
||||
<Input
|
||||
id="code"
|
||||
type="number"
|
||||
bind:value={formData.code}
|
||||
placeholder="Ej: 10"
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
<p class="text-[10px] text-muted-foreground mt-1">Debe ser un número entero.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="description" class="text-right">Descripción</Label>
|
||||
<div class="col-span-3">
|
||||
<Textarea
|
||||
id="description"
|
||||
bind:value={formData.description}
|
||||
placeholder="Ej: Esta leyenda aplica para..."
|
||||
maxlength={2000}
|
||||
disabled={loading}
|
||||
class="min-h-[100px]"
|
||||
/>
|
||||
<p class="text-[10px] text-muted-foreground mt-1 text-right">
|
||||
{formData.description.length}/2000
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="outline" onclick={() => open = false} disabled={loading}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Guardando...' : isEdit ? 'Actualizar' : 'Crear'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Legend } from '$lib/api/dashboard/a76/general_catalogs/legends';
|
||||
import type { ColumnDef } from '@tanstack/table-core';
|
||||
import { renderComponent } from '$lib/components/ui/data-table';
|
||||
import DataTableActions from './data-table-actions.svelte';
|
||||
|
||||
export function createColumns(onSuccess?: () => void): ColumnDef<Legend>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'code',
|
||||
header: 'Código',
|
||||
cell: ({ row }) => row.original.code?.toString() || '-'
|
||||
},
|
||||
{
|
||||
accessorKey: 'description',
|
||||
header: 'Descripción',
|
||||
cell: ({ row }) => row.original.description || '-'
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Acciones',
|
||||
cell: ({ row }) => {
|
||||
return renderComponent(DataTableActions, {
|
||||
item: row.original,
|
||||
onSuccess
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
|
||||
import { deleteLegend, type Legend } from "$lib/api/dashboard/a76/general_catalogs/legends";
|
||||
import { companyStore } from "$lib/stores/company.svelte";
|
||||
import { EllipsisVertical, Pencil, LoaderCircle, Trash2 } from 'lucide-svelte';
|
||||
import CreateDialog from '$lib/components/dashboard/general_catalogs/legend/create-edite-dialoge.svelte';
|
||||
|
||||
|
||||
let {
|
||||
item,
|
||||
onSuccess
|
||||
}: {
|
||||
item: Legend;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let dialogOpen = $state(false);
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm(`¿Estás seguro de eliminar la leyenda "${item.code}"?\n\nNota: No se puede eliminar si tiene registros relacionados.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!companyStore.activeCompany) {
|
||||
alert('❌ Error: No hay una compañía seleccionada');
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const response = await deleteLegend(item.id, companyStore.activeCompany.id);
|
||||
|
||||
if (response.error) {
|
||||
alert(`❌ Error al eliminar:\n\n${response.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Éxito
|
||||
if (response.status === 204 || response.status === 200 || !response.error) {
|
||||
alert(`✅ Leyenda "${item.code}" eliminada correctamente`);
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
const errorMsg = e instanceof Error ? e.message : 'Error desconocido al eliminar el registro';
|
||||
alert(`❌ Error al eliminar:\n\n${errorMsg}`);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="h-8 w-8 p-0">
|
||||
<span class="sr-only">Abrir menú</span>
|
||||
<EllipsisVertical class="h-4 w-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
|
||||
<DropdownMenu.Item onclick={() => dialogOpen = true}>
|
||||
<Pencil class="mr-2 h-4 w-4" />
|
||||
Editar
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item class="text-destructive focus:text-destructive" onclick={handleDelete} disabled={loading}>
|
||||
{#if loading}
|
||||
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
|
||||
{:else}
|
||||
<Trash2 class="mr-2 h-4 w-4" />
|
||||
{/if}
|
||||
Eliminar
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<CreateDialog
|
||||
bind:open={dialogOpen}
|
||||
item={item}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script lang="ts" generics="TData, TValue">
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
} from "@tanstack/table-core";
|
||||
import { createSvelteTable, FlexRender } from "$lib/components/ui/data-table/index.js";
|
||||
import * as Table from "$lib/components/ui/table/index.js";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
|
||||
type DataTableProps<TData, TValue> = {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
pageCount: number;
|
||||
totalItems: number;
|
||||
};
|
||||
|
||||
let {
|
||||
data,
|
||||
columns,
|
||||
pageCount,
|
||||
totalItems
|
||||
}: DataTableProps<TData, TValue> = $props();
|
||||
|
||||
const table = createSvelteTable({
|
||||
get data() { return data; },
|
||||
get columns() { return columns; },
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
get pageCount() { return pageCount; },
|
||||
});
|
||||
|
||||
function handlePageChange(newPage: number) {
|
||||
const url = new URL($page.url);
|
||||
url.searchParams.set('page', newPage.toString());
|
||||
goto(url, { keepFocus: true, noScroll: true });
|
||||
}
|
||||
|
||||
const currentPage = $derived(Number($page.url.searchParams.get('page') || 1));
|
||||
</script>
|
||||
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
|
||||
<Table.Row>
|
||||
{#each headerGroup.headers as header (header.id)}
|
||||
<Table.Head>
|
||||
{#if !header.isPlaceholder}
|
||||
<FlexRender
|
||||
content={header.column.columnDef.header}
|
||||
context={header.getContext()}
|
||||
/>
|
||||
{/if}
|
||||
</Table.Head>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each table.getRowModel().rows as row (row.id)}
|
||||
<Table.Row data-state={row.getIsSelected() && "selected"}>
|
||||
{#each row.getVisibleCells() as cell (cell.id)}
|
||||
<Table.Cell>
|
||||
<FlexRender
|
||||
content={cell.column.columnDef.cell}
|
||||
context={cell.getContext()}
|
||||
/>
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={columns.length} class="h-24 text-center">
|
||||
No hay resultados.
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end space-x-2 py-4 px-2">
|
||||
<div class="flex-1 text-sm text-muted-foreground">
|
||||
Total: {totalItems} registros
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage >= pageCount}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,126 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import {
|
||||
createLocation,
|
||||
updateLocation,
|
||||
type Location
|
||||
} from "$lib/api/dashboard/a76/general_catalogs/locations";
|
||||
import { companyStore } from "$lib/stores/company.svelte";
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
item = null,
|
||||
onSuccess
|
||||
}: {
|
||||
open: boolean;
|
||||
item?: Location | null;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
const isEdit = $derived(!!item);
|
||||
const title = $derived(isEdit ? "Editar Ubicación" : "Nueva Ubicación");
|
||||
|
||||
let formData = $state({
|
||||
location_code: "",
|
||||
location_description: ""
|
||||
});
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
if (item) {
|
||||
formData = {
|
||||
location_code: item.location_code || "",
|
||||
location_description: item.location_description || ""
|
||||
};
|
||||
} else {
|
||||
formData = { location_code: "", location_description: "" };
|
||||
}
|
||||
error = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) throw new Error("No hay una compañía seleccionada");
|
||||
|
||||
if (!formData.location_code.trim()) throw new Error("El código es requerido");
|
||||
|
||||
const basePayload = {
|
||||
location_description: formData.location_description?.trim() || null
|
||||
};
|
||||
|
||||
if (isEdit && item) {
|
||||
await updateLocation(item.id, basePayload, companyId);
|
||||
alert("✅ Ubicación actualizada correctamente");
|
||||
} else {
|
||||
const createPayload = {
|
||||
location_code: formData.location_code.trim(),
|
||||
...basePayload
|
||||
};
|
||||
await createLocation(createPayload, companyId);
|
||||
alert("✅ Ubicación creada correctamente");
|
||||
}
|
||||
|
||||
open = false;
|
||||
if (onSuccess) onSuccess();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : "Error al guardar";
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="fixed inset-0 z-[9999] bg-black/80 backdrop-blur-sm" />
|
||||
|
||||
<Dialog.Content class="fixed left-[50%] top-[50%] z-[10000] w-full max-w-[520px] translate-x-[-50%] translate-y-[-50%] border bg-background p-6 shadow-lg sm:rounded-lg">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{title}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-4 py-4">
|
||||
{#if error}
|
||||
<div class="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-4">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="location_code" class="text-right">Código *</Label>
|
||||
<div class="col-span-3">
|
||||
<Input id="location_code" bind:value={formData.location_code} maxlength={4} disabled={loading || isEdit} required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="location_description" class="text-right">Descripción</Label>
|
||||
<div class="col-span-3">
|
||||
<Input id="location_description" bind:value={formData.location_description} maxlength={20} disabled={loading} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="outline" onclick={() => open = false} disabled={loading}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Guardando...' : isEdit ? 'Actualizar' : 'Crear'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,195 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
// 👇 Verifica la ruta de tu archivo TS
|
||||
import {
|
||||
createMultiCurrencyType,
|
||||
updateMultiCurrencyType,
|
||||
type MultiCurrencyType
|
||||
} from "$lib/api/dashboard/a76/general_catalogs/multi-currency-types";
|
||||
import { companyStore } from "$lib/stores/company.svelte";
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
item = null,
|
||||
onSuccess
|
||||
}: {
|
||||
open: boolean;
|
||||
item?: MultiCurrencyType | null;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
const isEdit = $derived(!!item);
|
||||
const title = $derived(isEdit ? "Editar Tipo de Cambio Múltiple" : "Nuevo Tipo de Cambio Múltiple");
|
||||
|
||||
// Estado del formulario
|
||||
let formData = $state({
|
||||
currency_type_code: '',
|
||||
country_key: '',
|
||||
conversion_factor: null as number | null,
|
||||
date_str: '' // Usamos un string temporal para el input type="date"
|
||||
});
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Cargar datos al abrir
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
if (item) {
|
||||
// Truco: Convertir Entero (20251231) -> String ("2025-12-31")
|
||||
let dateFormatted = '';
|
||||
if (item.publication_date) {
|
||||
const s = item.publication_date.toString();
|
||||
if (s.length === 8) {
|
||||
dateFormatted = `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}`;
|
||||
}
|
||||
}
|
||||
|
||||
formData = {
|
||||
currency_type_code: item.currency_type_code,
|
||||
country_key: item.country_key || '',
|
||||
conversion_factor: item.conversion_factor,
|
||||
date_str: dateFormatted
|
||||
};
|
||||
} else {
|
||||
// Default: Fecha de hoy
|
||||
formData = {
|
||||
currency_type_code: '',
|
||||
country_key: '',
|
||||
conversion_factor: null,
|
||||
date_str: new Date().toISOString().split('T')[0]
|
||||
};
|
||||
}
|
||||
error = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) throw new Error('No hay una compañía seleccionada');
|
||||
|
||||
// Validaciones
|
||||
if (!formData.currency_type_code.trim()) throw new Error('El código de moneda es requerido');
|
||||
if (!formData.date_str) throw new Error('La fecha de publicación es requerida');
|
||||
|
||||
// Truco: Convertir String ("2025-12-31") -> Entero (20251231)
|
||||
// Quitamos los guiones y parseamos a int
|
||||
const dateInt = parseInt(formData.date_str.replaceAll('-', ''), 10);
|
||||
|
||||
// Preparar datos
|
||||
const dataToSend = {
|
||||
currency_type_code: formData.currency_type_code.trim().toUpperCase(),
|
||||
country_key: formData.country_key.trim().toUpperCase() || null,
|
||||
conversion_factor: formData.conversion_factor ? Number(formData.conversion_factor) : null,
|
||||
publication_date: dateInt // Mandamos el INT que espera Python
|
||||
};
|
||||
|
||||
if (isEdit && item) {
|
||||
await updateMultiCurrencyType(item.id, dataToSend, companyId);
|
||||
alert(`✅ Tipo de moneda múltiple actualizado correctamente`);
|
||||
} else {
|
||||
await createMultiCurrencyType(dataToSend, companyId);
|
||||
alert(`✅ Tipo de moneda múltiple creado correctamente`);
|
||||
}
|
||||
|
||||
open = false;
|
||||
if (onSuccess) onSuccess();
|
||||
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Error al guardar';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="sm:max-w-[500px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{title}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-4 py-4">
|
||||
{#if error}
|
||||
<div class="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-4">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="currency_code" class="text-right">Moneda <span class="text-destructive">*</span></Label>
|
||||
<div class="col-span-3">
|
||||
<Input
|
||||
id="currency_code"
|
||||
bind:value={formData.currency_type_code}
|
||||
placeholder="Ej: USD"
|
||||
maxlength={3}
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
<p class="text-[10px] text-muted-foreground mt-1">Código de moneda (FK).</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="country_key" class="text-right">País</Label>
|
||||
<div class="col-span-3">
|
||||
<Input
|
||||
id="country_key"
|
||||
bind:value={formData.country_key}
|
||||
placeholder="Ej: MEX"
|
||||
maxlength={3}
|
||||
disabled={loading}
|
||||
/>
|
||||
<p class="text-[10px] text-muted-foreground mt-1">Clave M3 del país (FK).</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="pub_date" class="text-right">Fecha <span class="text-destructive">*</span></Label>
|
||||
<div class="col-span-3">
|
||||
<Input
|
||||
id="pub_date"
|
||||
type="date"
|
||||
bind:value={formData.date_str}
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
<p class="text-[10px] text-muted-foreground mt-1">Se guarda como entero (YYYYMMDD).</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="factor" class="text-right">Factor</Label>
|
||||
<div class="col-span-3">
|
||||
<Input
|
||||
id="factor"
|
||||
type="number"
|
||||
step="0.000001"
|
||||
bind:value={formData.conversion_factor}
|
||||
placeholder="0.000000"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="outline" onclick={() => open = false} disabled={loading}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Guardando...' : isEdit ? 'Actualizar' : 'Crear'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ColumnDef } from '@tanstack/table-core';
|
||||
import type { Prevalidator } from '$lib/api/dashboard/a76/general_catalogs/prevalidators';
|
||||
import { renderComponent } from '$lib/components/ui/data-table';
|
||||
import DataTableActions from './data-table-actions.svelte';
|
||||
|
||||
export function createColumns(onSuccess?: () => void): ColumnDef<Prevalidator>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'code',
|
||||
header: 'Código',
|
||||
cell: ({ row }) => row.original.code || 'N/A'
|
||||
},
|
||||
{
|
||||
accessorKey: 'description',
|
||||
header: 'Descripción',
|
||||
cell: ({ row }) => row.original.description || 'N/A'
|
||||
},
|
||||
{
|
||||
accessorKey: 'customs_prevalidator',
|
||||
header: 'Aduana',
|
||||
cell: ({ row }) => row.original.customs_prevalidator || 'N/A'
|
||||
},
|
||||
{
|
||||
accessorKey: 'patent_prevalidator',
|
||||
header: 'Patente',
|
||||
cell: ({ row }) => row.original.patent_prevalidator || 'N/A'
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: ({ row }) => {
|
||||
return renderComponent(DataTableActions, {
|
||||
item: row.original,
|
||||
onSuccess
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { companyStore } from "$lib/stores/company.svelte";
|
||||
import {
|
||||
createPrevalidator,
|
||||
updatePrevalidator,
|
||||
type Prevalidator
|
||||
} from '$lib/api/dashboard/a76/general_catalogs/prevalidators';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
item = null,
|
||||
onSuccess
|
||||
}: {
|
||||
open: boolean;
|
||||
item?: Prevalidator | null;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
const isEdit = $derived(!!item);
|
||||
const title = $derived(isEdit ? `Editar Prevalidador ${item?.code || ''}` : "Nuevo Prevalidador");
|
||||
|
||||
// Estado del formulario
|
||||
let formData = $state({
|
||||
code: '',
|
||||
description: '',
|
||||
customs_prevalidator: '',
|
||||
patent_prevalidator: ''
|
||||
});
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Cargar datos
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
if (item) {
|
||||
formData = {
|
||||
code: item.code || '',
|
||||
description: item.description || '',
|
||||
customs_prevalidator: item.customs_prevalidator || '',
|
||||
patent_prevalidator: item.patent_prevalidator || ''
|
||||
};
|
||||
} else {
|
||||
// Reset
|
||||
formData = {
|
||||
code: '',
|
||||
description: '',
|
||||
customs_prevalidator: '',
|
||||
patent_prevalidator: ''
|
||||
};
|
||||
}
|
||||
error = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) {
|
||||
error = 'No hay compañía seleccionada';
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
if (isEdit && item) {
|
||||
await updatePrevalidator(item.id, formData, companyId);
|
||||
} else {
|
||||
await createPrevalidator(formData, companyId);
|
||||
}
|
||||
|
||||
open = false;
|
||||
if (onSuccess) onSuccess();
|
||||
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
error = e instanceof Error ? e.message : 'Error al guardar prevalidador';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="sm:max-w-[500px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{title}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="grid gap-4 py-4">
|
||||
{#if error}
|
||||
<div class="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="code">Código</Label>
|
||||
<Input id="code" bind:value={formData.code} placeholder="Ej. 123" maxlength={20} />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="description">Descripción</Label>
|
||||
<Input id="description" bind:value={formData.description} placeholder="Descripción del prevalidador" maxlength={50} />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="customs_prevalidator">Aduana</Label>
|
||||
<Input id="customs_prevalidator" bind:value={formData.customs_prevalidator} placeholder="Ej. 123" maxlength={20} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="patent_prevalidator">Patente</Label>
|
||||
<Input id="patent_prevalidator" bind:value={formData.patent_prevalidator} placeholder="Ej. 1234" maxlength={20} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="outline" onclick={() => open = false}>Cancelar</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Guardando...' : 'Guardar'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { EllipsisVertical, Pencil, Trash2, LoaderCircle } from 'lucide-svelte';
|
||||
import type { Prevalidator } from '$lib/api/dashboard/a76/general_catalogs/prevalidators';
|
||||
import { deletePrevalidator } from '$lib/api/dashboard/a76/general_catalogs/prevalidators';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import CreateEditDialog from './create-edit-dialog.svelte';
|
||||
|
||||
let {
|
||||
item,
|
||||
onSuccess
|
||||
}: {
|
||||
item: Prevalidator;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let dialogOpen = $state(false);
|
||||
let selectedItem = $state<Prevalidator | null>(null);
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm('¿Está seguro de eliminar este prevalidador?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) {
|
||||
alert('❌ Error: No hay compañía seleccionada');
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
await deletePrevalidator(item.id, companyId);
|
||||
alert('✅ Prevalidador eliminado correctamente');
|
||||
if (onSuccess) onSuccess();
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Error al eliminar el prevalidador';
|
||||
alert(`❌ Error: ${error}`);
|
||||
console.error('Error deleting prevalidator:', err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
selectedItem = item;
|
||||
dialogOpen = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="h-8 w-8 p-0">
|
||||
<span class="sr-only">Abrir menú</span>
|
||||
<EllipsisVertical class="h-4 w-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
|
||||
<DropdownMenu.Item onclick={handleEdit}>
|
||||
<Pencil class="mr-2 h-4 w-4" />
|
||||
Editar
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={handleDelete} class="text-red-600">
|
||||
{#if loading}
|
||||
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
|
||||
{:else}
|
||||
<Trash2 class="mr-2 h-4 w-4" />
|
||||
{/if}
|
||||
Eliminar
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<CreateEditDialog
|
||||
bind:open={dialogOpen}
|
||||
item={selectedItem}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
@@ -0,0 +1,102 @@
|
||||
<script lang="ts" generics="TData, TValue">
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
} from "@tanstack/table-core";
|
||||
import { createSvelteTable, FlexRender } from "$lib/components/ui/data-table/index.js";
|
||||
import * as Table from "$lib/components/ui/table/index.js";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
|
||||
type DataTableProps<TData, TValue> = {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
pageCount: number;
|
||||
totalItems: number;
|
||||
};
|
||||
|
||||
let {
|
||||
data,
|
||||
columns,
|
||||
pageCount,
|
||||
totalItems
|
||||
}: DataTableProps<TData, TValue> = $props();
|
||||
|
||||
const table = createSvelteTable({
|
||||
get data() { return data; },
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
pageCount: pageCount,
|
||||
});
|
||||
|
||||
function handlePageChange(newPage: number) {
|
||||
const url = new URL($page.url);
|
||||
url.searchParams.set('page', newPage.toString());
|
||||
goto(url);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
|
||||
<Table.Row>
|
||||
{#each headerGroup.headers as header (header.id)}
|
||||
<Table.Head>
|
||||
{#if !header.isPlaceholder}
|
||||
<FlexRender
|
||||
content={header.column.columnDef.header}
|
||||
context={header.getContext()}
|
||||
/>
|
||||
{/if}
|
||||
</Table.Head>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each table.getRowModel().rows as row (row.id)}
|
||||
<Table.Row data-state={row.getIsSelected() && "selected"}>
|
||||
{#each row.getVisibleCells() as cell (cell.id)}
|
||||
<Table.Cell>
|
||||
<FlexRender
|
||||
content={cell.column.columnDef.cell}
|
||||
context={cell.getContext()}
|
||||
/>
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={columns.length} class="h-24 text-center">
|
||||
No hay resultados.
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end space-x-2 py-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(Number($page.url.searchParams.get('page') || 1) - 1)}
|
||||
disabled={Number($page.url.searchParams.get('page') || 1) <= 1}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
Página {Number($page.url.searchParams.get('page') || 1)} de {pageCount}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(Number($page.url.searchParams.get('page') || 1) + 1)}
|
||||
disabled={Number($page.url.searchParams.get('page') || 1) >= pageCount}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ColumnDef } from '@tanstack/table-core';
|
||||
import type { Signature } from '$lib/api/dashboard/a76/general_catalogs/signatures';
|
||||
import { renderComponent } from '$lib/components/ui/data-table';
|
||||
import DataTableActions from './data-table-actions.svelte';
|
||||
|
||||
export function createColumns(onSuccess?: () => void): ColumnDef<Signature>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'code',
|
||||
header: 'Código',
|
||||
cell: ({ row }) => row.original.code || 'N/A'
|
||||
},
|
||||
{
|
||||
accessorKey: 'signature',
|
||||
header: 'Firma',
|
||||
cell: ({ row }) => row.original.signature ?? 'N/A'
|
||||
},
|
||||
{
|
||||
accessorKey: 'photo_path',
|
||||
header: 'Ruta Foto',
|
||||
cell: ({ row }) => row.original.photo_path ?? 'N/A'
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Acciones',
|
||||
cell: ({ row }) => renderComponent(DataTableActions, {
|
||||
item: row.original,
|
||||
onSuccess
|
||||
})
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { Textarea } from "$lib/components/ui/textarea"; // Para que quepa más texto en la firma
|
||||
// 👇 Verifica tu ruta de importación
|
||||
import {
|
||||
createSignature,
|
||||
updateSignature,
|
||||
type Signature
|
||||
} from "$lib/api/dashboard/a76/general_catalogs/signatures";
|
||||
import { companyStore } from "$lib/stores/company.svelte";
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
item = null,
|
||||
onSuccess
|
||||
}: {
|
||||
open: boolean;
|
||||
item?: Signature | null;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
const isEdit = $derived(!!item);
|
||||
const title = $derived(isEdit ? "Editar Firma" : "Nueva Firma");
|
||||
|
||||
// Estado del formulario
|
||||
let formData = $state({
|
||||
code: '',
|
||||
signature: '',
|
||||
photo_path: ''
|
||||
});
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Cargar datos al abrir
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
if (item) {
|
||||
formData = {
|
||||
code: item.code,
|
||||
signature: item.signature || '',
|
||||
photo_path: item.photo_path || ''
|
||||
};
|
||||
} else {
|
||||
formData = {
|
||||
code: '',
|
||||
signature: '',
|
||||
photo_path: ''
|
||||
};
|
||||
}
|
||||
error = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) throw new Error('No hay una compañía seleccionada');
|
||||
|
||||
// Validaciones
|
||||
if (!formData.code.trim()) throw new Error('El código es requerido');
|
||||
|
||||
// Preparar datos
|
||||
const dataToSend = {
|
||||
code: formData.code.trim(),
|
||||
signature: formData.signature.trim() || null,
|
||||
photo_path: formData.photo_path.trim() || null
|
||||
};
|
||||
|
||||
let response;
|
||||
|
||||
// 👇 companyId por fuera (Argumento separado)
|
||||
if (isEdit && item) {
|
||||
response = await updateSignature(item.id, dataToSend, companyId);
|
||||
} else {
|
||||
response = await createSignature(dataToSend, companyId);
|
||||
}
|
||||
|
||||
open = false;
|
||||
if (onSuccess) onSuccess();
|
||||
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Error al guardar la firma';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="sm:max-w-[500px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{title}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-4 py-4">
|
||||
{#if error}
|
||||
<div class="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-4">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="code" class="text-right">Código <span class="text-destructive">*</span></Label>
|
||||
<div class="col-span-3">
|
||||
<Input
|
||||
id="code"
|
||||
bind:value={formData.code}
|
||||
placeholder="Ej: REP_LEGAL"
|
||||
maxlength={10}
|
||||
disabled={loading || isEdit}
|
||||
required
|
||||
/>
|
||||
<p class="text-[10px] text-muted-foreground mt-1">Máximo 10 caracteres.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="signature" class="text-right">Firma / Nombre</Label>
|
||||
<div class="col-span-3">
|
||||
<Textarea
|
||||
id="signature"
|
||||
bind:value={formData.signature}
|
||||
placeholder="Ej: Juan Pérez - Representante Legal"
|
||||
maxlength={1000}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="photo_path" class="text-right">Ruta Foto</Label>
|
||||
<div class="col-span-3">
|
||||
<Input
|
||||
id="photo_path"
|
||||
bind:value={formData.photo_path}
|
||||
placeholder="Ej: /uploads/firmas/juan.png"
|
||||
maxlength={1000}
|
||||
disabled={loading}
|
||||
/>
|
||||
<p class="text-[10px] text-muted-foreground mt-1">Ruta del archivo (Texto).</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="outline" onclick={() => open = false} disabled={loading}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Guardando...' : isEdit ? 'Actualizar' : 'Crear'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,93 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { EllipsisVertical, Pencil, Trash2, LoaderCircle } from 'lucide-svelte';
|
||||
import type { Signature } from '$lib/api/dashboard/a76/general_catalogs/signatures';
|
||||
import { deleteSignature } from '$lib/api/dashboard/a76/general_catalogs/signatures';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import CreateEditDialog from './create-edit-dialog.svelte';
|
||||
|
||||
let {
|
||||
item,
|
||||
onSuccess
|
||||
}: {
|
||||
item: Signature;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let dialogOpen = $state(false);
|
||||
let selectedItem = $state<Signature | null>(null);
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm('¿Está seguro de eliminar esta firma electrónica?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) {
|
||||
alert('❌ Error: No hay compañía seleccionada');
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
await deleteSignature(item.id, companyId);
|
||||
alert('✅ Firma eliminada correctamente');
|
||||
if (onSuccess) onSuccess();
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Error al eliminar la firma';
|
||||
alert(`❌ Error: ${error}`);
|
||||
console.error('Error deleting signature:', err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
selectedItem = item;
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
function handleDialogSuccess() {
|
||||
dialogOpen = false;
|
||||
selectedItem = null;
|
||||
if (onSuccess) onSuccess();
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="relative size-8 p-0">
|
||||
<span class="sr-only">Abrir menú</span>
|
||||
<EllipsisVertical size={16} />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end" class="w-[160px]">
|
||||
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onclick={handleEdit}>
|
||||
<Pencil size={16} class="mr-2" />
|
||||
Editar
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onclick={handleDelete} class="text-destructive" disabled={loading}>
|
||||
{#if loading}
|
||||
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
|
||||
{:else}
|
||||
<Trash2 size={16} class="mr-2" />
|
||||
{/if}
|
||||
Eliminar
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<CreateEditDialog
|
||||
bind:open={dialogOpen}
|
||||
item={selectedItem}
|
||||
onSuccess={handleDialogSuccess}
|
||||
/>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script lang="ts" generics="TData, TValue">
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
} from "@tanstack/table-core";
|
||||
import { createSvelteTable, FlexRender } from "$lib/components/ui/data-table/index.js";
|
||||
import * as Table from "$lib/components/ui/table/index.js";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
|
||||
type DataTableProps<TData, TValue> = {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
pageCount: number;
|
||||
totalItems: number;
|
||||
};
|
||||
|
||||
let {
|
||||
data,
|
||||
columns,
|
||||
pageCount,
|
||||
totalItems
|
||||
}: DataTableProps<TData, TValue> = $props();
|
||||
|
||||
const table = createSvelteTable({
|
||||
get data() { return data; },
|
||||
get columns() { return columns; },
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
get pageCount() { return pageCount; },
|
||||
});
|
||||
|
||||
function handlePageChange(newPage: number) {
|
||||
const url = new URL($page.url);
|
||||
url.searchParams.set('page', newPage.toString());
|
||||
goto(url, { keepFocus: true, noScroll: true });
|
||||
}
|
||||
|
||||
const currentPage = $derived(Number($page.url.searchParams.get('page') || 1));
|
||||
</script>
|
||||
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
|
||||
<Table.Row>
|
||||
{#each headerGroup.headers as header (header.id)}
|
||||
<Table.Head>
|
||||
{#if !header.isPlaceholder}
|
||||
<FlexRender
|
||||
content={header.column.columnDef.header}
|
||||
context={header.getContext()}
|
||||
/>
|
||||
{/if}
|
||||
</Table.Head>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each table.getRowModel().rows as row (row.id)}
|
||||
<Table.Row data-state={row.getIsSelected() && "selected"}>
|
||||
{#each row.getVisibleCells() as cell (cell.id)}
|
||||
<Table.Cell>
|
||||
<FlexRender
|
||||
content={cell.column.columnDef.cell}
|
||||
context={cell.getContext()}
|
||||
/>
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={columns.length} class="h-24 text-center">
|
||||
No hay resultados.
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end space-x-2 py-4 px-2">
|
||||
<div class="flex-1 text-sm text-muted-foreground">
|
||||
Total: {totalItems} registros
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage >= pageCount}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,164 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import {
|
||||
createUnitConversion,
|
||||
updateUnitConversion,
|
||||
type UnitConversion
|
||||
} from "$lib/api/dashboard/a76/general_catalogs/unit-conversions"; // 👈 Asegúrate que la ruta del archivo ts coincida
|
||||
import { companyStore } from "$lib/stores/company.svelte";
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
item = null,
|
||||
onSuccess
|
||||
}: {
|
||||
open: boolean;
|
||||
item?: UnitConversion | null;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
const isEdit = $derived(!!item);
|
||||
const title = $derived(isEdit ? "Editar Conversión" : "Nueva Conversión");
|
||||
|
||||
// Estado del formulario
|
||||
let formData = $state({
|
||||
from_unit_code: '',
|
||||
to_unit_code: '',
|
||||
conversion_factor: null as number | null
|
||||
});
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Cargar datos al abrir
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
if (item) {
|
||||
formData = {
|
||||
from_unit_code: item.from_unit_code,
|
||||
to_unit_code: item.to_unit_code,
|
||||
conversion_factor: item.conversion_factor
|
||||
};
|
||||
} else {
|
||||
formData = {
|
||||
from_unit_code: '',
|
||||
to_unit_code: '',
|
||||
conversion_factor: null
|
||||
};
|
||||
}
|
||||
error = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) throw new Error('No hay una compañía seleccionada');
|
||||
|
||||
// Validaciones
|
||||
if (!formData.from_unit_code.trim()) throw new Error('La unidad origen es requerida');
|
||||
if (!formData.to_unit_code.trim()) throw new Error('La unidad destino es requerida');
|
||||
if (formData.conversion_factor === null || formData.conversion_factor === undefined) throw new Error('El factor de conversión es requerido');
|
||||
|
||||
// Preparar datos
|
||||
const dataToSend = {
|
||||
from_unit_code: formData.from_unit_code.trim().toUpperCase(), // Normalizamos a mayúsculas
|
||||
to_unit_code: formData.to_unit_code.trim().toUpperCase(),
|
||||
conversion_factor: Number(formData.conversion_factor)
|
||||
};
|
||||
|
||||
let response;
|
||||
|
||||
// 👇 companyId por fuera (Argumento separado)
|
||||
if (isEdit && item) {
|
||||
response = await updateUnitConversion(item.id, dataToSend, companyId);
|
||||
} else {
|
||||
response = await createUnitConversion(dataToSend, companyId);
|
||||
}
|
||||
|
||||
open = false;
|
||||
if (onSuccess) onSuccess();
|
||||
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Error al guardar la conversión';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="sm:max-w-[500px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{title}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-4 py-4">
|
||||
{#if error}
|
||||
<div class="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-4">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="from_code" class="text-right">De Unidad <span class="text-destructive">*</span></Label>
|
||||
<div class="col-span-3">
|
||||
<Input
|
||||
id="from_code"
|
||||
bind:value={formData.from_unit_code}
|
||||
placeholder="Ej: KGM"
|
||||
maxlength={5}
|
||||
disabled={loading}
|
||||
/>
|
||||
<p class="text-[10px] text-muted-foreground mt-1">Código de la unidad origen.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="to_code" class="text-right">A Unidad <span class="text-destructive">*</span></Label>
|
||||
<div class="col-span-3">
|
||||
<Input
|
||||
id="to_code"
|
||||
bind:value={formData.to_unit_code}
|
||||
placeholder="Ej: LBR"
|
||||
maxlength={5}
|
||||
disabled={loading}
|
||||
/>
|
||||
<p class="text-[10px] text-muted-foreground mt-1">Código de la unidad destino.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="factor" class="text-right">Factor <span class="text-destructive">*</span></Label>
|
||||
<div class="col-span-3">
|
||||
<Input
|
||||
id="factor"
|
||||
type="number"
|
||||
step="0.000001"
|
||||
bind:value={formData.conversion_factor}
|
||||
placeholder="Ej: 2.20462"
|
||||
disabled={loading}
|
||||
/>
|
||||
<p class="text-[10px] text-muted-foreground mt-1">Numeric(13, 6).</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="outline" onclick={() => open = false} disabled={loading}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Guardando...' : isEdit ? 'Actualizar' : 'Crear'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ColumnDef } from '@tanstack/table-core';
|
||||
import type { UnitConversion } from '$lib/api/dashboard/a76/general_catalogs/unit-conversions';
|
||||
import { renderComponent } from '$lib/components/ui/data-table';
|
||||
import DataTableActions from './data-table-actions.svelte';
|
||||
|
||||
export function createColumns(onSuccess?: () => void): ColumnDef<UnitConversion>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'from_unit_code',
|
||||
header: 'Desde código'
|
||||
},
|
||||
{
|
||||
accessorKey: 'to_unit_code',
|
||||
header: 'Hacia código'
|
||||
},
|
||||
{
|
||||
accessorKey: 'conversion_factor',
|
||||
header: 'Factor de conversión'
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: ({ row }) =>
|
||||
renderComponent(DataTableActions, {
|
||||
conversion: row.original,
|
||||
onSuccess
|
||||
})
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { createUnitConversion, updateUnitConversion, type UnitConversion } from "$lib/api/dashboard/a76/general_catalogs/unit-conversions";
|
||||
import { companyStore } from "$lib/stores/company.svelte";
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
conversion = null,
|
||||
mode = 'create',
|
||||
onSuccess
|
||||
}: {
|
||||
open: boolean;
|
||||
conversion?: UnitConversion | null;
|
||||
mode?: 'create' | 'edit';
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
const isEdit = $derived(mode === 'edit');
|
||||
const title = $derived(isEdit ? "Editar Conversión" : "Nueva Conversión");
|
||||
|
||||
let formData = $state({
|
||||
from_unit_code: '',
|
||||
to_unit_code: '',
|
||||
conversion_factor: ''
|
||||
});
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (conversion) {
|
||||
formData = {
|
||||
from_unit_code: conversion.from_unit_code || '',
|
||||
to_unit_code: conversion.to_unit_code || '',
|
||||
conversion_factor: conversion.conversion_factor.toString() || ''
|
||||
};
|
||||
} else {
|
||||
formData = {
|
||||
from_unit_code: '',
|
||||
to_unit_code: '',
|
||||
conversion_factor: ''
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) throw new Error('No hay una compañía seleccionada');
|
||||
|
||||
if (!formData.from_unit_code.trim()) throw new Error('El código origen es requerido');
|
||||
if (!formData.to_unit_code.trim()) throw new Error('El código destino es requerido');
|
||||
if (!formData.conversion_factor || formData.conversion_factor === '') throw new Error('El factor de conversión es requerido');
|
||||
|
||||
const dataToSend = {
|
||||
from_unit_code: formData.from_unit_code.trim(),
|
||||
to_unit_code: formData.to_unit_code.trim(),
|
||||
conversion_factor: parseFloat(formData.conversion_factor)
|
||||
};
|
||||
|
||||
if (isNaN(dataToSend.conversion_factor)) {
|
||||
throw new Error('El factor de conversión debe ser un número válido');
|
||||
}
|
||||
|
||||
if (isEdit && conversion) {
|
||||
await updateUnitConversion(conversion.id, dataToSend, companyId);
|
||||
alert(`✅ Conversión "${dataToSend.from_unit_code} → ${dataToSend.to_unit_code}" actualizada correctamente`);
|
||||
} else {
|
||||
await createUnitConversion(dataToSend, companyId);
|
||||
alert(`✅ Conversión "${dataToSend.from_unit_code} → ${dataToSend.to_unit_code}" creada correctamente`);
|
||||
}
|
||||
|
||||
open = false;
|
||||
if (onSuccess) onSuccess();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Error al guardar';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{title}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-4">
|
||||
{#if error}
|
||||
<div class="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-4 py-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="from_unit_code">Código origen <span class="text-destructive">*</span></Label>
|
||||
<Input
|
||||
id="from_unit_code"
|
||||
bind:value={formData.from_unit_code}
|
||||
maxlength={5}
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="to_unit_code">Código destino <span class="text-destructive">*</span></Label>
|
||||
<Input
|
||||
id="to_unit_code"
|
||||
bind:value={formData.to_unit_code}
|
||||
maxlength={5}
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="conversion_factor">Factor de conversión <span class="text-destructive">*</span></Label>
|
||||
<Input
|
||||
id="conversion_factor"
|
||||
type="number"
|
||||
step="0.000001"
|
||||
bind:value={formData.conversion_factor}
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="outline" onclick={() => open = false} disabled={loading}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Guardando...' : isEdit ? 'Actualizar' : 'Crear'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
|
||||
import { deleteUnitConversion, type UnitConversion } from "$lib/api/dashboard/a76/general_catalogs/unit-conversions";
|
||||
import { companyStore } from "$lib/stores/company.svelte";
|
||||
import { EllipsisVertical, Pencil, LoaderCircle, Trash2 } from 'lucide-svelte';
|
||||
import CreateEditDialog from "./create-edit-dialog.svelte";
|
||||
|
||||
let {
|
||||
conversion,
|
||||
onSuccess
|
||||
}: {
|
||||
conversion: UnitConversion;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
let dialogOpen = $state(false);
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm(`¿Estás seguro de eliminar la conversión "${conversion.from_unit_code} → ${conversion.to_unit_code}"?\n\nNota: No se puede eliminar si tiene registros relacionados.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!companyStore.activeCompany) {
|
||||
alert('❌ Error: No hay una compañía seleccionada');
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
await deleteUnitConversion(conversion.id, companyStore.activeCompany.id);
|
||||
alert(`✅ Conversión "${conversion.from_unit_code} → ${conversion.to_unit_code}" eliminada correctamente`);
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch (e) {
|
||||
const errorMsg = e instanceof Error ? e.message : 'Error desconocido al eliminar el registro';
|
||||
alert(`❌ Error al eliminar:\n\n${errorMsg}`);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="h-8 w-8 p-0">
|
||||
<span class="sr-only">Abrir menú</span>
|
||||
<EllipsisVertical class="h-4 w-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
|
||||
<DropdownMenu.Item onclick={() => dialogOpen = true}>
|
||||
<Pencil class="mr-2 h-4 w-4" />
|
||||
Editar
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item class="text-destructive focus:text-destructive" onclick={handleDelete} disabled={loading}>
|
||||
{#if loading}
|
||||
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
|
||||
{:else}
|
||||
<Trash2 class="mr-2 h-4 w-4" />
|
||||
{/if}
|
||||
Eliminar
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<CreateEditDialog
|
||||
bind:open={dialogOpen}
|
||||
conversion={conversion}
|
||||
mode="edit"
|
||||
{onSuccess}
|
||||
/>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script lang="ts" generics="TData, TValue">
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
} from "@tanstack/table-core";
|
||||
import { createSvelteTable, FlexRender } from "$lib/components/ui/data-table/index.js";
|
||||
import * as Table from "$lib/components/ui/table/index.js";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
|
||||
type DataTableProps<TData, TValue> = {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
pageCount: number;
|
||||
totalItems: number;
|
||||
};
|
||||
|
||||
let {
|
||||
data,
|
||||
columns,
|
||||
pageCount,
|
||||
totalItems
|
||||
}: DataTableProps<TData, TValue> = $props();
|
||||
|
||||
const table = createSvelteTable({
|
||||
get data() { return data; },
|
||||
get columns() { return columns; },
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
get pageCount() { return pageCount; },
|
||||
});
|
||||
|
||||
function handlePageChange(newPage: number) {
|
||||
const url = new URL($page.url);
|
||||
url.searchParams.set('page', newPage.toString());
|
||||
goto(url, { keepFocus: true, noScroll: true });
|
||||
}
|
||||
|
||||
const currentPage = $derived(Number($page.url.searchParams.get('page') || 1));
|
||||
</script>
|
||||
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
|
||||
<Table.Row>
|
||||
{#each headerGroup.headers as header (header.id)}
|
||||
<Table.Head>
|
||||
{#if !header.isPlaceholder}
|
||||
<FlexRender
|
||||
content={header.column.columnDef.header}
|
||||
context={header.getContext()}
|
||||
/>
|
||||
{/if}
|
||||
</Table.Head>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each table.getRowModel().rows as row (row.id)}
|
||||
<Table.Row data-state={row.getIsSelected() && "selected"}>
|
||||
{#each row.getVisibleCells() as cell (cell.id)}
|
||||
<Table.Cell>
|
||||
<FlexRender
|
||||
content={cell.column.columnDef.cell}
|
||||
context={cell.getContext()}
|
||||
/>
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={columns.length} class="h-24 text-center">
|
||||
No hay resultados.
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end space-x-2 py-4 px-2">
|
||||
<div class="flex-1 text-sm text-muted-foreground">
|
||||
Total: {totalItems} registros
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage >= pageCount}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { UnitOfMeasureACE } from '$lib/api/dashboard/a76/general_catalogs/units-of-measure';
|
||||
import type { ColumnDef } from '@tanstack/table-core';
|
||||
import { renderComponent } from '$lib/components/ui/data-table';
|
||||
import DataTableActions from './data-table-actions.svelte';
|
||||
|
||||
export function createColumns(onSuccess?: () => void): ColumnDef<UnitOfMeasureACE>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'code',
|
||||
header: 'Código',
|
||||
cell: ({ row }) => row.original.code || '-'
|
||||
},
|
||||
{
|
||||
accessorKey: 'description',
|
||||
header: 'Descripción',
|
||||
cell: ({ row }) => row.original.description || '-'
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Acciones',
|
||||
cell: ({ row }) => {
|
||||
return renderComponent(DataTableActions, {
|
||||
item: row.original,
|
||||
onSuccess
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { createUnitOfMeasureACE, updateUnitOfMeasureACE, type UnitOfMeasureACE } from "$lib/api/dashboard/a76/general_catalogs/units-of-measure";
|
||||
import { companyStore } from "$lib/stores/company.svelte";
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
item = null,
|
||||
onSuccess
|
||||
}: {
|
||||
open: boolean;
|
||||
item?: UnitOfMeasureACE | null;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
const isEdit = $derived(!!item);
|
||||
const title = $derived(isEdit ? "Editar Unidad ACE" : "Nueva Unidad ACE");
|
||||
|
||||
let formData = $state({
|
||||
code: '',
|
||||
description: ''
|
||||
});
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (item) {
|
||||
formData = {
|
||||
code: item.code || '',
|
||||
description: item.description || ''
|
||||
};
|
||||
} else {
|
||||
formData = {
|
||||
code: '',
|
||||
description: ''
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) throw new Error('No hay una compañía seleccionada');
|
||||
|
||||
if (!formData.code.trim()) throw new Error('El código es requerido');
|
||||
|
||||
const dataToSend = {
|
||||
code: formData.code.trim(),
|
||||
description: formData.description.trim() || null
|
||||
};
|
||||
|
||||
let response;
|
||||
if (isEdit && item) {
|
||||
response = await updateUnitOfMeasureACE(item.id, dataToSend, companyId);
|
||||
} else {
|
||||
response = await createUnitOfMeasureACE(dataToSend, companyId);
|
||||
}
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
|
||||
open = false;
|
||||
if (onSuccess) onSuccess();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Error al guardar';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{title}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-4">
|
||||
{#if error}
|
||||
<div class="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-4 py-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="code">Código <span class="text-destructive">*</span></Label>
|
||||
<Input
|
||||
id="code"
|
||||
bind:value={formData.code}
|
||||
maxlength={4}
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="description">Descripción</Label>
|
||||
<Input
|
||||
id="description"
|
||||
bind:value={formData.description}
|
||||
maxlength={49}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="outline" onclick={() => open = false} disabled={loading}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Guardando...' : isEdit ? 'Actualizar' : 'Crear'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
|
||||
import { deleteUnitOfMeasureACE, type UnitOfMeasureACE } from "$lib/api/dashboard/a76/general_catalogs/units-of-measure";
|
||||
import { companyStore } from "$lib/stores/company.svelte";
|
||||
import { EllipsisVertical, Pencil, LoaderCircle, Trash2 } from 'lucide-svelte';
|
||||
import CreateEditDialog from "./create-edit-dialog.svelte";
|
||||
|
||||
let {
|
||||
item,
|
||||
onSuccess
|
||||
}: {
|
||||
item: UnitOfMeasureACE;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
let dialogOpen = $state(false);
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm(`¿Estás seguro de eliminar la unidad ACE "${item.code}"?\n\nNota: No se puede eliminar si tiene registros relacionados.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!companyStore.activeCompany) {
|
||||
alert('❌ Error: No hay una compañía seleccionada');
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
const response = await deleteUnitOfMeasureACE(item.id, companyStore.activeCompany.id);
|
||||
|
||||
if (response.error) {
|
||||
alert(`❌ Error al eliminar:\n\n${response.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.status === 204 || response.status === 200 || !response.error) {
|
||||
alert(`✅ Unidad ACE "${item.code}" eliminada correctamente`);
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
const errorMsg = e instanceof Error ? e.message : 'Error desconocido al eliminar el registro';
|
||||
alert(`❌ Error al eliminar:\n\n${errorMsg}`);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="h-8 w-8 p-0">
|
||||
<span class="sr-only">Abrir menú</span>
|
||||
<EllipsisVertical class="h-4 w-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
|
||||
<DropdownMenu.Item onclick={() => dialogOpen = true}>
|
||||
<Pencil class="mr-2 h-4 w-4" />
|
||||
Editar
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item class="text-destructive focus:text-destructive" onclick={handleDelete} disabled={loading}>
|
||||
{#if loading}
|
||||
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
|
||||
{:else}
|
||||
<Trash2 class="mr-2 h-4 w-4" />
|
||||
{/if}
|
||||
Eliminar
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<CreateEditDialog
|
||||
bind:open={dialogOpen}
|
||||
item={item}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script lang="ts" generics="TData, TValue">
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
} from "@tanstack/table-core";
|
||||
import { createSvelteTable, FlexRender } from "$lib/components/ui/data-table/index.js";
|
||||
import * as Table from "$lib/components/ui/table/index.js";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
|
||||
type DataTableProps<TData, TValue> = {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
pageCount: number;
|
||||
totalItems: number;
|
||||
};
|
||||
|
||||
let {
|
||||
data,
|
||||
columns,
|
||||
pageCount,
|
||||
totalItems
|
||||
}: DataTableProps<TData, TValue> = $props();
|
||||
|
||||
const table = createSvelteTable({
|
||||
get data() { return data; },
|
||||
get columns() { return columns; },
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
get pageCount() { return pageCount; },
|
||||
});
|
||||
|
||||
function handlePageChange(newPage: number) {
|
||||
const url = new URL($page.url);
|
||||
url.searchParams.set('page', newPage.toString());
|
||||
goto(url, { keepFocus: true, noScroll: true });
|
||||
}
|
||||
|
||||
const currentPage = $derived(Number($page.url.searchParams.get('page') || 1));
|
||||
</script>
|
||||
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
|
||||
<Table.Row>
|
||||
{#each headerGroup.headers as header (header.id)}
|
||||
<Table.Head>
|
||||
{#if !header.isPlaceholder}
|
||||
<FlexRender
|
||||
content={header.column.columnDef.header}
|
||||
context={header.getContext()}
|
||||
/>
|
||||
{/if}
|
||||
</Table.Head>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each table.getRowModel().rows as row (row.id)}
|
||||
<Table.Row data-state={row.getIsSelected() && "selected"}>
|
||||
{#each row.getVisibleCells() as cell (cell.id)}
|
||||
<Table.Cell>
|
||||
<FlexRender
|
||||
content={cell.column.columnDef.cell}
|
||||
context={cell.getContext()}
|
||||
/>
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={columns.length} class="h-24 text-center">
|
||||
No hay resultados.
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end space-x-2 py-4 px-2">
|
||||
<div class="flex-1 text-sm text-muted-foreground">
|
||||
Total: {totalItems} registros
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage >= pageCount}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { ColumnDef } from "@tanstack/table-core";
|
||||
import type { UnitOfMeasureAmerican } from "$lib/api/dashboard/a76/general_catalogs/units-of-measure";
|
||||
import { renderComponent } from "$lib/components/ui/data-table/index.js";
|
||||
import DataTableActions from "./data-table-actions.svelte";
|
||||
|
||||
export function createColumns(onSuccess?: () => void): ColumnDef<UnitOfMeasureAmerican>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: "code",
|
||||
header: "Código",
|
||||
},
|
||||
{
|
||||
accessorKey: "description",
|
||||
header: "Descripción",
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) =>
|
||||
renderComponent(DataTableActions, {
|
||||
unit: row.original,
|
||||
onSuccess
|
||||
}),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import type {
|
||||
UnitOfMeasureAmerican,
|
||||
UnitOfMeasureAmericanCreate,
|
||||
UnitOfMeasureAmericanUpdate
|
||||
} from '$lib/api/dashboard/a76/general_catalogs/units-of-measure';
|
||||
import {
|
||||
createUnitOfMeasureAmerican,
|
||||
updateUnitOfMeasureAmerican
|
||||
} from '$lib/api/dashboard/a76/general_catalogs/units-of-measure';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
unit?: UnitOfMeasureAmerican;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(), unit, onSuccess }: Props = $props();
|
||||
|
||||
let formData = $state({
|
||||
code: '',
|
||||
description: ''
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
if (unit) {
|
||||
formData = {
|
||||
code: unit.code,
|
||||
description: unit.description || ''
|
||||
};
|
||||
} else {
|
||||
formData = { code: '', description: '' };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
|
||||
const activeCompanyId = companyStore.activeCompany?.id;
|
||||
if (!activeCompanyId) {
|
||||
alert('No hay una compañía activa seleccionada');
|
||||
return;
|
||||
}
|
||||
|
||||
const data: UnitOfMeasureAmericanCreate | UnitOfMeasureAmericanUpdate = {
|
||||
code: formData.code,
|
||||
description: formData.description || null
|
||||
};
|
||||
|
||||
const response = unit
|
||||
? await updateUnitOfMeasureAmerican(unit.id, data, activeCompanyId)
|
||||
: await createUnitOfMeasureAmerican(data, activeCompanyId);
|
||||
|
||||
if (response.error) {
|
||||
alert(response.error.detail || 'Error al guardar');
|
||||
} else {
|
||||
alert(unit ? 'Unidad actualizada' : 'Unidad creada');
|
||||
open = false;
|
||||
onSuccess?.();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="sm:max-w-[425px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{unit ? 'Editar' : 'Crear'} Unidad Americana</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<form onsubmit={handleSubmit} class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="code">Código * (máx. 3 caracteres)</Label>
|
||||
<Input id="code" bind:value={formData.code} required maxlength="3" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="description">Descripción (máx. 40 caracteres)</Label>
|
||||
<Input id="description" bind:value={formData.description} maxlength="40" />
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="outline" onclick={() => (open = false)}>Cancelar</Button>
|
||||
<Button type="submit">Guardar</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { MoreHorizontal, Pencil, Trash2 } from 'lucide-svelte';
|
||||
import CreateEditDialog from './create-edit-dialog.svelte';
|
||||
import type { UnitOfMeasureAmerican } from '$lib/api/dashboard/a76/general_catalogs/units-of-measure';
|
||||
import { deleteUnitOfMeasureAmerican } from '$lib/api/dashboard/a76/general_catalogs/units-of-measure';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
|
||||
interface Props {
|
||||
unit: UnitOfMeasureAmerican;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
let { unit, onSuccess }: Props = $props();
|
||||
let dialogOpen = $state(false);
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm('¿Estás seguro de eliminar esta unidad?')) return;
|
||||
|
||||
const activeCompanyId = companyStore.activeCompany?.id;
|
||||
if (!activeCompanyId) {
|
||||
alert('No hay una compañía activa seleccionada');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await deleteUnitOfMeasureAmerican(unit.id, activeCompanyId);
|
||||
if (response.error) {
|
||||
alert(response.error.detail || 'Error al eliminar');
|
||||
} else if (response.status === 204 || response.status === 200) {
|
||||
alert('Unidad eliminada');
|
||||
onSuccess?.();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="h-8 w-8 p-0">
|
||||
<span class="sr-only">Abrir menú</span>
|
||||
<MoreHorizontal class="h-4 w-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
|
||||
<DropdownMenu.Item onclick={() => (dialogOpen = true)}>
|
||||
<Pencil class="mr-2 h-4 w-4" />
|
||||
Editar
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onclick={handleDelete}>
|
||||
<Trash2 class="mr-2 h-4 w-4" />
|
||||
Eliminar
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<CreateEditDialog bind:open={dialogOpen} {unit} {onSuccess} />
|
||||
@@ -0,0 +1,106 @@
|
||||
<script lang="ts" generics="TData, TValue">
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
} from "@tanstack/table-core";
|
||||
import { createSvelteTable, FlexRender } from "$lib/components/ui/data-table/index.js";
|
||||
import * as Table from "$lib/components/ui/table/index.js";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
|
||||
type DataTableProps<TData, TValue> = {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
pageCount: number;
|
||||
totalItems: number;
|
||||
};
|
||||
|
||||
let {
|
||||
data,
|
||||
columns,
|
||||
pageCount,
|
||||
totalItems
|
||||
}: DataTableProps<TData, TValue> = $props();
|
||||
|
||||
const table = createSvelteTable({
|
||||
get data() { return data; },
|
||||
get columns() { return columns; },
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
get pageCount() { return pageCount; },
|
||||
});
|
||||
|
||||
function handlePageChange(newPage: number) {
|
||||
const url = new URL($page.url);
|
||||
url.searchParams.set('page', newPage.toString());
|
||||
goto(url, { keepFocus: true, noScroll: true });
|
||||
}
|
||||
|
||||
const currentPage = $derived(Number($page.url.searchParams.get('page') || 1));
|
||||
</script>
|
||||
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
|
||||
<Table.Row>
|
||||
{#each headerGroup.headers as header (header.id)}
|
||||
<Table.Head>
|
||||
{#if !header.isPlaceholder}
|
||||
<FlexRender
|
||||
content={header.column.columnDef.header}
|
||||
context={header.getContext()}
|
||||
/>
|
||||
{/if}
|
||||
</Table.Head>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each table.getRowModel().rows as row (row.id)}
|
||||
<Table.Row data-state={row.getIsSelected() && "selected"}>
|
||||
{#each row.getVisibleCells() as cell (cell.id)}
|
||||
<Table.Cell>
|
||||
<FlexRender
|
||||
content={cell.column.columnDef.cell}
|
||||
context={cell.getContext()}
|
||||
/>
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={columns.length} class="h-24 text-center">
|
||||
No hay resultados.
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end space-x-2 py-4 px-2">
|
||||
<div class="flex-1 text-sm text-muted-foreground">
|
||||
Total: {totalItems} registros
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage >= pageCount}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ColumnDef } from "@tanstack/table-core";
|
||||
import type { UnitOfMeasureCustoms } from "$lib/api/dashboard/a76/general_catalogs/units-of-measure";
|
||||
import { renderComponent } from "$lib/components/ui/data-table/index.js";
|
||||
import DataTableActions from "./data-table-actions.svelte";
|
||||
|
||||
export function createColumns(onSuccess?: () => void): ColumnDef<UnitOfMeasureCustoms>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: "code",
|
||||
header: "Código",
|
||||
},
|
||||
{
|
||||
accessorKey: "description",
|
||||
header: "Descripción",
|
||||
},
|
||||
{
|
||||
accessorKey: "scaii_unit_code",
|
||||
header: "Código SCAII",
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) =>
|
||||
renderComponent(DataTableActions, {
|
||||
unit: row.original,
|
||||
onSuccess
|
||||
}),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import type {
|
||||
UnitOfMeasureCustoms,
|
||||
UnitOfMeasureCustomsCreate,
|
||||
UnitOfMeasureCustomsUpdate
|
||||
} from '$lib/api/dashboard/a76/general_catalogs/units-of-measure';
|
||||
import {
|
||||
createUnitOfMeasureCustoms,
|
||||
updateUnitOfMeasureCustoms
|
||||
} from '$lib/api/dashboard/a76/general_catalogs/units-of-measure';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
unit?: UnitOfMeasureCustoms;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(), unit, onSuccess }: Props = $props();
|
||||
|
||||
let formData = $state({
|
||||
code: '',
|
||||
description: '',
|
||||
scaii_unit_code: ''
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
if (unit) {
|
||||
formData = {
|
||||
code: unit.code,
|
||||
description: unit.description || '',
|
||||
scaii_unit_code: unit.scaii_unit_code || ''
|
||||
};
|
||||
} else {
|
||||
formData = { code: '', description: '', scaii_unit_code: '' };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
|
||||
const activeCompanyId = companyStore.activeCompany?.id;
|
||||
if (!activeCompanyId) {
|
||||
alert('No hay una compañía activa seleccionada');
|
||||
return;
|
||||
}
|
||||
|
||||
const data: UnitOfMeasureCustomsCreate | UnitOfMeasureCustomsUpdate = {
|
||||
code: formData.code,
|
||||
description: formData.description || null,
|
||||
scaii_unit_code: formData.scaii_unit_code || null
|
||||
};
|
||||
|
||||
const response = unit
|
||||
? await updateUnitOfMeasureCustoms(unit.id, data, activeCompanyId)
|
||||
: await createUnitOfMeasureCustoms(data, activeCompanyId);
|
||||
|
||||
if (response.error) {
|
||||
alert(response.error.detail || 'Error al guardar');
|
||||
} else {
|
||||
alert(unit ? 'Unidad actualizada' : 'Unidad creada');
|
||||
open = false;
|
||||
onSuccess?.();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="sm:max-w-[425px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{unit ? 'Editar' : 'Crear'} Unidad Aduanas MEX</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<form onsubmit={handleSubmit} class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="code">Código * (máx. 2 caracteres)</Label>
|
||||
<Input id="code" bind:value={formData.code} required maxlength="2" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="description">Descripción (máx. 20 caracteres)</Label>
|
||||
<Input id="description" bind:value={formData.description} maxlength="20" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="scaii_unit_code">Código SCAII</Label>
|
||||
<Input id="scaii_unit_code" bind:value={formData.scaii_unit_code} maxlength="5" />
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="outline" onclick={() => (open = false)}>Cancelar</Button>
|
||||
<Button type="submit">Guardar</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { MoreHorizontal, Pencil, Trash2 } from 'lucide-svelte';
|
||||
import CreateEditDialog from './create-edit-dialog.svelte';
|
||||
import type { UnitOfMeasureCustoms } from '$lib/api/dashboard/a76/general_catalogs/units-of-measure';
|
||||
import { deleteUnitOfMeasureCustoms } from '$lib/api/dashboard/a76/general_catalogs/units-of-measure';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
|
||||
interface Props {
|
||||
unit: UnitOfMeasureCustoms;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
let { unit, onSuccess }: Props = $props();
|
||||
let dialogOpen = $state(false);
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm('¿Estás seguro de eliminar esta unidad?')) return;
|
||||
|
||||
const activeCompanyId = companyStore.activeCompany?.id;
|
||||
if (!activeCompanyId) {
|
||||
alert('No hay una compañía activa seleccionada');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await deleteUnitOfMeasureCustoms(unit.id, activeCompanyId);
|
||||
if (response.error) {
|
||||
alert(response.error.detail || 'Error al eliminar');
|
||||
} else if (response.status === 204 || response.status === 200) {
|
||||
alert('Unidad eliminada');
|
||||
onSuccess?.();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="h-8 w-8 p-0">
|
||||
<span class="sr-only">Abrir menú</span>
|
||||
<MoreHorizontal class="h-4 w-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
|
||||
<DropdownMenu.Item onclick={() => (dialogOpen = true)}>
|
||||
<Pencil class="mr-2 h-4 w-4" />
|
||||
Editar
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onclick={handleDelete}>
|
||||
<Trash2 class="mr-2 h-4 w-4" />
|
||||
Eliminar
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<CreateEditDialog bind:open={dialogOpen} {unit} {onSuccess} />
|
||||
@@ -0,0 +1,106 @@
|
||||
<script lang="ts" generics="TData, TValue">
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
} from "@tanstack/table-core";
|
||||
import { createSvelteTable, FlexRender } from "$lib/components/ui/data-table/index.js";
|
||||
import * as Table from "$lib/components/ui/table/index.js";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
|
||||
type DataTableProps<TData, TValue> = {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
pageCount: number;
|
||||
totalItems: number;
|
||||
};
|
||||
|
||||
let {
|
||||
data,
|
||||
columns,
|
||||
pageCount,
|
||||
totalItems
|
||||
}: DataTableProps<TData, TValue> = $props();
|
||||
|
||||
const table = createSvelteTable({
|
||||
get data() { return data; },
|
||||
get columns() { return columns; },
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
get pageCount() { return pageCount; },
|
||||
});
|
||||
|
||||
function handlePageChange(newPage: number) {
|
||||
const url = new URL($page.url);
|
||||
url.searchParams.set('page', newPage.toString());
|
||||
goto(url, { keepFocus: true, noScroll: true });
|
||||
}
|
||||
|
||||
const currentPage = $derived(Number($page.url.searchParams.get('page') || 1));
|
||||
</script>
|
||||
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
|
||||
<Table.Row>
|
||||
{#each headerGroup.headers as header (header.id)}
|
||||
<Table.Head>
|
||||
{#if !header.isPlaceholder}
|
||||
<FlexRender
|
||||
content={header.column.columnDef.header}
|
||||
context={header.getContext()}
|
||||
/>
|
||||
{/if}
|
||||
</Table.Head>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each table.getRowModel().rows as row (row.id)}
|
||||
<Table.Row data-state={row.getIsSelected() && "selected"}>
|
||||
{#each row.getVisibleCells() as cell (cell.id)}
|
||||
<Table.Cell>
|
||||
<FlexRender
|
||||
content={cell.column.columnDef.cell}
|
||||
context={cell.getContext()}
|
||||
/>
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={columns.length} class="h-24 text-center">
|
||||
No hay resultados.
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end space-x-2 py-4 px-2">
|
||||
<div class="flex-1 text-sm text-muted-foreground">
|
||||
Total: {totalItems} registros
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage >= pageCount}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ColumnDef } from '@tanstack/table-core';
|
||||
import type { UnitOfMeasureGeneral } from '$lib/api/dashboard/a76/general_catalogs/units-of-measure';
|
||||
import { renderComponent } from '$lib/components/ui/data-table';
|
||||
import DataTableActions from './data-table-actions.svelte';
|
||||
|
||||
export function createColumns(onSuccess?: () => void): ColumnDef<UnitOfMeasureGeneral>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'code',
|
||||
header: 'Código',
|
||||
cell: (info) => info.getValue()
|
||||
},
|
||||
{
|
||||
accessorKey: 'description',
|
||||
header: 'Descripción',
|
||||
cell: (info) => info.getValue() || '-'
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Acciones',
|
||||
cell: ({ row }) => {
|
||||
return renderComponent(DataTableActions, {
|
||||
unit: row.original,
|
||||
onSuccess
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import type {
|
||||
UnitOfMeasureGeneral,
|
||||
UnitOfMeasureGeneralCreate,
|
||||
UnitOfMeasureGeneralUpdate
|
||||
} from '$lib/api/dashboard/a76/general_catalogs/units-of-measure';
|
||||
import {
|
||||
createUnitOfMeasureGeneral,
|
||||
updateUnitOfMeasureGeneral
|
||||
} from '$lib/api/dashboard/a76/general_catalogs/units-of-measure';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
unit?: UnitOfMeasureGeneral;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(), unit, onSuccess }: Props = $props();
|
||||
|
||||
let formData = $state({
|
||||
code: '',
|
||||
description: ''
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
if (unit) {
|
||||
formData = {
|
||||
code: unit.code,
|
||||
description: unit.description || ''
|
||||
};
|
||||
} else {
|
||||
formData = { code: '', description: '' };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
|
||||
const activeCompanyId = companyStore.activeCompany?.id;
|
||||
if (!activeCompanyId) {
|
||||
alert('No hay una compañía activa seleccionada');
|
||||
return;
|
||||
}
|
||||
|
||||
const data: UnitOfMeasureGeneralCreate | UnitOfMeasureGeneralUpdate = {
|
||||
code: formData.code,
|
||||
description: formData.description || null
|
||||
};
|
||||
|
||||
const response = unit
|
||||
? await updateUnitOfMeasureGeneral(unit.id, data, activeCompanyId)
|
||||
: await createUnitOfMeasureGeneral(data, activeCompanyId);
|
||||
|
||||
if (response.error) {
|
||||
alert(response.error.detail || 'Error al guardar');
|
||||
} else {
|
||||
alert(unit ? 'Unidad actualizada' : 'Unidad creada');
|
||||
open = false;
|
||||
onSuccess?.();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="sm:max-w-[425px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{unit ? 'Editar' : 'Crear'} Unidad General</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<form onsubmit={handleSubmit} class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="code">Código *</Label>
|
||||
<Input id="code" bind:value={formData.code} required />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="description">Descripción</Label>
|
||||
<Input id="description" bind:value={formData.description} />
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="outline" onclick={() => (open = false)}>Cancelar</Button>
|
||||
<Button type="submit">Guardar</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { MoreHorizontal, Pencil, Trash2 } from 'lucide-svelte';
|
||||
import CreateEditDialog from './create-edit-dialog.svelte';
|
||||
import type { UnitOfMeasureGeneral } from '$lib/api/dashboard/a76/general_catalogs/units-of-measure';
|
||||
import { deleteUnitOfMeasureGeneral } from '$lib/api/dashboard/a76/general_catalogs/units-of-measure';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
|
||||
interface Props {
|
||||
unit: UnitOfMeasureGeneral;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
let { unit, onSuccess }: Props = $props();
|
||||
let dialogOpen = $state(false);
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm('¿Estás seguro de eliminar esta unidad?')) return;
|
||||
|
||||
const activeCompanyId = companyStore.activeCompany?.id;
|
||||
if (!activeCompanyId) {
|
||||
alert('No hay una compañía activa seleccionada');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await deleteUnitOfMeasureGeneral(unit.id, activeCompanyId);
|
||||
if (response.error) {
|
||||
alert(response.error.detail || 'Error al eliminar');
|
||||
} else if (response.status === 204 || response.status === 200) {
|
||||
alert('Unidad eliminada');
|
||||
onSuccess?.();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="h-8 w-8 p-0">
|
||||
<span class="sr-only">Abrir menú</span>
|
||||
<MoreHorizontal class="h-4 w-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
|
||||
<DropdownMenu.Item onclick={() => (dialogOpen = true)}>
|
||||
<Pencil class="mr-2 h-4 w-4" />
|
||||
Editar
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onclick={handleDelete}>
|
||||
<Trash2 class="mr-2 h-4 w-4" />
|
||||
Eliminar
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<CreateEditDialog bind:open={dialogOpen} {unit} {onSuccess} />
|
||||
@@ -0,0 +1,107 @@
|
||||
<script lang="ts" generics="TData, TValue">
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
} from "@tanstack/table-core";
|
||||
import { createSvelteTable, FlexRender } from "$lib/components/ui/data-table/index.js";
|
||||
import * as Table from "$lib/components/ui/table/index.js";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
|
||||
type DataTableProps<TData, TValue> = {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
pageCount: number;
|
||||
totalItems: number;
|
||||
};
|
||||
|
||||
let {
|
||||
data,
|
||||
columns,
|
||||
pageCount,
|
||||
totalItems
|
||||
}: DataTableProps<TData, TValue> = $props();
|
||||
|
||||
const table = createSvelteTable({
|
||||
get data() { return data; },
|
||||
get columns() { return columns; },
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
get pageCount() { return pageCount; },
|
||||
});
|
||||
|
||||
function handlePageChange(newPage: number) {
|
||||
const url = new URL($page.url);
|
||||
url.searchParams.set('page', newPage.toString());
|
||||
goto(url, { keepFocus: true, noScroll: true });
|
||||
}
|
||||
|
||||
const currentPage = $derived(Number($page.url.searchParams.get('page') || 1));
|
||||
</script>
|
||||
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
|
||||
<Table.Row>
|
||||
{#each headerGroup.headers as header (header.id)}
|
||||
<Table.Head>
|
||||
{#if !header.isPlaceholder}
|
||||
<FlexRender
|
||||
content={header.column.columnDef.header}
|
||||
context={header.getContext()}
|
||||
/>
|
||||
{/if}
|
||||
</Table.Head>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each table.getRowModel().rows as row (row.id)}
|
||||
<Table.Row data-state={row.getIsSelected() && "selected"}>
|
||||
{#each row.getVisibleCells() as cell (cell.id)}
|
||||
<Table.Cell>
|
||||
<FlexRender
|
||||
content={cell.column.columnDef.cell}
|
||||
context={cell.getContext()}
|
||||
/>
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={columns.length} class="h-24 text-center">
|
||||
No hay resultados.
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex items-center justify-end space-x-2 py-4 px-2">
|
||||
<div class="flex-1 text-sm text-muted-foreground">
|
||||
Total: {totalItems} registros
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage >= pageCount}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { ColumnDef } from "@tanstack/table-core";
|
||||
import type { UnitOfMeasureOMA } from "$lib/api/dashboard/a76/general_catalogs/units-of-measure";
|
||||
import { renderComponent } from "$lib/components/ui/data-table/index.js";
|
||||
import DataTableActions from "./data-table-actions.svelte";
|
||||
|
||||
export function createColumns(onSuccess?: () => void): ColumnDef<UnitOfMeasureOMA>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: "code",
|
||||
header: "Código",
|
||||
},
|
||||
{
|
||||
accessorKey: "description",
|
||||
header: "Descripción",
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) =>
|
||||
renderComponent(DataTableActions, {
|
||||
unit: row.original,
|
||||
onSuccess
|
||||
}),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import type {
|
||||
UnitOfMeasureOMA,
|
||||
UnitOfMeasureOMACreate,
|
||||
UnitOfMeasureOMAUpdate
|
||||
} from '$lib/api/dashboard/a76/general_catalogs/units-of-measure';
|
||||
import {
|
||||
createUnitOfMeasureOMA,
|
||||
updateUnitOfMeasureOMA
|
||||
} from '$lib/api/dashboard/a76/general_catalogs/units-of-measure';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
unit?: UnitOfMeasureOMA;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(), unit, onSuccess }: Props = $props();
|
||||
|
||||
let formData = $state({
|
||||
code: '',
|
||||
description: ''
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
if (unit) {
|
||||
formData = {
|
||||
code: unit.code,
|
||||
description: unit.description || ''
|
||||
};
|
||||
} else {
|
||||
formData = { code: '', description: '' };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
|
||||
const activeCompanyId = companyStore.activeCompany?.id;
|
||||
if (!activeCompanyId) {
|
||||
alert('No hay una compañía activa seleccionada');
|
||||
return;
|
||||
}
|
||||
|
||||
const data: UnitOfMeasureOMACreate | UnitOfMeasureOMAUpdate = {
|
||||
code: formData.code,
|
||||
description: formData.description || null
|
||||
};
|
||||
|
||||
const response = unit
|
||||
? await updateUnitOfMeasureOMA(unit.id, data, activeCompanyId)
|
||||
: await createUnitOfMeasureOMA(data, activeCompanyId);
|
||||
|
||||
if (response.error) {
|
||||
alert(response.error.detail || 'Error al guardar');
|
||||
} else {
|
||||
alert(unit ? 'Unidad actualizada' : 'Unidad creada');
|
||||
open = false;
|
||||
onSuccess?.();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="sm:max-w-[425px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{unit ? 'Editar' : 'Crear'} Unidad OMA</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<form onsubmit={handleSubmit} class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="code">Código * (máx. 10 caracteres)</Label>
|
||||
<Input id="code" bind:value={formData.code} required maxlength="10" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="description">Descripción (máx. 200 caracteres)</Label>
|
||||
<Input id="description" bind:value={formData.description} maxlength="200" />
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="outline" onclick={() => (open = false)}>Cancelar</Button>
|
||||
<Button type="submit">Guardar</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { MoreHorizontal, Pencil, Trash2 } from 'lucide-svelte';
|
||||
import CreateEditDialog from './create-edit-dialog.svelte';
|
||||
import type { UnitOfMeasureOMA } from '$lib/api/dashboard/a76/general_catalogs/units-of-measure';
|
||||
import { deleteUnitOfMeasureOMA } from '$lib/api/dashboard/a76/general_catalogs/units-of-measure';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
|
||||
interface Props {
|
||||
unit: UnitOfMeasureOMA;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
let { unit, onSuccess }: Props = $props();
|
||||
let dialogOpen = $state(false);
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm('¿Estás seguro de eliminar esta unidad?')) return;
|
||||
|
||||
const activeCompanyId = companyStore.activeCompany?.id;
|
||||
if (!activeCompanyId) {
|
||||
alert('No hay una compañía activa seleccionada');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await deleteUnitOfMeasureOMA(unit.id, activeCompanyId);
|
||||
if (response.error) {
|
||||
alert(response.error.detail || 'Error al eliminar');
|
||||
} else if (response.status === 204 || response.status === 200) {
|
||||
alert('Unidad eliminada');
|
||||
onSuccess?.();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="h-8 w-8 p-0">
|
||||
<span class="sr-only">Abrir menú</span>
|
||||
<MoreHorizontal class="h-4 w-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
|
||||
<DropdownMenu.Item onclick={() => (dialogOpen = true)}>
|
||||
<Pencil class="mr-2 h-4 w-4" />
|
||||
Editar
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onclick={handleDelete}>
|
||||
<Trash2 class="mr-2 h-4 w-4" />
|
||||
Eliminar
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<CreateEditDialog bind:open={dialogOpen} {unit} {onSuccess} />
|
||||
@@ -0,0 +1,106 @@
|
||||
<script lang="ts" generics="TData, TValue">
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
} from "@tanstack/table-core";
|
||||
import { createSvelteTable, FlexRender } from "$lib/components/ui/data-table/index.js";
|
||||
import * as Table from "$lib/components/ui/table/index.js";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
|
||||
type DataTableProps<TData, TValue> = {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
pageCount: number;
|
||||
totalItems: number;
|
||||
};
|
||||
|
||||
let {
|
||||
data,
|
||||
columns,
|
||||
pageCount,
|
||||
totalItems
|
||||
}: DataTableProps<TData, TValue> = $props();
|
||||
|
||||
const table = createSvelteTable({
|
||||
get data() { return data; },
|
||||
get columns() { return columns; },
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
get pageCount() { return pageCount; },
|
||||
});
|
||||
|
||||
function handlePageChange(newPage: number) {
|
||||
const url = new URL($page.url);
|
||||
url.searchParams.set('page', newPage.toString());
|
||||
goto(url, { keepFocus: true, noScroll: true });
|
||||
}
|
||||
|
||||
const currentPage = $derived(Number($page.url.searchParams.get('page') || 1));
|
||||
</script>
|
||||
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
|
||||
<Table.Row>
|
||||
{#each headerGroup.headers as header (header.id)}
|
||||
<Table.Head>
|
||||
{#if !header.isPlaceholder}
|
||||
<FlexRender
|
||||
content={header.column.columnDef.header}
|
||||
context={header.getContext()}
|
||||
/>
|
||||
{/if}
|
||||
</Table.Head>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each table.getRowModel().rows as row (row.id)}
|
||||
<Table.Row data-state={row.getIsSelected() && "selected"}>
|
||||
{#each row.getVisibleCells() as cell (cell.id)}
|
||||
<Table.Cell>
|
||||
<FlexRender
|
||||
content={cell.column.columnDef.cell}
|
||||
context={cell.getContext()}
|
||||
/>
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={columns.length} class="h-24 text-center">
|
||||
No hay resultados.
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end space-x-2 py-4 px-2">
|
||||
<div class="flex-1 text-sm text-muted-foreground">
|
||||
Total: {totalItems} registros
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage >= pageCount}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,139 +1,178 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { Textarea } from "$lib/components/ui/textarea";
|
||||
import { createIdentifier, updateIdentifier, type Identifier } from "$lib/api/dashboard/a76/general_catalogs/identifiers";
|
||||
// 👇 Importar tipos correctos
|
||||
import {
|
||||
createIdentifier,
|
||||
updateIdentifier,
|
||||
type Identifier
|
||||
} from "$lib/api/dashboard/a76/general_catalogs/identifiers";
|
||||
import { companyStore } from "$lib/stores/company.svelte";
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
mode = 'create',
|
||||
item = null,
|
||||
onSuccess
|
||||
}: {
|
||||
open: boolean;
|
||||
mode?: 'create' | 'edit';
|
||||
item?: Identifier | null;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
let {
|
||||
open = $bindable(false),
|
||||
item = null,
|
||||
onSuccess
|
||||
}: {
|
||||
open: boolean;
|
||||
item?: Identifier | null;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
const isEdit = $derived(mode === 'edit');
|
||||
const title = $derived(isEdit ? "Editar Identificador" : "Nuevo Identificador");
|
||||
// Calculamos si es edición basado en si hay item
|
||||
const isEdit = $derived(!!item);
|
||||
const title = $derived(isEdit ? "Editar Identificador" : "Nuevo Identificador");
|
||||
|
||||
let formData = $state({
|
||||
code: '',
|
||||
description: '',
|
||||
let formData = $state({
|
||||
code: '',
|
||||
description: '',
|
||||
level: '',
|
||||
complement: ''
|
||||
});
|
||||
});
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
if (isEdit && item) {
|
||||
formData = {
|
||||
code: item.code,
|
||||
description: item.description || '',
|
||||
// Efecto para cargar o limpiar datos
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
if (item) {
|
||||
formData = {
|
||||
code: item.code,
|
||||
description: item.description || '',
|
||||
level: item.level || '',
|
||||
complement: item.complement || ''
|
||||
};
|
||||
} else {
|
||||
formData = {
|
||||
code: '',
|
||||
description: '',
|
||||
};
|
||||
} else {
|
||||
formData = {
|
||||
code: '',
|
||||
description: '',
|
||||
level: '',
|
||||
complement: ''
|
||||
};
|
||||
}
|
||||
error = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) {
|
||||
error = 'No hay compañía seleccionada';
|
||||
return;
|
||||
};
|
||||
}
|
||||
error = null;
|
||||
}
|
||||
});
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
async function handleSubmit() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (isEdit && item) {
|
||||
response = await updateIdentifier(item.id, {
|
||||
code: formData.code,
|
||||
description: formData.description || null,
|
||||
level: formData.level || null,
|
||||
complement: formData.complement || null
|
||||
});
|
||||
} else {
|
||||
response = await createIdentifier({
|
||||
code: formData.code,
|
||||
description: formData.description || null,
|
||||
level: formData.level || null,
|
||||
complement: formData.complement || null,
|
||||
company_id: companyId
|
||||
});
|
||||
}
|
||||
try {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) throw new Error('No hay una compañía seleccionada');
|
||||
|
||||
if (response.error) {
|
||||
error = response.error;
|
||||
return;
|
||||
}
|
||||
// Validaciones básicas
|
||||
if (!formData.code.trim()) throw new Error('La clave es requerida');
|
||||
|
||||
open = false;
|
||||
if (onSuccess) onSuccess();
|
||||
} catch (e) {
|
||||
error = 'Error de conexión';
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
// Preparar payload (SIN company_id adentro)
|
||||
const dataToSend = {
|
||||
code: formData.code.trim(),
|
||||
description: formData.description.trim() || null,
|
||||
level: formData.level.trim() || null,
|
||||
complement: formData.complement.trim() || null
|
||||
};
|
||||
|
||||
let response;
|
||||
// 👇 AQUI ESTA EL CAMBIO IMPORTANTE: companyId va por fuera
|
||||
if (isEdit && item) {
|
||||
response = await updateIdentifier(item.id, dataToSend, companyId);
|
||||
} else {
|
||||
response = await createIdentifier(dataToSend, companyId);
|
||||
}
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
|
||||
open = false;
|
||||
if (onSuccess) onSuccess();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Error al guardar';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="sm:max-w-[425px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{title}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="grid gap-4 py-4">
|
||||
{#if error}
|
||||
<div class="text-red-500 text-sm mb-2">{error}</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="code" class="text-right">Clave</Label>
|
||||
<Input id="code" bind:value={formData.code} class="col-span-3" disabled={loading} />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="description" class="text-right">Descripción</Label>
|
||||
<Textarea id="description" bind:value={formData.description} class="col-span-3" disabled={loading} />
|
||||
</div>
|
||||
<Dialog.Content class="sm:max-w-[500px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{title}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-4 py-4">
|
||||
{#if error}
|
||||
<div class="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-4">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="code" class="text-right">Clave <span class="text-destructive">*</span></Label>
|
||||
<div class="col-span-3">
|
||||
<Input
|
||||
id="code"
|
||||
bind:value={formData.code}
|
||||
disabled={loading || isEdit}
|
||||
placeholder="Ej: CI"
|
||||
maxlength={2}
|
||||
required
|
||||
/>
|
||||
<p class="text-[10px] text-muted-foreground mt-1">Máximo 2 caracteres.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="description" class="text-right">Descripción</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
bind:value={formData.description}
|
||||
class="col-span-3"
|
||||
disabled={loading}
|
||||
maxlength={1000}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="level" class="text-right">Nivel</Label>
|
||||
<Input id="level" bind:value={formData.level} class="col-span-3" disabled={loading} />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="level" class="text-right">Nivel</Label>
|
||||
<div class="col-span-3">
|
||||
<Input
|
||||
id="level"
|
||||
bind:value={formData.level}
|
||||
disabled={loading}
|
||||
placeholder="Ej: G"
|
||||
maxlength={1}
|
||||
/>
|
||||
<p class="text-[10px] text-muted-foreground mt-1">Máximo 1 caracter (G, S, etc).</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="complement" class="text-right">Complemento</Label>
|
||||
<Textarea id="complement" bind:value={formData.complement} class="col-span-3" disabled={loading} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="complement" class="text-right">Complemento</Label>
|
||||
<Textarea
|
||||
id="complement"
|
||||
bind:value={formData.complement}
|
||||
class="col-span-3"
|
||||
disabled={loading}
|
||||
maxlength={5000}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button type="submit" onclick={handleSubmit} disabled={loading}>
|
||||
{loading ? 'Guardando...' : 'Guardar'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="outline" onclick={() => open = false} disabled={loading}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Guardando...' : isEdit ? 'Actualizar' : 'Crear'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
89
frontend/src/lib/components/dashboard/invoices/columns.ts
Normal file
89
frontend/src/lib/components/dashboard/invoices/columns.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Definición de columnas para la tabla de facturas
|
||||
*/
|
||||
import type { Invoice } from '$lib/api/dashboard/a76/invoices';
|
||||
import DataTableActions from './data-table-actions.svelte';
|
||||
|
||||
export function createColumns() {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'id',
|
||||
header: 'ID',
|
||||
cell: (info: any) => info.getValue(),
|
||||
enableSorting: true
|
||||
},
|
||||
{
|
||||
accessorKey: 'operation_type',
|
||||
header: 'Tipo',
|
||||
cell: (info: any) => {
|
||||
const type = info.getValue();
|
||||
return type === 'imp' ? 'Importación' : type === 'exp' ? 'Exportación' : '-';
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'invoice_number',
|
||||
header: 'Número de Factura',
|
||||
cell: (info: any) => info.getValue() || '-'
|
||||
},
|
||||
{
|
||||
accessorKey: 'invoice_type',
|
||||
header: 'Tipo Factura',
|
||||
cell: (info: any) => info.getValue() || '-'
|
||||
},
|
||||
{
|
||||
accessorKey: 'project_number',
|
||||
header: 'Proyecto',
|
||||
cell: (info: any) => info.getValue() || '-'
|
||||
},
|
||||
{
|
||||
accessorKey: 'compliance_mx.pedimento',
|
||||
header: 'Pedimento',
|
||||
cell: (info: any) => {
|
||||
const row = info.row.original;
|
||||
return row.compliance_mx?.pedimento || '-';
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'invoice_date',
|
||||
header: 'Fecha Factura',
|
||||
cell: (info: any) => {
|
||||
const date = info.getValue();
|
||||
if (!date) return '-';
|
||||
return new Date(date).toLocaleDateString('es-MX');
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'financials.value_mn',
|
||||
header: 'Valor MN',
|
||||
cell: (info: any) => {
|
||||
const row = info.row.original;
|
||||
const value = row.financials?.value_mn;
|
||||
if (value === null || value === undefined) return '-';
|
||||
return new Intl.NumberFormat('es-MX', {
|
||||
style: 'currency',
|
||||
currency: 'MXN'
|
||||
}).format(value);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'traffic_light_status',
|
||||
header: 'Semáforo',
|
||||
cell: (info: any) => info.getValue() || '-'
|
||||
},
|
||||
{
|
||||
accessorKey: 'capture_date',
|
||||
header: 'Fecha Captura',
|
||||
cell: (info: any) => {
|
||||
const date = info.getValue();
|
||||
if (!date) return '-';
|
||||
return new Date(date).toLocaleDateString('es-MX');
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Acciones',
|
||||
cell: (info: any) => DataTableActions,
|
||||
enableSorting: false
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,681 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import * as Select from "$lib/components/ui/select";
|
||||
import { invoicesApi, type Invoice, type CreateInvoiceData, type UpdateInvoiceData } from "$lib/api/dashboard/a76/invoices";
|
||||
import { companyStore } from "$lib/stores/company.svelte";
|
||||
import { LoaderCircle } from 'lucide-svelte';
|
||||
import * as Tabs from "$lib/components/ui/tabs";
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
item = $bindable<Invoice | null>(null),
|
||||
defaultOperationType,
|
||||
defaultInvoiceType,
|
||||
onSuccess
|
||||
}: {
|
||||
open: boolean;
|
||||
item?: Invoice | null;
|
||||
defaultOperationType?: 'imp' | 'exp';
|
||||
defaultInvoiceType?: string;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
let formData = $state({
|
||||
// Header fields
|
||||
operation_type: "imp" as "imp" | "exp",
|
||||
invoice_type: "",
|
||||
invoice_number: "",
|
||||
project_number: "",
|
||||
purchase_order: "",
|
||||
related_doc_id: null as number | null,
|
||||
invoice_date: "",
|
||||
traffic_light_status: "",
|
||||
observation_es: "",
|
||||
observation_en: "",
|
||||
comments_status: "",
|
||||
cfdi_uuid: "",
|
||||
path_pdf: "",
|
||||
path_xml: "",
|
||||
// Compliance MX fields
|
||||
pedimento: "",
|
||||
pedimento_code: "",
|
||||
remesa: null as number | null,
|
||||
aduana: "",
|
||||
customs_broker_id: "",
|
||||
provider_id: "",
|
||||
sold_to_id: "",
|
||||
shipped_to_id: "",
|
||||
shipped_by_id: "",
|
||||
is_mixed: false,
|
||||
waste_type: "",
|
||||
appendix_17: null as number | null,
|
||||
edocument: "",
|
||||
// Financials fields
|
||||
currency: "MXN",
|
||||
exchange_rate: null as number | null,
|
||||
value_mn: null as number | null,
|
||||
value_me: null as number | null,
|
||||
customs_value_mn: null as number | null,
|
||||
freight: null as number | null,
|
||||
insurance: null as number | null,
|
||||
iva_mn: null as number | null,
|
||||
iva_factor: null as number | null,
|
||||
total_quantity: null as number | null,
|
||||
gross_weight: null as number | null,
|
||||
net_weight: null as number | null,
|
||||
bundle_count: null as number | null
|
||||
});
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Actualizar formData cuando item cambia
|
||||
$effect(() => {
|
||||
if (item) {
|
||||
formData = {
|
||||
operation_type: item.operation_type || "imp",
|
||||
invoice_type: item.invoice_type || "",
|
||||
invoice_number: item.invoice_number || "",
|
||||
project_number: item.project_number || "",
|
||||
purchase_order: item.purchase_order || "",
|
||||
related_doc_id: item.related_doc_id || null,
|
||||
invoice_date: item.invoice_date || "",
|
||||
traffic_light_status: item.traffic_light_status || "",
|
||||
observation_es: item.observation_es || "",
|
||||
observation_en: item.observation_en || "",
|
||||
comments_status: item.comments_status || "",
|
||||
cfdi_uuid: item.cfdi_uuid || "",
|
||||
path_pdf: item.path_pdf || "",
|
||||
path_xml: item.path_xml || "",
|
||||
pedimento: item.compliance_mx?.pedimento || "",
|
||||
pedimento_code: item.compliance_mx?.pedimento_code || "",
|
||||
remesa: item.compliance_mx?.remesa || null,
|
||||
aduana: item.compliance_mx?.aduana || "",
|
||||
customs_broker_id: item.compliance_mx?.customs_broker_id || "",
|
||||
provider_id: item.compliance_mx?.provider_id || "",
|
||||
sold_to_id: item.compliance_mx?.sold_to_id || "",
|
||||
shipped_to_id: item.compliance_mx?.shipped_to_id || "",
|
||||
shipped_by_id: item.compliance_mx?.shipped_by_id || "",
|
||||
is_mixed: item.compliance_mx?.is_mixed || false,
|
||||
waste_type: item.compliance_mx?.waste_type || "",
|
||||
appendix_17: item.compliance_mx?.appendix_17 || null,
|
||||
edocument: item.compliance_mx?.edocument || "",
|
||||
currency: item.financials?.currency || "MXN",
|
||||
exchange_rate: item.financials?.exchange_rate || null,
|
||||
value_mn: item.financials?.value_mn || null,
|
||||
value_me: item.financials?.value_me || null,
|
||||
customs_value_mn: item.financials?.customs_value_mn || null,
|
||||
freight: item.financials?.freight || null,
|
||||
insurance: item.financials?.insurance || null,
|
||||
iva_mn: item.financials?.iva_mn || null,
|
||||
iva_factor: item.financials?.iva_factor || null,
|
||||
total_quantity: item.financials?.total_quantity || null,
|
||||
gross_weight: item.financials?.gross_weight || null,
|
||||
net_weight: item.financials?.net_weight || null,
|
||||
bundle_count: item.financials?.bundle_count || null
|
||||
};
|
||||
} else {
|
||||
resetForm();
|
||||
}
|
||||
});
|
||||
|
||||
const isEditing = $derived(!!item);
|
||||
|
||||
function resetForm() {
|
||||
formData = {
|
||||
operation_type: defaultOperationType || "imp",
|
||||
invoice_type: defaultInvoiceType || "",
|
||||
invoice_number: "",
|
||||
project_number: "",
|
||||
purchase_order: "",
|
||||
related_doc_id: null,
|
||||
invoice_date: "",
|
||||
traffic_light_status: "",
|
||||
observation_es: "",
|
||||
observation_en: "",
|
||||
comments_status: "",
|
||||
cfdi_uuid: "",
|
||||
path_pdf: "",
|
||||
path_xml: "",
|
||||
pedimento: "",
|
||||
pedimento_code: "",
|
||||
remesa: null,
|
||||
aduana: "",
|
||||
customs_broker_id: "",
|
||||
provider_id: "",
|
||||
sold_to_id: "",
|
||||
shipped_to_id: "",
|
||||
shipped_by_id: "",
|
||||
is_mixed: false,
|
||||
waste_type: "",
|
||||
appendix_17: null,
|
||||
edocument: "",
|
||||
currency: "MXN",
|
||||
exchange_rate: null,
|
||||
value_mn: null,
|
||||
value_me: null,
|
||||
customs_value_mn: null,
|
||||
freight: null,
|
||||
insurance: null,
|
||||
iva_mn: null,
|
||||
iva_factor: null,
|
||||
total_quantity: null,
|
||||
gross_weight: null,
|
||||
net_weight: null,
|
||||
bundle_count: null
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!companyStore.activeCompany) {
|
||||
error = "No hay compañía seleccionada";
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (isEditing && item) {
|
||||
const payload: UpdateInvoiceData = {
|
||||
operation_type: formData.operation_type,
|
||||
invoice_type: formData.invoice_type || null,
|
||||
invoice_number: formData.invoice_number || null,
|
||||
project_number: formData.project_number || null,
|
||||
purchase_order: formData.purchase_order || null,
|
||||
related_doc_id: formData.related_doc_id,
|
||||
invoice_date: formData.invoice_date || null,
|
||||
traffic_light_status: formData.traffic_light_status || null,
|
||||
observation_es: formData.observation_es || null,
|
||||
observation_en: formData.observation_en || null,
|
||||
comments_status: formData.comments_status || null,
|
||||
cfdi_uuid: formData.cfdi_uuid || null,
|
||||
path_pdf: formData.path_pdf || null,
|
||||
path_xml: formData.path_xml || null,
|
||||
compliance_mx: {
|
||||
pedimento: formData.pedimento || null,
|
||||
pedimento_code: formData.pedimento_code || null,
|
||||
remesa: formData.remesa,
|
||||
aduana: formData.aduana || null,
|
||||
customs_broker_id: formData.customs_broker_id || null,
|
||||
provider_id: formData.provider_id || null,
|
||||
sold_to_id: formData.sold_to_id || null,
|
||||
shipped_to_id: formData.shipped_to_id || null,
|
||||
shipped_by_id: formData.shipped_by_id || null,
|
||||
is_mixed: formData.is_mixed,
|
||||
waste_type: formData.waste_type || null,
|
||||
appendix_17: formData.appendix_17,
|
||||
edocument: formData.edocument || null
|
||||
},
|
||||
financials: {
|
||||
currency: formData.currency || null,
|
||||
exchange_rate: formData.exchange_rate,
|
||||
value_mn: formData.value_mn,
|
||||
value_me: formData.value_me,
|
||||
customs_value_mn: formData.customs_value_mn,
|
||||
freight: formData.freight,
|
||||
insurance: formData.insurance,
|
||||
iva_mn: formData.iva_mn,
|
||||
iva_factor: formData.iva_factor,
|
||||
total_quantity: formData.total_quantity,
|
||||
gross_weight: formData.gross_weight,
|
||||
net_weight: formData.net_weight,
|
||||
bundle_count: formData.bundle_count
|
||||
}
|
||||
};
|
||||
response = await invoicesApi.update(item.id, companyStore.activeCompany.id, payload);
|
||||
} else {
|
||||
const payload: CreateInvoiceData = {
|
||||
operation_type: formData.operation_type,
|
||||
invoice_type: formData.invoice_type || null,
|
||||
invoice_number: formData.invoice_number || null,
|
||||
project_number: formData.project_number || null,
|
||||
purchase_order: formData.purchase_order || null,
|
||||
related_doc_id: formData.related_doc_id,
|
||||
invoice_date: formData.invoice_date || null,
|
||||
traffic_light_status: formData.traffic_light_status || null,
|
||||
observation_es: formData.observation_es || null,
|
||||
observation_en: formData.observation_en || null,
|
||||
comments_status: formData.comments_status || null,
|
||||
cfdi_uuid: formData.cfdi_uuid || null,
|
||||
path_pdf: formData.path_pdf || null,
|
||||
path_xml: formData.path_xml || null,
|
||||
compliance_mx: {
|
||||
pedimento: formData.pedimento || null,
|
||||
pedimento_code: formData.pedimento_code || null,
|
||||
remesa: formData.remesa,
|
||||
aduana: formData.aduana || null,
|
||||
customs_broker_id: formData.customs_broker_id || null,
|
||||
provider_id: formData.provider_id || null,
|
||||
sold_to_id: formData.sold_to_id || null,
|
||||
shipped_to_id: formData.shipped_to_id || null,
|
||||
shipped_by_id: formData.shipped_by_id || null,
|
||||
is_mixed: formData.is_mixed,
|
||||
waste_type: formData.waste_type || null,
|
||||
appendix_17: formData.appendix_17,
|
||||
edocument: formData.edocument || null
|
||||
},
|
||||
financials: {
|
||||
currency: formData.currency || null,
|
||||
exchange_rate: formData.exchange_rate,
|
||||
value_mn: formData.value_mn,
|
||||
value_me: formData.value_me,
|
||||
customs_value_mn: formData.customs_value_mn,
|
||||
freight: formData.freight,
|
||||
insurance: formData.insurance,
|
||||
iva_mn: formData.iva_mn,
|
||||
iva_factor: formData.iva_factor,
|
||||
total_quantity: formData.total_quantity,
|
||||
gross_weight: formData.gross_weight,
|
||||
net_weight: formData.net_weight,
|
||||
bundle_count: formData.bundle_count
|
||||
}
|
||||
};
|
||||
response = await invoicesApi.create(companyStore.activeCompany.id, payload);
|
||||
}
|
||||
|
||||
if (response.error) {
|
||||
if (response.status === 401) {
|
||||
error = 'Sesión expirada. Recargando página...';
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
} else {
|
||||
error = response.error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Éxito
|
||||
open = false;
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : "Error al guardar";
|
||||
console.error("Error saving:", e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenChange(newOpen: boolean) {
|
||||
if (!newOpen) {
|
||||
resetForm();
|
||||
error = null;
|
||||
}
|
||||
open = newOpen;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root {open} onOpenChange={handleOpenChange}>
|
||||
<Dialog.Content class="sm:max-w-full max-h-[90vh] overflow-y-auto">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>
|
||||
{isEditing ? "Editar Factura" : "Nueva Factura"}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{isEditing
|
||||
? "Modifica los datos de la factura"
|
||||
: "Ingresa los datos de la nueva factura"}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<form onsubmit={handleSubmit} class="space-y-4">
|
||||
<Tabs.Root value="general" class="w-full">
|
||||
<Tabs.List class="grid w-full grid-cols-3">
|
||||
<Tabs.Trigger value="general">General</Tabs.Trigger>
|
||||
<Tabs.Trigger value="compliance">Cumplimiento</Tabs.Trigger>
|
||||
<Tabs.Trigger value="financials">Financieros</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<!-- General Tab -->
|
||||
<Tabs.Content value="general" class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="operation_type">Tipo de Operación *</Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={formData.operation_type}
|
||||
onValueChange={(v: string) => {
|
||||
if (v) formData.operation_type = v as "imp" | "exp";
|
||||
}}
|
||||
>
|
||||
<Select.Trigger>
|
||||
{formData.operation_type === 'imp' ? 'Importación' : formData.operation_type === 'exp' ? 'Exportación' : 'Seleccionar tipo'}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="imp">Importación</Select.Item>
|
||||
<Select.Item value="exp">Exportación</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div> <div class="space-y-2">
|
||||
<Label for="invoice_number">Número de Factura</Label>
|
||||
<Input
|
||||
id="invoice_number"
|
||||
bind:value={formData.invoice_number}
|
||||
placeholder="Número de factura"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="invoice_type">Tipo de Factura</Label>
|
||||
<Input
|
||||
id="invoice_type"
|
||||
bind:value={formData.invoice_type}
|
||||
placeholder="Tipo de factura"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="project_number">Número de Proyecto</Label>
|
||||
<Input
|
||||
id="project_number"
|
||||
bind:value={formData.project_number}
|
||||
placeholder="Número de proyecto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="purchase_order">Orden de Compra</Label>
|
||||
<Input
|
||||
id="purchase_order"
|
||||
bind:value={formData.purchase_order}
|
||||
placeholder="Orden de compra"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="invoice_date">Fecha de Factura</Label>
|
||||
<Input
|
||||
id="invoice_date"
|
||||
type="date"
|
||||
bind:value={formData.invoice_date}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="traffic_light_status">Semáforo</Label>
|
||||
<Input
|
||||
id="traffic_light_status"
|
||||
bind:value={formData.traffic_light_status}
|
||||
placeholder="Estado del semáforo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="cfdi_uuid">CFDI UUID</Label>
|
||||
<Input
|
||||
id="cfdi_uuid"
|
||||
bind:value={formData.cfdi_uuid}
|
||||
placeholder="UUID del CFDI"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="observation_es">Observaciones (Español)</Label>
|
||||
<Input
|
||||
id="observation_es"
|
||||
bind:value={formData.observation_es}
|
||||
placeholder="Observaciones en español"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="observation_en">Observaciones (Inglés)</Label>
|
||||
<Input
|
||||
id="observation_en"
|
||||
bind:value={formData.observation_en}
|
||||
placeholder="Observaciones en inglés"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- Compliance Tab -->
|
||||
<Tabs.Content value="compliance" class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="pedimento">Pedimento</Label>
|
||||
<Input
|
||||
id="pedimento"
|
||||
bind:value={formData.pedimento}
|
||||
placeholder="Número de pedimento"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="pedimento_code">Código de Pedimento</Label>
|
||||
<Input
|
||||
id="pedimento_code"
|
||||
bind:value={formData.pedimento_code}
|
||||
placeholder="R1, K1, etc."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="remesa">Remesa</Label>
|
||||
<Input
|
||||
id="remesa"
|
||||
type="number"
|
||||
bind:value={formData.remesa}
|
||||
placeholder="Número de remesa"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="aduana">Aduana</Label>
|
||||
<Input
|
||||
id="aduana"
|
||||
bind:value={formData.aduana}
|
||||
placeholder="Código de aduana"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="customs_broker_id">Agente Aduanal</Label>
|
||||
<Input
|
||||
id="customs_broker_id"
|
||||
bind:value={formData.customs_broker_id}
|
||||
placeholder="ID del agente aduanal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="provider_id">Proveedor</Label>
|
||||
<Input
|
||||
id="provider_id"
|
||||
bind:value={formData.provider_id}
|
||||
placeholder="ID del proveedor"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="edocument">E-Document</Label>
|
||||
<Input
|
||||
id="edocument"
|
||||
bind:value={formData.edocument}
|
||||
placeholder="Número de e-document"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 flex items-center gap-2 pt-8">
|
||||
<input
|
||||
id="is_mixed"
|
||||
type="checkbox"
|
||||
bind:checked={formData.is_mixed}
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<Label for="is_mixed" class="!mt-0">Operación Mixta</Label>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- Financials Tab -->
|
||||
<Tabs.Content value="financials" class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="currency">Moneda</Label>
|
||||
<Input
|
||||
id="currency"
|
||||
bind:value={formData.currency}
|
||||
placeholder="MXN, USD, etc."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="exchange_rate">Tipo de Cambio</Label>
|
||||
<Input
|
||||
id="exchange_rate"
|
||||
type="number"
|
||||
step="0.000001"
|
||||
bind:value={formData.exchange_rate}
|
||||
placeholder="Tipo de cambio"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="value_mn">Valor MN</Label>
|
||||
<Input
|
||||
id="value_mn"
|
||||
type="number"
|
||||
step="0.01"
|
||||
bind:value={formData.value_mn}
|
||||
placeholder="Valor en moneda nacional"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="value_me">Valor ME</Label>
|
||||
<Input
|
||||
id="value_me"
|
||||
type="number"
|
||||
step="0.01"
|
||||
bind:value={formData.value_me}
|
||||
placeholder="Valor en moneda extranjera"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="customs_value_mn">Valor Aduana MN</Label>
|
||||
<Input
|
||||
id="customs_value_mn"
|
||||
type="number"
|
||||
step="0.01"
|
||||
bind:value={formData.customs_value_mn}
|
||||
placeholder="Valor de aduana en MN"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="freight">Flete</Label>
|
||||
<Input
|
||||
id="freight"
|
||||
type="number"
|
||||
step="0.01"
|
||||
bind:value={formData.freight}
|
||||
placeholder="Costo de flete"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="insurance">Seguro</Label>
|
||||
<Input
|
||||
id="insurance"
|
||||
type="number"
|
||||
step="0.01"
|
||||
bind:value={formData.insurance}
|
||||
placeholder="Costo de seguro"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="iva_mn">IVA MN</Label>
|
||||
<Input
|
||||
id="iva_mn"
|
||||
type="number"
|
||||
step="0.01"
|
||||
bind:value={formData.iva_mn}
|
||||
placeholder="IVA en MN"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="total_quantity">Cantidad Total</Label>
|
||||
<Input
|
||||
id="total_quantity"
|
||||
type="number"
|
||||
step="0.01"
|
||||
bind:value={formData.total_quantity}
|
||||
placeholder="Cantidad total"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="gross_weight">Peso Bruto</Label>
|
||||
<Input
|
||||
id="gross_weight"
|
||||
type="number"
|
||||
step="0.01"
|
||||
bind:value={formData.gross_weight}
|
||||
placeholder="Peso bruto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="net_weight">Peso Neto</Label>
|
||||
<Input
|
||||
id="net_weight"
|
||||
type="number"
|
||||
step="0.01"
|
||||
bind:value={formData.net_weight}
|
||||
placeholder="Peso neto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="bundle_count">Número de Bultos</Label>
|
||||
<Input
|
||||
id="bundle_count"
|
||||
type="number"
|
||||
bind:value={formData.bundle_count}
|
||||
placeholder="Número de bultos"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
|
||||
{#if error}
|
||||
<div class="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onclick={() => (open = false)}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{#if loading}
|
||||
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
|
||||
{/if}
|
||||
{isEditing ? "Actualizar" : "Crear"}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import type { Invoice } from '$lib/api/dashboard/a76/invoices';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { Ellipsis, Eye, Pencil, Trash2 } from 'lucide-svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
interface Props {
|
||||
invoice: Invoice;
|
||||
}
|
||||
|
||||
let { invoice }: Props = $props();
|
||||
|
||||
function dispatchView() {
|
||||
window.dispatchEvent(new CustomEvent('invoiceView', { detail: invoice }));
|
||||
}
|
||||
|
||||
function dispatchDelete() {
|
||||
window.dispatchEvent(new CustomEvent('invoiceDelete', { detail: invoice }));
|
||||
}
|
||||
|
||||
function dispatchEdit() {
|
||||
window.dispatchEvent(new CustomEvent('invoiceEdit', { detail: invoice }));
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="h-8 w-8 p-0">
|
||||
<span class="sr-only">Abrir menú</span>
|
||||
<Ellipsis class="h-4 w-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<DropdownMenu.Item onclick={dispatchView}>
|
||||
<Eye class="mr-2 h-4 w-4" />
|
||||
Ver Detalles
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item onclick={dispatchEdit}>
|
||||
<Pencil class="mr-2 h-4 w-4" />
|
||||
Editar
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onclick={dispatchDelete} class="text-destructive">
|
||||
<Trash2 class="mr-2 h-4 w-4" />
|
||||
Eliminar
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
131
frontend/src/lib/components/dashboard/invoices/data-table.svelte
Normal file
131
frontend/src/lib/components/dashboard/invoices/data-table.svelte
Normal file
@@ -0,0 +1,131 @@
|
||||
<script lang="ts" generics="TData, TValue">
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel
|
||||
} from "@tanstack/table-core";
|
||||
import { createSvelteTable, FlexRender } from "$lib/components/ui/data-table/index.js";
|
||||
import * as Table from "$lib/components/ui/table/index.js";
|
||||
|
||||
type DataTableProps<TData, TValue> = {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
loading: boolean;
|
||||
hasMore: boolean;
|
||||
loadMore: () => void;
|
||||
};
|
||||
|
||||
let {
|
||||
data,
|
||||
columns,
|
||||
loading,
|
||||
hasMore,
|
||||
loadMore
|
||||
}: DataTableProps<TData, TValue> = $props();
|
||||
|
||||
const table = createSvelteTable({
|
||||
get data() {
|
||||
return data;
|
||||
},
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel()
|
||||
});
|
||||
|
||||
let scrollContainer = $state<HTMLDivElement>();
|
||||
let loadingTrigger = $state<HTMLDivElement>();
|
||||
|
||||
// Intersection Observer para detectar cuando el usuario llega al final
|
||||
onMount(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const [entry] = entries;
|
||||
if (entry.isIntersecting && hasMore && !loading) {
|
||||
loadMore();
|
||||
}
|
||||
},
|
||||
{
|
||||
root: scrollContainer,
|
||||
threshold: 0.1
|
||||
}
|
||||
);
|
||||
|
||||
if (loadingTrigger) {
|
||||
observer.observe(loadingTrigger);
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="w-full">
|
||||
<div class="rounded-md border max-h-[600px] overflow-y-auto" bind:this={scrollContainer}>
|
||||
<Table.Root>
|
||||
<Table.Header class="sticky top-0 bg-background z-10">
|
||||
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
|
||||
<Table.Row>
|
||||
{#each headerGroup.headers as header (header.id)}
|
||||
<Table.Head>
|
||||
{#if !header.isPlaceholder}
|
||||
<FlexRender
|
||||
content={header.column.columnDef.header}
|
||||
context={header.getContext()}
|
||||
/>
|
||||
{/if}
|
||||
</Table.Head>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each table.getRowModel().rows as row (row.id)}
|
||||
<Table.Row data-state={row.getIsSelected() && "selected"}>
|
||||
{#each row.getVisibleCells() as cell (cell.id)}
|
||||
<Table.Cell>
|
||||
{#if cell.column.id === 'actions'}
|
||||
{@const cellDef = cell.column.columnDef.cell}
|
||||
{#if cellDef && typeof cellDef === 'function'}
|
||||
{@const Component = cellDef(cell.getContext())}
|
||||
<Component invoice={cell.row.original} />
|
||||
{/if}
|
||||
{:else}
|
||||
<FlexRender
|
||||
content={cell.column.columnDef.cell}
|
||||
context={cell.getContext()}
|
||||
/>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={columns.length} class="h-24 text-center">
|
||||
No hay resultados.
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
|
||||
<!-- Loading Trigger - Se activa cuando es visible -->
|
||||
{#if hasMore}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={columns.length} class="h-20 text-center">
|
||||
<div bind:this={loadingTrigger}>
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<div class="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
|
||||
<span class="text-muted-foreground text-sm">Cargando más...</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-muted-foreground text-sm">
|
||||
Desplázate para cargar más
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as AlertDialog from "$lib/components/ui/alert-dialog";
|
||||
import { invoicesApi, type Invoice } from "$lib/api/dashboard/a76/invoices";
|
||||
import { companyStore } from "$lib/stores/company.svelte";
|
||||
import { LoaderCircle } from 'lucide-svelte';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
item,
|
||||
onSuccess
|
||||
}: {
|
||||
open: boolean;
|
||||
item: Invoice | null;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
async function handleDelete() {
|
||||
if (!item || !companyStore.activeCompany) return;
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const response = await invoicesApi.delete(item.id, companyStore.activeCompany.id);
|
||||
|
||||
if (response.error) {
|
||||
error = response.error;
|
||||
return;
|
||||
}
|
||||
|
||||
// Éxito
|
||||
open = false;
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : "Error al eliminar";
|
||||
console.error("Error deleting:", e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenChange(newOpen: boolean) {
|
||||
if (!newOpen) {
|
||||
error = null;
|
||||
}
|
||||
open = newOpen;
|
||||
}
|
||||
</script>
|
||||
|
||||
<AlertDialog.Root bind:open onOpenChange={handleOpenChange}>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>¿Estás seguro?</AlertDialog.Title>
|
||||
<AlertDialog.Description class="space-y-2">
|
||||
<p>Esta acción no se puede deshacer. Se eliminará permanentemente esta factura:</p>
|
||||
{#if item}
|
||||
<div class="mt-2 rounded-lg bg-muted p-3 space-y-2">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-medium">ID:</span>
|
||||
<span class="font-semibold">{item.id}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-medium">Número de Factura:</span>
|
||||
<code class="font-mono font-semibold">{item.invoice_number || 'N/A'}</code>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-medium">Tipo:</span>
|
||||
<span class="text-xs">
|
||||
{item.operation_type === 'imp' ? 'Importación' :
|
||||
item.operation_type === 'exp' ? 'Exportación' : 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-medium">Proyecto:</span>
|
||||
<span class="text-xs">{item.project_number || 'N/A'}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-medium">Pedimento:</span>
|
||||
<span class="text-xs">{item.compliance_mx?.pedimento || 'N/A'}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if error}
|
||||
<div class="mt-2 rounded-lg border border-destructive bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel disabled={loading}>Cancelar</AlertDialog.Cancel>
|
||||
<AlertDialog.Action
|
||||
onclick={handleDelete}
|
||||
disabled={loading}
|
||||
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{#if loading}
|
||||
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
|
||||
{/if}
|
||||
Eliminar
|
||||
</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
@@ -0,0 +1,435 @@
|
||||
<script lang="ts">
|
||||
import type { Invoice } from '$lib/api/dashboard/a76/invoices';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Tabs from '$lib/components/ui/tabs';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
invoice
|
||||
}: {
|
||||
open: boolean;
|
||||
invoice: Invoice | null;
|
||||
} = $props();
|
||||
|
||||
function formatDate(dateString: string | null | undefined): string {
|
||||
if (!dateString) return '-';
|
||||
return new Date(dateString).toLocaleDateString('es-MX');
|
||||
}
|
||||
|
||||
function formatCurrency(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined) return '-';
|
||||
return new Intl.NumberFormat('es-MX', {
|
||||
style: 'currency',
|
||||
currency: 'MXN'
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatNumber(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined) return '-';
|
||||
return new Intl.NumberFormat('es-MX').format(value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root {open} onOpenChange={(v) => (open = v)}>
|
||||
<Dialog.Content class="max-w-5xl max-h-[90vh] overflow-y-auto">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Detalles de Factura #{invoice?.id}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Información completa de la factura
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
{#if invoice}
|
||||
<Tabs.Root value="general" class="w-full">
|
||||
<Tabs.List class="grid w-full grid-cols-5">
|
||||
<Tabs.Trigger value="general">General</Tabs.Trigger>
|
||||
<Tabs.Trigger value="compliance">Cumplimiento</Tabs.Trigger>
|
||||
<Tabs.Trigger value="financials">Financieros</Tabs.Trigger>
|
||||
<Tabs.Trigger value="logistics">Logística</Tabs.Trigger>
|
||||
<Tabs.Trigger value="details">Detalles</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<!-- General Tab -->
|
||||
<Tabs.Content value="general" class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Tipo de Operación</p>
|
||||
<p class="text-base">
|
||||
{#if invoice.operation_type === 'imp'}
|
||||
<Badge>Importación</Badge>
|
||||
{:else if invoice.operation_type === 'exp'}
|
||||
<Badge variant="secondary">Exportación</Badge>
|
||||
{:else}
|
||||
-
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Número de Factura</p>
|
||||
<p class="text-base">{invoice.invoice_number || '-'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Tipo de Factura</p>
|
||||
<p class="text-base">{invoice.invoice_type || '-'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Número de Proyecto</p>
|
||||
<p class="text-base">{invoice.project_number || '-'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Orden de Compra</p>
|
||||
<p class="text-base">{invoice.purchase_order || '-'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Fecha de Factura</p>
|
||||
<p class="text-base">{formatDate(invoice.invoice_date)}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Fecha de Captura</p>
|
||||
<p class="text-base">{formatDate(invoice.capture_date)}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Semáforo</p>
|
||||
<p class="text-base">{invoice.traffic_light_status || '-'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">CFDI UUID</p>
|
||||
<p class="text-xs break-all">{invoice.cfdi_uuid || '-'}</p>
|
||||
</div> <div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Actualizado</p>
|
||||
<p class="text-base">{invoice.is_updated ? 'Sí' : 'No'}</p>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2">
|
||||
<p class="text-sm font-medium text-muted-foreground">Observaciones (ES)</p>
|
||||
<p class="text-base">{invoice.observation_es || '-'}</p>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2">
|
||||
<p class="text-sm font-medium text-muted-foreground">Observaciones (EN)</p>
|
||||
<p class="text-base">{invoice.observation_en || '-'}</p>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2">
|
||||
<p class="text-sm font-medium text-muted-foreground">Log de Proceso</p>
|
||||
<p class="text-base">{invoice.process_log || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- Compliance Tab -->
|
||||
<Tabs.Content value="compliance" class="space-y-4">
|
||||
{#if invoice.compliance_mx}
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Pedimento</p>
|
||||
<p class="text-base font-semibold">{invoice.compliance_mx.pedimento || '-'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Código de Pedimento</p>
|
||||
<p class="text-base">{invoice.compliance_mx.pedimento_code || '-'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Remesa</p>
|
||||
<p class="text-base">{invoice.compliance_mx.remesa || '-'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Aduana</p>
|
||||
<p class="text-base">{invoice.compliance_mx.aduana || '-'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Agente Aduanal ID</p>
|
||||
<p class="text-base">{invoice.compliance_mx.customs_broker_id || '-'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Proveedor</p>
|
||||
<p class="text-base">{invoice.compliance_mx.provider_id || '-'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Vendido A</p>
|
||||
<p class="text-base">{invoice.compliance_mx.sold_to_id || '-'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Enviado A</p>
|
||||
<p class="text-base">{invoice.compliance_mx.shipped_to_id || '-'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Enviado Por</p>
|
||||
<p class="text-base">{invoice.compliance_mx.shipped_by_id || '-'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Operación Mixta</p>
|
||||
<p class="text-base">{invoice.compliance_mx.is_mixed ? 'Sí' : 'No'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Tipo de Desperdicio</p>
|
||||
<p class="text-base">{invoice.compliance_mx.waste_type || '-'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Apéndice 17</p>
|
||||
<p class="text-base">{invoice.compliance_mx.appendix_17 || '-'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">E-Document</p>
|
||||
<p class="text-base">{invoice.compliance_mx.edocument || '-'}</p>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2">
|
||||
<p class="text-sm font-medium text-muted-foreground">Firma Electrónica</p>
|
||||
<p class="text-xs break-all">{invoice.compliance_mx.electronic_signature || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-muted-foreground">No hay información de cumplimiento disponible.</p>
|
||||
{/if}
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- Financials Tab -->
|
||||
<Tabs.Content value="financials" class="space-y-4">
|
||||
{#if invoice.financials}
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Moneda</p>
|
||||
<p class="text-base">{invoice.financials.currency || '-'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Tipo de Cambio</p>
|
||||
<p class="text-base">{formatNumber(invoice.financials.exchange_rate)}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Valor MN</p>
|
||||
<p class="text-base font-semibold">{formatCurrency(invoice.financials.value_mn)}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Valor ME</p>
|
||||
<p class="text-base font-semibold">{formatNumber(invoice.financials.value_me)}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Valor Aduana MN</p>
|
||||
<p class="text-base">{formatCurrency(invoice.financials.customs_value_mn)}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Flete</p>
|
||||
<p class="text-base">{formatCurrency(invoice.financials.freight)}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Seguro</p>
|
||||
<p class="text-base">{formatCurrency(invoice.financials.insurance)}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">IVA MN</p>
|
||||
<p class="text-base">{formatCurrency(invoice.financials.iva_mn)}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Factor IVA</p>
|
||||
<p class="text-base">{formatNumber(invoice.financials.iva_factor)}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Cantidad Total</p>
|
||||
<p class="text-base">{formatNumber(invoice.financials.total_quantity)}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Peso Bruto</p>
|
||||
<p class="text-base">{formatNumber(invoice.financials.gross_weight)}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Peso Neto</p>
|
||||
<p class="text-base">{formatNumber(invoice.financials.net_weight)}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Número de Bultos</p>
|
||||
<p class="text-base">{invoice.financials.bundle_count || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-muted-foreground">No hay información financiera disponible.</p>
|
||||
{/if}
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- Logistics Tab -->
|
||||
<Tabs.Content value="logistics" class="space-y-4">
|
||||
{#if invoice.logistics && invoice.logistics.length > 0}
|
||||
<div class="space-y-6">
|
||||
{#each invoice.logistics as logistics, index}
|
||||
<div class="border rounded-lg p-4">
|
||||
<h4 class="font-semibold mb-3">Logística #{index + 1}</h4>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Transportista</p>
|
||||
<p class="text-base">{logistics.carrier_id || '-'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Tipo de Transporte</p>
|
||||
<p class="text-base">{logistics.transport_type || '-'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Modo de Transporte</p>
|
||||
<p class="text-base">{logistics.transport_mode || '-'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Conductor</p>
|
||||
<p class="text-base">{logistics.driver_name || '-'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Número de Vehículo</p>
|
||||
<p class="text-base">{logistics.vehicle_num || '-'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Placa</p>
|
||||
<p class="text-base">{logistics.license_plate || '-'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Número de Sello</p>
|
||||
<p class="text-base">{logistics.seal_number || '-'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Guía</p>
|
||||
<p class="text-base">{logistics.guide_number || '-'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Fecha Entrada/Salida</p>
|
||||
<p class="text-base">{formatDate(logistics.entry_exit_date)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-muted-foreground">No hay información de logística disponible.</p>
|
||||
{/if}
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- Details Tab -->
|
||||
<Tabs.Content value="details" class="space-y-4">
|
||||
{#if invoice.details && invoice.details.length > 0}
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h4 class="font-semibold mb-3">Detalles de Venta</h4>
|
||||
<div class="space-y-3">
|
||||
{#each invoice.details as detail}
|
||||
<div class="border rounded-lg p-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Línea</p>
|
||||
<p class="text-base">{detail.line_number}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Orden de Venta</p>
|
||||
<p class="text-base">{detail.sales_order || '-'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Descripción de Colores</p>
|
||||
<p class="text-base">{detail.colors_description || '-'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Código de Color</p>
|
||||
<p class="text-base">{detail.square_color_code || '-'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Bultos</p>
|
||||
<p class="text-base">{detail.line_bundles || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if invoice.collections && invoice.collections.length > 0}
|
||||
<div>
|
||||
<h4 class="font-semibold mb-3">Cobranzas</h4>
|
||||
<div class="space-y-3">
|
||||
{#each invoice.collections as collection}
|
||||
<div class="border rounded-lg p-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Concepto</p>
|
||||
<p class="text-base">{collection.concept || '-'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Monto</p>
|
||||
<p class="text-base">{formatCurrency(collection.amount)}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Fecha de Cobranza</p>
|
||||
<p class="text-base">{formatDate(collection.collection_date)}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Cobrado</p>
|
||||
<p class="text-base">{collection.is_collected ? 'Sí' : 'No'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Cobrador</p>
|
||||
<p class="text-base">{collection.collector_user || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-muted-foreground">No hay detalles de venta o cobranzas disponibles.</p>
|
||||
{/if}
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
{/if}
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={() => (open = false)}>
|
||||
Cerrar
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -1,11 +1,9 @@
|
||||
import type { ColumnDef } from '@tanstack/table-core';
|
||||
import type { Location } from '$lib/api/dashboard/a76/general_catalogs/locations';
|
||||
import { renderComponent } from '$lib/components/ui/data-table';
|
||||
import DataTableActions from './data-table-actions.svelte';
|
||||
|
||||
export interface Location {
|
||||
location_code: string;
|
||||
location_description: string | null;
|
||||
}
|
||||
|
||||
export function createColumns(): ColumnDef<Location>[] {
|
||||
export function createColumns(onSuccess?: () => void): ColumnDef<Location>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'location_code',
|
||||
@@ -14,7 +12,15 @@ export function createColumns(): ColumnDef<Location>[] {
|
||||
{
|
||||
accessorKey: 'location_description',
|
||||
header: 'Descripción',
|
||||
cell: ({ row }) => row.original.location_description || '-'
|
||||
cell: ({ row }) => row.original.location_description || '—'
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Acciones',
|
||||
cell: ({ row }) => renderComponent(DataTableActions, {
|
||||
item: row.original,
|
||||
onSuccess
|
||||
})
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { EllipsisVertical, Pencil, Trash2, LoaderCircle } from 'lucide-svelte';
|
||||
import type { Location } from '$lib/api/dashboard/a76/general_catalogs/locations';
|
||||
import { deleteLocation } from '$lib/api/dashboard/a76/general_catalogs/locations';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import CreateEditDialog from '../general_catalogs/locations/create-edit-dialog.svelte';
|
||||
|
||||
let {
|
||||
item,
|
||||
onSuccess
|
||||
}: {
|
||||
item: Location;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let dialogOpen = $state(false);
|
||||
let selectedItem = $state<Location | null>(null);
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm('¿Está seguro de que desea eliminar esta ubicación?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) {
|
||||
alert('❌ Error: No hay compañía seleccionada');
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
await deleteLocation(item.id, companyId);
|
||||
alert('✅ Ubicación eliminada correctamente');
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Error al eliminar la ubicación';
|
||||
alert(`❌ Error: ${error}`);
|
||||
console.error('Error deleting:', err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
selectedItem = item;
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
function handleDialogSuccess() {
|
||||
dialogOpen = false;
|
||||
selectedItem = null;
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="relative size-8 p-0">
|
||||
<span class="sr-only">Abrir menú</span>
|
||||
<EllipsisVertical size={16} />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end" class="w-[160px]">
|
||||
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onclick={handleEdit}>
|
||||
<Pencil size={16} class="mr-2" />
|
||||
Editar
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onclick={handleDelete} class="text-destructive" disabled={loading}>
|
||||
{#if loading}
|
||||
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
|
||||
{:else}
|
||||
<Trash2 size={16} class="mr-2" />
|
||||
{/if}
|
||||
Eliminar
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<CreateEditDialog
|
||||
bind:open={dialogOpen}
|
||||
item={selectedItem}
|
||||
onSuccess={handleDialogSuccess}
|
||||
/>
|
||||
@@ -3,7 +3,7 @@
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { createPackage, updatePackage, type Package, type PackageCreate, type PackageUpdate } from "$lib/api/dashboard/a76/general_catalogs/packages";
|
||||
import { createPackage, updatePackage, type Package} from "$lib/api/dashboard/a76/general_catalogs/packages";
|
||||
import { companyStore } from "$lib/stores/company.svelte";
|
||||
|
||||
let {
|
||||
@@ -95,9 +95,9 @@
|
||||
|
||||
let response;
|
||||
if (isEdit && item) {
|
||||
response = await updatePackage(item.id, dataToSend);
|
||||
response = await updatePackage(item.id, dataToSend, companyId);
|
||||
} else {
|
||||
response = await createPackage({ ...dataToSend, company_id: companyId });
|
||||
response = await createPackage(dataToSend, companyId);
|
||||
}
|
||||
|
||||
if (response.error) {
|
||||
|
||||
@@ -20,7 +20,12 @@
|
||||
let selectedItem = $state<Package | null>(null);
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm(`¿Estás seguro de eliminar el bulto "${item.key}"?`)) {
|
||||
if (!confirm(`¿Estás seguro de eliminar el bulto "${item.key}"?\n\nNota: No se puede eliminar si tiene registros relacionados.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!companyStore.activeCompany) {
|
||||
alert('❌ Error: No hay una compañía seleccionada');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -28,7 +33,7 @@
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const response = await deletePackage(item.id);
|
||||
const response = await deletePackage(item.id, companyStore.activeCompany.id);
|
||||
|
||||
if (response.error) {
|
||||
if (response.status === 401) {
|
||||
@@ -38,18 +43,21 @@
|
||||
}, 1500);
|
||||
} else {
|
||||
error = response.error;
|
||||
alert(`Error al eliminar: ${response.error}`);
|
||||
alert(`❌ Error al eliminar:\n\n${response.error}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Éxito
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
if (response.status === 204 || response.status === 200 || !response.error) {
|
||||
alert(`✅ Bulto "${item.key}" eliminado correctamente`);
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : "Error al eliminar";
|
||||
alert(`Error: ${error}`);
|
||||
alert(`❌ Error: ${error}`);
|
||||
console.error("Error deleting:", e);
|
||||
} finally {
|
||||
loading = false;
|
||||
|
||||
@@ -1,123 +1,110 @@
|
||||
<script lang="ts" generics="TData, TValue">
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel
|
||||
getCoreRowModel,
|
||||
} from "@tanstack/table-core";
|
||||
import { createSvelteTable, FlexRender } from "$lib/components/ui/data-table/index.js";
|
||||
import * as Table from "$lib/components/ui/table/index.js";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
|
||||
type DataTableProps<TData, TValue> = {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
loading: boolean;
|
||||
hasMore: boolean;
|
||||
loadMore: () => void;
|
||||
pageCount: number;
|
||||
totalItems: number;
|
||||
};
|
||||
|
||||
let {
|
||||
data,
|
||||
columns,
|
||||
loading,
|
||||
hasMore,
|
||||
loadMore
|
||||
pageCount,
|
||||
totalItems
|
||||
}: DataTableProps<TData, TValue> = $props();
|
||||
|
||||
const table = createSvelteTable({
|
||||
get data() {
|
||||
return data;
|
||||
},
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel()
|
||||
get columns() {
|
||||
return columns;
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
get pageCount() {
|
||||
return pageCount;
|
||||
},
|
||||
});
|
||||
|
||||
let scrollContainer = $state<HTMLDivElement>();
|
||||
let loadingTrigger = $state<HTMLDivElement>();
|
||||
|
||||
// Intersection Observer para detectar cuando el usuario llega al final
|
||||
onMount(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const [entry] = entries;
|
||||
if (entry.isIntersecting && hasMore && !loading) {
|
||||
loadMore();
|
||||
}
|
||||
},
|
||||
{
|
||||
root: scrollContainer,
|
||||
threshold: 0.1
|
||||
}
|
||||
);
|
||||
|
||||
if (loadingTrigger) {
|
||||
observer.observe(loadingTrigger);
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
});
|
||||
function handlePageChange(newPage: number) {
|
||||
const url = new URL($page.url);
|
||||
url.searchParams.set('page', newPage.toString());
|
||||
goto(url);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="w-full">
|
||||
<div class="rounded-md border max-h-[600px] overflow-y-auto" bind:this={scrollContainer}>
|
||||
<Table.Root>
|
||||
<Table.Header class="sticky top-0 bg-background z-10">
|
||||
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
|
||||
<Table.Row>
|
||||
{#each headerGroup.headers as header (header.id)}
|
||||
<Table.Head>
|
||||
{#if !header.isPlaceholder}
|
||||
<FlexRender
|
||||
content={header.column.columnDef.header}
|
||||
context={header.getContext()}
|
||||
/>
|
||||
{/if}
|
||||
</Table.Head>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each table.getRowModel().rows as row (row.id)}
|
||||
<Table.Row data-state={row.getIsSelected() && "selected"}>
|
||||
{#each row.getVisibleCells() as cell (cell.id)}
|
||||
<Table.Cell>
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
|
||||
<Table.Row>
|
||||
{#each headerGroup.headers as header (header.id)}
|
||||
<Table.Head>
|
||||
{#if !header.isPlaceholder}
|
||||
<FlexRender
|
||||
content={cell.column.columnDef.cell}
|
||||
context={cell.getContext()}
|
||||
content={header.column.columnDef.header}
|
||||
context={header.getContext()}
|
||||
/>
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={columns.length} class="h-24 text-center">
|
||||
No hay resultados.
|
||||
{/if}
|
||||
</Table.Head>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each table.getRowModel().rows as row (row.id)}
|
||||
<Table.Row data-state={row.getIsSelected() && "selected"}>
|
||||
{#each row.getVisibleCells() as cell (cell.id)}
|
||||
<Table.Cell>
|
||||
<FlexRender
|
||||
content={cell.column.columnDef.cell}
|
||||
context={cell.getContext()}
|
||||
/>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
|
||||
<!-- Loading Trigger - Se activa cuando es visible -->
|
||||
{#if hasMore}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={columns.length} class="h-20 text-center">
|
||||
<div bind:this={loadingTrigger}>
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<div class="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
|
||||
<span class="text-muted-foreground text-sm">Cargando más...</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-muted-foreground text-sm">
|
||||
Desplázate para cargar más
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={columns.length} class="h-24 text-center">
|
||||
No hay resultados.
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end space-x-2 py-4">
|
||||
<div class="flex-1 text-sm text-muted-foreground">
|
||||
Total: {totalItems}
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(Number($page.url.searchParams.get('page') || 1) - 1)}
|
||||
disabled={Number($page.url.searchParams.get('page') || 1) <= 1}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(Number($page.url.searchParams.get('page') || 1) + 1)}
|
||||
disabled={Number($page.url.searchParams.get('page') || 1) >= pageCount}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import * as Select from "$lib/components/ui/select";
|
||||
import * as Select from "$lib/components/ui/select";
|
||||
import { createPort, updatePort, type Port, PortType } from "$lib/api/dashboard/a76/general_catalogs/ports";
|
||||
import { companyStore } from "$lib/stores/company.svelte";
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
@@ -60,23 +61,29 @@
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) {
|
||||
error = 'Selecciona una compañía para guardar el puerto';
|
||||
return;
|
||||
}
|
||||
|
||||
let response;
|
||||
if (isEdit && item) {
|
||||
response = await updatePort(item.id, {
|
||||
port_code: formData.port_code,
|
||||
description: formData.description || null,
|
||||
location_code: formData.location_code,
|
||||
location_description: formData.location_description || null,
|
||||
port_type: formData.port_type
|
||||
});
|
||||
location_code: formData.location_code,
|
||||
location_description: formData.location_description || null,
|
||||
port_type: formData.port_type
|
||||
}, companyId);
|
||||
} else {
|
||||
response = await createPort({
|
||||
port_code: formData.port_code,
|
||||
description: formData.description || null,
|
||||
location_code: formData.location_code,
|
||||
location_description: formData.location_description || null,
|
||||
port_type: formData.port_type
|
||||
});
|
||||
location_code: formData.location_code,
|
||||
location_description: formData.location_description || null,
|
||||
port_type: formData.port_type
|
||||
}, companyId);
|
||||
}
|
||||
|
||||
if (response.error) {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { deletePort, type Port } from "$lib/api/dashboard/a76/general_catalogs/ports";
|
||||
import { EllipsisVertical, Pencil, LoaderCircle, Trash2 } from 'lucide-svelte';
|
||||
import CreateEditDialog from "./create-edit-dialog.svelte";
|
||||
import { companyStore } from "$lib/stores/company.svelte";
|
||||
|
||||
let {
|
||||
item,
|
||||
@@ -22,11 +23,17 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) {
|
||||
alert('Selecciona una compañía antes de eliminar');
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const response = await deletePort(item.id);
|
||||
const response = await deletePort(item.id, companyId);
|
||||
|
||||
if (response.error) {
|
||||
error = response.error;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user