Feature/Integracion de nasvegacion free mouse para goods
This commit is contained in:
95
frontend/src/lib/components/keyboard/KeyboardManager.svelte
Normal file
95
frontend/src/lib/components/keyboard/KeyboardManager.svelte
Normal 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)} />
|
||||
111
frontend/src/lib/components/keyboard/ShortcutsHelpModal.svelte
Normal file
111
frontend/src/lib/components/keyboard/ShortcutsHelpModal.svelte
Normal 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}
|
||||
29
frontend/src/lib/config/shortcuts.ts
Normal file
29
frontend/src/lib/config/shortcuts.ts
Normal 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;
|
||||
17
frontend/src/lib/hooks/use-shortcuts.ts
Normal file
17
frontend/src/lib/hooks/use-shortcuts.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
49
frontend/src/lib/stores/shortcut-store.ts
Normal file
49
frontend/src/lib/stores/shortcut-store.ts
Normal 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);
|
||||
@@ -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?.()}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user