feature/continuacion-vista-tablas-location
This commit is contained in:
@@ -1,64 +1,84 @@
|
||||
import type { PaginatedResponse } from '$lib/types';
|
||||
import { api } from '$lib/api';
|
||||
|
||||
/** Contexto de uso: Fixed Asset (activo fijo) o inventario */
|
||||
export type LocationSystem = 'fixed_asset' | 'inventory';
|
||||
|
||||
export interface Location {
|
||||
id: number;
|
||||
location_code: string;
|
||||
location_description: string | null;
|
||||
company_id: number;
|
||||
tenant_id: number;
|
||||
clave_localizacion: string;
|
||||
localizacion: string | null;
|
||||
system: LocationSystem;
|
||||
}
|
||||
|
||||
export interface LocationCreate {
|
||||
location_code: string;
|
||||
location_description?: string | null;
|
||||
clave_localizacion: string;
|
||||
localizacion?: string | null;
|
||||
system: LocationSystem;
|
||||
/** Used when system === 'fixed_asset' */
|
||||
department?: string | null;
|
||||
responsible?: string | null;
|
||||
observations?: string | null;
|
||||
}
|
||||
|
||||
export interface LocationUpdate {
|
||||
location_description?: string | null;
|
||||
clave_localizacion?: string;
|
||||
localizacion?: string | null;
|
||||
system?: LocationSystem;
|
||||
}
|
||||
|
||||
export interface LocationListResponse extends PaginatedResponse {
|
||||
export interface LocationListResponse {
|
||||
items: Location[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export interface LocationFilters {
|
||||
location_code?: string;
|
||||
location_description?: string;
|
||||
clave_localizacion?: string;
|
||||
localizacion?: string;
|
||||
system?: LocationSystem;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
import { portsApi, PortType } from './ports';
|
||||
const BASE_URL = '/v1/a76/locations';
|
||||
|
||||
function buildQuery(companyId: number, params?: LocationFilters): string {
|
||||
const search = new URLSearchParams();
|
||||
search.set('company_id', String(companyId));
|
||||
if (params?.page != null) search.set('page', String(params.page));
|
||||
if (params?.page_size != null) search.set('page_size', String(params.page_size));
|
||||
if (params?.clave_localizacion) search.set('clave_localizacion', params.clave_localizacion);
|
||||
if (params?.localizacion) search.set('localizacion', params.localizacion);
|
||||
if (params?.system) search.set('system', params.system);
|
||||
return search.toString();
|
||||
}
|
||||
|
||||
export async function getLocations(
|
||||
companyId: number,
|
||||
filters?: LocationFilters
|
||||
): Promise<LocationListResponse> {
|
||||
const res = await portsApi.list(companyId, filters || {});
|
||||
return (res.data || res) as unknown as LocationListResponse;
|
||||
const q = buildQuery(companyId, filters);
|
||||
const res = await api.get<LocationListResponse>(`${BASE_URL}/?${q}`);
|
||||
return (res.data ?? res) as LocationListResponse;
|
||||
}
|
||||
|
||||
export async function getLocation(
|
||||
locationId: number,
|
||||
companyId: number
|
||||
): Promise<Location> {
|
||||
const res = await portsApi.get(locationId, companyId);
|
||||
return (res.data || res) as unknown as Location;
|
||||
const q = new URLSearchParams({ company_id: String(companyId) });
|
||||
const res = await api.get<Location>(`${BASE_URL}/${locationId}?${q}`);
|
||||
return (res.data ?? res) as Location;
|
||||
}
|
||||
|
||||
export async function createLocation(
|
||||
data: LocationCreate,
|
||||
companyId: number
|
||||
): Promise<Location> {
|
||||
const res = await portsApi.create({
|
||||
port_code: data.location_code,
|
||||
location_code: data.location_code,
|
||||
description: null,
|
||||
location_description: data.location_description || null,
|
||||
port_type: PortType.ENTRY
|
||||
}, companyId);
|
||||
return (res.data || res) as unknown as Location;
|
||||
const q = new URLSearchParams({ company_id: String(companyId) });
|
||||
const res = await api.post<Location>(`${BASE_URL}/?${q}`, data);
|
||||
return (res.data ?? res) as Location;
|
||||
}
|
||||
|
||||
export async function updateLocation(
|
||||
@@ -66,15 +86,15 @@ export async function updateLocation(
|
||||
data: LocationUpdate,
|
||||
companyId: number
|
||||
): Promise<Location> {
|
||||
const res = await portsApi.update(locationId, {
|
||||
location_description: data.location_description
|
||||
}, companyId);
|
||||
return (res.data || res) as unknown as Location;
|
||||
const q = new URLSearchParams({ company_id: String(companyId) });
|
||||
const res = await api.put<Location>(`${BASE_URL}/${locationId}/?${q}`, data);
|
||||
return (res.data ?? res) as Location;
|
||||
}
|
||||
|
||||
export async function deleteLocation(
|
||||
locationId: number,
|
||||
companyId: number
|
||||
): Promise<void> {
|
||||
await portsApi.delete(locationId, companyId);
|
||||
}
|
||||
const q = new URLSearchParams({ company_id: String(companyId) });
|
||||
await api.delete(`${BASE_URL}/${locationId}?${q}`);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
import {
|
||||
createLocation,
|
||||
updateLocation,
|
||||
type Location
|
||||
type Location,
|
||||
type LocationSystem
|
||||
} from '$lib/api/dashboard/a76/general_catalogs/locations';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { obtenerAtajosFormularioLocalidades } from '$lib/config/shortcuts/dashboard/general_catalogs/locations/edit';
|
||||
@@ -21,14 +22,18 @@
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
// Atajos
|
||||
|
||||
const isEdit = $derived(!!item);
|
||||
const title = $derived(isEdit ? 'Editar Ubicación' : 'Nueva Ubicación');
|
||||
|
||||
const systemOptions: { value: LocationSystem; label: string }[] = [
|
||||
{ value: 'fixed_asset', label: 'Activo fijo (FA)' },
|
||||
{ value: 'inventory', label: 'Inventario' }
|
||||
];
|
||||
|
||||
let formData = $state({
|
||||
location_code: '',
|
||||
location_description: ''
|
||||
clave_localizacion: '',
|
||||
localizacion: '',
|
||||
system: 'fixed_asset' as LocationSystem
|
||||
});
|
||||
|
||||
let loading = $state(false);
|
||||
@@ -38,11 +43,16 @@
|
||||
if (open) {
|
||||
if (item) {
|
||||
formData = {
|
||||
location_code: item.location_code || '',
|
||||
location_description: item.location_description || ''
|
||||
clave_localizacion: item.clave_localizacion ?? '',
|
||||
localizacion: item.localizacion ?? '',
|
||||
system: item.system ?? 'fixed_asset'
|
||||
};
|
||||
} else {
|
||||
formData = { location_code: '', location_description: '' };
|
||||
formData = {
|
||||
clave_localizacion: '',
|
||||
localizacion: '',
|
||||
system: 'fixed_asset'
|
||||
};
|
||||
}
|
||||
error = null;
|
||||
}
|
||||
@@ -55,20 +65,31 @@
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) throw new Error('No hay una compañía seleccionada');
|
||||
|
||||
if (!formData.location_code.trim()) throw new Error('El código es requerido');
|
||||
if (!formData.clave_localizacion.trim()) throw new Error('La clave es requerida');
|
||||
|
||||
const basePayload = {
|
||||
location_description: formData.location_description?.trim() || null
|
||||
localizacion: formData.localizacion?.trim() || null
|
||||
};
|
||||
|
||||
if (isEdit && item) {
|
||||
await updateLocation(item.id, basePayload, companyId);
|
||||
await updateLocation(
|
||||
item.id,
|
||||
{
|
||||
...basePayload,
|
||||
clave_localizacion: formData.clave_localizacion.trim(),
|
||||
system: formData.system
|
||||
},
|
||||
companyId
|
||||
);
|
||||
} else {
|
||||
const createPayload = {
|
||||
location_code: formData.location_code.trim(),
|
||||
...basePayload
|
||||
};
|
||||
await createLocation(createPayload, companyId);
|
||||
await createLocation(
|
||||
{
|
||||
clave_localizacion: formData.clave_localizacion.trim(),
|
||||
...basePayload,
|
||||
system: formData.system
|
||||
},
|
||||
companyId
|
||||
);
|
||||
}
|
||||
|
||||
open = false;
|
||||
@@ -107,12 +128,12 @@
|
||||
|
||||
<div class="grid gap-4">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="location_code" class="text-right">Código *</Label>
|
||||
<Label for="clave_localizacion" class="text-right">Clave *</Label>
|
||||
<div class="col-span-3">
|
||||
<Input
|
||||
id="location_code"
|
||||
bind:value={formData.location_code}
|
||||
maxlength={4}
|
||||
id="clave_localizacion"
|
||||
bind:value={formData.clave_localizacion}
|
||||
maxlength={20}
|
||||
disabled={loading || isEdit}
|
||||
required
|
||||
/>
|
||||
@@ -120,16 +141,32 @@
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="location_description" class="text-right">Descripción</Label>
|
||||
<Label for="localizacion" class="text-right">Localización</Label>
|
||||
<div class="col-span-3">
|
||||
<Input
|
||||
id="location_description"
|
||||
bind:value={formData.location_description}
|
||||
maxlength={20}
|
||||
id="localizacion"
|
||||
bind:value={formData.localizacion}
|
||||
maxlength={200}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="system" class="text-right">Sistema</Label>
|
||||
<div class="col-span-3">
|
||||
<select
|
||||
id="system"
|
||||
bind:value={formData.system}
|
||||
disabled={loading}
|
||||
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
{#each systemOptions as opt}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
|
||||
@@ -10,10 +10,12 @@
|
||||
let {
|
||||
open = $bindable(false),
|
||||
regimen = 'Temporal',
|
||||
operationType = 'imp' as 'imp' | 'exp',
|
||||
onSelect
|
||||
}: {
|
||||
open: boolean;
|
||||
regimen: string;
|
||||
regimen?: string;
|
||||
operationType?: 'imp' | 'exp';
|
||||
onSelect: (invoice: Invoice) => void;
|
||||
} = $props();
|
||||
|
||||
@@ -25,15 +27,16 @@
|
||||
if (!companyStore.activeCompany) return;
|
||||
loading = true;
|
||||
try {
|
||||
let filters: any = {
|
||||
operation_type: 'imp',
|
||||
invoice_number: searchTerm
|
||||
const filters: any = {
|
||||
operation_type: operationType,
|
||||
invoice_number: searchTerm || undefined
|
||||
};
|
||||
|
||||
if (regimen === 'Temporal' || regimen === 'TEMPORAL SCAF') {
|
||||
filters.invoice_type = 'TEM';
|
||||
} else if (regimen === 'Definitiva' || regimen === 'DEFINITIVO SCAF') {
|
||||
filters.invoice_type = 'DEF';
|
||||
if (operationType === 'imp' && regimen) {
|
||||
if (regimen === 'Temporal' || regimen === 'TEMPORAL SCAF') {
|
||||
filters.invoice_type = 'TEM';
|
||||
} else if (regimen === 'Definitiva' || regimen === 'DEFINITIVO SCAF') {
|
||||
filters.invoice_type = 'DEF';
|
||||
}
|
||||
}
|
||||
|
||||
const res = await invoicesApi.list(companyStore.activeCompany.id, 1, 50, filters);
|
||||
@@ -60,12 +63,18 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-w-2xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Seleccionar Factura ({regimen})</Dialog.Title>
|
||||
<Dialog.Title>
|
||||
{operationType === 'exp' ? 'Seleccionar Factura de Exportación' : `Seleccionar Factura (${regimen})`}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Busca y selecciona una factura del catálogo de importación para el régimen {regimen}.
|
||||
{#if operationType === 'exp'}
|
||||
Busca y selecciona una factura del catálogo de exportación.
|
||||
{:else}
|
||||
Busca y selecciona una factura del catálogo de importación para el régimen {regimen}.
|
||||
{/if}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
<script lang="ts">
|
||||
import * as Sheet from '$lib/components/ui/sheet';
|
||||
import * as Tabs from '$lib/components/ui/tabs';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '$lib/components/ui/radio-group';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import { Loader2, Package, Save, X, FileText } from 'lucide-svelte';
|
||||
import { Loader2, Package, Save, X, FileText, Folder } from 'lucide-svelte';
|
||||
import type { Invoice } from '$lib/api/dashboard/a76/invoices';
|
||||
import type { Item } from '$lib/api/dashboard/a76/items';
|
||||
import { invoicesApi } from '$lib/api/dashboard/a76/invoices';
|
||||
import { itemsApi, type Item } from '$lib/api/dashboard/a76/items';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
|
||||
// Child components
|
||||
import InvoiceSelectorModal from '../../InvoiceSelectorModal.svelte';
|
||||
import MainData from './main-data.svelte';
|
||||
import ItemConfiguration from './item-configuration.svelte';
|
||||
import PackagesSection from './packages-section.svelte';
|
||||
@@ -63,15 +71,64 @@
|
||||
}
|
||||
});
|
||||
|
||||
const visibility = $derived.by(() => getVisibility(invoiceType ?? invoice?.invoice_type, operationType ?? invoice?.operation_type));
|
||||
const showCrTrackingBlock = $derived.by(() => {
|
||||
const normalizedOperationType = operationType ?? invoice?.operation_type;
|
||||
if (normalizedOperationType === 1 || normalizedOperationType === 'exp') {
|
||||
return false;
|
||||
}
|
||||
// Selectores de factura (FK): estado y líneas
|
||||
let showImportInvoiceModal = $state(false);
|
||||
let showExportInvoiceModal = $state(false);
|
||||
let showExportLinePicker = $state(false);
|
||||
let showImportLinePicker = $state(false);
|
||||
let selectedImportInvoiceId = $state<number | null>(null);
|
||||
let selectedExportInvoiceId = $state<number | null>(null);
|
||||
let importInvoiceLines = $state<Item[]>([]);
|
||||
let exportInvoiceLines = $state<Item[]>([]);
|
||||
let loadingImportLines = $state(false);
|
||||
let loadingExportLines = $state(false);
|
||||
|
||||
return visibility.showCrTrackingHeader;
|
||||
});
|
||||
async function loadImportLines(invoiceId: number) {
|
||||
const companyId = companyStore?.activeCompany?.id;
|
||||
if (!companyId) return;
|
||||
loadingImportLines = true;
|
||||
try {
|
||||
const res = await itemsApi.listByInvoice(invoiceId, companyId);
|
||||
importInvoiceLines = res.data?.items ?? [];
|
||||
} catch {
|
||||
importInvoiceLines = [];
|
||||
} finally {
|
||||
loadingImportLines = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadExportLines(invoiceId: number) {
|
||||
const companyId = companyStore?.activeCompany?.id;
|
||||
if (!companyId) return;
|
||||
loadingExportLines = true;
|
||||
try {
|
||||
const res = await itemsApi.listByInvoice(invoiceId, companyId);
|
||||
exportInvoiceLines = res.data?.items ?? [];
|
||||
} catch {
|
||||
exportInvoiceLines = [];
|
||||
} finally {
|
||||
loadingExportLines = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectImportInvoice(inv: Invoice) {
|
||||
editingItem.fa_data = editingItem.fa_data || {};
|
||||
editingItem.fa_data.search_invoice = inv.invoice_number ?? '';
|
||||
editingItem.fa_data.movement_type_import = (inv.invoice_type as string) || 'TEM';
|
||||
editingItem.fa_data.search_line = undefined;
|
||||
selectedImportInvoiceId = inv.id ?? null;
|
||||
if (selectedImportInvoiceId) loadImportLines(selectedImportInvoiceId);
|
||||
}
|
||||
|
||||
function handleSelectExportInvoice(inv: Invoice) {
|
||||
editingItem.fa_data = editingItem.fa_data || {};
|
||||
editingItem.fa_data.search_invoice = inv.invoice_number ?? '';
|
||||
editingItem.fa_data.search_line = undefined;
|
||||
selectedExportInvoiceId = inv.id ?? null;
|
||||
if (selectedExportInvoiceId) loadExportLines(selectedExportInvoiceId);
|
||||
}
|
||||
|
||||
const visibility = $derived.by(() => getVisibility(invoiceType ?? invoice?.invoice_type, operationType ?? invoice?.operation_type));
|
||||
/** Show link-to-import block for import (CR tracking) or for export when showExportLinkToImportBlock. */
|
||||
const showLinkToImportBlock = $derived.by(() => {
|
||||
const normalizedOperationType = operationType ?? invoice?.operation_type;
|
||||
@@ -80,16 +137,81 @@
|
||||
}
|
||||
return visibility.showCrTrackingHeader;
|
||||
});
|
||||
const isExport = $derived.by(() => {
|
||||
const op = operationType ?? invoice?.operation_type;
|
||||
return op === 1 || op === 'exp';
|
||||
});
|
||||
/** Show repair-import block (Factura de Expo / Línea de Expo) for REP/REPAR. */
|
||||
const showRepairBlock = $derived.by(() => {
|
||||
const op = operationType ?? invoice?.operation_type;
|
||||
if (op === 1 || op === 'exp') return false;
|
||||
return visibility.showRepairLinkToExportBlock;
|
||||
});
|
||||
|
||||
// Resolver invoice id desde search_invoice al abrir el sheet (para cargar líneas)
|
||||
$effect(() => {
|
||||
if (!open || !companyStore?.activeCompany?.id || !editingItem?.fa_data) return;
|
||||
const num = editingItem.fa_data.search_invoice;
|
||||
const movementType = editingItem.fa_data.movement_type_import;
|
||||
if (showLinkToImportBlock && num && !selectedImportInvoiceId && !loadingImportLines) {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await invoicesApi.list(companyStore.activeCompany!.id, 1, 5, {
|
||||
operation_type: 'imp',
|
||||
invoice_number: num,
|
||||
invoice_type: movementType === 'DEF' ? 'DEF' : 'TEM'
|
||||
});
|
||||
const items = res.data?.items ?? [];
|
||||
const inv = items.find((i: Invoice) => i.invoice_number === num);
|
||||
if (inv?.id) {
|
||||
selectedImportInvoiceId = inv.id;
|
||||
await loadImportLines(inv.id);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})();
|
||||
}
|
||||
if (showRepairBlock && num && !selectedExportInvoiceId && !loadingExportLines) {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await invoicesApi.list(companyStore.activeCompany!.id, 1, 5, {
|
||||
operation_type: 'exp',
|
||||
invoice_number: num
|
||||
});
|
||||
const items = res.data?.items ?? [];
|
||||
const inv = items.find((i: Invoice) => i.invoice_number === num);
|
||||
if (inv?.id) {
|
||||
selectedExportInvoiceId = inv.id;
|
||||
await loadExportLines(inv.id);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!open) {
|
||||
selectedImportInvoiceId = null;
|
||||
selectedExportInvoiceId = null;
|
||||
importInvoiceLines = [];
|
||||
exportInvoiceLines = [];
|
||||
}
|
||||
});
|
||||
|
||||
const importRegimenLabel = $derived(
|
||||
(editingItem.fa_data?.movement_type_import?.toUpperCase() === 'DEF' ? 'Definitiva' : 'Temporal')
|
||||
);
|
||||
|
||||
const showCrTrackingBlock = $derived.by(() => {
|
||||
const normalizedOperationType = operationType ?? invoice?.operation_type;
|
||||
if (normalizedOperationType === 1 || normalizedOperationType === 'exp') {
|
||||
return false;
|
||||
}
|
||||
return visibility.showCrTrackingHeader;
|
||||
});
|
||||
const isExport = $derived.by(() => {
|
||||
const op = operationType ?? invoice?.operation_type;
|
||||
return op === 1 || op === 'exp';
|
||||
});
|
||||
const visibleTabs = $derived.by(() => [
|
||||
{ value: 'generales', label: 'General', visible: true },
|
||||
{ value: 'continuacion', label: 'Continuación', visible: true },
|
||||
@@ -137,114 +259,207 @@
|
||||
{#if line}
|
||||
{#if showRepairBlock}
|
||||
<!-- Importación de Reparación: Genera Descarga? + Factura de Expo + Línea de Expo -->
|
||||
<div class="space-y-2 rounded border border-zinc-200 bg-white p-2 dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-wide text-zinc-500">Genera Descarga?</span>
|
||||
<label class="flex items-center gap-1.5">
|
||||
<input type="radio" name="fa_rep_download" value="si" checked={editingItem.fa_data?.download === true} onchange={() => (editingItem.fa_data = editingItem.fa_data || {}, editingItem.fa_data.download = true)} class="rounded border-input" />
|
||||
<span class="text-xs">Sí</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5">
|
||||
<input type="radio" name="fa_rep_download" value="no" checked={editingItem.fa_data?.download === false || editingItem.fa_data?.download === undefined} onchange={() => (editingItem.fa_data = editingItem.fa_data || {}, editingItem.fa_data.download = false)} class="rounded border-input" />
|
||||
<span class="text-xs">No</span>
|
||||
</label>
|
||||
<div class="space-y-3 rounded-md border border-zinc-200 bg-white p-3 dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs font-medium text-muted-foreground">Genera Descarga?</Label>
|
||||
<RadioGroup
|
||||
value={editingItem.fa_data?.download === true ? 'si' : 'no'}
|
||||
onValueChange={(v) => {
|
||||
editingItem.fa_data = editingItem.fa_data || {};
|
||||
editingItem.fa_data.download = v === 'si';
|
||||
}}
|
||||
class="flex gap-4"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<RadioGroupItem value="si" id="fa_rep_download_si" />
|
||||
<Label for="fa_rep_download_si" class="text-xs cursor-pointer">Sí</Label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<RadioGroupItem value="no" id="fa_rep_download_no" />
|
||||
<Label for="fa_rep_download_no" class="text-xs cursor-pointer">No</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-2 lg:grid-cols-4">
|
||||
<div class="rounded border border-zinc-200 bg-white p-2 dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<label for="fa_rep_search_invoice" class="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-zinc-500">
|
||||
Factura de Expo
|
||||
</label>
|
||||
<input
|
||||
id="fa_rep_search_invoice"
|
||||
type="text"
|
||||
bind:value={editingItem.fa_data.search_invoice}
|
||||
class="flex h-8 w-full rounded-md border border-input bg-background px-3 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
/>
|
||||
<!-- Fila ligada: selector de factura (FK) + línea (FK), estilo Input + Folder -->
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<div class="min-w-[120px] flex-1 space-y-1">
|
||||
<Label class="text-xs">Factura de Expo</Label>
|
||||
<div class="flex gap-1">
|
||||
<Input
|
||||
readonly
|
||||
value={editingItem.fa_data?.search_invoice || ''}
|
||||
class="h-8 flex-1 bg-muted cursor-pointer text-sm"
|
||||
placeholder="Seleccionar factura..."
|
||||
onclick={() => (showExportInvoiceModal = true)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-8 w-8 shrink-0"
|
||||
onclick={() => (showExportInvoiceModal = true)}
|
||||
>
|
||||
<Folder class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded border border-zinc-200 bg-white p-2 dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<label for="fa_rep_search_line" class="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-zinc-500">
|
||||
Línea de Expo
|
||||
</label>
|
||||
<input
|
||||
id="fa_rep_search_line"
|
||||
type="number"
|
||||
min="0"
|
||||
bind:value={editingItem.fa_data.search_line}
|
||||
class="flex h-8 w-full rounded-md border border-input bg-background px-3 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
/>
|
||||
<div class="min-w-[90px] flex-1 space-y-1">
|
||||
<Label for="fa_rep_search_line" class="text-xs">Línea de Expo</Label>
|
||||
<div class="flex gap-1">
|
||||
<Input
|
||||
id="fa_rep_search_line"
|
||||
readonly
|
||||
value={editingItem.fa_data?.search_line != null ? String(editingItem.fa_data.search_line) : ''}
|
||||
class="h-8 flex-1 bg-muted cursor-pointer text-sm"
|
||||
placeholder={loadingExportLines ? 'Cargando...' : 'Línea'}
|
||||
disabled={!selectedExportInvoiceId || loadingExportLines}
|
||||
onclick={() => selectedExportInvoiceId && !loadingExportLines && (showExportLinePicker = true)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-8 w-8 shrink-0"
|
||||
disabled={!selectedExportInvoiceId || loadingExportLines}
|
||||
onclick={() => (showExportLinePicker = true)}
|
||||
>
|
||||
<Folder class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded border border-zinc-200 bg-white p-2 dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<label for="fa_rep_search_type" class="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-zinc-500">
|
||||
Tipo Búsqueda
|
||||
</label>
|
||||
<input
|
||||
id="fa_rep_search_type"
|
||||
type="text"
|
||||
bind:value={editingItem.fa_data.search_type}
|
||||
class="flex h-8 w-full rounded-md border border-input bg-background px-3 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
/>
|
||||
<div class="min-w-[100px] flex-1 space-y-1">
|
||||
<Label for="fa_rep_search_type" class="text-xs">Tipo Búsqueda</Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={editingItem.fa_data?.search_type || ''}
|
||||
onValueChange={(v) => {
|
||||
editingItem.fa_data = editingItem.fa_data || {};
|
||||
editingItem.fa_data.search_type = v ?? undefined;
|
||||
}}
|
||||
>
|
||||
<Select.Trigger id="fa_rep_search_type" class="h-8 text-sm">
|
||||
<span class="truncate">
|
||||
{editingItem.fa_data?.search_type || 'Seleccionar...'}
|
||||
</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="Factura">Factura</Select.Item>
|
||||
<Select.Item value="NumParte">NumParte</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if showLinkToImportBlock}
|
||||
<div class="space-y-2 rounded border border-zinc-200 bg-white p-2 dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<!-- Genera Descarga? visible when block is shown (expo or import CR per legacy). -->
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-wide text-zinc-500">Genera Descarga?</span>
|
||||
<label class="flex items-center gap-1.5">
|
||||
<input type="radio" name="fa_download" value="si" checked={editingItem.fa_data?.download === true} onchange={() => (editingItem.fa_data = editingItem.fa_data || {}, editingItem.fa_data.download = true)} class="rounded border-input" />
|
||||
<span class="text-xs">Sí</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5">
|
||||
<input type="radio" name="fa_download" value="no" checked={editingItem.fa_data?.download === false || editingItem.fa_data?.download === undefined} onchange={() => (editingItem.fa_data = editingItem.fa_data || {}, editingItem.fa_data.download = false)} class="rounded border-input" />
|
||||
<span class="text-xs">No</span>
|
||||
</label>
|
||||
<div class="space-y-3 rounded-md border border-zinc-200 bg-white p-3 dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs font-medium text-muted-foreground">Genera Descarga?</Label>
|
||||
<RadioGroup
|
||||
value={editingItem.fa_data?.download === true ? 'si' : 'no'}
|
||||
onValueChange={(v) => {
|
||||
editingItem.fa_data = editingItem.fa_data || {};
|
||||
editingItem.fa_data.download = v === 'si';
|
||||
}}
|
||||
class="flex gap-4"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<RadioGroupItem value="si" id="fa_download_si" />
|
||||
<Label for="fa_download_si" class="text-xs cursor-pointer">Sí</Label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<RadioGroupItem value="no" id="fa_download_no" />
|
||||
<Label for="fa_download_no" class="text-xs cursor-pointer">No</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-2 lg:grid-cols-4">
|
||||
<div class="rounded border border-zinc-200 bg-white p-2 dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<label for="fa_search_invoice" class="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-zinc-500">
|
||||
Factura Impo
|
||||
</label>
|
||||
<input
|
||||
id="fa_search_invoice"
|
||||
type="text"
|
||||
bind:value={editingItem.fa_data.search_invoice}
|
||||
class="flex h-8 w-full rounded-md border border-input bg-background px-3 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
/>
|
||||
<!-- Fila ligada: selector de factura (FK) + línea (FK) -->
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<div class="min-w-[100px] flex-1 space-y-1">
|
||||
<Label class="text-xs">Tipo Importación</Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={editingItem.fa_data?.movement_type_import || 'TEM'}
|
||||
onValueChange={(v) => {
|
||||
editingItem.fa_data = editingItem.fa_data || {};
|
||||
editingItem.fa_data.movement_type_import = v || 'TEM';
|
||||
selectedImportInvoiceId = null;
|
||||
importInvoiceLines = [];
|
||||
}}
|
||||
>
|
||||
<Select.Trigger class="h-8 text-sm">
|
||||
<span>{editingItem.fa_data?.movement_type_import === 'DEF' ? 'DEF' : 'TEM'}</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="TEM">TEM (Temporal)</Select.Item>
|
||||
<Select.Item value="DEF">DEF (Definitiva)</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<div class="rounded border border-zinc-200 bg-white p-2 dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<label for="fa_search_line" class="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-zinc-500">
|
||||
Línea
|
||||
</label>
|
||||
<input
|
||||
id="fa_search_line"
|
||||
type="number"
|
||||
min="0"
|
||||
bind:value={editingItem.fa_data.search_line}
|
||||
class="flex h-8 w-full rounded-md border border-input bg-background px-3 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
/>
|
||||
<div class="min-w-[120px] flex-1 space-y-1">
|
||||
<Label class="text-xs">Factura Impo</Label>
|
||||
<div class="flex gap-1">
|
||||
<Input
|
||||
readonly
|
||||
value={editingItem.fa_data?.search_invoice || ''}
|
||||
class="h-8 flex-1 bg-muted cursor-pointer text-sm"
|
||||
placeholder="Seleccionar factura..."
|
||||
onclick={() => (showImportInvoiceModal = true)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-8 w-8 shrink-0"
|
||||
onclick={() => (showImportInvoiceModal = true)}
|
||||
>
|
||||
<Folder class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded border border-zinc-200 bg-white p-2 dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<label for="fa_search_type" class="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-zinc-500">
|
||||
Tipo Búsqueda
|
||||
</label>
|
||||
<input
|
||||
id="fa_search_type"
|
||||
type="text"
|
||||
bind:value={editingItem.fa_data.search_type}
|
||||
class="flex h-8 w-full rounded-md border border-input bg-background px-3 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
/>
|
||||
<div class="min-w-[90px] flex-1 space-y-1">
|
||||
<Label for="fa_search_line" class="text-xs">Línea</Label>
|
||||
<div class="flex gap-1">
|
||||
<Input
|
||||
id="fa_search_line"
|
||||
readonly
|
||||
value={editingItem.fa_data?.search_line != null ? String(editingItem.fa_data.search_line) : ''}
|
||||
class="h-8 flex-1 bg-muted cursor-pointer text-sm"
|
||||
placeholder={loadingImportLines ? 'Cargando...' : 'Línea'}
|
||||
disabled={!selectedImportInvoiceId || loadingImportLines}
|
||||
onclick={() => selectedImportInvoiceId && !loadingImportLines && (showImportLinePicker = true)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-8 w-8 shrink-0"
|
||||
disabled={!selectedImportInvoiceId || loadingImportLines}
|
||||
onclick={() => (showImportLinePicker = true)}
|
||||
>
|
||||
<Folder class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded border border-zinc-200 bg-white p-2 dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<label for="fa_movement_type_import" class="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-zinc-500">
|
||||
Tipo Importación / Tipo Movimiento
|
||||
</label>
|
||||
<input
|
||||
id="fa_movement_type_import"
|
||||
type="text"
|
||||
bind:value={editingItem.fa_data.movement_type_import}
|
||||
class="flex h-8 w-full rounded-md border border-input bg-background px-3 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
/>
|
||||
<div class="min-w-[100px] flex-1 space-y-1">
|
||||
<Label for="fa_search_type" class="text-xs">Tipo Búsqueda</Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={editingItem.fa_data?.search_type || ''}
|
||||
onValueChange={(v) => {
|
||||
editingItem.fa_data = editingItem.fa_data || {};
|
||||
editingItem.fa_data.search_type = v ?? undefined;
|
||||
}}
|
||||
>
|
||||
<Select.Trigger id="fa_search_type" class="h-8 text-sm">
|
||||
<span class="truncate">
|
||||
{editingItem.fa_data?.search_type || 'Seleccionar...'}
|
||||
</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="Factura">Factura</Select.Item>
|
||||
<Select.Item value="NumParte">NumParte</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -367,4 +582,66 @@
|
||||
</footer>
|
||||
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
</Sheet.Root>
|
||||
|
||||
<InvoiceSelectorModal
|
||||
bind:open={showImportInvoiceModal}
|
||||
regimen={importRegimenLabel}
|
||||
operationType="imp"
|
||||
onSelect={handleSelectImportInvoice}
|
||||
/>
|
||||
<InvoiceSelectorModal
|
||||
bind:open={showExportInvoiceModal}
|
||||
operationType="exp"
|
||||
onSelect={handleSelectExportInvoice}
|
||||
/>
|
||||
|
||||
<!-- Diálogo para elegir línea (Expo) -->
|
||||
<Dialog.Root bind:open={showExportLinePicker}>
|
||||
<Dialog.Content class="max-w-sm">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="text-sm">Seleccionar línea</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div class="max-h-[280px] overflow-y-auto py-2">
|
||||
{#each exportInvoiceLines as lineItem}
|
||||
{@const num = lineItem.line_number ?? lineItem.id}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-3 py-2 text-left text-sm hover:bg-muted rounded-md"
|
||||
onclick={() => {
|
||||
editingItem.fa_data = editingItem.fa_data || {};
|
||||
editingItem.fa_data.search_line = typeof num === 'number' ? num : undefined;
|
||||
showExportLinePicker = false;
|
||||
}}
|
||||
>
|
||||
Línea {num}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<!-- Diálogo para elegir línea (Impo) -->
|
||||
<Dialog.Root bind:open={showImportLinePicker}>
|
||||
<Dialog.Content class="max-w-sm">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="text-sm">Seleccionar línea</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div class="max-h-[280px] overflow-y-auto py-2">
|
||||
{#each importInvoiceLines as lineItem}
|
||||
{@const num = lineItem.line_number ?? lineItem.id}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-3 py-2 text-left text-sm hover:bg-muted rounded-md"
|
||||
onclick={() => {
|
||||
editingItem.fa_data = editingItem.fa_data || {};
|
||||
editingItem.fa_data.search_line = typeof num === 'number' ? num : undefined;
|
||||
showImportLinePicker = false;
|
||||
}}
|
||||
>
|
||||
Línea {num}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,309 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Loader2, Search, Plus, ArrowLeft } from 'lucide-svelte';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import {
|
||||
getLocations,
|
||||
createLocation,
|
||||
type Location,
|
||||
type LocationSystem
|
||||
} from '$lib/api/dashboard/a76/general_catalogs/locations';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
system = 'fixed_asset' as LocationSystem,
|
||||
onSelect
|
||||
}: {
|
||||
open: boolean;
|
||||
system?: LocationSystem;
|
||||
onSelect: (location: Location) => void;
|
||||
} = $props();
|
||||
|
||||
let locations: Location[] = $state([]);
|
||||
let filtered: Location[] = $state([]);
|
||||
let loading = $state(false);
|
||||
let searchTerm = $state('');
|
||||
let error = $state('');
|
||||
let showRegisterForm = $state(false);
|
||||
|
||||
// Register form state
|
||||
let formData = $state({
|
||||
clave_localizacion: '',
|
||||
localizacion: '',
|
||||
department: '',
|
||||
responsible: '',
|
||||
observations: ''
|
||||
});
|
||||
let formLoading = $state(false);
|
||||
let formError = $state('');
|
||||
|
||||
async function loadLocations() {
|
||||
const companyId = companyStore?.activeCompany?.id;
|
||||
if (!companyId) {
|
||||
error = 'No hay compañía seleccionada';
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const res = await getLocations(companyId, {
|
||||
system,
|
||||
page_size: 500
|
||||
});
|
||||
locations = res.items ?? [];
|
||||
filtered = locations;
|
||||
} catch (e) {
|
||||
error = 'Error al cargar ubicaciones';
|
||||
console.error(e);
|
||||
locations = [];
|
||||
filtered = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilter() {
|
||||
if (!searchTerm.trim()) {
|
||||
filtered = locations;
|
||||
} else {
|
||||
const t = searchTerm.toLowerCase();
|
||||
filtered = locations.filter(
|
||||
(loc) =>
|
||||
(loc.clave_localizacion ?? '').toLowerCase().includes(t) ||
|
||||
(loc.localizacion ?? '').toLowerCase().includes(t)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelect(loc: Location) {
|
||||
onSelect(loc);
|
||||
open = false;
|
||||
}
|
||||
|
||||
function openRegisterForm() {
|
||||
showRegisterForm = true;
|
||||
formData = {
|
||||
clave_localizacion: '',
|
||||
localizacion: '',
|
||||
department: '',
|
||||
responsible: '',
|
||||
observations: ''
|
||||
};
|
||||
formError = '';
|
||||
}
|
||||
|
||||
function closeRegisterForm() {
|
||||
showRegisterForm = false;
|
||||
formError = '';
|
||||
}
|
||||
|
||||
async function handleRegisterSubmit() {
|
||||
const companyId = companyStore?.activeCompany?.id;
|
||||
if (!companyId) {
|
||||
formError = 'No hay compañía seleccionada';
|
||||
return;
|
||||
}
|
||||
if (!formData.clave_localizacion.trim()) {
|
||||
formError = 'La clave es requerida';
|
||||
return;
|
||||
}
|
||||
formLoading = true;
|
||||
formError = '';
|
||||
try {
|
||||
const created = await createLocation(
|
||||
{
|
||||
clave_localizacion: formData.clave_localizacion.trim(),
|
||||
localizacion: formData.localizacion?.trim() || null,
|
||||
system,
|
||||
department: formData.department?.trim() || null,
|
||||
responsible: formData.responsible?.trim() || null,
|
||||
observations: formData.observations?.trim() || null
|
||||
},
|
||||
companyId
|
||||
);
|
||||
onSelect(created);
|
||||
open = false;
|
||||
} catch (e) {
|
||||
formError = e instanceof Error ? e.message : 'Error al guardar';
|
||||
} finally {
|
||||
formLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (open && !showRegisterForm) loadLocations();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
searchTerm;
|
||||
applyFilter();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!open) showRegisterForm = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="!max-w-[60vw] w-[60vw] max-h-[90vh] p-0 flex flex-col">
|
||||
<Dialog.Header class="px-6 py-4 border-b">
|
||||
<Dialog.Title class="text-lg font-semibold">Catálogo de ubicaciones (maquinaria y equipo)</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
{#if showRegisterForm}
|
||||
<!-- Registrar nueva ubicación (formulario) -->
|
||||
<form
|
||||
class="flex flex-col flex-1 overflow-hidden"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleRegisterSubmit();
|
||||
}}
|
||||
>
|
||||
<div class="flex-1 overflow-auto px-6 py-4 space-y-4">
|
||||
{#if formError}
|
||||
<div class="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
|
||||
{formError}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grid gap-4">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="reg-clave" class="text-right">Clave *</Label>
|
||||
<div class="col-span-3">
|
||||
<Input
|
||||
id="reg-clave"
|
||||
bind:value={formData.clave_localizacion}
|
||||
maxlength={20}
|
||||
placeholder="Clave de localización"
|
||||
class="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="reg-localizacion" class="text-right">Localización</Label>
|
||||
<div class="col-span-3">
|
||||
<Input
|
||||
id="reg-localizacion"
|
||||
bind:value={formData.localizacion}
|
||||
maxlength={200}
|
||||
placeholder="Nombre o descripción"
|
||||
class="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="reg-department" class="text-right">Departamento</Label>
|
||||
<div class="col-span-3">
|
||||
<Input
|
||||
id="reg-department"
|
||||
bind:value={formData.department}
|
||||
maxlength={100}
|
||||
placeholder="Opcional"
|
||||
class="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="reg-responsible" class="text-right">Responsable</Label>
|
||||
<div class="col-span-3">
|
||||
<Input
|
||||
id="reg-responsible"
|
||||
bind:value={formData.responsible}
|
||||
maxlength={200}
|
||||
placeholder="Opcional"
|
||||
class="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="reg-observations" class="text-right">Observaciones</Label>
|
||||
<div class="col-span-3">
|
||||
<Input
|
||||
id="reg-observations"
|
||||
bind:value={formData.observations}
|
||||
placeholder="Opcional"
|
||||
class="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-6 py-4 border-t flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onclick={closeRegisterForm} disabled={formLoading}>
|
||||
<ArrowLeft class="mr-2 h-4 w-4" />
|
||||
Volver al listado
|
||||
</Button>
|
||||
<Button type="submit" disabled={formLoading}>
|
||||
{#if formLoading}
|
||||
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
|
||||
{/if}
|
||||
Guardar
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<!-- Listado + búsqueda -->
|
||||
<div class="px-6 py-3 border-b bg-zinc-50 dark:bg-zinc-900 flex items-center gap-2">
|
||||
<div class="flex items-center gap-2 flex-1">
|
||||
<Search class="w-4 h-4 text-zinc-400 shrink-0" />
|
||||
<Input
|
||||
bind:value={searchTerm}
|
||||
placeholder="Buscar por clave o localización..."
|
||||
class="flex-1 h-9"
|
||||
/>
|
||||
</div>
|
||||
<Button type="button" variant="default" size="sm" onclick={openRegisterForm}>
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
Registrar nueva ubicación
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-auto px-6 py-4">
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-20">
|
||||
<Loader2 class="w-8 h-8 animate-spin text-zinc-900 dark:text-zinc-100" />
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="flex items-center justify-center py-20 text-red-600">
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="border rounded-md overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-zinc-900 dark:bg-zinc-800 text-white">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left font-semibold border-r border-zinc-700">Clave</th>
|
||||
<th class="px-3 py-2 text-left font-semibold">Localización</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each filtered as loc}
|
||||
<tr
|
||||
class="border-b hover:bg-zinc-100 dark:hover:bg-zinc-800 cursor-pointer transition-colors"
|
||||
onclick={() => handleSelect(loc)}
|
||||
>
|
||||
<td class="px-3 py-2 border-r">{loc.clave_localizacion ?? '—'}</td>
|
||||
<td class="px-3 py-2">{loc.localizacion ?? '—'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{#if filtered.length === 0}
|
||||
<tr>
|
||||
<td colspan="2" class="px-3 py-8 text-center text-zinc-500">
|
||||
No se encontraron resultados
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 border-t flex justify-end gap-2">
|
||||
<Button variant="outline" onclick={() => (open = false)}>Cancelar</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -8,6 +8,7 @@
|
||||
import type { Item, LineDescriptions } from '$lib/api/dashboard/a76/items';
|
||||
import type { InvoiceItemVisibility } from '$lib/config/invoice-item-visibility';
|
||||
import PaymentMethodDialog from './payment-method-dialog.svelte';
|
||||
import LocationSelectorDialog from './location-selector-dialog.svelte';
|
||||
|
||||
let {
|
||||
lineItem = $bindable(),
|
||||
@@ -51,6 +52,7 @@
|
||||
|
||||
let paymentMethodDialogOpen = $state(false);
|
||||
let payment_method_description = $state('');
|
||||
let locationSelectorOpen = $state(false);
|
||||
|
||||
// Load payment method description when payment_method exists
|
||||
$effect(() => {
|
||||
@@ -89,6 +91,10 @@
|
||||
lineItem.payment_method = method.key;
|
||||
payment_method_description = method.description;
|
||||
}
|
||||
|
||||
function handleLocationSelect(loc: { clave_localizacion: string; localizacion?: string | null }) {
|
||||
descriptions.machinery_location = loc.localizacion ?? loc.clave_localizacion ?? '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-1 items-start gap-2 lg:grid-cols-2">
|
||||
@@ -224,15 +230,27 @@
|
||||
{/if}
|
||||
|
||||
{#if visibility.showContinuationLocation}
|
||||
<!-- Location -->
|
||||
<div class="space-y-1">
|
||||
<!-- Location (catálogo a76, sistema fixed_asset) -->
|
||||
<div class="space-y-1">
|
||||
<div class="space-y-0.5">
|
||||
<Label for="localizacion_maquinaria" class="text-xs">Machinery and equipment location:</Label>
|
||||
<div class="flex gap-1">
|
||||
<Input id="localizacion_maquinaria" bind:value={descriptions.machinery_location} class="h-6 text-xs" />
|
||||
<button
|
||||
id="localizacion_maquinaria"
|
||||
type="button"
|
||||
onclick={() => (locationSelectorOpen = true)}
|
||||
class="flex h-6 min-w-[120px] flex-1 items-center rounded-md border border-input bg-transparent px-2 text-left text-xs ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{descriptions.machinery_location || 'Seleccionar ubicación...'}
|
||||
</button>
|
||||
</div>
|
||||
<Label for="localizacion_maquinaria" class="text-xs">Location variable</Label>
|
||||
<Label for="localizacion_maquinaria" class="text-xs text-muted-foreground">Location variable</Label>
|
||||
</div>
|
||||
<LocationSelectorDialog
|
||||
bind:open={locationSelectorOpen}
|
||||
system="fixed_asset"
|
||||
onSelect={handleLocationSelect}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -3,24 +3,35 @@ import type { Location } from '$lib/api/dashboard/a76/general_catalogs/locations
|
||||
import { renderComponent } from '$lib/components/ui/data-table';
|
||||
import DataTableActions from './data-table-actions.svelte';
|
||||
|
||||
const SYSTEM_LABELS: Record<string, string> = {
|
||||
fixed_asset: 'Activo fijo (FA)',
|
||||
inventory: 'Inventario'
|
||||
};
|
||||
|
||||
export function createColumns(onSuccess?: () => void): ColumnDef<Location>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'location_code',
|
||||
header: 'Código',
|
||||
},
|
||||
{
|
||||
accessorKey: 'location_description',
|
||||
header: 'Descripción',
|
||||
cell: ({ row }) => row.original.location_description || '—'
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Acciones',
|
||||
cell: ({ row }) => renderComponent(DataTableActions, {
|
||||
item: row.original,
|
||||
onSuccess
|
||||
})
|
||||
}
|
||||
];
|
||||
return [
|
||||
{
|
||||
accessorKey: 'clave_localizacion',
|
||||
header: 'Clave'
|
||||
},
|
||||
{
|
||||
accessorKey: 'localizacion',
|
||||
header: 'Localización',
|
||||
cell: ({ row }) => row.original.localizacion ?? '—'
|
||||
},
|
||||
{
|
||||
accessorKey: 'system',
|
||||
header: 'Sistema',
|
||||
cell: ({ row }) => SYSTEM_LABELS[row.original.system] ?? row.original.system
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Acciones',
|
||||
cell: ({ row }) =>
|
||||
renderComponent(DataTableActions, {
|
||||
item: row.original,
|
||||
onSuccess
|
||||
})
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
<script lang="ts">
|
||||
import { createColumns } from '$lib/components/dashboard/locations/columns';
|
||||
import CreateDialog from '$lib/components/dashboard/general_catalogs/locations/create-edit-dialog.svelte';
|
||||
import DataTable from '$lib/components/dashboard/locations/data-table.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Plus } from 'lucide-svelte';
|
||||
import {
|
||||
getLocations,
|
||||
type Location,
|
||||
type LocationSystem
|
||||
} from '$lib/api/dashboard/a76/general_catalogs/locations';
|
||||
|
||||
let {
|
||||
companyId,
|
||||
system: initialSystem = undefined,
|
||||
showSystemFilter = true,
|
||||
compact = false
|
||||
}: {
|
||||
companyId: number;
|
||||
/** Filtro por sistema cuando está embebido (ej. solo fixed_asset o solo inventory). */
|
||||
system?: LocationSystem;
|
||||
/** Mostrar selector de sistema (FA / Inventario). Por defecto true. */
|
||||
showSystemFilter?: boolean;
|
||||
/** Vista compacta sin título ni descripción. */
|
||||
compact?: boolean;
|
||||
} = $props();
|
||||
|
||||
let items = $state<Location[]>([]);
|
||||
let total = $state(0);
|
||||
let page = $state(1);
|
||||
let pageSize = $state(50);
|
||||
let pages = $state(0);
|
||||
let loading = $state(true);
|
||||
let dialogOpen = $state(false);
|
||||
let searchClave = $state('');
|
||||
let searchLocalizacion = $state('');
|
||||
let filterSystem = $state<LocationSystem | ''>(initialSystem ?? '');
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
async function load() {
|
||||
if (!companyId) return;
|
||||
loading = true;
|
||||
try {
|
||||
const filters: Record<string, string | number> = {
|
||||
page,
|
||||
page_size: pageSize
|
||||
};
|
||||
if (searchClave) filters.clave_localizacion = searchClave;
|
||||
if (searchLocalizacion) filters.localizacion = searchLocalizacion;
|
||||
if (filterSystem) filters.system = filterSystem;
|
||||
const res = await getLocations(companyId, filters);
|
||||
items = res.items ?? [];
|
||||
total = res.total ?? 0;
|
||||
page = res.page ?? 1;
|
||||
pageSize = res.page_size ?? 50;
|
||||
pages = pageSize > 0 ? Math.ceil(total / pageSize) : 0;
|
||||
} catch (e) {
|
||||
console.error('Error loading locations:', e);
|
||||
items = [];
|
||||
total = 0;
|
||||
pages = 0;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSuccess() {
|
||||
load();
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
page = 1;
|
||||
load();
|
||||
}, 400);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void companyId;
|
||||
void page;
|
||||
load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
{#if !compact}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold tracking-tight">Ubicaciones</h2>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Clave y localización por sistema (FA / Inventario)
|
||||
</p>
|
||||
</div>
|
||||
<Button onclick={() => (dialogOpen = true)} size="sm">
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
Nueva
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-end">
|
||||
<Button onclick={() => (dialogOpen = true)} size="sm" variant="outline">
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
Nueva ubicación
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-4 items-end flex-wrap">
|
||||
<div class="grid w-full max-w-[200px] items-center gap-1.5">
|
||||
<Input
|
||||
placeholder="Clave..."
|
||||
bind:value={searchClave}
|
||||
oninput={handleSearch}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid w-full max-w-[200px] items-center gap-1.5">
|
||||
<Input
|
||||
placeholder="Localización..."
|
||||
bind:value={searchLocalizacion}
|
||||
oninput={handleSearch}
|
||||
/>
|
||||
</div>
|
||||
{#if showSystemFilter}
|
||||
<div class="grid w-full max-w-[180px] items-center gap-1.5">
|
||||
<select
|
||||
bind:value={filterSystem}
|
||||
onchange={handleSearch}
|
||||
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm"
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
<option value="fixed_asset">Activo fijo (FA)</option>
|
||||
<option value="inventory">Inventario</option>
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border">
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-12 text-muted-foreground">
|
||||
Cargando...
|
||||
</div>
|
||||
{:else}
|
||||
<DataTable
|
||||
data={items}
|
||||
columns={createColumns(handleSuccess)}
|
||||
pageCount={pages}
|
||||
totalItems={total}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<CreateDialog bind:open={dialogOpen} onSuccess={handleSuccess} />
|
||||
</div>
|
||||
@@ -278,10 +278,6 @@ export function getSidebarData(): SidebarData {
|
||||
title: m["sidebar.general_catalogs.customs_warehouses"](),
|
||||
url: "/dashboard/reference_data/customs_warehouses",
|
||||
},
|
||||
{
|
||||
title: m["sidebar.general_catalogs.locations"](),
|
||||
url: "/dashboard/general_catalogs/locations",
|
||||
},
|
||||
{
|
||||
title: m["sidebar.general_catalogs.doda"](),
|
||||
url: "/dashboard/general_catalogs/doda",
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getAuthTokens, authenticatedFetch } from '$lib/server/api';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => {
|
||||
const parentData = await parent();
|
||||
const { accessToken } = getAuthTokens(cookies);
|
||||
|
||||
if (!accessToken) {
|
||||
return {
|
||||
error: 'No authenticated',
|
||||
locations: { items: [], total: 0, page: 1, page_size: 10, pages: 0 }
|
||||
};
|
||||
}
|
||||
|
||||
const page = Number(url.searchParams.get('page')) || 1;
|
||||
const pageSize = Number(url.searchParams.get('page_size')) || 50;
|
||||
|
||||
const cookieCompanyId = cookies.get('active_company_id');
|
||||
const companyId = cookieCompanyId
|
||||
? parseInt(cookieCompanyId)
|
||||
: parentData.companies?.[0]?.id;
|
||||
|
||||
if (!companyId) {
|
||||
return {
|
||||
error: 'No company selected',
|
||||
locations: { items: [], total: 0, page, page_size: pageSize, pages: 0 }
|
||||
};
|
||||
}
|
||||
|
||||
const filters: Record<string, string> = {};
|
||||
const location_code = url.searchParams.get('location_code');
|
||||
const location_description = url.searchParams.get('location_description');
|
||||
|
||||
if (location_code) filters.location_code = location_code;
|
||||
if (location_description) filters.location_description = location_description;
|
||||
|
||||
try {
|
||||
const queryParams = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
page_size: pageSize.toString(),
|
||||
company_id: companyId.toString(),
|
||||
...filters
|
||||
});
|
||||
|
||||
const response = await authenticatedFetch(
|
||||
`v1/a76/ports/?${queryParams.toString()}`,
|
||||
{ method: 'GET', cache: 'no-store' },
|
||||
cookies,
|
||||
fetch
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
error: 'Failed to load',
|
||||
locations: { items: [], total: 0, page, page_size: pageSize, pages: 0 }
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return { locations: data };
|
||||
} catch (error) {
|
||||
console.error('Error loading locations:', error);
|
||||
return {
|
||||
error: 'Error loading',
|
||||
locations: { items: [], total: 0, page: 1, page_size: pageSize, pages: 0 }
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -1,89 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { browser } from '$app/environment';
|
||||
import { createColumns } from '$lib/components/dashboard/locations/columns';
|
||||
import CreateDialog from '$lib/components/dashboard/general_catalogs/locations/create-edit-dialog.svelte';
|
||||
import DataTable from '$lib/components/dashboard/locations/data-table.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Plus } from 'lucide-svelte';
|
||||
import { useShortcuts } from '$lib/hooks/use-shortcuts';
|
||||
import { obtenerAtajosListaLocalidades } from '$lib/config/shortcuts/dashboard/general_catalogs/locations/list';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let dialogOpen = $state(false);
|
||||
|
||||
// Atajos
|
||||
useShortcuts(
|
||||
'Lista Localidades',
|
||||
obtenerAtajosListaLocalidades({
|
||||
manejarNuevo: () => (dialogOpen = true),
|
||||
manejarActualizar: handleSuccess
|
||||
})
|
||||
);
|
||||
|
||||
let searchCode = $state($page.url.searchParams.get('location_code') || '');
|
||||
let searchDesc = $state($page.url.searchParams.get('location_description') || '');
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
function handleSuccess() {
|
||||
const url = new URL($page.url);
|
||||
goto(url, { invalidateAll: true });
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
if (!browser) return;
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
const url = new URL($page.url);
|
||||
if (searchCode) url.searchParams.set('location_code', searchCode);
|
||||
else url.searchParams.delete('location_code');
|
||||
|
||||
if (searchDesc) url.searchParams.set('location_description', searchDesc);
|
||||
else url.searchParams.delete('location_description');
|
||||
|
||||
url.searchParams.set('page', '1');
|
||||
goto(url, { keepFocus: true, noScroll: true });
|
||||
}, 500);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold tracking-tight">Ubicaciones</h1>
|
||||
<p class="text-muted-foreground">Catálogo de ubicaciones de puertos</p>
|
||||
</div>
|
||||
<Button onclick={() => (dialogOpen = true)}>
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
Nueva Ubicación
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4 items-end">
|
||||
<div class="grid w-full max-w-sm items-center gap-1.5">
|
||||
<Input placeholder="Buscar por código..." bind:value={searchCode} oninput={handleSearch} />
|
||||
</div>
|
||||
<div class="grid w-full max-w-sm items-center gap-1.5">
|
||||
<Input
|
||||
placeholder="Buscar por descripción..."
|
||||
bind:value={searchDesc}
|
||||
oninput={handleSearch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border">
|
||||
<DataTable
|
||||
data={data.locations?.items || []}
|
||||
columns={createColumns(handleSuccess)}
|
||||
pageCount={data.locations?.pages || 0}
|
||||
totalItems={data.locations?.total || 0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CreateDialog bind:open={dialogOpen} onSuccess={handleSuccess} />
|
||||
</div>
|
||||
Reference in New Issue
Block a user