feat(fin,ops): frontend Facturas/Cobranza, bitácora de embarque y uploader de documentos

- Facturas: lista, alta y detalle (Conceptos / Pagos-cobranza / Datos) con
  totales+IVA+saldo y acciones Emitir/Enviar/Cancelar
- Embarque: botón "Generar factura", pestaña "Bitácora" (hitos por defecto
  import/export, completar/agregar) y subida real de archivos a MinIO en documentos
- Clientes/Proveedores: subida de archivos en documentos (RelatedManager)
- clientes API fin + ops(eventos) + uploads; sidebar grupo Facturación
- svelte-check: 0 errores de tipo en archivos nuevos

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aduanasoft
2026-07-15 07:24:15 -06:00
parent 0b12ad5354
commit e724aeae50
10 changed files with 782 additions and 29 deletions

View File

@@ -0,0 +1,107 @@
/**
* Cliente API — Facturación y Cobranza.
*/
import { api } from '$lib/api';
export type InvoiceStatus = 'borrador' | 'emitida' | 'enviada' | 'pagada' | 'cancelada';
export interface Invoice {
id: number;
reference: string | null;
shipment_id: number | null;
quote_id: number | null;
account_id: number | null;
currency: string;
status: InvoiceStatus;
issue_date: string | null;
due_date: string | null;
subtotal: number;
tax_rate: number;
tax_amount: number;
total: number;
paid_amount: number;
balance: number;
bank_info: string | null;
notes: string | null;
sent_at: string | null;
paid_at: string | null;
owner_user_id: string | null;
created_by: string | null;
updated_by: string | null;
tenant_id: number;
company_id: number;
created_at: string;
updated_at: string;
}
export type InvoiceInput = Partial<Omit<Invoice, 'id' | 'status' | 'subtotal' | 'tax_amount' | 'total' | 'paid_amount' | 'balance' | 'sent_at' | 'paid_at' | 'tenant_id' | 'company_id' | 'created_at' | 'updated_at' | 'created_by' | 'updated_by'>>;
export interface InvoiceItem {
id: number;
invoice_id: number;
concept: string;
description: string | null;
quantity: number;
unit_amount: number;
line_total: number;
tenant_id: number;
company_id: number;
}
export type InvoiceItemInput = Partial<Omit<InvoiceItem, 'id' | 'line_total' | 'tenant_id' | 'company_id'>> & {
invoice_id: number;
concept: string;
};
export interface Payment {
id: number;
invoice_id: number;
amount: number;
payment_date: string | null;
method: string | null;
reference: string | null;
notes: string | null;
tenant_id: number;
company_id: number;
created_at: string;
}
export type PaymentInput = Partial<Omit<Payment, 'id' | 'tenant_id' | 'company_id' | 'created_at'>> & {
invoice_id: number;
amount: number;
};
function qp(companyId: number, extra?: Record<string, string | number | undefined>) {
const qs = new URLSearchParams({ company_id: String(companyId) });
for (const [k, v] of Object.entries(extra ?? {})) if (v !== undefined && v !== '') qs.set(k, String(v));
return qs.toString();
}
async function unwrap<T>(p: Promise<{ data?: T; error?: string }>): Promise<T> {
const res = await p;
if (res.error) throw new Error(res.error);
return res.data as T;
}
export const invoicesAPI = {
list: (companyId: number, params?: { search?: string; status?: string; account_id?: number }) =>
unwrap<Invoice[]>(api.get(`/v1/fin/invoices?${qp(companyId, params)}`)),
get: (id: number, companyId: number) => unwrap<Invoice>(api.get(`/v1/fin/invoices/${id}?${qp(companyId)}`)),
create: (data: InvoiceInput, companyId: number) => unwrap<Invoice>(api.post(`/v1/fin/invoices?${qp(companyId)}`, data)),
fromShipment: (shipmentId: number, companyId: number) =>
unwrap<Invoice>(api.post(`/v1/fin/invoices/from-shipment?${qp(companyId, { shipment_id: shipmentId })}`, {})),
update: (id: number, data: Partial<InvoiceInput>, companyId: number) => unwrap<Invoice>(api.patch(`/v1/fin/invoices/${id}?${qp(companyId)}`, data)),
emit: (id: number, companyId: number) => unwrap<Invoice>(api.patch(`/v1/fin/invoices/${id}/emit?${qp(companyId)}`, {})),
send: (id: number, companyId: number) => unwrap<Invoice>(api.patch(`/v1/fin/invoices/${id}/send?${qp(companyId)}`, {})),
cancel: (id: number, companyId: number) => unwrap<Invoice>(api.patch(`/v1/fin/invoices/${id}/cancel?${qp(companyId)}`, {})),
remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/fin/invoices/${id}?${qp(companyId)}`)),
items: (id: number, companyId: number) => unwrap<InvoiceItem[]>(api.get(`/v1/fin/invoices/${id}/items?${qp(companyId)}`)),
payments: (id: number, companyId: number) => unwrap<Payment[]>(api.get(`/v1/fin/invoices/${id}/payments?${qp(companyId)}`))
};
export const invoiceItemsAPI = {
create: (data: InvoiceItemInput, companyId: number) => unwrap<InvoiceItem>(api.post(`/v1/fin/invoice-items?${qp(companyId)}`, data)),
update: (id: number, data: Partial<InvoiceItemInput>, companyId: number) => unwrap<InvoiceItem>(api.patch(`/v1/fin/invoice-items/${id}?${qp(companyId)}`, data)),
remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/fin/invoice-items/${id}?${qp(companyId)}`))
};
export const paymentsAPI = {
create: (data: PaymentInput, companyId: number) => unwrap<Payment>(api.post(`/v1/fin/payments?${qp(companyId)}`, data)),
remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/fin/payments/${id}?${qp(companyId)}`))
};

View File

@@ -39,6 +39,26 @@ export interface Shipment {
} }
export type ShipmentInput = Partial<Omit<Shipment, 'id' | 'tenant_id' | 'company_id' | 'created_at' | 'updated_at' | 'created_by' | 'updated_by'>>; export type ShipmentInput = Partial<Omit<Shipment, 'id' | 'tenant_id' | 'company_id' | 'created_at' | 'updated_at' | 'created_by' | 'updated_by'>>;
export interface ShipmentEvent {
id: number;
shipment_id: number;
event_type: string | null;
title: string;
status: string;
position: number;
planned_date: string | null;
actual_date: string | null;
notes: string | null;
tenant_id: number;
company_id: number;
created_at: string;
updated_at: string;
}
export type ShipmentEventInput = Partial<Omit<ShipmentEvent, 'id' | 'tenant_id' | 'company_id' | 'created_at' | 'updated_at'>> & {
shipment_id: number;
title: string;
};
export interface ShipmentDocument { export interface ShipmentDocument {
id: number; id: number;
shipment_id: number; shipment_id: number;
@@ -80,7 +100,18 @@ export const shipmentsAPI = {
update: (id: number, data: Partial<ShipmentInput>, companyId: number) => unwrap<Shipment>(api.patch(`/v1/ops/shipments/${id}?${qp(companyId)}`, data)), update: (id: number, data: Partial<ShipmentInput>, companyId: number) => unwrap<Shipment>(api.patch(`/v1/ops/shipments/${id}?${qp(companyId)}`, data)),
remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/ops/shipments/${id}?${qp(companyId)}`)), remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/ops/shipments/${id}?${qp(companyId)}`)),
documents: (shipmentId: number, companyId: number) => documents: (shipmentId: number, companyId: number) =>
unwrap<ShipmentDocument[]>(api.get(`/v1/ops/shipments/${shipmentId}/documents?${qp(companyId)}`)) unwrap<ShipmentDocument[]>(api.get(`/v1/ops/shipments/${shipmentId}/documents?${qp(companyId)}`)),
events: (shipmentId: number, companyId: number) =>
unwrap<ShipmentEvent[]>(api.get(`/v1/ops/shipments/${shipmentId}/events?${qp(companyId)}`)),
seedEvents: (shipmentId: number, companyId: number) =>
unwrap<ShipmentEvent[]>(api.post(`/v1/ops/shipments/${shipmentId}/events/seed?${qp(companyId)}`, {}))
};
export const shipmentEventsAPI = {
create: (data: ShipmentEventInput, companyId: number) => unwrap<ShipmentEvent>(api.post(`/v1/ops/shipment-events?${qp(companyId)}`, data)),
update: (id: number, data: Partial<ShipmentEventInput>, companyId: number) => unwrap<ShipmentEvent>(api.patch(`/v1/ops/shipment-events/${id}?${qp(companyId)}`, data)),
complete: (id: number, companyId: number) => unwrap<ShipmentEvent>(api.patch(`/v1/ops/shipment-events/${id}/complete?${qp(companyId)}`, {})),
remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/ops/shipment-events/${id}?${qp(companyId)}`))
}; };
export const shipmentDocumentsAPI = { export const shipmentDocumentsAPI = {

View File

@@ -0,0 +1,33 @@
/**
* Cliente API — subida de archivos a MinIO/S3 (documentos del CRM y Operaciones).
*/
import { api } from '$lib/api';
export interface UploadResult {
file_key: string;
file_url: string;
name: string;
content_type: string | null;
size_bytes: number;
}
/** Sube un archivo (multipart) y devuelve su file_key permanente + URL firmada. */
export async function uploadFile(file: File, companyId: number): Promise<UploadResult> {
const fd = new FormData();
fd.append('file', file);
const res = await api.request<UploadResult>(`/v1/crm/uploads?company_id=${companyId}`, {
method: 'POST',
body: fd
});
if (res.error) throw new Error(res.error);
return res.data!;
}
/** Obtiene una URL firmada fresca para abrir un archivo por su file_key. */
export async function uploadUrl(fileKey: string, companyId: number): Promise<string> {
const res = await api.get<{ url: string }>(
`/v1/crm/uploads/url?key=${encodeURIComponent(fileKey)}&company_id=${companyId}`
);
if (res.error) throw new Error(res.error);
return res.data!.url;
}

View File

@@ -9,6 +9,7 @@
type Address, type Contact, type Document, type AddressInput, type ContactInput, type DocumentInput type Address, type Contact, type Document, type AddressInput, type ContactInput, type DocumentInput
} from '$lib/api/crm'; } from '$lib/api/crm';
import { ADDRESS_TYPES, DOC_TYPES, CONTACT_AREAS, labelOf } from '$lib/components/crm/format'; import { ADDRESS_TYPES, DOC_TYPES, CONTACT_AREAS, labelOf } from '$lib/components/crm/format';
import { uploadFile, uploadUrl } from '$lib/api/uploads';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
// Dueño de los registros relacionados y qué sección mostrar // Dueño de los registros relacionados y qué sección mostrar
@@ -33,6 +34,34 @@
let addressForm = $state<AddressInput>({ address_type: 'fiscal', country: 'MX', is_primary: false }); let addressForm = $state<AddressInput>({ address_type: 'fiscal', country: 'MX', is_primary: false });
let contactForm = $state<ContactInput>({ first_name: '' }); let contactForm = $state<ContactInput>({ first_name: '' });
let documentForm = $state<DocumentInput>({ doc_type: 'constancia_fiscal', name: '' }); let documentForm = $state<DocumentInput>({ doc_type: 'constancia_fiscal', name: '' });
let uploading = $state(false);
async function onFilePicked(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file || !companyId) return;
uploading = true;
try {
const up = await uploadFile(file, companyId);
documentForm = { ...documentForm, file_key: up.file_key, file_url: up.file_url, name: documentForm.name || up.name };
toast.success('Archivo subido');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'No se pudo subir el archivo');
} finally {
uploading = false;
}
}
async function openDoc(d: Document) {
if (!companyId) return;
try {
const url = d.file_key ? await uploadUrl(d.file_key, companyId) : d.file_url;
if (url) window.open(url, '_blank', 'noopener');
else toast.error('El documento no tiene archivo');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo abrir el archivo');
}
}
const companyId = $derived(companyStore.activeCompany?.id ?? null); const companyId = $derived(companyStore.activeCompany?.id ?? null);
const ownerParam = $derived(ownerType === 'account' ? { account_id: ownerId } : { supplier_id: ownerId }); const ownerParam = $derived(ownerType === 'account' ? { account_id: ownerId } : { supplier_id: ownerId });
@@ -205,7 +234,7 @@
<Table.Row> <Table.Row>
<Table.Cell>{labelOf(DOC_TYPES, d.doc_type)}</Table.Cell> <Table.Cell>{labelOf(DOC_TYPES, d.doc_type)}</Table.Cell>
<Table.Cell class="font-medium">{d.name}</Table.Cell> <Table.Cell class="font-medium">{d.name}</Table.Cell>
<Table.Cell>{#if d.file_url}<a class="text-primary hover:underline" href={d.file_url} target="_blank" rel="noopener">Ver</a>{:else}{/if}</Table.Cell> <Table.Cell>{#if d.file_key || d.file_url}<button type="button" class="text-primary hover:underline" onclick={() => openDoc(d)}>Ver</button>{:else}{/if}</Table.Cell>
<Table.Cell class="text-right"><Button variant="ghost" size="sm" onclick={() => removeDocument(d)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button></Table.Cell> <Table.Cell class="text-right"><Button variant="ghost" size="sm" onclick={() => removeDocument(d)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button></Table.Cell>
</Table.Row> </Table.Row>
{/each} {/each}
@@ -261,8 +290,11 @@
<form class="grid gap-3" onsubmit={saveDocument}> <form class="grid gap-3" onsubmit={saveDocument}>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo</span><select class={inputCls} bind:value={documentForm.doc_type}>{#each DOC_TYPES as t (t.value)}<option value={t.value}>{t.label}</option>{/each}</select></label> <label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo</span><select class={inputCls} bind:value={documentForm.doc_type}>{#each DOC_TYPES as t (t.value)}<option value={t.value}>{t.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Nombre *</span><input class={inputCls} bind:value={documentForm.name} required /></label> <label class="flex flex-col gap-1 text-sm"><span class="font-medium">Nombre *</span><input class={inputCls} bind:value={documentForm.name} required /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">URL del archivo</span><input class={inputCls} bind:value={documentForm.file_url} placeholder="https://…" /></label> <label class="flex flex-col gap-1 text-sm">
<p class="text-xs text-muted-foreground">La subida de archivos a MinIO se conectará en una siguiente iteración; por ahora se registra la referencia (URL).</p> <span class="font-medium">Archivo {#if uploading}<span class="text-xs text-muted-foreground">(subiendo…)</span>{:else if documentForm.file_key}<span class="text-xs text-emerald-600">(cargado)</span>{/if}</span>
<input type="file" class={inputCls} onchange={onFilePicked} />
</label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">o URL externa</span><input class={inputCls} bind:value={documentForm.file_url} placeholder="https://…" /></label>
<div class="flex justify-end gap-2"><Button type="button" variant="outline" onclick={() => (activeModal = null)}>Cancelar</Button><Button type="submit" disabled={saving}>Guardar</Button></div> <div class="flex justify-end gap-2"><Button type="button" variant="outline" onclick={() => (activeModal = null)}>Cancelar</Button><Button type="submit" disabled={saving}>Guardar</Button></div>
</form> </form>
{/if} {/if}

View File

@@ -231,6 +231,23 @@ export const DOC_KINDS: Option[] = [
{ value: 'otro', label: 'Otro' } { value: 'otro', label: 'Otro' }
]; ];
// ----- Facturación -----
export const INVOICE_STATUS: Option[] = [
{ value: 'borrador', label: 'Borrador' },
{ value: 'emitida', label: 'Emitida' },
{ value: 'enviada', label: 'Enviada' },
{ value: 'pagada', label: 'Pagada' },
{ value: 'cancelada', label: 'Cancelada' }
];
export const PAYMENT_METHODS: Option[] = [
{ value: 'transferencia', label: 'Transferencia' },
{ value: 'efectivo', label: 'Efectivo' },
{ value: 'cheque', label: 'Cheque' },
{ value: 'tarjeta', label: 'Tarjeta' },
{ value: 'otro', label: 'Otro' }
];
export const SHIPMENT_DOC_TYPES: Option[] = [ export const SHIPMENT_DOC_TYPES: Option[] = [
{ value: 'MBL', label: 'MBL (Master Bill of Lading)' }, { value: 'MBL', label: 'MBL (Master Bill of Lading)' },
{ value: 'HBL', label: 'HBL (House Bill of Lading)' }, { value: 'HBL', label: 'HBL (House Bill of Lading)' },

View File

@@ -5,6 +5,7 @@ import {
Shield, Shield,
Briefcase, Briefcase,
Ship, Ship,
Receipt,
} from '@lucide/svelte'; } from '@lucide/svelte';
export type SystemContext = 'fixed_asset' | 'inventory'; export type SystemContext = 'fixed_asset' | 'inventory';
@@ -60,6 +61,14 @@ export function getNavMain(): NavMainItem[] {
{ title: 'Embarques', url: '/dashboard/ops/embarques' }, { title: 'Embarques', url: '/dashboard/ops/embarques' },
], ],
}, },
{
title: 'Facturación',
url: '/dashboard/fin/facturas',
icon: Receipt,
items: [
{ title: 'Facturas y cobranza', url: '/dashboard/fin/facturas' },
],
},
{ {
title: 'Usuarios', title: 'Usuarios',
url: '/dashboard/users', url: '/dashboard/users',

View File

@@ -0,0 +1,121 @@
<script lang="ts">
import { Receipt, Plus, Trash2, Search, ChevronRight } from '@lucide/svelte';
import * as Card from '$lib/components/ui/card';
import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import { invoicesAPI, type Invoice } from '$lib/api/fin';
import { INVOICE_STATUS, labelOf, formatMoney } from '$lib/components/crm/format';
import { toast } from 'svelte-sonner';
let items = $state<Invoice[]>([]);
let loading = $state(false);
let search = $state('');
let statusFilter = $state('');
const companyId = $derived(companyStore.activeCompany?.id ?? null);
const filtered = $derived(
items.filter((i) => {
if (statusFilter && i.status !== statusFilter) return false;
if (search.trim()) return `${i.reference ?? ''}`.toLowerCase().includes(search.trim().toLowerCase());
return true;
})
);
const statusClass: Record<string, string> = {
borrador: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400',
emitida: 'bg-blue-100 text-blue-700 dark:bg-blue-950/40 dark:text-blue-400',
enviada: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-950/40 dark:text-cyan-400',
pagada: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400',
cancelada: 'bg-red-100 text-red-700 dark:bg-red-950/40 dark:text-red-400'
};
$effect(() => {
const cid = companyId;
if (!cid) return;
void load(cid);
});
async function load(cid: number) {
loading = true;
try {
items = await invoicesAPI.list(cid);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar las facturas');
} finally {
loading = false;
}
}
async function remove(i: Invoice) {
if (!companyId || !confirm(`¿Eliminar la factura ${i.reference ?? i.id}?`)) return;
try {
await invoicesAPI.remove(i.id, companyId);
toast.success('Factura eliminada');
await load(companyId);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo eliminar');
}
}
const inputCls = 'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
</script>
<div class="space-y-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><Receipt class="h-6 w-6" /> Facturas y cobranza</h1>
<p class="mt-1 text-sm text-muted-foreground">Emisión de facturas y registro de pagos.</p>
</div>
<Button href="/dashboard/fin/facturas/nuevo" disabled={!companyId}><Plus class="mr-1 h-4 w-4" /> Nueva factura</Button>
</div>
<Card.Root>
<Card.Header>
<div class="flex flex-wrap items-center gap-3">
<div class="relative max-w-sm flex-1">
<Search class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<input class="w-full py-2 pl-8 pr-3 {inputCls}" placeholder="Buscar por folio…" bind:value={search} />
</div>
<select class={inputCls} bind:value={statusFilter}>
<option value="">Todos los estatus</option>
{#each INVOICE_STATUS as s (s.value)}<option value={s.value}>{s.label}</option>{/each}
</select>
</div>
</Card.Header>
<Card.Content>
{#if loading}
<p class="py-6 text-center text-sm text-muted-foreground">Cargando…</p>
{:else if filtered.length === 0}
<p class="py-6 text-center text-sm text-muted-foreground">Sin facturas.</p>
{:else}
<div class="overflow-x-auto">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Folio</Table.Head>
<Table.Head>Estatus</Table.Head>
<Table.Head class="text-right">Total</Table.Head>
<Table.Head class="text-right">Saldo</Table.Head>
<Table.Head class="text-right">Acciones</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each filtered as i (i.id)}
<Table.Row>
<Table.Cell class="font-medium"><a class="hover:underline" href={`/dashboard/fin/facturas/${i.id}`}>{i.reference ?? `#${i.id}`}</a></Table.Cell>
<Table.Cell><span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {statusClass[i.status] ?? ''}">{labelOf(INVOICE_STATUS, i.status)}</span></Table.Cell>
<Table.Cell class="text-right">{formatMoney(i.total, i.currency)}</Table.Cell>
<Table.Cell class="text-right">{formatMoney(i.balance, i.currency)}</Table.Cell>
<Table.Cell class="text-right">
<Button variant="ghost" size="sm" href={`/dashboard/fin/facturas/${i.id}`} aria-label="Abrir"><ChevronRight class="h-4 w-4" /></Button>
<Button variant="ghost" size="sm" onclick={() => remove(i)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{/if}
</Card.Content>
</Card.Root>
</div>

View File

@@ -0,0 +1,230 @@
<script lang="ts">
import { ArrowLeft, Receipt, Plus, Trash2, Send, FileCheck, X } from '@lucide/svelte';
import { page } from '$app/state';
import * as Card from '$lib/components/ui/card';
import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import {
invoicesAPI, invoiceItemsAPI, paymentsAPI,
type Invoice, type InvoiceInput, type InvoiceItem, type InvoiceItemInput, type Payment, type PaymentInput
} from '$lib/api/fin';
import { accountsAPI, type Account } from '$lib/api/crm';
import { INVOICE_STATUS, QUOTE_CONCEPTS, PAYMENT_METHODS, labelOf, formatMoney } from '$lib/components/crm/format';
import { toast } from 'svelte-sonner';
const invoiceId = $derived(Number(page.params.id));
const companyId = $derived(companyStore.activeCompany?.id ?? null);
let invoice = $state<Invoice | null>(null);
let items = $state<InvoiceItem[]>([]);
let payments = $state<Payment[]>([]);
let accounts = $state<Account[]>([]);
let form = $state<InvoiceInput>({});
let tab = $state('conceptos');
let loading = $state(false);
let saving = $state(false);
let busy = $state(false);
let addingItem = $state(false);
let addingPay = $state(false);
let newItem = $state<InvoiceItemInput>({ invoice_id: 0, concept: 'flete_internacional', quantity: 1, unit_amount: 0 });
let newPay = $state<PaymentInput>({ invoice_id: 0, amount: 0, method: 'transferencia' });
$effect(() => {
const cid = companyId;
const id = invoiceId;
if (!cid || !id) return;
void load(cid, id);
});
async function load(cid: number, id: number) {
loading = true;
try {
[invoice, items, payments, accounts] = await Promise.all([
invoicesAPI.get(id, cid), invoicesAPI.items(id, cid), invoicesAPI.payments(id, cid), accountsAPI.list(cid)
]);
form = { ...invoice };
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo cargar la factura');
} finally {
loading = false;
}
}
async function reload() {
if (companyId) {
[invoice, items, payments] = await Promise.all([
invoicesAPI.get(invoiceId, companyId), invoicesAPI.items(invoiceId, companyId), invoicesAPI.payments(invoiceId, companyId)
]);
form = { ...invoice };
}
}
async function saveHeader() {
if (!companyId || !invoice) return;
saving = true;
try {
invoice = await invoicesAPI.update(invoice.id, form, companyId);
await reload();
toast.success('Cambios guardados');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo guardar');
} finally {
saving = false;
}
}
async function doAction(action: 'emit' | 'send' | 'cancel') {
if (!companyId || !invoice) return;
busy = true;
try {
invoice = await invoicesAPI[action](invoice.id, companyId);
form = { ...invoice };
toast.success('Factura actualizada');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo actualizar');
} finally {
busy = false;
}
}
function startItem() { newItem = { invoice_id: invoiceId, concept: 'flete_internacional', quantity: 1, unit_amount: 0 }; addingItem = true; }
async function saveItem() {
if (!companyId) return;
try { await invoiceItemsAPI.create({ ...newItem, invoice_id: invoiceId }, companyId); addingItem = false; await reload(); toast.success('Concepto agregado'); }
catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo agregar'); }
}
async function removeItem(it: InvoiceItem) {
if (!companyId || !confirm('¿Eliminar concepto?')) return;
await invoiceItemsAPI.remove(it.id, companyId); await reload();
}
function startPay() { newPay = { invoice_id: invoiceId, amount: 0, method: 'transferencia' }; addingPay = true; }
async function savePay() {
if (!companyId) return;
try { await paymentsAPI.create({ ...newPay, invoice_id: invoiceId }, companyId); addingPay = false; await reload(); toast.success('Pago registrado'); }
catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo registrar'); }
}
async function removePay(p: Payment) {
if (!companyId || !confirm('¿Eliminar pago?')) return;
await paymentsAPI.remove(p.id, companyId); await reload();
}
const statusClass: Record<string, string> = {
borrador: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400',
emitida: 'bg-blue-100 text-blue-700 dark:bg-blue-950/40 dark:text-blue-400',
enviada: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-950/40 dark:text-cyan-400',
pagada: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400',
cancelada: 'bg-red-100 text-red-700 dark:bg-red-950/40 dark:text-red-400'
};
const inputCls = 'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
</script>
<div class="space-y-6">
<Button variant="ghost" size="sm" href="/dashboard/fin/facturas"><ArrowLeft class="mr-1 h-4 w-4" /> Facturas</Button>
{#if loading && !invoice}
<p class="text-sm text-muted-foreground">Cargando…</p>
{:else if invoice}
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><Receipt class="h-6 w-6" /> {invoice.reference ?? `Factura #${invoice.id}`}</h1>
<p class="mt-1 text-sm"><span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {statusClass[invoice.status] ?? ''}">{labelOf(INVOICE_STATUS, invoice.status)}</span></p>
</div>
<div class="flex flex-wrap gap-2">
{#if invoice.status === 'borrador'}<Button size="sm" variant="outline" onclick={() => doAction('emit')} disabled={busy}><FileCheck class="mr-1 h-4 w-4" /> Emitir</Button>{/if}
{#if invoice.status === 'emitida'}<Button size="sm" variant="outline" onclick={() => doAction('send')} disabled={busy}><Send class="mr-1 h-4 w-4" /> Enviar</Button>{/if}
{#if invoice.status !== 'cancelada' && invoice.status !== 'pagada'}<Button size="sm" variant="outline" onclick={() => doAction('cancel')} disabled={busy}><X class="mr-1 h-4 w-4" /> Cancelar</Button>{/if}
</div>
</div>
<div class="grid gap-4 sm:grid-cols-4">
<Card.Root><Card.Header><Card.Description>Subtotal</Card.Description><Card.Title class="text-lg">{formatMoney(invoice.subtotal, invoice.currency)}</Card.Title></Card.Header></Card.Root>
<Card.Root><Card.Header><Card.Description>Impuesto ({invoice.tax_rate}%)</Card.Description><Card.Title class="text-lg">{formatMoney(invoice.tax_amount, invoice.currency)}</Card.Title></Card.Header></Card.Root>
<Card.Root><Card.Header><Card.Description>Total</Card.Description><Card.Title class="text-lg">{formatMoney(invoice.total, invoice.currency)}</Card.Title></Card.Header></Card.Root>
<Card.Root><Card.Header><Card.Description>Saldo</Card.Description><Card.Title class="text-lg {invoice.balance > 0 ? 'text-amber-600' : 'text-emerald-600'}">{formatMoney(invoice.balance, invoice.currency)}</Card.Title></Card.Header></Card.Root>
</div>
<Card.Root>
<Card.Content class="pt-6">
<div class="mb-5 flex flex-wrap gap-1 border-b">
{#each [{ id: 'conceptos', label: 'Conceptos' }, { id: 'pagos', label: 'Pagos / cobranza' }, { id: 'datos', label: 'Datos' }] as t (t.id)}
<button type="button" class="border-b-2 px-3 py-2 text-sm font-medium transition-colors {tab === t.id ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}" onclick={() => (tab = t.id)}>{t.label}</button>
{/each}
</div>
{#if tab === 'conceptos'}
<div class="mb-3 flex justify-end"><Button size="sm" variant="outline" onclick={startItem}><Plus class="mr-1 h-4 w-4" /> Agregar concepto</Button></div>
{#if addingItem}
<div class="mb-4 grid gap-3 rounded-md border p-3 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Concepto</span><select class={inputCls} bind:value={newItem.concept}>{#each QUOTE_CONCEPTS as c (c.value)}<option value={c.value}>{c.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Descripción</span><input class={inputCls} bind:value={newItem.description} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cantidad</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={newItem.quantity} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Importe unitario</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={newItem.unit_amount} /></label>
<div class="flex justify-end gap-2 sm:col-span-2"><Button variant="outline" size="sm" onclick={() => (addingItem = false)}>Cancelar</Button><Button size="sm" onclick={saveItem}>Guardar</Button></div>
</div>
{/if}
{#if items.length === 0}
<p class="text-sm text-muted-foreground">Sin conceptos.</p>
{:else}
<Table.Root>
<Table.Header><Table.Row><Table.Head>Concepto</Table.Head><Table.Head class="text-right">Cant.</Table.Head><Table.Head class="text-right">Unitario</Table.Head><Table.Head class="text-right">Importe</Table.Head><Table.Head></Table.Head></Table.Row></Table.Header>
<Table.Body>
{#each items as it (it.id)}
<Table.Row>
<Table.Cell class="font-medium">{labelOf(QUOTE_CONCEPTS, it.concept)}{#if it.description}<span class="block text-xs text-muted-foreground">{it.description}</span>{/if}</Table.Cell>
<Table.Cell class="text-right">{it.quantity}</Table.Cell>
<Table.Cell class="text-right">{formatMoney(it.unit_amount, invoice.currency)}</Table.Cell>
<Table.Cell class="text-right">{formatMoney(it.line_total, invoice.currency)}</Table.Cell>
<Table.Cell class="text-right"><Button variant="ghost" size="sm" onclick={() => removeItem(it)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button></Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
{:else if tab === 'pagos'}
<div class="mb-3 flex justify-end"><Button size="sm" variant="outline" onclick={startPay}><Plus class="mr-1 h-4 w-4" /> Registrar pago</Button></div>
{#if addingPay}
<div class="mb-4 grid gap-3 rounded-md border p-3 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Monto</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={newPay.amount} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Fecha</span><input type="date" class={inputCls} bind:value={newPay.payment_date} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Método</span><select class={inputCls} bind:value={newPay.method}>{#each PAYMENT_METHODS as m (m.value)}<option value={m.value}>{m.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Referencia</span><input class={inputCls} bind:value={newPay.reference} /></label>
<div class="flex justify-end gap-2 sm:col-span-2"><Button variant="outline" size="sm" onclick={() => (addingPay = false)}>Cancelar</Button><Button size="sm" onclick={savePay}>Guardar</Button></div>
</div>
{/if}
{#if payments.length === 0}
<p class="text-sm text-muted-foreground">Sin pagos registrados.</p>
{:else}
<Table.Root>
<Table.Header><Table.Row><Table.Head>Fecha</Table.Head><Table.Head>Método</Table.Head><Table.Head>Referencia</Table.Head><Table.Head class="text-right">Monto</Table.Head><Table.Head></Table.Head></Table.Row></Table.Header>
<Table.Body>
{#each payments as p (p.id)}
<Table.Row>
<Table.Cell>{p.payment_date ?? '—'}</Table.Cell>
<Table.Cell>{labelOf(PAYMENT_METHODS, p.method)}</Table.Cell>
<Table.Cell class="font-mono text-xs">{p.reference ?? '—'}</Table.Cell>
<Table.Cell class="text-right">{formatMoney(p.amount, invoice.currency)}</Table.Cell>
<Table.Cell class="text-right"><Button variant="ghost" size="sm" onclick={() => removePay(p)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button></Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
{:else}
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Folio</span><input class={inputCls} bind:value={form.reference} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cliente</span><select class={inputCls} bind:value={form.account_id}><option value={undefined}>—</option>{#each accounts as a (a.id)}<option value={a.id}>{a.name}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Moneda</span><input class={inputCls} maxlength="3" bind:value={form.currency} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">% Impuesto</span><input type="number" min="0" max="100" step="0.01" class={inputCls} bind:value={form.tax_rate} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Emisión</span><input type="date" class={inputCls} bind:value={form.issue_date} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Vencimiento</span><input type="date" class={inputCls} bind:value={form.due_date} /></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Datos bancarios</span><textarea rows="2" class={inputCls} bind:value={form.bank_info}></textarea></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Notas</span><textarea rows="2" class={inputCls} bind:value={form.notes}></textarea></label>
</div>
<div class="mt-6 flex justify-end border-t pt-4"><Button onclick={saveHeader} disabled={saving}>{saving ? 'Guardando…' : 'Guardar cambios'}</Button></div>
{/if}
</Card.Content>
</Card.Root>
{/if}
</div>

View File

@@ -0,0 +1,60 @@
<script lang="ts">
import { ArrowLeft, Receipt } from '@lucide/svelte';
import { goto } from '$app/navigation';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import { invoicesAPI, type InvoiceInput } from '$lib/api/fin';
import { accountsAPI, type Account } from '$lib/api/crm';
import { toast } from 'svelte-sonner';
let form = $state<InvoiceInput>({ currency: 'MXN', tax_rate: 16 });
let accounts = $state<Account[]>([]);
let saving = $state(false);
const companyId = $derived(companyStore.activeCompany?.id ?? null);
$effect(() => {
const cid = companyId;
if (!cid) return;
void (async () => { accounts = await accountsAPI.list(cid); })();
});
async function save() {
if (!companyId) return;
saving = true;
try {
const created = await invoicesAPI.create(form, companyId);
toast.success('Factura creada');
await goto(`/dashboard/fin/facturas/${created.id}`);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo crear la factura');
} finally {
saving = false;
}
}
const inputCls = 'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
</script>
<div class="space-y-6">
<Button variant="ghost" size="sm" href="/dashboard/fin/facturas"><ArrowLeft class="mr-1 h-4 w-4" /> Facturas</Button>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><Receipt class="h-6 w-6" /> Nueva factura</h1>
<p class="text-sm text-muted-foreground">Tip: también puedes generar la factura automáticamente desde un embarque (botón en el embarque).</p>
<Card.Root>
<Card.Content class="pt-6">
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Folio</span><input class={inputCls} bind:value={form.reference} placeholder="F-0001" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cliente</span><select class={inputCls} bind:value={form.account_id}><option value={undefined}>—</option>{#each accounts as a (a.id)}<option value={a.id}>{a.name}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Moneda</span><input class={inputCls} maxlength="3" bind:value={form.currency} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">% Impuesto (IVA)</span><input type="number" min="0" max="100" step="0.01" class={inputCls} bind:value={form.tax_rate} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Vencimiento</span><input type="date" class={inputCls} bind:value={form.due_date} /></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Datos bancarios</span><textarea rows="2" class={inputCls} bind:value={form.bank_info}></textarea></label>
</div>
<div class="mt-6 flex justify-end gap-2 border-t pt-4">
<Button variant="outline" href="/dashboard/fin/facturas">Cancelar</Button>
<Button onclick={save} disabled={saving || !companyId}>{saving ? 'Guardando…' : 'Crear y agregar conceptos'}</Button>
</div>
</Card.Content>
</Card.Root>
</div>

View File

@@ -1,15 +1,18 @@
<script lang="ts"> <script lang="ts">
import { ArrowLeft, Ship, Plus, Trash2 } from '@lucide/svelte'; import { ArrowLeft, Ship, Plus, Trash2, Check, Receipt, ListChecks, Upload } from '@lucide/svelte';
import { page } from '$app/state'; import { page } from '$app/state';
import { goto } from '$app/navigation';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import * as Table from '$lib/components/ui/table'; import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte'; import { companyStore } from '$lib/stores/company.svelte';
import { accountsAPI, suppliersAPI, type Account, type Supplier } from '$lib/api/crm'; import { accountsAPI, suppliersAPI, type Account, type Supplier } from '$lib/api/crm';
import { import {
shipmentsAPI, shipmentDocumentsAPI, shipmentsAPI, shipmentDocumentsAPI, shipmentEventsAPI,
type Shipment, type ShipmentInput, type ShipmentDocument, type ShipmentDocumentInput type Shipment, type ShipmentInput, type ShipmentDocument, type ShipmentDocumentInput, type ShipmentEvent
} from '$lib/api/ops'; } from '$lib/api/ops';
import { invoicesAPI } from '$lib/api/fin';
import { uploadFile, uploadUrl } from '$lib/api/uploads';
import { import {
OPERATION_TYPES, TRANSPORT_MODES, SERVICE_TYPES, SHIPMENT_STATUS, OPERATION_TYPES, TRANSPORT_MODES, SERVICE_TYPES, SHIPMENT_STATUS,
DOC_KINDS, SHIPMENT_DOC_TYPES, labelOf, formatDate DOC_KINDS, SHIPMENT_DOC_TYPES, labelOf, formatDate
@@ -24,11 +27,16 @@
let accounts = $state<Account[]>([]); let accounts = $state<Account[]>([]);
let suppliers = $state<Supplier[]>([]); let suppliers = $state<Supplier[]>([]);
let docs = $state<ShipmentDocument[]>([]); let docs = $state<ShipmentDocument[]>([]);
let events = $state<ShipmentEvent[]>([]);
let tab = $state('datos'); let tab = $state('datos');
let loading = $state(false); let loading = $state(false);
let saving = $state(false); let saving = $state(false);
let adding = $state(false); let busy = $state(false);
let addingDoc = $state(false);
let uploading = $state(false);
let newDoc = $state<ShipmentDocumentInput>({ shipment_id: 0, doc_kind: 'master', doc_type: 'MBL' }); let newDoc = $state<ShipmentDocumentInput>({ shipment_id: 0, doc_kind: 'master', doc_type: 'MBL' });
let addingEvent = $state(false);
let newEvent = $state<{ title: string; notes?: string }>({ title: '' });
$effect(() => { $effect(() => {
const cid = companyId; const cid = companyId;
@@ -40,11 +48,9 @@
async function load(cid: number, id: number) { async function load(cid: number, id: number) {
loading = true; loading = true;
try { try {
[shipment, accounts, suppliers, docs] = await Promise.all([ [shipment, accounts, suppliers, docs, events] = await Promise.all([
shipmentsAPI.get(id, cid), shipmentsAPI.get(id, cid), accountsAPI.list(cid), suppliersAPI.list(cid),
accountsAPI.list(cid), shipmentsAPI.documents(id, cid), shipmentsAPI.events(id, cid)
suppliersAPI.list(cid),
shipmentsAPI.documents(id, cid)
]); ]);
form = { ...shipment }; form = { ...shipment };
} catch (e) { } catch (e) {
@@ -68,29 +74,101 @@
} }
} }
function startAdd() { async function generateInvoice() {
newDoc = { shipment_id: shipmentId, doc_kind: 'master', doc_type: 'MBL' }; if (!companyId || !shipment) return;
adding = true; if (!confirm('¿Generar la factura de este embarque?')) return;
busy = true;
try {
const inv = await invoicesAPI.fromShipment(shipment.id, companyId);
toast.success('Factura generada');
await goto(`/dashboard/fin/facturas/${inv.id}`);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo generar la factura');
} finally {
busy = false;
}
}
// ----- Documentos -----
function startDoc() { newDoc = { shipment_id: shipmentId, doc_kind: 'master', doc_type: 'MBL' }; addingDoc = true; }
async function onFilePicked(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file || !companyId) return;
uploading = true;
try {
const up = await uploadFile(file, companyId);
newDoc = { ...newDoc, file_key: up.file_key, file_url: up.file_url, number: newDoc.number };
if (!newDoc.number) newDoc.number = up.name;
toast.success('Archivo subido');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'No se pudo subir el archivo');
} finally {
uploading = false;
}
} }
async function saveDoc() { async function saveDoc() {
if (!companyId) return; if (!companyId) return;
try { try {
await shipmentDocumentsAPI.create({ ...newDoc, shipment_id: shipmentId }, companyId); await shipmentDocumentsAPI.create({ ...newDoc, shipment_id: shipmentId }, companyId);
toast.success('Documento agregado'); addingDoc = false;
adding = false;
docs = await shipmentsAPI.documents(shipmentId, companyId); docs = await shipmentsAPI.documents(shipmentId, companyId);
toast.success('Documento agregado');
} catch (e) { } catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo agregar'); toast.error(e instanceof Error ? e.message : 'No se pudo agregar');
} }
} }
async function openDoc(d: ShipmentDocument) {
if (!companyId) return;
try {
const url = d.file_key ? await uploadUrl(d.file_key, companyId) : d.file_url;
if (url) window.open(url, '_blank', 'noopener');
else toast.error('El documento no tiene archivo');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo abrir el archivo');
}
}
async function removeDoc(d: ShipmentDocument) { async function removeDoc(d: ShipmentDocument) {
if (!companyId || !confirm('¿Eliminar documento?')) return; if (!companyId || !confirm('¿Eliminar documento?')) return;
await shipmentDocumentsAPI.remove(d.id, companyId); await shipmentDocumentsAPI.remove(d.id, companyId);
docs = await shipmentsAPI.documents(shipmentId, companyId); docs = await shipmentsAPI.documents(shipmentId, companyId);
} }
// ----- Bitácora -----
async function seedEvents() {
if (!companyId) return;
busy = true;
try {
events = await shipmentsAPI.seedEvents(shipmentId, companyId);
toast.success('Hitos generados');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudieron generar los hitos');
} finally {
busy = false;
}
}
async function completeEvent(ev: ShipmentEvent) {
if (!companyId) return;
await shipmentEventsAPI.complete(ev.id, companyId);
events = await shipmentsAPI.events(shipmentId, companyId);
}
async function saveEvent() {
if (!companyId || !newEvent.title.trim()) { toast.error('El título es obligatorio'); return; }
await shipmentEventsAPI.create({ shipment_id: shipmentId, title: newEvent.title, notes: newEvent.notes, position: events.length }, companyId);
newEvent = { title: '' };
addingEvent = false;
events = await shipmentsAPI.events(shipmentId, companyId);
}
async function removeEvent(ev: ShipmentEvent) {
if (!companyId || !confirm('¿Eliminar hito?')) return;
await shipmentEventsAPI.remove(ev.id, companyId);
events = await shipmentsAPI.events(shipmentId, companyId);
}
const inputCls = 'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring'; const inputCls = 'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
</script> </script>
@@ -100,15 +178,18 @@
{#if loading && !shipment} {#if loading && !shipment}
<p class="text-sm text-muted-foreground">Cargando…</p> <p class="text-sm text-muted-foreground">Cargando…</p>
{:else if shipment} {:else if shipment}
<div> <div class="flex flex-wrap items-start justify-between gap-3">
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><Ship class="h-6 w-6" /> {shipment.reference ?? `Embarque #${shipment.id}`}</h1> <div>
<p class="mt-1 text-sm text-muted-foreground">{labelOf(SHIPMENT_STATUS, shipment.status)}{#if shipment.booking_number} · Booking {shipment.booking_number}{/if}</p> <h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><Ship class="h-6 w-6" /> {shipment.reference ?? `Embarque #${shipment.id}`}</h1>
<p class="mt-1 text-sm text-muted-foreground">{labelOf(SHIPMENT_STATUS, shipment.status)}{#if shipment.booking_number} · Booking {shipment.booking_number}{/if}</p>
</div>
<Button size="sm" variant="outline" onclick={generateInvoice} disabled={busy}><Receipt class="mr-1 h-4 w-4" /> Generar factura</Button>
</div> </div>
<Card.Root> <Card.Root>
<Card.Content class="pt-6"> <Card.Content class="pt-6">
<div class="mb-5 flex flex-wrap gap-1 border-b"> <div class="mb-5 flex flex-wrap gap-1 border-b">
{#each [{ id: 'datos', label: 'Datos del embarque' }, { id: 'documentos', label: 'Documentos' }] as t (t.id)} {#each [{ id: 'datos', label: 'Datos del embarque' }, { id: 'documentos', label: 'Documentos' }, { id: 'bitacora', label: 'Bitácora' }] as t (t.id)}
<button type="button" class="border-b-2 px-3 py-2 text-sm font-medium transition-colors {tab === t.id ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}" onclick={() => (tab = t.id)}>{t.label}</button> <button type="button" class="border-b-2 px-3 py-2 text-sm font-medium transition-colors {tab === t.id ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}" onclick={() => (tab = t.id)}>{t.label}</button>
{/each} {/each}
</div> </div>
@@ -135,16 +216,19 @@
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Notas</span><textarea rows="2" class={inputCls} bind:value={form.notes}></textarea></label> <label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Notas</span><textarea rows="2" class={inputCls} bind:value={form.notes}></textarea></label>
</div> </div>
<div class="mt-6 flex justify-end border-t pt-4"><Button onclick={save} disabled={saving}>{saving ? 'Guardando…' : 'Guardar cambios'}</Button></div> <div class="mt-6 flex justify-end border-t pt-4"><Button onclick={save} disabled={saving}>{saving ? 'Guardando…' : 'Guardar cambios'}</Button></div>
{:else} {:else if tab === 'documentos'}
<div class="mb-3 flex justify-end"><Button size="sm" variant="outline" onclick={startAdd}><Plus class="mr-1 h-4 w-4" /> Agregar documento</Button></div> <div class="mb-3 flex justify-end"><Button size="sm" variant="outline" onclick={startDoc}><Plus class="mr-1 h-4 w-4" /> Agregar documento</Button></div>
{#if adding} {#if addingDoc}
<div class="mb-4 grid gap-3 rounded-md border p-3 sm:grid-cols-2"> <div class="mb-4 grid gap-3 rounded-md border p-3 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo doc.</span><select class={inputCls} bind:value={newDoc.doc_kind}>{#each DOC_KINDS as k (k.value)}<option value={k.value}>{k.label}</option>{/each}</select></label> <label class="flex flex-col gap-1 text-sm"><span class="font-medium">Clase</span><select class={inputCls} bind:value={newDoc.doc_kind}>{#each DOC_KINDS as k (k.value)}<option value={k.value}>{k.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Documento</span><select class={inputCls} bind:value={newDoc.doc_type}>{#each SHIPMENT_DOC_TYPES as t (t.value)}<option value={t.value}>{t.label}</option>{/each}</select></label> <label class="flex flex-col gap-1 text-sm"><span class="font-medium">Documento</span><select class={inputCls} bind:value={newDoc.doc_type}>{#each SHIPMENT_DOC_TYPES as t (t.value)}<option value={t.value}>{t.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Número</span><input class={inputCls} bind:value={newDoc.number} /></label> <label class="flex flex-col gap-1 text-sm"><span class="font-medium">Número</span><input class={inputCls} bind:value={newDoc.number} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Fecha emisión</span><input type="date" class={inputCls} bind:value={newDoc.issue_date} /></label> <label class="flex flex-col gap-1 text-sm"><span class="font-medium">Fecha emisión</span><input type="date" class={inputCls} bind:value={newDoc.issue_date} /></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">URL del archivo</span><input class={inputCls} bind:value={newDoc.file_url} placeholder="https://…" /></label> <label class="flex flex-col gap-1 text-sm sm:col-span-2">
<div class="flex justify-end gap-2 sm:col-span-2"><Button variant="outline" size="sm" onclick={() => (adding = false)}>Cancelar</Button><Button size="sm" onclick={saveDoc}>Guardar</Button></div> <span class="font-medium flex items-center gap-1"><Upload class="h-3.5 w-3.5" /> Archivo {#if uploading}<span class="text-xs text-muted-foreground">(subiendo…)</span>{:else if newDoc.file_key}<span class="text-xs text-emerald-600">(cargado)</span>{/if}</span>
<input type="file" class={inputCls} onchange={onFilePicked} />
</label>
<div class="flex justify-end gap-2 sm:col-span-2"><Button variant="outline" size="sm" onclick={() => (addingDoc = false)}>Cancelar</Button><Button size="sm" onclick={saveDoc} disabled={uploading}>Guardar</Button></div>
</div> </div>
{/if} {/if}
{#if docs.length === 0} {#if docs.length === 0}
@@ -159,13 +243,42 @@
<Table.Cell class="font-medium">{labelOf(SHIPMENT_DOC_TYPES, d.doc_type)}</Table.Cell> <Table.Cell class="font-medium">{labelOf(SHIPMENT_DOC_TYPES, d.doc_type)}</Table.Cell>
<Table.Cell class="font-mono text-xs">{d.number ?? '—'}</Table.Cell> <Table.Cell class="font-mono text-xs">{d.number ?? '—'}</Table.Cell>
<Table.Cell>{formatDate(d.issue_date)}</Table.Cell> <Table.Cell>{formatDate(d.issue_date)}</Table.Cell>
<Table.Cell>{#if d.file_url}<a class="text-primary hover:underline" href={d.file_url} target="_blank" rel="noopener">Ver</a>{:else}{/if}</Table.Cell> <Table.Cell>{#if d.file_key || d.file_url}<button type="button" class="text-primary hover:underline" onclick={() => openDoc(d)}>Ver</button>{:else}{/if}</Table.Cell>
<Table.Cell class="text-right"><Button variant="ghost" size="sm" onclick={() => removeDoc(d)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button></Table.Cell> <Table.Cell class="text-right"><Button variant="ghost" size="sm" onclick={() => removeDoc(d)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button></Table.Cell>
</Table.Row> </Table.Row>
{/each} {/each}
</Table.Body> </Table.Body>
</Table.Root> </Table.Root>
{/if} {/if}
{:else}
<div class="mb-3 flex flex-wrap justify-end gap-2">
{#if events.length === 0}<Button size="sm" variant="outline" onclick={seedEvents} disabled={busy}><ListChecks class="mr-1 h-4 w-4" /> Generar hitos</Button>{/if}
<Button size="sm" variant="outline" onclick={() => (addingEvent = true)}><Plus class="mr-1 h-4 w-4" /> Agregar hito</Button>
</div>
{#if addingEvent}
<div class="mb-4 grid gap-3 rounded-md border p-3">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Título del hito</span><input class={inputCls} bind:value={newEvent.title} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Notas</span><input class={inputCls} bind:value={newEvent.notes} /></label>
<div class="flex justify-end gap-2"><Button variant="outline" size="sm" onclick={() => (addingEvent = false)}>Cancelar</Button><Button size="sm" onclick={saveEvent}>Guardar</Button></div>
</div>
{/if}
{#if events.length === 0}
<p class="text-sm text-muted-foreground">Sin hitos. Usa "Generar hitos" para crear la secuencia según el tipo de operación.</p>
{:else}
<ol class="space-y-2">
{#each events as ev (ev.id)}
<li class="flex items-center gap-3 rounded-md border p-3">
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs {ev.status === 'completado' ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}">{ev.status === 'completado' ? '✓' : ev.position + 1}</span>
<div class="flex-1">
<p class="text-sm font-medium {ev.status === 'completado' ? 'line-through text-muted-foreground' : ''}">{ev.title}</p>
{#if ev.actual_date}<p class="text-xs text-muted-foreground">Completado: {formatDate(ev.actual_date)}</p>{/if}
</div>
{#if ev.status !== 'completado'}<Button variant="ghost" size="sm" onclick={() => completeEvent(ev)} aria-label="Completar"><Check class="h-4 w-4 text-emerald-600" /></Button>{/if}
<Button variant="ghost" size="sm" onclick={() => removeEvent(ev)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button>
</li>
{/each}
</ol>
{/if}
{/if} {/if}
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>