Merge branch 'features/Creacion-formularios_catalogos_generales' into development

This commit is contained in:
AlexeerCT
2025-12-24 15:05:09 -06:00
279 changed files with 23252 additions and 3644 deletions

View File

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

View File

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

View File

@@ -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
});
}
}
];
}

View File

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

View File

@@ -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;

View File

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

View File

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

View File

@@ -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
});
}
}
];
}

View File

@@ -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}
/>

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
});
}
}
];
}

View File

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

View File

@@ -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}
/>

View File

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

View File

@@ -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
});
}
}
];
}

View File

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

View File

@@ -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}
/>

View File

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

View File

@@ -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
});
}
}
];
}

View File

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

View File

@@ -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}
/>

View File

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

View File

@@ -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
});
}
}
];
}

View File

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

View File

@@ -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}
/>

View File

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

View File

@@ -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
});
}
}
];
}

View File

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

View File

@@ -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}
/>

View File

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

View File

@@ -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
})
}
];
}

View File

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

View File

@@ -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}
/>

View File

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

View File

@@ -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
});
}
}
];
}

View File

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

View File

@@ -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}
/>

View File

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

View File

@@ -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
});
}
}
];
}

View File

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

View File

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

View File

@@ -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}
/>

View File

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

View File

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

View File

@@ -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
});
}
}
];
}

View File

@@ -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}
/>

View File

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

View File

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

View File

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

View File

@@ -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
});
}
}
];
}

View File

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

View File

@@ -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}
/>

View File

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

View File

@@ -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
})
}
];
}

View File

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

View File

@@ -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}
/>

View File

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

View File

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

View File

@@ -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
})
}
];
}

View File

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

View File

@@ -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}
/>

View File

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

View File

@@ -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
});
}
}
];
}

View File

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

View File

@@ -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}
/>

View File

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

View File

@@ -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
}),
},
];
}

View File

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

View File

@@ -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} />

View File

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

View File

@@ -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
}),
},
];
}

View File

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

View File

@@ -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} />

View File

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

View File

@@ -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
});
}
}
];
}

View File

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

View File

@@ -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} />

View File

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

View File

@@ -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
}),
},
];
}

View File

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

View File

@@ -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} />

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -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
})
}
];
}

View File

@@ -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}
/>

View File

@@ -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) {

View File

@@ -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;

View File

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

View File

@@ -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) {

View File

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