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:
107
frontend/src/lib/api/fin/index.ts
Normal file
107
frontend/src/lib/api/fin/index.ts
Normal 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)}`))
|
||||||
|
};
|
||||||
@@ -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 = {
|
||||||
|
|||||||
33
frontend/src/lib/api/uploads.ts
Normal file
33
frontend/src/lib/api/uploads.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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)' },
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
121
frontend/src/routes/dashboard/fin/facturas/+page.svelte
Normal file
121
frontend/src/routes/dashboard/fin/facturas/+page.svelte
Normal 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>
|
||||||
230
frontend/src/routes/dashboard/fin/facturas/[id]/+page.svelte
Normal file
230
frontend/src/routes/dashboard/fin/facturas/[id]/+page.svelte
Normal 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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user