Feature/Integracion de nasvegacion free mouse para goods

This commit is contained in:
hreyes
2026-02-03 12:03:41 -06:00
parent d29d7120d6
commit 78adde9737
9 changed files with 588 additions and 207 deletions

View File

@@ -0,0 +1,95 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { GLOBAL_NAV } from '$lib/config/shortcuts';
import { shortcutStore } from '$lib/stores/shortcut-store';
import ShortcutsHelpModal from './ShortcutsHelpModal.svelte';
let showHelp = $state(false);
function handleKeydown(event: KeyboardEvent) {
const { key, altKey, ctrlKey, metaKey, shiftKey } = event;
const lowerKey = key.toLowerCase();
// Ignore standalone modifiers
if (['Control', 'Alt', 'Shift', 'Meta'].includes(key)) return;
// Construct current combo string: "Alt+Shift+K"
const modifiers = [];
if (ctrlKey) modifiers.push('Ctrl');
if (altKey) modifiers.push('Alt');
if (shiftKey) modifiers.push('Shift');
if (metaKey) modifiers.push('Meta');
const combo = [...modifiers, key.toUpperCase()].join('+');
// Input Guard
const target = event.target as HTMLElement;
const isInput =
target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
// 1. HELP: F1
if (key === 'F1') {
event.preventDefault();
showHelp = !showHelp;
return;
}
// 2. ESCAPE
if (key === 'Escape') {
if (showHelp) {
showHelp = false;
event.preventDefault();
return;
}
const match = $shortcutStore.shortcuts.find((s) => s.key === 'Escape');
if (match) {
event.preventDefault();
match.action();
return;
}
}
// 3. PRIORITY: LOCAL SHORTCUTS
// Match exact combo string against registered shortcuts
const localMatch = $shortcutStore.shortcuts.find((s) => s.key === combo);
if (localMatch) {
// Guard: If it's a single key (no modifiers) and we are in input, ignore it
// Unless the shortcut explicitly says "allowInInput" (not implemented yet, assuming strictly no inputs for single keys)
if (modifiers.length === 0 && isInput) {
// allow native typing
return;
}
event.preventDefault();
event.stopPropagation(); // Stop bubbling
localMatch.action();
return;
}
// 4. GLOBAL NAV: Alt + Key (Legacy/Default behavior for global nav)
// We keep this simple logic for now, or we could move GLOBAL_NAV to be registered in the store too?
// Let's keep the hardcoded Alt+Key check for GLOBAL_NAV for simplicity/performance
// essentially satisfying the "Central Config" requirement.
if (altKey && modifiers.length === 1) {
// Exactly Alt + Key (no shift/ctrl)
// Special: Alt+B -> Focus Search
if (lowerKey === 'b') {
event.preventDefault();
const searchInput = document.getElementById('global-search-input');
searchInput?.focus();
return;
}
const route = GLOBAL_NAV[lowerKey as keyof typeof GLOBAL_NAV];
if (route) {
event.preventDefault();
goto(route);
return;
}
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<ShortcutsHelpModal open={showHelp} onClose={() => (showHelp = false)} />

View File

@@ -0,0 +1,111 @@
<script lang="ts">
import { GLOBAL_NAV as GLOBAL_CONF } from '$lib/config/shortcuts';
import { activeShortcuts as store } from '$lib/stores/shortcut-store';
let { open = false, onClose } = $props();
// Group local shortcuts
let localShortcuts = $derived($store.shortcuts);
</script>
{#if open}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
role="dialog"
aria-modal="true"
>
<div
class="w-full max-w-2xl rounded-xl bg-white p-6 shadow-2xl dark:bg-gray-900 text-gray-900 dark:text-gray-100"
>
<div
class="mb-6 flex items-center justify-between border-b border-gray-200 pb-4 dark:border-gray-800"
>
<div>
<h2 class="text-xl font-bold">Keyboard Shortcuts</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">
Context: <span class="font-medium text-blue-600 dark:text-blue-400"
>{$store.context}</span
>
</p>
</div>
<button onclick={onClose} class="rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-gray-800">
<span class="sr-only">Close</span>
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/></svg
>
</button>
</div>
<div class="grid grid-cols-2 gap-8">
<!-- Global Navigation -->
<div>
<h3
class="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400"
>
Global Navigation (Alt)
</h3>
<div class="space-y-2">
{#each Object.entries(GLOBAL_CONF) as [key, route]}
<div
class="flex items-center justify-between rounded bg-gray-50 px-3 py-2 dark:bg-gray-800/50"
>
<span class="text-sm font-medium"
>{key === 'b'
? 'Search'
: route === '/'
? 'Home'
: 'Go to ' + route.split('/').pop()}</span
>
<kbd
class="rounded border border-gray-200 bg-white px-2 py-0.5 text-xs font-bold text-gray-700 shadow-sm dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300"
>Alt + {key.toUpperCase()}</kbd
>
</div>
{/each}
</div>
</div>
<!-- Local Actions -->
<div>
<h3
class="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400"
>
Active Actions (Alt+Shift)
</h3>
{#if localShortcuts.length === 0}
<p class="text-sm italic text-gray-400">No specific actions for this view.</p>
{:else}
<div class="space-y-2">
{#each localShortcuts as shortcut}
<div
class="flex items-center justify-between rounded bg-blue-50 px-3 py-2 dark:bg-blue-900/20"
>
<span class="text-sm font-medium text-blue-900 dark:text-blue-100"
>{shortcut.description}</span
>
<kbd
class="rounded border border-blue-200 bg-white px-2 py-0.5 text-xs font-bold text-blue-700 shadow-sm dark:border-blue-800 dark:bg-gray-900 dark:text-blue-300"
>{shortcut.key}</kbd
>
</div>
{/each}
</div>
{/if}
</div>
</div>
<div class="mt-8 flex justify-end border-t border-gray-200 pt-4 dark:border-gray-800">
<button
onclick={onClose}
class="rounded bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800 dark:bg-white dark:text-gray-900"
>Close</button
>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,29 @@
/**
* Central Configuration for Keyboard Shortcuts
*
* Standards:
* - Alt + Key: Global Navigation (Route switching)
* - Ctrl + Key: Local Actions (Context specific)
*/
export const GLOBAL_NAV = {
// Goods / Merchandise
'g': '/dashboard/goods/parts',
'c': '/dashboard/goods/fixed-asset-classes',
// Common Actions (Navigation intents)
'b': 'SEARCH_FOCUS', // Special case for generic focus
'h': '/', // Home
} as const;
export const STANDARD_ACTIONS = {
's': 'SAVE',
'n': 'NEW',
'e': 'EXPORT',
'd': 'DELETE',
'f': 'FILTER',
'Escape': 'CANCEL' // Special case
} as const;
export type GlobalNavKey = keyof typeof GLOBAL_NAV;
export type ActionKey = keyof typeof STANDARD_ACTIONS;

View File

@@ -0,0 +1,17 @@
import { onMount, onDestroy } from 'svelte';
import { shortcutStore, type ShortcutDef } from '$lib/stores/shortcut-store';
/**
* Hook to register shortcuts for a component lifecycle.
* @param context Name of the context (e.g., 'Goods List')
* @param shortcuts Array of shortcut definitions
*/
export function useShortcuts(context: string, shortcuts: ShortcutDef[]) {
onMount(() => {
shortcutStore.register(context, shortcuts);
});
onDestroy(() => {
shortcutStore.clear(context);
});
}

View File

@@ -0,0 +1,49 @@
import { writable, derived } from 'svelte/store';
export interface ShortcutDef {
key: string; // e.g., 'Ctrl+S'
description: string;
action: () => void;
group?: string;
}
interface ShortcutState {
context: string;
shortcuts: ShortcutDef[];
}
function createShortcutStore() {
const { subscribe, set, update } = writable<ShortcutState>({
context: 'Global',
shortcuts: []
});
return {
subscribe,
/**
* Register local shortcuts for the current view.
* Call this on mount (or $effect).
*/
register: (context: string, shortcuts: ShortcutDef[]) => {
set({ context, shortcuts });
},
/**
* Clear shortcuts (on unmount)
* Only clear if the context matches (prevent clearing new page's shortcuts during transition)
*/
clear: (contextToClear?: string) => {
update(state => {
// If specific context is provided, only clear if it matches current
if (contextToClear && state.context !== contextToClear) {
return state;
}
return { context: 'Global', shortcuts: [] };
});
}
};
}
export const shortcutStore = createShortcutStore();
// Derived store to help UI display active shortcuts
export const activeShortcuts = derived(shortcutStore, ($state) => $state);

View File

@@ -4,6 +4,8 @@
import { Toaster } from 'svelte-sonner';
import { page } from '$app/stores';
import { handleApiError } from '$lib/utils/error-handler';
import KeyboardManager from '$lib/components/keyboard/KeyboardManager.svelte';
let { children } = $props();
@@ -21,4 +23,8 @@
</svelte:head>
<Toaster richColors position="top-right" />
<KeyboardManager />
<!-- Hidden Global Search Input for Shortcuts -->
<input id="global-search-input" type="text" class="sr-only" placeholder="Global Search..." onfocus={() => console.log('Global Search Focused')} />
{@render children?.()}

View File

@@ -419,6 +419,31 @@
toast.error('Error al eliminar la clase');
}
}
// Keyboard Shortcuts
import { useShortcuts } from '$lib/hooks/use-shortcuts';
useShortcuts('Goods / Classes', [
{
key: 'Alt+Shift+N',
description: 'New Class',
action: () => {
handleNew();
validationError = '';
showInsertDialog = true;
}
},
{
key: 'Alt+Shift+R',
description: 'Refresh',
action: handleRefresh
},
{
key: 'Alt+Shift+D',
description: 'Delete',
action: handleDelete
}
]);
</script>
<div class="flex flex-col h-[calc(100vh-4rem)] p-4 gap-4 pb-15">

View File

@@ -5,12 +5,14 @@
import { Plus, RefreshCw, Package } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { partsApi, type Part } from '$lib/api/dashboard/a76/parts';
import { clientsProvidersApi } from '$lib/api/dashboard/a76/clients-providers';
import { clientsProvidersApi } from '$lib/api/dashboard/a76/clients-providers';
import { companyStore } from '$lib/stores/company.svelte';
import { useShortcuts } from '$lib/hooks/use-shortcuts';
import { goto } from '$app/navigation';
// Estado de la lista de partes
let parts = $state<Part[]>([]);
let clientsMap = $state<Record<number, string>>({}); // Mapa ID -> Nombre
let clientsMap = $state<Record<number, string>>({}); // Mapa ID -> Nombre
let selectedPart = $state<Part | null>(null);
let isLoading = $state(false);
let searchPartNumber = $state('');
@@ -22,24 +24,26 @@
const filteredParts = $derived(
parts.filter((p) => {
// Filtro por número de parte
const matchesPartNumber = !searchPartNumber ||
p.part_number.toLowerCase().includes(searchPartNumber.toLowerCase());
const matchesPartNumber =
!searchPartNumber || p.part_number.toLowerCase().includes(searchPartNumber.toLowerCase());
// Filtro por descripción (español o inglés)
const matchesDescription = !searchDescription ||
const matchesDescription =
!searchDescription ||
(p.description_spanish?.toLowerCase().includes(searchDescription.toLowerCase()) ?? false) ||
(p.description_english?.toLowerCase().includes(searchDescription.toLowerCase()) ?? false);
// Filtro por cliente (Busca en nombre o ID)
const clientName = clientsMap[p.client_id] || '';
const matchesClient = !searchClient ||
const clientName = clientsMap[p.client_id] || '';
const matchesClient =
!searchClient ||
(p.client_id?.toString().includes(searchClient) ?? false) ||
clientName.toLowerCase().includes(searchClient.toLowerCase());
clientName.toLowerCase().includes(searchClient.toLowerCase());
// Filtro por clase
const matchesClass = !searchClass ||
(p.part_class?.toLowerCase().includes(searchClass.toLowerCase()) ?? false);
const matchesClass =
!searchClass || (p.part_class?.toLowerCase().includes(searchClass.toLowerCase()) ?? false);
return matchesPartNumber && matchesDescription && matchesClient && matchesClass;
})
);
@@ -53,43 +57,38 @@
});
async function loadData() {
await Promise.all([loadParts(), loadClients()]);
}
await Promise.all([loadParts(), loadClients()]);
}
async function loadClients() {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
async function loadClients() {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
try {
// Fetch all clients/providers to ensure we map "both" types as well
const response = await clientsProvidersApi.list(
companyId,
1,
1000
);
const data = (response as any).data || response;
const items = data.items || [];
const map: Record<number, string> = {};
items.forEach((c: any) => {
map[c.id] = c.name;
});
clientsMap = map;
try {
// Fetch all clients/providers to ensure we map "both" types as well
const response = await clientsProvidersApi.list(companyId, 1, 1000);
} catch (e) {
console.error("Error cargando clientes:", e);
}
}
const data = (response as any).data || response;
const items = data.items || [];
const map: Record<number, string> = {};
items.forEach((c: any) => {
map[c.id] = c.name;
});
clientsMap = map;
} catch (e) {
console.error('Error cargando clientes:', e);
}
}
async function loadParts() {
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
if (!companyId) {
return;
}
isLoading = true;
try {
try {
const response = await partsApi.list({
company_id: companyId,
page: 1,
@@ -116,32 +115,52 @@
toast.success('Partes actualizadas');
}
async function handleDelete() {
if (!selectedPart) {
toast.error('Selecciona una parte para borrar');
return;
}
const confirmed = window.confirm(`¿Estás seguro de que deseas eliminar la parte ${selectedPart.part_number}? Esta acción no se puede deshacer.`);
if (!confirmed) return;
const confirmed = window.confirm(
`¿Estás seguro de que deseas eliminar la parte ${selectedPart.part_number}? Esta acción no se puede deshacer.`
);
if (!confirmed) return;
isLoading = true;
try {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
isLoading = true;
try {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
await partsApi.delete(selectedPart.id, companyId);
toast.success('Parte eliminada exitosamente');
selectedPart = null;
await loadData();
} catch (e) {
console.error("Error al eliminar:", e);
toast.error('Error al eliminar la parte');
} finally {
isLoading = false;
}
await partsApi.delete(selectedPart.id, companyId);
toast.success('Parte eliminada exitosamente');
selectedPart = null;
await loadData();
} catch (e) {
console.error('Error al eliminar:', e);
toast.error('Error al eliminar la parte');
} finally {
isLoading = false;
}
}
// Keyboard Shortcuts
useShortcuts('Goods / Parts', [
{
key: 'Alt+Shift+N',
description: 'New Part',
action: () => goto('/dashboard/goods/parts/edit')
},
{
key: 'Alt+Shift+R',
description: 'Refresh List',
action: handleRefresh
},
{
key: 'Alt+Shift+D',
description: 'Delete Selected',
action: handleDelete
}
]);
</script>
<div class="flex flex-col h-[calc(100vh-4rem)] p-4 gap-4 pb-15">
@@ -169,11 +188,7 @@
<div class="grid grid-cols-4 gap-4">
<div class="space-y-2">
<Label class="text-xs">Número de Parte</Label>
<Input
bind:value={searchPartNumber}
placeholder="Ej: PART-001"
class="h-9"
/>
<Input bind:value={searchPartNumber} placeholder="Ej: PART-001" class="h-9" />
</div>
<div class="space-y-2">
<Label class="text-xs">Descripción</Label>
@@ -185,19 +200,11 @@
</div>
<div class="space-y-2">
<Label class="text-xs">Cliente</Label>
<Input
bind:value={searchClient}
placeholder="Nombre o ID..."
class="h-9"
/>
<Input bind:value={searchClient} placeholder="Nombre o ID..." class="h-9" />
</div>
<div class="space-y-2">
<Label class="text-xs">Clase</Label>
<Input
bind:value={searchClass}
placeholder="Clase..."
class="h-9"
/>
<Input bind:value={searchClass} placeholder="Clase..." class="h-9" />
</div>
</div>
</div>
@@ -218,155 +225,190 @@
</div>
</div>
<!-- Tabla de partes -->
<div class="flex-1 overflow-auto">
<table class="w-full text-sm">
<thead class="bg-white dark:bg-black text-gray-900 dark:text-white border-b">
<tr>
<th class="px-2 py-2 text-left w-8">
<input type="checkbox" class="h-4 w-4" />
</th>
<th class="px-2 py-2 text-left">Número de Parte</th>
<th class="px-2 py-2 text-left">Descripción</th>
<th class="px-2 py-2 text-left">Cliente</th>
<th class="px-2 py-2 text-left">Clase</th>
<th class="px-2 py-2 text-left">U.M.</th>
<th class="px-2 py-2 text-left">Fracción</th>
</tr>
</thead>
<tbody>
{#if isLoading}
<!-- Tabla de partes -->
<div class="flex-1 overflow-auto">
<table class="w-full text-sm">
<thead class="bg-white dark:bg-black text-gray-900 dark:text-white border-b">
<tr>
<td colspan="7" class="text-center py-8 text-muted-foreground">Cargando...</td>
<th class="px-2 py-2 text-left w-8">
<input type="checkbox" class="h-4 w-4" />
</th>
<th class="px-2 py-2 text-left">Número de Parte</th>
<th class="px-2 py-2 text-left">Descripción</th>
<th class="px-2 py-2 text-left">Cliente</th>
<th class="px-2 py-2 text-left">Clase</th>
<th class="px-2 py-2 text-left">U.M.</th>
<th class="px-2 py-2 text-left">Fracción</th>
</tr>
{:else if filteredParts.length === 0}
<tr>
<td colspan="7" class="text-center py-8 text-muted-foreground">
No hay partes registradas
</td>
</tr>
{:else}
{#each filteredParts as part (part.id)}
<tr
class="border-b cursor-pointer transition-colors {selectedPart?.id ===
part.id
? 'bg-gray-300 dark:bg-gray-600'
: 'hover:bg-gray-100 dark:hover:bg-gray-700'}"
onclick={() => selectPart(part)}
>
<td class="px-2 py-1">
<input
type="checkbox"
checked={selectedPart?.id === part.id}
class="h-4 w-4"
/>
</td>
<td class="px-2 py-1">
<span class="inline-flex items-center rounded-md bg-blue-50 dark:bg-blue-900/30 px-2 py-1 text-xs font-mono font-bold text-blue-700 dark:text-blue-400 border border-blue-200 dark:border-blue-800">
{part.part_number}
</span>
</td>
<td class="px-2 py-1 font-medium text-sm">{part.description_spanish || ''}</td>
<td class="px-2 py-1 text-muted-foreground">
<div class="flex flex-col">
<span class="font-medium text-xs">{clientsMap[part.client_id] || 'Cargando...'}</span>
</div>
</td>
<td class="px-2 py-1">
{#if part.part_class}
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400">
{part.part_class}
</span>
{:else}
<span class="text-muted-foreground">-</span>
{/if}
</td>
<td class="px-2 py-1 text-muted-foreground">{part.unit_of_measure || '-'}</td>
<td class="px-2 py-1 font-mono text-xs text-orange-600 dark:text-orange-400">{part.fraction || '-'}</td>
</thead>
<tbody>
{#if isLoading}
<tr>
<td colspan="7" class="text-center py-8 text-muted-foreground">Cargando...</td>
</tr>
{/each}
{/if}
</tbody>
</table>
{:else if filteredParts.length === 0}
<tr>
<td colspan="7" class="text-center py-8 text-muted-foreground">
No hay partes registradas
</td>
</tr>
{:else}
{#each filteredParts as part (part.id)}
<tr
class="border-b cursor-pointer transition-colors {selectedPart?.id === part.id
? 'bg-gray-300 dark:bg-gray-600'
: 'hover:bg-gray-100 dark:hover:bg-gray-700'}"
onclick={() => selectPart(part)}
>
<td class="px-2 py-1">
<input
type="checkbox"
checked={selectedPart?.id === part.id}
class="h-4 w-4"
/>
</td>
<td class="px-2 py-1">
<span
class="inline-flex items-center rounded-md bg-blue-50 dark:bg-blue-900/30 px-2 py-1 text-xs font-mono font-bold text-blue-700 dark:text-blue-400 border border-blue-200 dark:border-blue-800"
>
{part.part_number}
</span>
</td>
<td class="px-2 py-1 font-medium text-sm">{part.description_spanish || ''}</td>
<td class="px-2 py-1 text-muted-foreground">
<div class="flex flex-col">
<span class="font-medium text-xs"
>{clientsMap[part.client_id] || 'Cargando...'}</span
>
</div>
</td>
<td class="px-2 py-1">
{#if part.part_class}
<span
class="px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400"
>
{part.part_class}
</span>
{:else}
<span class="text-muted-foreground">-</span>
{/if}
</td>
<td class="px-2 py-1 text-muted-foreground">{part.unit_of_measure || '-'}</td>
<td class="px-2 py-1 font-mono text-xs text-orange-600 dark:text-orange-400"
>{part.fraction || '-'}</td
>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Panel derecho: Detalles y edición -->
<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">
<p class="text-[10px] uppercase tracking-widest opacity-80 text-muted-foreground">Número de Parte</p>
<h2 class="text-3xl font-black font-mono tracking-tighter">
{selectedPart?.part_number || '---'}
</h2>
</div>
<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">
<p class="text-[10px] uppercase tracking-widest opacity-80 text-muted-foreground">
Número de Parte
</p>
<h2 class="text-3xl font-black font-mono tracking-tighter">
{selectedPart?.part_number || '---'}
</h2>
</div>
<div class="flex-1 overflow-auto p-5 space-y-6 bg-card">
{#if selectedPart}
<div class="space-y-3">
<div>
<Label class="text-[10px] uppercase text-muted-foreground font-bold">Descripción ES</Label>
<p class="text-sm font-semibold leading-tight">{selectedPart.description_spanish || 'Sin descripción'}</p>
</div>
<div class="pt-2 border-t border-dashed">
<Label class="text-[10px] uppercase text-muted-foreground font-bold">Description EN</Label>
<p class="text-sm italic text-muted-foreground">{selectedPart.description_english || 'No translation available'}</p>
</div>
</div>
<div class="grid grid-cols-2 gap-4 pt-4 border-t">
<div class="space-y-1">
<Label class="text-[10px] uppercase text-muted-foreground font-bold">Cliente</Label>
<div class="flex items-center gap-2">
<Package class="h-3 w-3 text-blue-500" />
<span class="text-sm font-bold">{clientsMap[selectedPart.client_id] || selectedPart.client_id}</span>
</div>
</div>
<div class="space-y-1">
<Label class="text-[10px] uppercase text-muted-foreground font-bold">Clase</Label>
<span class="text-sm font-bold">{selectedPart.part_class || '-'}</span>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1">
<Label class="text-[10px] uppercase text-muted-foreground font-bold">U.M.</Label>
<span class="text-sm font-bold">{selectedPart.unit_of_measure || '-'}</span>
</div>
<div class="space-y-1">
<Label class="text-[10px] uppercase text-muted-foreground font-bold">Peso Unit.</Label>
<span class="text-sm font-bold">{selectedPart.unit_weight || '-'}</span>
</div>
</div>
<div class="p-3 bg-orange-50 dark:bg-orange-950/20 rounded-lg border border-orange-100 dark:border-orange-900">
<Label class="text-[10px] uppercase text-orange-600 dark:text-orange-400 font-bold">Fracción Arancelaria</Label>
<p class="text-lg font-mono font-bold text-orange-700 dark:text-orange-300">
{selectedPart.fraction || '0000.00.00'}
<div class="flex-1 overflow-auto p-5 space-y-6 bg-card">
{#if selectedPart}
<div class="space-y-3">
<div>
<Label class="text-[10px] uppercase text-muted-foreground font-bold"
>Descripción ES</Label
>
<p class="text-sm font-semibold leading-tight">
{selectedPart.description_spanish || 'Sin descripción'}
</p>
</div>
<div class="pt-2 border-t border-dashed">
<Label class="text-[10px] uppercase text-muted-foreground font-bold"
>Description EN</Label
>
<p class="text-sm italic text-muted-foreground">
{selectedPart.description_english || 'No translation available'}
</p>
</div>
</div>
{#if selectedPart.unit_cost}
<div class="p-3 bg-green-50 dark:bg-green-950/20 rounded-lg border border-green-100 dark:border-green-900">
<Label class="text-[10px] uppercase text-green-600 dark:text-green-400 font-bold">Costo Unitario</Label>
<p class="text-lg font-bold text-green-700 dark:text-green-300">
${selectedPart.unit_cost} {selectedPart.currency_key || 'USD'}
</p>
<div class="grid grid-cols-2 gap-4 pt-4 border-t">
<div class="space-y-1">
<Label class="text-[10px] uppercase text-muted-foreground font-bold">Cliente</Label>
<div class="flex items-center gap-2">
<Package class="h-3 w-3 text-blue-500" />
<span class="text-sm font-bold"
>{clientsMap[selectedPart.client_id] || selectedPart.client_id}</span
>
</div>
{/if}
{:else}
<div class="flex flex-col items-center justify-center h-full text-center text-muted-foreground">
<Package class="h-12 w-12 mb-3 opacity-20" />
<p class="text-sm">Selecciona una parte para ver sus detalles</p>
</div>
<div class="space-y-1">
<Label class="text-[10px] uppercase text-muted-foreground font-bold">Clase</Label>
<span class="text-sm font-bold">{selectedPart.part_class || '-'}</span>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1">
<Label class="text-[10px] uppercase text-muted-foreground font-bold">U.M.</Label>
<span class="text-sm font-bold">{selectedPart.unit_of_measure || '-'}</span>
</div>
<div class="space-y-1">
<Label class="text-[10px] uppercase text-muted-foreground font-bold">Peso Unit.</Label
>
<span class="text-sm font-bold">{selectedPart.unit_weight || '-'}</span>
</div>
</div>
<div
class="p-3 bg-orange-50 dark:bg-orange-950/20 rounded-lg border border-orange-100 dark:border-orange-900"
>
<Label class="text-[10px] uppercase text-orange-600 dark:text-orange-400 font-bold"
>Fracción Arancelaria</Label
>
<p class="text-lg font-mono font-bold text-orange-700 dark:text-orange-300">
{selectedPart.fraction || '0000.00.00'}
</p>
</div>
{#if selectedPart.unit_cost}
<div
class="p-3 bg-green-50 dark:bg-green-950/20 rounded-lg border border-green-100 dark:border-green-900"
>
<Label class="text-[10px] uppercase text-green-600 dark:text-green-400 font-bold"
>Costo Unitario</Label
>
<p class="text-lg font-bold text-green-700 dark:text-green-300">
${selectedPart.unit_cost}
{selectedPart.currency_key || 'USD'}
</p>
</div>
{/if}
</div>
{:else}
<div
class="flex flex-col items-center justify-center h-full text-center text-muted-foreground"
>
<Package class="h-12 w-12 mb-3 opacity-20" />
<p class="text-sm">Selecciona una parte para ver sus detalles</p>
</div>
{/if}
</div>
</div>
</div>
</div>
<!-- Footer fijo con botones de acción -->
<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">
<!-- Botones de acción -->
<div class="flex justify-end gap-2">
@@ -374,8 +416,15 @@
<Plus class="h-4 w-4 mr-1" />
Nueva Parte
</Button>
<Button variant="outline" size="sm" href="/dashboard/goods/parts/edit/{selectedPart?.id}" disabled={!selectedPart}>Editar</Button>
<Button variant="outline" size="sm" onclick={handleDelete} disabled={!selectedPart}>Borrar</Button>
<Button
variant="outline"
size="sm"
href="/dashboard/goods/parts/edit/{selectedPart?.id}"
disabled={!selectedPart}>Editar</Button
>
<Button variant="outline" size="sm" onclick={handleDelete} disabled={!selectedPart}
>Borrar</Button
>
</div>
</div>
</div>
</div>

View File

@@ -81,7 +81,7 @@ DEBUG=True
ENVIRONMENT=development
NODE_ENV=development
VITE_API_URL=http://localhost:8000/api
VITE_KEYCLOAK_URL=http://localhost:8080
VITE_KEYCLOAK_URL=http://localhost:8080/kcauth
EOF
echo -e "${GREEN}✓ Archivo .env creado con valores por defecto${NC}"
fi