feature/nav-tab-client-provider

This commit is contained in:
hreyes
2026-02-04 12:41:01 -06:00
parent 4ad6bc07e3
commit 20f1ed4714
5 changed files with 904 additions and 592 deletions

View File

@@ -22,6 +22,9 @@ export const GLOBAL_NAV = {
// Customs & Brokers
'a': '/dashboard/customs_brokers',
// Clients & Providers
'o': '/dashboard/clients_and_providers',
// Invoices
'i': '/dashboard/invoices',

View File

@@ -0,0 +1,41 @@
import type { ShortcutDef } from '$lib/stores/shortcut-store';
export const obtenerAtajosEdicionSocio = (acciones: {
irGeneral: () => void;
irDireccion: () => void;
irProgramas: () => void;
irConfig: () => void;
guardar: () => void;
cancelar: () => void;
}): ShortcutDef[] => [
{
key: 'Alt+Digit1',
description: 'Tab General',
action: acciones.irGeneral
},
{
key: 'Alt+Digit2',
description: 'Tab Dirección',
action: acciones.irDireccion
},
{
key: 'Alt+Digit3',
description: 'Tab Programas',
action: acciones.irProgramas
},
{
key: 'Alt+Digit4',
description: 'Tab Configuración',
action: acciones.irConfig
},
{
key: 'Ctrl+S',
description: 'Guardar',
action: acciones.guardar
},
{
key: 'Escape',
description: 'Cancelar / Volver',
action: acciones.cancelar
}
];

View File

@@ -0,0 +1,38 @@
import type { ShortcutDef } from '$lib/stores/shortcut-store';
export const obtenerAtajosListaSocios = (acciones: {
filtrarTodos: () => void;
filtrarClientes: () => void;
filtrarProveedores: () => void;
nuevo: () => void;
recargar: () => void;
}): ShortcutDef[] => [
{
key: 'Alt+Digit1',
description: 'Ver Todos',
action: acciones.filtrarTodos,
context: 'Clients Providers Navigation'
},
{
key: 'Alt+Digit2',
description: 'Ver Clientes',
action: acciones.filtrarClientes,
context: 'Clients Providers Navigation'
},
{
key: 'Alt+Digit3',
description: 'Ver Proveedores',
action: acciones.filtrarProveedores,
context: 'Clients Providers Navigation'
},
{
key: 'Alt+Shift+N',
description: 'Nuevo Socio',
action: acciones.nuevo
},
{
key: 'Alt+Shift+R',
description: 'Actualizar Lista',
action: acciones.recargar
}
];

View File

@@ -3,7 +3,10 @@
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { clientsProvidersApi, type ClientProvider } from '$lib/api/dashboard/a76/clients-providers';
import {
clientsProvidersApi,
type ClientProvider
} from '$lib/api/dashboard/a76/clients-providers';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
@@ -21,7 +24,7 @@
let items = $state<ClientProvider[]>(data.items || []);
let selectedItem = $state<ClientProvider | null>(null);
let isLoading = $state(false);
// Server-side filtering/pagination parameters
let currentPage = $state(data.page || 1);
let pageSize = $state(50);
@@ -37,7 +40,7 @@
let error = $state<string | ApiError | null>(data.error || null);
// --- Lifecycle ---
onMount(() => {
if (browser) {
// Sincronizar token de cookies a localStorage si es necesario
@@ -49,7 +52,8 @@
};
const cookieToken = getCookie('access_token');
const localToken = localStorage.getItem('access_token');
if (cookieToken && cookieToken !== localToken) localStorage.setItem('access_token', cookieToken);
if (cookieToken && cookieToken !== localToken)
localStorage.setItem('access_token', cookieToken);
// Listen for company changes
const handleCompanyChange = () => loadItems(1); // Reload from page 1
@@ -68,9 +72,9 @@
try {
const filters: any = {};
if (searchType !== 'both') filters.type = searchType;
// Note: The API technically supports name/rfc fitlering if backend implements it.
// Assuming backend supports 'name' and 'rfc' query params based on standard patterns,
// or we filter client side if the list is small.
// Note: The API technically supports name/rfc fitlering if backend implements it.
// Assuming backend supports 'name' and 'rfc' query params based on standard patterns,
// or we filter client side if the list is small.
// Given pagination, we should try sending them. If backend ignores them, we might need client filtering.
// Ideally backend should handle this. I will assume backend filters for now or add query params.
if (searchName) filters.name = searchName;
@@ -105,7 +109,7 @@
searchType = value;
loadItems(1);
}
function handleSearch() {
loadItems(1);
}
@@ -118,10 +122,13 @@
if (selectedItem) goto(`/dashboard/clients_and_providers/edit/${selectedItem.id}`);
}
import { useShortcuts } from '$lib/hooks/use-shortcuts';
import { obtenerAtajosListaSocios } from '$lib/config/shortcuts/clients-providers-list-shortcuts';
async function handleDelete() {
if (!selectedItem || !companyStore.activeCompany?.id) return;
if (!confirm('¿Estás seguro de eliminar este registro?')) return;
try {
await clientsProvidersApi.delete(selectedItem.id, companyStore.activeCompany.id);
toast.success('Registro eliminado');
@@ -132,6 +139,16 @@
}
}
useShortcuts(
'Clients Providers List',
obtenerAtajosListaSocios({
filtrarTodos: () => handleTypeChange('both'),
filtrarClientes: () => handleTypeChange('client'),
filtrarProveedores: () => handleTypeChange('provider'),
nuevo: () => goto('/dashboard/clients_and_providers/edit/new'),
recargar: () => loadItems(1)
})
);
</script>
<div class="flex flex-col h-[calc(100vh-4rem)] p-4 gap-4 pb-15">
@@ -145,7 +162,9 @@
<!-- Error Message -->
{#if error}
<Card.Root class="bg-card dark:bg-black text-card-foreground dark:text-white border-destructive dark:border-red-800">
<Card.Root
class="bg-card dark:bg-black text-card-foreground dark:text-white border-destructive dark:border-red-800"
>
<Card.Header>
<Card.Title class="text-destructive">Error</Card.Title>
<Card.Description>
@@ -168,18 +187,18 @@
<div class="grid grid-cols-4 gap-4">
<div class="space-y-2">
<Label class="text-xs">Nombre / Razón Social</Label>
<Input
bind:value={searchName}
placeholder="Buscar por nombre..."
<Input
bind:value={searchName}
placeholder="Buscar por nombre..."
class="h-9"
onkeydown={(e) => e.key === 'Enter' && handleSearch()}
/>
</div>
<div class="space-y-2">
<Label class="text-xs">RFC</Label>
<Input
bind:value={searchRfc}
placeholder="RFC..."
<Input
bind:value={searchRfc}
placeholder="RFC..."
class="h-9"
onkeydown={(e) => e.key === 'Enter' && handleSearch()}
/>
@@ -188,7 +207,11 @@
<Label class="text-xs">Tipo</Label>
<Select.Root type="single" value={searchType} onValueChange={handleTypeChange}>
<Select.Trigger class="h-9">
{searchType === 'both' ? 'Todos' : searchType === 'client' ? 'Clientes' : 'Proveedores'}
{searchType === 'both'
? 'Todos'
: searchType === 'client'
? 'Clientes'
: 'Proveedores'}
</Select.Trigger>
<Select.Content>
<Select.Item value="both">Todos</Select.Item>
@@ -234,13 +257,23 @@
</thead>
<tbody>
{#if isLoading}
<tr><td colspan="5" class="text-center py-8 text-muted-foreground">Cargando...</td></tr>
<tr
><td colspan="5" class="text-center py-8 text-muted-foreground">Cargando...</td
></tr
>
{:else if items.length === 0}
<tr><td colspan="5" class="text-center py-8 text-muted-foreground">No se encontraron registros</td></tr>
<tr
><td colspan="5" class="text-center py-8 text-muted-foreground"
>No se encontraron registros</td
></tr
>
{:else}
{#each items as item (item.id)}
<tr
class="border-b cursor-pointer transition-colors hover:bg-muted/50 {selectedItem?.id === item.id ? 'bg-muted' : ''}"
<tr
class="border-b cursor-pointer transition-colors hover:bg-muted/50 {selectedItem?.id ===
item.id
? 'bg-muted'
: ''}"
onclick={() => selectItem(item)}
>
<td class="px-3 py-2 font-mono text-xs text-muted-foreground">{item.id}</td>
@@ -248,15 +281,28 @@
<td class="px-3 py-2">{item.name}</td>
<td class="px-3 py-2">
{#if item.client_or_provider === 'client'}
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">Cliente</span>
<span
class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700"
>Cliente</span
>
{:else if item.client_or_provider === 'provider'}
<span class="inline-flex items-center rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-700">Proveedor</span>
<span
class="inline-flex items-center rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-700"
>Proveedor</span
>
{:else}
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700">Ambos</span>
<span
class="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700"
>Ambos</span
>
{/if}
</td>
<td class="px-3 py-2">
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {item.is_active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}">
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {item.is_active
? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-700'}"
>
{item.is_active ? 'Activo' : 'Inactivo'}
</span>
</td>
@@ -266,113 +312,140 @@
</tbody>
</table>
</div>
<!-- Simple Pagination Controls -->
<div class="p-2 border-t flex justify-end gap-2">
<Button
variant="outline"
size="sm"
disabled={currentPage === 1 || isLoading}
onclick={() => loadItems(currentPage - 1)}
>
Anterior
</Button>
<span class="flex items-center text-xs text-muted-foreground px-2">
Página {currentPage} de {Math.ceil(totalItems / pageSize)}
</span>
<Button
variant="outline"
size="sm"
disabled={items.length < pageSize || isLoading}
onclick={() => loadItems(currentPage + 1)}
>
Siguiente
</Button>
</div>
<!-- Simple Pagination Controls -->
<div class="p-2 border-t flex justify-end gap-2">
<Button
variant="outline"
size="sm"
disabled={currentPage === 1 || isLoading}
onclick={() => loadItems(currentPage - 1)}
>
Anterior
</Button>
<span class="flex items-center text-xs text-muted-foreground px-2">
Página {currentPage} de {Math.ceil(totalItems / pageSize)}
</span>
<Button
variant="outline"
size="sm"
disabled={items.length < pageSize || isLoading}
onclick={() => loadItems(currentPage + 1)}
>
Siguiente
</Button>
</div>
</div>
</div>
<!-- Right Panel: Details -->
<div class="w-96 flex-none flex flex-col border rounded-xl bg-muted/30 shadow-sm overflow-hidden">
<div
class="w-96 flex-none flex flex-col border rounded-xl bg-muted/30 shadow-sm overflow-hidden"
>
<div class="p-4 border-b bg-card">
<p class="text-[10px] uppercase tracking-widest opacity-80 text-muted-foreground">Detalles del Registro</p>
<h2 class="text-xl font-black font-mono tracking-tighter truncate" title={selectedItem?.name || ''}>
<p class="text-[10px] uppercase tracking-widest opacity-80 text-muted-foreground">
Detalles del Registro
</p>
<h2
class="text-xl font-black font-mono tracking-tighter truncate"
title={selectedItem?.name || ''}
>
{selectedItem?.name || '---'}
</h2>
<div class="flex items-center gap-2 mt-1">
<span class="text-xs font-mono text-muted-foreground">{selectedItem?.rfc || ''}</span>
</div>
<div class="flex items-center gap-2 mt-1">
<span class="text-xs font-mono text-muted-foreground">{selectedItem?.rfc || ''}</span>
</div>
</div>
<div class="flex-1 overflow-auto p-5 space-y-6 bg-card">
{#if selectedItem}
<div class="grid grid-cols-1 gap-4">
<div class="space-y-1">
<Label class="text-[10px] uppercase text-muted-foreground font-bold flex items-center gap-1">
<Users size={10} /> Tipo
</Label>
<p class="text-sm font-medium capitalize">{selectedItem.client_or_provider}</p>
</div>
{#if selectedItem.address}
<div class="pt-4 border-t space-y-3">
<Label class="text-[10px] uppercase text-muted-foreground font-bold flex items-center gap-1">
<MapPin size={10} /> Dirección
</Label>
<div class="text-sm space-y-1">
<p>{selectedItem.address.streets || ''} {selectedItem.address.exterior_number || ''} {selectedItem.address.interior_number ? 'Int ' + selectedItem.address.interior_number : ''}</p>
<p>{selectedItem.address.neighborhood || ''}</p>
<p>{selectedItem.address.city || ''}, {selectedItem.address.state || ''}</p>
<p>{selectedItem.address.postal_code || ''}, {selectedItem.address.country || ''}</p>
</div>
</div>
<div class="pt-4 border-t space-y-3">
<Label class="text-[10px] uppercase text-muted-foreground font-bold flex items-center gap-1">
<Hash size={10} /> Contacto
</Label>
{#if selectedItem.address.email}
<div class="flex items-center gap-2 text-sm">
<Mail size={14} class="text-muted-foreground" />
<span>{selectedItem.address.email}</span>
</div>
{/if}
{#if selectedItem.address.phone}
<div class="flex items-center gap-2 text-sm">
<Phone size={14} class="text-muted-foreground" />
<span>{selectedItem.address.phone}</span>
</div>
{/if}
</div>
{:else}
<div class="p-4 rounded-md bg-yellow-50 dark:bg-yellow-900/10 border border-yellow-200 dark:border-yellow-900">
<p class="text-xs text-yellow-600 dark:text-yellow-400">Sin dirección registrada</p>
</div>
{/if}
<div class="space-y-1">
<Label
class="text-[10px] uppercase text-muted-foreground font-bold flex items-center gap-1"
>
<Users size={10} /> Tipo
</Label>
<p class="text-sm font-medium capitalize">{selectedItem.client_or_provider}</p>
</div>
{#if selectedItem.programs}
<div class="pt-4 border-t space-y-3">
<Label class="text-[10px] uppercase text-muted-foreground font-bold flex items-center gap-1">
<Building2 size={10} /> Programas
</Label>
<div class="grid grid-cols-2 gap-2 text-sm">
<div>
<span class="text-xs text-muted-foreground block">Programa</span>
<span class="font-medium">{selectedItem.programs.program || '-'}</span>
</div>
<div>
<span class="text-xs text-muted-foreground block">Número</span>
<span class="font-medium">{selectedItem.programs.program_number || '-'}</span>
</div>
<div>
<span class="text-xs text-muted-foreground block">TAX ID</span>
<span class="font-medium">{selectedItem.programs.tax_id || '-'}</span>
</div>
</div>
</div>
{/if}
{#if selectedItem.address}
<div class="pt-4 border-t space-y-3">
<Label
class="text-[10px] uppercase text-muted-foreground font-bold flex items-center gap-1"
>
<MapPin size={10} /> Dirección
</Label>
<div class="text-sm space-y-1">
<p>
{selectedItem.address.streets || ''}
{selectedItem.address.exterior_number || ''}
{selectedItem.address.interior_number
? 'Int ' + selectedItem.address.interior_number
: ''}
</p>
<p>{selectedItem.address.neighborhood || ''}</p>
<p>{selectedItem.address.city || ''}, {selectedItem.address.state || ''}</p>
<p>
{selectedItem.address.postal_code || ''}, {selectedItem.address.country || ''}
</p>
</div>
</div>
<div class="pt-4 border-t space-y-3">
<Label
class="text-[10px] uppercase text-muted-foreground font-bold flex items-center gap-1"
>
<Hash size={10} /> Contacto
</Label>
{#if selectedItem.address.email}
<div class="flex items-center gap-2 text-sm">
<Mail size={14} class="text-muted-foreground" />
<span>{selectedItem.address.email}</span>
</div>
{/if}
{#if selectedItem.address.phone}
<div class="flex items-center gap-2 text-sm">
<Phone size={14} class="text-muted-foreground" />
<span>{selectedItem.address.phone}</span>
</div>
{/if}
</div>
{:else}
<div
class="p-4 rounded-md bg-yellow-50 dark:bg-yellow-900/10 border border-yellow-200 dark:border-yellow-900"
>
<p class="text-xs text-yellow-600 dark:text-yellow-400">Sin dirección registrada</p>
</div>
{/if}
{#if selectedItem.programs}
<div class="pt-4 border-t space-y-3">
<Label
class="text-[10px] uppercase text-muted-foreground font-bold flex items-center gap-1"
>
<Building2 size={10} /> Programas
</Label>
<div class="grid grid-cols-2 gap-2 text-sm">
<div>
<span class="text-xs text-muted-foreground block">Programa</span>
<span class="font-medium">{selectedItem.programs.program || '-'}</span>
</div>
<div>
<span class="text-xs text-muted-foreground block">Número</span>
<span class="font-medium">{selectedItem.programs.program_number || '-'}</span>
</div>
<div>
<span class="text-xs text-muted-foreground block">TAX ID</span>
<span class="font-medium">{selectedItem.programs.tax_id || '-'}</span>
</div>
</div>
</div>
{/if}
</div>
{:else}
<div class="flex flex-col items-center justify-center h-full text-center text-muted-foreground opacity-50">
<div
class="flex flex-col items-center justify-center h-full text-center text-muted-foreground opacity-50"
>
<Building2 class="h-12 w-12 mb-3" />
<p class="text-sm">Selecciona un registro</p>
</div>
@@ -383,7 +456,9 @@
</div>
<!-- Sticky Footer Actions -->
<div class="fixed bottom-0 left-0 right-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 border-t shadow-lg z-[5] group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] ml-[calc(var(--sidebar-width))]">
<div
class="fixed bottom-0 left-0 right-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 border-t shadow-lg z-[5] group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] ml-[calc(var(--sidebar-width))]"
>
<div class="px-4 py-4 max-w-[1400px] mx-auto">
<div class="flex justify-end gap-2">
<Button size="sm" href="/dashboard/clients_and_providers/edit">
@@ -393,7 +468,13 @@
<Button variant="outline" size="sm" onclick={handleEdit} disabled={!selectedItem}>
Editar
</Button>
<Button variant="outline" size="sm" onclick={handleDelete} disabled={!selectedItem} class="text-destructive hover:text-destructive">
<Button
variant="outline"
size="sm"
onclick={handleDelete}
disabled={!selectedItem}
class="text-destructive hover:text-destructive"
>
Borrar
</Button>
</div>