chore: baseline plantilla-proyectos como base del CRM
This commit is contained in:
188
frontend/src/app.css
Normal file
188
frontend/src/app.css
Normal file
@@ -0,0 +1,188 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@plugin '@tailwindcss/forms';
|
||||
@plugin '@tailwindcss/typography';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--radius: 0.65rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.623 0.214 259.815);
|
||||
--primary-foreground: oklch(0.97 0.014 254.604);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.623 0.214 259.815);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||
--sidebar-primary: oklch(0.623 0.214 259.815);
|
||||
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
|
||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.623 0.214 259.815);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.546 0.245 262.881);
|
||||
--primary-foreground: oklch(0.98 0.01 262.881);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.488 0.243 264.376);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.21 0.006 285.885);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.546 0.245 262.881);
|
||||
--sidebar-primary-foreground: oklch(0.379 0.146 265.522);
|
||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.488 0.243 264.376);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground overflow-x-hidden;
|
||||
}
|
||||
|
||||
/* Asegurar que el texto de los inputs de fecha sea legible en modo oscuro
|
||||
y en navegadores WebKit */
|
||||
input[type="date"],
|
||||
input[type="datetime-local"] {
|
||||
color: var(--color-foreground);
|
||||
-webkit-text-fill-color: var(--color-foreground);
|
||||
}
|
||||
|
||||
input[type="date"]::-webkit-calendar-picker-indicator,
|
||||
input[type="datetime-local"]::-webkit-calendar-picker-indicator {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.dark input[type="date"]::-webkit-calendar-picker-indicator,
|
||||
.dark input[type="datetime-local"]::-webkit-calendar-picker-indicator {
|
||||
cursor: pointer;
|
||||
filter: invert(1) brightness(1.15);
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.catalog-table-shell {
|
||||
@apply rounded-md border border-border/80 bg-card shadow-sm;
|
||||
}
|
||||
|
||||
.catalog-table-scroll {
|
||||
@apply relative w-full flex-1 overflow-auto bg-card;
|
||||
}
|
||||
|
||||
.catalog-table-header {
|
||||
@apply sticky top-0 z-20 border-b border-border/80 bg-card/95 shadow-sm backdrop-blur-md;
|
||||
}
|
||||
|
||||
.catalog-table-head-cell {
|
||||
@apply whitespace-nowrap text-sm font-semibold text-foreground/90;
|
||||
}
|
||||
|
||||
.catalog-table-row {
|
||||
@apply transition-colors hover:bg-accent/35;
|
||||
}
|
||||
|
||||
.catalog-table-row-selected {
|
||||
@apply bg-accent/65 text-accent-foreground hover:bg-accent/65;
|
||||
}
|
||||
|
||||
.catalog-table-sticky-left {
|
||||
@apply sticky left-0 border-r border-border/70 bg-card shadow-[4px_0_12px_-6px_rgba(0,0,0,0.12)] dark:shadow-[4px_0_12px_-6px_rgba(0,0,0,0.35)];
|
||||
}
|
||||
|
||||
.catalog-table-sticky-right {
|
||||
@apply sticky right-0 border-l border-border/70 bg-card shadow-[-4px_0_12px_-6px_rgba(0,0,0,0.08)] dark:shadow-[-4px_0_12px_-6px_rgba(0,0,0,0.25)];
|
||||
}
|
||||
|
||||
.catalog-table-sticky-row-hover {
|
||||
@apply bg-card group-hover/inv-list:bg-accent/35;
|
||||
}
|
||||
|
||||
.catalog-table-sticky-row-selected {
|
||||
@apply bg-accent/65 text-accent-foreground;
|
||||
}
|
||||
}
|
||||
22
frontend/src/app.d.ts
vendored
Normal file
22
frontend/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
interface Locals {
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
interface PageData {
|
||||
licenseError?: {
|
||||
type: string;
|
||||
message: string;
|
||||
status: number;
|
||||
};
|
||||
}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export { };
|
||||
22
frontend/src/app.html
Normal file
22
frontend/src/app.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="%paraglide.lang%">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Mi Aplicación</title>
|
||||
<meta name="description" content="Plataforma SaaS para gestión de comercio exterior conforme a Anexos 24, 30 y 22 del SAT" />
|
||||
<script>
|
||||
// Cargar el tema antes de renderizar para evitar flash
|
||||
(function() {
|
||||
const theme = localStorage.getItem('theme') || 'dark';
|
||||
if (theme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
7
frontend/src/demo.spec.ts
Normal file
7
frontend/src/demo.spec.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('sum test', () => {
|
||||
it('adds 1 + 2 to equal 3', () => {
|
||||
expect(1 + 2).toBe(3);
|
||||
});
|
||||
});
|
||||
25
frontend/src/hooks.server.ts
Normal file
25
frontend/src/hooks.server.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
import { paraglideMiddleware } from '$lib/paraglide/server';
|
||||
import { sequence } from '@sveltejs/kit/hooks';
|
||||
import { getAccessTokenFromCookies } from '$lib/server/access-token-cookie';
|
||||
|
||||
const handleParaglide: Handle = ({ event, resolve }) => paraglideMiddleware(event.request, ({ request, locale }) => {
|
||||
event.request = request;
|
||||
|
||||
return resolve(event, {
|
||||
transformPageChunk: ({ html }) => html.replace('%paraglide.lang%', locale)
|
||||
});
|
||||
});
|
||||
|
||||
const handleAuth: Handle = async ({ event, resolve }) => {
|
||||
// Obtener el token de las cookies
|
||||
const token = getAccessTokenFromCookies(event.cookies);
|
||||
|
||||
// Agregar el token a los locals para que esté disponible en toda la app
|
||||
event.locals.token = token || null;
|
||||
event.locals.isAuthenticated = !!token;
|
||||
|
||||
return resolve(event);
|
||||
};
|
||||
|
||||
export const handle: Handle = sequence(handleAuth, handleParaglide);
|
||||
4
frontend/src/hooks.ts
Normal file
4
frontend/src/hooks.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { deLocalizeUrl } from '$lib/paraglide/runtime';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
|
||||
export const reroute = (request: { url: string }) => deLocalizeUrl(request.url).pathname;
|
||||
259
frontend/src/lib/Reporte_Pruebas.MD
Normal file
259
frontend/src/lib/Reporte_Pruebas.MD
Normal file
@@ -0,0 +1,259 @@
|
||||
# Reporte de Pruebas — Anexo 76
|
||||
|
||||
## Resumen ejecutivo
|
||||
|
||||
| Herramienta | Archivos | Pruebas | Estado |
|
||||
|-------------|----------|---------|--------|
|
||||
| Backend — pytest | 4 | 11 | Pasando |
|
||||
| Frontend — Vitest (server) | 8 | 68 | Pasando |
|
||||
| Frontend — Playwright (E2E) | 5 | 28 | Pasando |
|
||||
| **Total** | **17** | **107** | **Pasando** |
|
||||
|
||||
---
|
||||
|
||||
## Backend — pytest (11 pruebas)
|
||||
|
||||
Ubicacion: `backend/tests/`
|
||||
|
||||
### e2e/test_inventory_flow_anexo24.py — 1 prueba
|
||||
|
||||
**test_e2e_inventory_flow_import_then_export** — la prueba mas importante del repositorio. Simula el flujo completo del negocio:
|
||||
|
||||
1. Crea una factura de importacion TEM con 10 piezas
|
||||
2. La procesa — genera movimiento ENTRY en el inventario
|
||||
3. Crea una factura de exportacion consumiendo 4 piezas
|
||||
4. La procesa — genera CONSUMPTION y DISCHARGE
|
||||
5. Verifica que el saldo neto es positivo y menor a 10
|
||||
|
||||
### integration/ — 5 pruebas
|
||||
|
||||
- **test_process_export_endpoint_consumes_existing_balances** — exportacion consume saldos existentes correctamente
|
||||
- **test_process_export_prevents_negative_balance** — exportacion no puede consumir mas de lo que hay
|
||||
- **test_process_endpoint_prevents_double_processing_import** — una factura no se puede procesar dos veces
|
||||
- **test_process_import_endpoint_creates_balance_entries** — importacion TEM genera entradas de balance
|
||||
- **test_process_import_def_does_not_create_balance_entries** — importacion DEF no genera entradas de balance
|
||||
|
||||
### unit/ — 5 pruebas
|
||||
|
||||
- **test_net_balance_accounts_for_returns_and_entry_void** — balance neto calcula correctamente entradas, consumos, devoluciones y anulaciones
|
||||
- **test_fifo_consumption_algorithm_uses_oldest_lots_first** — algoritmo PEPS consume primero los lotes mas antiguos
|
||||
- **test_assign_values_iva_lines_currency_me** — calculo de IVA en moneda extranjera
|
||||
- **test_assign_values_iva_lines_currency_mn** — calculo de IVA en moneda local
|
||||
- **test_assign_values_iva_lines_currency_mc** — calculo de IVA en moneda manual
|
||||
|
||||
### Comando
|
||||
|
||||
```bash
|
||||
docker exec -it anexo76-backend pytest /app/tests/ -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Frontend Vitest — 68 pruebas
|
||||
|
||||
Vitest corre en Node sin navegador. Prueba funciones puras que reciben datos y devuelven un resultado.
|
||||
|
||||
### backend.test.ts — 2 pruebas
|
||||
|
||||
Verifica que el backend esta disponible desde el contenedor del frontend usando la red interna de Docker.
|
||||
|
||||
- **el backend esta corriendo y responde** — GET /api/health devuelve 200
|
||||
- **el endpoint de facturas responde** — el endpoint de invoices responde (200, 401, 403 o 422 son validos)
|
||||
|
||||
Nota: no se usa `docker exec` porque el contenedor del frontend no tiene acceso a Docker. Se usa la URL interna `http://backend:8000` de la red Docker Compose.
|
||||
|
||||
### utils.getInvoiceTypeColor.test.ts — 19 pruebas
|
||||
|
||||
Prueba que cada tipo de factura devuelve el color Tailwind correcto para mostrarse en la UI.
|
||||
|
||||
- Entradas invalidas (null, undefined, '') devuelven gris por defecto
|
||||
- TEM / IMPO TEM -> rojo (bg-red-100)
|
||||
- DEF / IMPO DEF -> verde (bg-green-100)
|
||||
- MEX / COMP MEX -> morado (bg-purple-100)
|
||||
- CAM REG -> azul (bg-blue-100)
|
||||
- EXPO / EXDEF / PTERM -> azul (bg-blue-100)
|
||||
- IMP REP -> azul claro (bg-sky-100)
|
||||
- Tipo desconocido -> gris por defecto
|
||||
|
||||
### utils.getFileHelpers.test.ts — 9 pruebas
|
||||
|
||||
Prueba extraccion de nombres de archivo desde rutas y formateo para mostrar al usuario.
|
||||
|
||||
- null / undefined -> string vacio
|
||||
- Ruta normal '/uploads/file.png' -> 'file.png'
|
||||
- Ruta con query string '/uploads/file.png?token=123' -> 'file.png' sin el token
|
||||
- Con fileType -> 'file.png (PDF)'
|
||||
|
||||
### utils.getBackendAssetUrl.test.ts — 5 pruebas
|
||||
|
||||
Prueba construccion de URLs del backend evitando duplicar /api.
|
||||
|
||||
Deuda tecnica identificada: la funcion depende de import.meta.env.VITE_API_URL. Se pasa baseUrl explicitamente en cada test.
|
||||
|
||||
- null / undefined -> string vacio
|
||||
- URL completa -> se devuelve sin modificar
|
||||
- /api/... con base /api -> evita /api/api/
|
||||
- Ruta normal -> URL completa correcta
|
||||
|
||||
### date-utils.test.ts — 17 pruebas
|
||||
|
||||
Prueba conversion de fechas entre zona local y UTC ISO.
|
||||
|
||||
- prepareDateForBackend: vacio -> null, fecha + hora -> ISO string valido
|
||||
- loadServerDate: null/undefined -> '', ISO UTC -> YYYY-MM-DD, fecha plana sin modificar
|
||||
- addDaysLocal: vacio -> '', suma normal, cambio de mes, ano bisiesto (2024-02-29)
|
||||
- getCurrentLocal*: verifica formato con regex — no valor exacto porque cambia cada dia
|
||||
|
||||
### csv-import-commit-metrics.test.ts — 11 pruebas
|
||||
|
||||
Prueba metricas del resultado de importacion CSV.
|
||||
|
||||
- totalSkippedFromCommit: null -> 0, objeto vacio -> 0, suma correcta de campos skipped
|
||||
- criticalReferenceGaps: null -> 0, objeto vacio -> 0, campo presente -> su valor
|
||||
- referenceStateReady: null -> true, campo booleano respeta el valor
|
||||
|
||||
### csv-import-status-api.test.ts — 4 pruebas
|
||||
|
||||
Prueba isWaitingConfirmationPayload. fetchCsvImportStatus no se prueba porque depende del backend.
|
||||
|
||||
- null / {} -> false
|
||||
- { status: 'waiting_confirmation' } -> true
|
||||
- { job_id: 'abc', total_rows: 10 } -> true
|
||||
|
||||
### Comando
|
||||
|
||||
```bash
|
||||
docker exec -it anexo76-frontend pnpm test:unit --project server
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Frontend Playwright E2E — 28 pruebas
|
||||
|
||||
Playwright abre un navegador real y prueba flujos completos con el backend y Keycloak levantados.
|
||||
|
||||
### Configuracion
|
||||
|
||||
- **workers: 1** — las pruebas con login no pueden correr en paralelo
|
||||
- **headless: false** — necesario para estabilidad en Windows
|
||||
- **timeout: 60000** — Keycloak puede tardar hasta 60 segundos
|
||||
- **storageState** — sesion guardada en e2e/.auth/user.json y reutilizada
|
||||
|
||||
### auth.setup.ts — 1 prueba
|
||||
|
||||
Login inicial con credenciales demo/demo123. Guarda la sesion para reutilizar.
|
||||
|
||||
### full-flow.spec.ts — 4 pruebas
|
||||
|
||||
Conecta backend y frontend — corre pytest primero y si pasa, verifica el frontend.
|
||||
|
||||
- **pruebas del backend pasan antes de continuar** — ejecuta pytest /app/tests/ y verifica 11 passed
|
||||
- **Import Invoices TEM carga despues de que backend pasa** — URL /invoices + main visible
|
||||
- **Export Invoices carga despues de que backend pasa** — URL /invoices + main visible
|
||||
- **Reportes de facturas carga despues de que backend pasa** — URL /reports + main visible
|
||||
|
||||
### login.spec.ts — 3 pruebas
|
||||
|
||||
- Login exitoso redirige al dashboard
|
||||
- Dashboard muestra h1 visible
|
||||
- Credenciales incorrectas se quedan en /login
|
||||
|
||||
### navigation.spec.ts — 10 pruebas
|
||||
|
||||
- Dashboard carga con h1 visible
|
||||
- Header muestra Aduanasoft S.A. de C.V.
|
||||
- Menu lateral tiene Audit Logs, Customs Brokers, Fractions, Pedimentos
|
||||
- Audit Logs, Customs Brokers, Fraction Sitar y Pedimentos cargan sin error
|
||||
|
||||
### modules.spec.ts — 10 pruebas
|
||||
|
||||
- Import Invoices TEM y DEF cargan sin error
|
||||
- Export Invoices carga sin error
|
||||
- Fixed Catalogs, General Catalogs, Transportes, Goods, Settings, Reportes cargan sin error
|
||||
- Logout redirige a /login
|
||||
|
||||
### Comando
|
||||
|
||||
```bash
|
||||
# Desde la carpeta frontend en Windows
|
||||
pnpm test:e2e
|
||||
|
||||
# Suite especifica
|
||||
pnpm test:e2e --grep "Flujo completo"
|
||||
pnpm test:e2e --grep "Login"
|
||||
pnpm test:e2e --grep "Modulos"
|
||||
pnpm test:e2e --grep "Navegacion"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Archivos que NO se prueban con Vitest
|
||||
|
||||
| Archivo | Razon |
|
||||
|---------|-------|
|
||||
| api.ts | Depende de fetch, cookies, window, document |
|
||||
| auth.ts | Depende de Keycloak JS, cookies, window |
|
||||
| session-manager.ts | Depende de window, setInterval, eventos DOM |
|
||||
| sso.ts | Depende de Keycloak JS y browser |
|
||||
| csv-import-pending.ts | Depende de localStorage |
|
||||
| csv-import-session.ts | Depende de sessionStorage |
|
||||
| csv-upload-row-count.ts | Depende de File.text(), API del browser |
|
||||
| fetchCsvImportStatus | Depende de api que necesita el backend |
|
||||
|
||||
---
|
||||
|
||||
## Pruebas pendientes para el futuro
|
||||
|
||||
### Necesitan datos en la BD
|
||||
- Buscar una factura especifica en Import Invoices
|
||||
- Filtrar por fecha en Reportes
|
||||
- Verificar que tablas muestran registros tras procesar una factura
|
||||
|
||||
### Necesitan mas usuarios
|
||||
- RBAC: usuario sin permisos intenta acceder a ruta protegida
|
||||
- Admin vs usuario normal ven opciones distintas
|
||||
|
||||
### Necesitan flujo completo
|
||||
- Subir un CSV y verificar que el proceso funciona
|
||||
- Crear una factura y verificar que aparece en la tabla
|
||||
|
||||
---
|
||||
|
||||
## Estructura de archivos final
|
||||
|
||||
```
|
||||
backend/tests/
|
||||
├── conftest.py
|
||||
├── fixtures/
|
||||
│ └── builders.py
|
||||
├── e2e/
|
||||
│ └── test_inventory_flow_anexo24.py 1 test
|
||||
├── integration/
|
||||
│ ├── test_export_process_api.py 3 tests
|
||||
│ └── test_import_process_api.py 2 tests
|
||||
└── unit/
|
||||
└── invoices/
|
||||
├── test_balance_algorithm.py 2 tests
|
||||
└── test_currency_conversion.py 3 tests
|
||||
|
||||
frontend/src/lib/
|
||||
├── backend.test.ts 2 tests
|
||||
├── utils.getInvoiceTypeColor.test.ts 19 tests
|
||||
├── utils.getFileHelpers.test.ts 9 tests
|
||||
├── utils.getBackendAssetUrl.test.ts 5 tests
|
||||
├── date-utils.test.ts 17 tests
|
||||
├── csv-import-commit-metrics.test.ts 11 tests
|
||||
└── csv-import-status-api.test.ts 4 tests
|
||||
|
||||
frontend/e2e/
|
||||
├── .auth/user.json
|
||||
├── auth.setup.ts 1 test
|
||||
├── full-flow.spec.ts 4 tests
|
||||
├── login.spec.ts 3 tests
|
||||
├── navigation.spec.ts 10 tests
|
||||
└── modules.spec.ts 10 tests
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Anexo 76 — Reporte de Pruebas v3.0 — 107 pruebas totales*
|
||||
71
frontend/src/lib/access-token-cookie-browser.ts
Normal file
71
frontend/src/lib/access-token-cookie-browser.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { browser } from '$app/environment';
|
||||
import {
|
||||
ACCESS_TOKEN_CHUNK_COUNT,
|
||||
accessTokenChunkName,
|
||||
splitAccessTokenForCookies,
|
||||
ACCESS_TOKEN_MAX_CHUNKS
|
||||
} from '$lib/access-token-cookie.shared';
|
||||
|
||||
function readCookieRaw(name: string): string | null {
|
||||
if (!browser) return null;
|
||||
const value = `; ${document.cookie}`;
|
||||
const parts = value.split(`; ${name}=`);
|
||||
if (parts.length === 2) return parts.pop()?.split(';').shift() ?? null;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getAccessTokenFromDocument(): string | null {
|
||||
if (!browser) return null;
|
||||
const countRaw = readCookieRaw(ACCESS_TOKEN_CHUNK_COUNT);
|
||||
if (countRaw) {
|
||||
const n = parseInt(countRaw, 10);
|
||||
if (!Number.isFinite(n) || n < 1 || n > ACCESS_TOKEN_MAX_CHUNKS) return null;
|
||||
let out = '';
|
||||
for (let i = 0; i < n; i++) {
|
||||
const p = readCookieRaw(accessTokenChunkName(i));
|
||||
if (p == null) return null;
|
||||
out += p;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return readCookieRaw('access_token');
|
||||
}
|
||||
|
||||
export function hasAccessTokenInDocument(): boolean {
|
||||
if (!browser) return false;
|
||||
return !!(readCookieRaw('access_token') || readCookieRaw(ACCESS_TOKEN_CHUNK_COUNT));
|
||||
}
|
||||
|
||||
export function clearAccessTokenOnDocument() {
|
||||
if (!browser) return;
|
||||
const secure = window.location.protocol === 'https:' ? '; Secure' : '';
|
||||
const blank = `; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; SameSite=Lax${secure}`;
|
||||
const clear = (name: string) => {
|
||||
document.cookie = `${name}=${blank}`;
|
||||
};
|
||||
clear('access_token');
|
||||
clear(ACCESS_TOKEN_CHUNK_COUNT);
|
||||
for (let i = 0; i < ACCESS_TOKEN_MAX_CHUNKS; i++) {
|
||||
clear(accessTokenChunkName(i));
|
||||
}
|
||||
}
|
||||
|
||||
/** Misma política que auth setCookie: expires + SameSite + Secure en HTTPS. */
|
||||
export function setAccessTokenOnDocument(token: string, days: number = 7) {
|
||||
if (!browser) return;
|
||||
clearAccessTokenOnDocument();
|
||||
const exp = new Date();
|
||||
exp.setDate(exp.getDate() + days);
|
||||
const secure = window.location.protocol === 'https:' ? '; Secure' : '';
|
||||
const suffix = `; path=/; expires=${exp.toUTCString()}; SameSite=Lax${secure}`;
|
||||
|
||||
const split = splitAccessTokenForCookies(token);
|
||||
if (split.kind === 'single') {
|
||||
document.cookie = `access_token=${split.value}${suffix}`;
|
||||
return;
|
||||
}
|
||||
document.cookie = `${ACCESS_TOKEN_CHUNK_COUNT}=${split.parts.length}${suffix}`;
|
||||
split.parts.forEach((part, i) => {
|
||||
document.cookie = `${accessTokenChunkName(i)}=${part}${suffix}`;
|
||||
});
|
||||
}
|
||||
32
frontend/src/lib/access-token-cookie.shared.ts
Normal file
32
frontend/src/lib/access-token-cookie.shared.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Fragmentación del JWT access_token en varias cookies cuando supera el límite ~4KB del navegador.
|
||||
* La lógica de fetch / Bearer no cambia: solo lectura/escritura de cookies.
|
||||
*/
|
||||
|
||||
/** Por debajo de esto se usa una sola cookie `access_token` (compatibilidad). */
|
||||
export const ACCESS_TOKEN_MAX_SINGLE = 2800;
|
||||
|
||||
export const ACCESS_TOKEN_CHUNK_SIZE = 2800;
|
||||
|
||||
/** Número de fragmentos; si existe, el token está en access_token_0..access_token_{n-1}. */
|
||||
export const ACCESS_TOKEN_CHUNK_COUNT = 'access_token_chunks';
|
||||
|
||||
export const accessTokenChunkName = (index: number) => `access_token_${index}`;
|
||||
|
||||
export type AccessTokenSplit =
|
||||
| { kind: 'single'; value: string }
|
||||
| { kind: 'chunks'; parts: string[] };
|
||||
|
||||
export function splitAccessTokenForCookies(token: string): AccessTokenSplit {
|
||||
if (token.length <= ACCESS_TOKEN_MAX_SINGLE) {
|
||||
return { kind: 'single', value: token };
|
||||
}
|
||||
const parts: string[] = [];
|
||||
for (let i = 0; i < token.length; i += ACCESS_TOKEN_CHUNK_SIZE) {
|
||||
parts.push(token.slice(i, i + ACCESS_TOKEN_CHUNK_SIZE));
|
||||
}
|
||||
return { kind: 'chunks', parts };
|
||||
}
|
||||
|
||||
/** Máximo de fragmentos soportados (JWT muy grande). */
|
||||
export const ACCESS_TOKEN_MAX_CHUNKS = 32;
|
||||
33
frontend/src/lib/actions/portal.ts
Normal file
33
frontend/src/lib/actions/portal.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Svelte action that teleports a DOM node to a target element outside the
|
||||
* current component tree. This ensures the node is not affected by focus
|
||||
* traps, overlays, or event interceptors (e.g. Radix DismissibleLayer) that
|
||||
* are scoped to a parent Dialog/Sheet portal.
|
||||
*
|
||||
* Usage:
|
||||
* <div use:portal>...</div> → appended to <body>
|
||||
* <div use:portal={'#target'}>...</div> → appended to #target
|
||||
*/
|
||||
export function portal(node: HTMLElement, target: HTMLElement | string = 'body') {
|
||||
function mount() {
|
||||
const targetEl =
|
||||
typeof target === 'string'
|
||||
? (document.querySelector(target) as HTMLElement | null)
|
||||
: target;
|
||||
|
||||
if (!targetEl) return;
|
||||
targetEl.appendChild(node);
|
||||
}
|
||||
|
||||
mount();
|
||||
|
||||
return {
|
||||
update(newTarget: HTMLElement | string) {
|
||||
target = newTarget;
|
||||
mount();
|
||||
},
|
||||
destroy() {
|
||||
node.remove();
|
||||
}
|
||||
};
|
||||
}
|
||||
843
frontend/src/lib/api.ts
Normal file
843
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,843 @@
|
||||
/**
|
||||
* Cliente API para comunicación con el backend
|
||||
*/
|
||||
import { getToken } from './auth';
|
||||
import { browser } from '$app/environment';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { clearAccessTokenOnDocument, setAccessTokenOnDocument } from '$lib/access-token-cookie-browser';
|
||||
|
||||
/** Base URL absoluta para fetch; corrige `http:host` sin `//` y añade `http://` si no hay esquema. */
|
||||
function normalizeAbsoluteApiBaseUrl(raw: string): string {
|
||||
let s = (raw ?? '').trim().replace(/\/+$/, '');
|
||||
if (!s) return '';
|
||||
if (s.startsWith('http:') && !s.startsWith('http://')) {
|
||||
s = 'http://' + s.slice('http:'.length).replace(/^\/+/, '');
|
||||
}
|
||||
if (s.startsWith('https:') && !s.startsWith('https://')) {
|
||||
s = 'https://' + s.slice('https:'.length).replace(/^\/+/, '');
|
||||
}
|
||||
if (s.startsWith('/')) return s;
|
||||
if (/^https?:\/\//i.test(s)) return s;
|
||||
return `http://${s.replace(/^\/+/, '')}`;
|
||||
}
|
||||
|
||||
const API_BASE_URL = normalizeAbsoluteApiBaseUrl(String(import.meta.env.VITE_API_URL ?? ''));
|
||||
|
||||
export interface ApiResponse<T = any> {
|
||||
data?: T;
|
||||
error?: string;
|
||||
validationErrors?: Array<{
|
||||
field: string;
|
||||
message: string;
|
||||
code?: string;
|
||||
solution?: string[];
|
||||
value?: any;
|
||||
}>;
|
||||
status: number;
|
||||
}
|
||||
|
||||
/** Reemplaza referencias técnicas `line[n]` por texto más claro para el usuario. */
|
||||
export function humanizeLineReferences(text: string): string {
|
||||
return text.replace(/\bline\[(\d+)\]/gi, 'partida $1');
|
||||
}
|
||||
|
||||
function humanizeFieldPath(field: string): string {
|
||||
const rawField = (field || '').trim();
|
||||
if (!rawField) return 'campo';
|
||||
|
||||
const lineMatch = rawField.match(/^line\[(\d+)\]\.(.+)$/i);
|
||||
const fieldPath = lineMatch?.[2] || rawField;
|
||||
const label = fieldPath
|
||||
.replace(/^body\./i, '')
|
||||
.replace(/\./g, ' → ')
|
||||
.replace(/_/g, ' ');
|
||||
|
||||
if (lineMatch) {
|
||||
return `Partida ${lineMatch[1]} - ${label}`;
|
||||
}
|
||||
|
||||
return label;
|
||||
}
|
||||
|
||||
function humanizeValidationMessage(message: string): string {
|
||||
const rawMessage = (message || '').trim();
|
||||
if (!rawMessage) return 'error de validación';
|
||||
|
||||
return rawMessage
|
||||
.replace(/\b(field required|is required)\b/gi, 'es obligatorio')
|
||||
.replace(/\b(value is not a valid decimal)\b/gi, 'debe ser un número válido')
|
||||
.replace(/\b(value is not a valid integer)\b/gi, 'debe ser un número entero válido');
|
||||
}
|
||||
|
||||
function formatValidationHint(field: string, message: string, code?: string): string {
|
||||
const fieldLabel = humanizeFieldPath(field);
|
||||
const normalizedMessage = humanizeValidationMessage(message);
|
||||
|
||||
if (code === 'REQUIRED' || code === 'REQUIRED_FIELD' || /es obligatorio|es requerido/i.test(normalizedMessage)) {
|
||||
return `Completa ${fieldLabel}.`;
|
||||
}
|
||||
|
||||
if (code === 'AMERICAN_FRACTION_NOT_FOUND') {
|
||||
return 'La fracción americana seleccionada no existe. Elige una opción del catálogo.';
|
||||
}
|
||||
|
||||
if (code === 'UNIT_OF_MEASURE_NOT_FOUND') {
|
||||
return 'La unidad de medida seleccionada no existe. Elige una opción del catálogo.';
|
||||
}
|
||||
|
||||
if (code === 'ORIGIN_COUNTRY_NOT_FOUND') {
|
||||
return 'El país de origen seleccionado no existe. Elige una opción del catálogo.';
|
||||
}
|
||||
|
||||
if (code === 'CLASS_NOT_FOUND') {
|
||||
return 'La clase seleccionada no existe. Elige una opción del catálogo.';
|
||||
}
|
||||
|
||||
if (code === 'FRACTION_TYPE_INVALID') {
|
||||
return 'Selecciona un tipo de tarifa válido.';
|
||||
}
|
||||
|
||||
return normalizedMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Título y descripción listos para toasts / alertas a partir de ApiResponse.
|
||||
* Prioriza los mensajes que ya envía el backend y evita duplicar rutas técnicas.
|
||||
*/
|
||||
export function friendlyApiErrorParts(res: ApiResponse): { title: string; description: string } {
|
||||
const validationErrors = res.validationErrors;
|
||||
if (validationErrors?.length) {
|
||||
const blocks = validationErrors.map((e) => {
|
||||
const base = formatValidationHint(e.field || '', e.message || '', e.code);
|
||||
const hints = e.solution?.filter(Boolean).length
|
||||
? '\n' + e.solution!.map((s) => `• ${humanizeLineReferences(s)}`).join('\n')
|
||||
: '';
|
||||
return base + hints;
|
||||
});
|
||||
const description = blocks.join('\n\n').trim();
|
||||
const rawTitle = (res.error || '').trim();
|
||||
const title =
|
||||
rawTitle &&
|
||||
!rawTitle.startsWith('Error de validación') &&
|
||||
rawTitle !== 'Error de validación'
|
||||
? rawTitle
|
||||
: 'Revisa los datos de la partida';
|
||||
return { title, description: description || rawTitle || 'Corrige los datos e intenta de nuevo.' };
|
||||
}
|
||||
|
||||
if (res.error) {
|
||||
const err = humanizeValidationMessage(humanizeLineReferences(res.error.trim()));
|
||||
if (err.startsWith('Error de validación:')) {
|
||||
return {
|
||||
title: 'Revisa los datos ingresados',
|
||||
description: err.replace(/^Error de validación:\s*/i, '').trim() || err
|
||||
};
|
||||
}
|
||||
return { title: 'No se pudo completar la acción', description: err };
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Error',
|
||||
description: 'Ocurrió un error inesperado. Intenta de nuevo o contacta a soporte si continúa.'
|
||||
};
|
||||
}
|
||||
|
||||
let isRefreshing = false;
|
||||
let refreshSubscribers: ((token: string) => void)[] = [];
|
||||
|
||||
/**
|
||||
* Agrega una petición a la cola de espera mientras se refresca el token
|
||||
*/
|
||||
function subscribeTokenRefresh(callback: (token: string) => void) {
|
||||
refreshSubscribers.push(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifica a todas las peticiones en espera que el token se ha refrescado
|
||||
*/
|
||||
function onTokenRefreshed(token: string) {
|
||||
refreshSubscribers.forEach((callback) => callback(token));
|
||||
refreshSubscribers = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresca el token silenciosamente usando el endpoint server-side.
|
||||
*
|
||||
* El servidor lee el refresh_token desde la cookie HttpOnly,
|
||||
* llama a Keycloak, actualiza las cookies y devuelve el nuevo access_token.
|
||||
* El refresh_token NUNCA es leído por este código JavaScript.
|
||||
*/
|
||||
async function refreshToken(): Promise<string | null> {
|
||||
if (!browser) return null;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api-sveltekit/auth/silent-refresh', {
|
||||
method: 'POST',
|
||||
credentials: 'include', // Envía cookies HttpOnly automáticamente
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('❌ [API] Silent refresh falló, status:', response.status);
|
||||
clearAccessTokenOnDocument();
|
||||
const hubBase = (import.meta.env.VITE_HUB_URL || 'https://workspace.aduanasoft.com').replace(/\/+$/, '');
|
||||
setTimeout(() => {
|
||||
window.location.href = `${hubBase}/login?return_to=${encodeURIComponent(window.location.origin + '/login?sso_verified=1')}`;
|
||||
}, 1500);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json() as { access_token?: string };
|
||||
|
||||
if (data.access_token) {
|
||||
setAccessTokenOnDocument(data.access_token);
|
||||
|
||||
// Actualizar authStore en memoria
|
||||
try {
|
||||
const { authStore } = await import('./auth');
|
||||
authStore.setToken(data.access_token);
|
||||
} catch {}
|
||||
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('❌ [API] Error en silent refresh:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Construye los headers de autenticación (Bearer + X-Tenant-Override SSO multi-tenant).
|
||||
* Compartido por fetchApi y fetchBlob para garantizar trato uniforme.
|
||||
*/
|
||||
function buildAuthHeaders(baseHeaders: Record<string, string> = {}): Record<string, string> {
|
||||
const headers: Record<string, string> = { ...baseHeaders };
|
||||
const token = getToken();
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// sso_tenant_pub es una cookie no-HttpOnly que el servidor setea junto con sso_tenant_id.
|
||||
if (browser) {
|
||||
const tenantPub = document.cookie
|
||||
.split('; ')
|
||||
.find((c) => c.startsWith('sso_tenant_pub='))
|
||||
?.split('=')[1];
|
||||
if (tenantPub) {
|
||||
headers['X-Tenant-Override'] = tenantPub;
|
||||
}
|
||||
|
||||
// active_system (SCAF/SCAII): cookie no-HttpOnly → header explícito para el backend.
|
||||
const activeSystem = document.cookie
|
||||
.split('; ')
|
||||
.find((c) => c.startsWith('active_system='))
|
||||
?.split('=')[1];
|
||||
if (activeSystem) {
|
||||
headers['X-Active-System'] = activeSystem;
|
||||
}
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Realiza una petición al API con manejo automático de refresh token
|
||||
*/
|
||||
async function fetchApi<T = any>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {},
|
||||
retryCount = 0
|
||||
): Promise<ApiResponse<T>> {
|
||||
// Si ya estamos refrescando el token, esperar
|
||||
if (isRefreshing && retryCount === 0) {
|
||||
return new Promise((resolve) => {
|
||||
subscribeTokenRefresh((newToken) => {
|
||||
resolve(fetchApi<T>(endpoint, options, 1));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const token = getToken();
|
||||
|
||||
if (!token && !endpoint.includes('/auth/login')) {
|
||||
console.warn('⚠️ [API] No hay token disponible para', endpoint);
|
||||
}
|
||||
|
||||
const baseHeaders: Record<string, string> = {
|
||||
...((options.headers as Record<string, string>) || {})
|
||||
};
|
||||
|
||||
// Only set Content-Type to application/json if not already set and body is not FormData
|
||||
if (!baseHeaders['Content-Type'] && !(options.body instanceof FormData)) {
|
||||
baseHeaders['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
const headers = buildAuthHeaders(baseHeaders);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'include' // Importante: envía cookies con cada request
|
||||
});
|
||||
|
||||
// 403 = permisos, no autenticación: nunca intentar refresh.
|
||||
if (response.status === 403 && !endpoint.includes('/auth/refresh') && retryCount === 0) {
|
||||
if (browser) {
|
||||
toast.error('No tienes permisos para realizar esta acción', {
|
||||
duration: 4000,
|
||||
description: 'Contacta a tu administrador si crees que esto es un error'
|
||||
});
|
||||
}
|
||||
const data = await response.json();
|
||||
return {
|
||||
error: data.detail || 'No tienes permisos para realizar esta acción',
|
||||
status: 403
|
||||
};
|
||||
}
|
||||
|
||||
// 402 = licencia inválida/expirada: no intentar refresh.
|
||||
if (response.status === 402 && !endpoint.includes('/auth/refresh') && retryCount === 0) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
return {
|
||||
error: data.message || data.detail || 'Licencia inválida o expirada',
|
||||
status: 402
|
||||
};
|
||||
}
|
||||
|
||||
// Solo 401 dispara silent refresh.
|
||||
if (response.status === 401 && !endpoint.includes('/auth/refresh') && retryCount === 0) {
|
||||
// Si es 401, intentar refrescar el token
|
||||
isRefreshing = true;
|
||||
|
||||
try {
|
||||
const newToken = await refreshToken();
|
||||
|
||||
if (newToken) {
|
||||
// Token refrescado exitosamente
|
||||
onTokenRefreshed(newToken);
|
||||
isRefreshing = false;
|
||||
// Reintentar la petición original con el nuevo token
|
||||
return await fetchApi<T>(endpoint, options, 1);
|
||||
} else {
|
||||
console.error('❌ [API] No se pudo refrescar el token');
|
||||
isRefreshing = false;
|
||||
// Retornar error 401 para que la capa superior lo maneje
|
||||
return {
|
||||
error: 'Sesión expirada. Por favor, inicia sesión nuevamente.',
|
||||
status: 401
|
||||
};
|
||||
}
|
||||
} catch (refreshError) {
|
||||
console.error('❌ [API] Error al refrescar:', refreshError);
|
||||
isRefreshing = false;
|
||||
return {
|
||||
error: 'Error al refrescar la sesión',
|
||||
status: 401
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Manejar respuestas sin contenido (204 No Content)
|
||||
if (response.status === 204) {
|
||||
return {
|
||||
data: null as T,
|
||||
status: response.status
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
// Manejo especial para errores 422 (validation error)
|
||||
if (response.status === 422) {
|
||||
// HTTPException(detail={ message, errors }) — catálogo / CSV parity
|
||||
const det = data.detail || (typeof data.message === 'object' ? data.message : null);
|
||||
if (
|
||||
det &&
|
||||
typeof det === "object" &&
|
||||
!Array.isArray(det) &&
|
||||
Array.isArray((det as { errors?: unknown }).errors)
|
||||
) {
|
||||
const d = det as {
|
||||
message?: string;
|
||||
errors: Array<{ col?: string; msg?: string; field?: string; message?: string }>;
|
||||
};
|
||||
|
||||
// Mapping for catalog column names to DTO field names
|
||||
const colToField: Record<string, string> = {
|
||||
"CLAVE TRANSPORTISTA": "transporter_key",
|
||||
NOMBRE: "name",
|
||||
"NOMBRE CORTO": "short_name",
|
||||
RESPONSABLE: "responsible",
|
||||
RFC: "rfc",
|
||||
CALLES: "streets",
|
||||
"CODIGO POSTAL": "postal_code",
|
||||
CIUDAD: "city",
|
||||
ESTADO: "state",
|
||||
PAIS: "country",
|
||||
"CODIGO CARGADOR": "loader_code",
|
||||
"CODIGO CAAT": "caat_code",
|
||||
"CODIGO TRANS": "transport_code",
|
||||
"TIPO INTERFASE TRANS": "transport_interface_type",
|
||||
"SERVIDOR FTP": "ftp_server",
|
||||
"USUARIO FTP": "ftp_user",
|
||||
"CLAVE ACCESO FTP": "ftp_password",
|
||||
"DIRECTORIO FTP": "ftp_directory",
|
||||
// Vehicles
|
||||
CLAVE: "vehicle_key",
|
||||
"CLAVE ACE": "ace_vehicle_key", // Fallback for vehicles
|
||||
"CLAVE TRANSPORTE": "transporter_key",
|
||||
VIN: "series",
|
||||
"TIPO TRANSPORTE": "transport_type",
|
||||
"CODIGO DE ENTIDAD": "entity_code",
|
||||
TRANSPONDEDOR: "transponder_number",
|
||||
"NUMERO DOT": "dot_number",
|
||||
PLACAS: "plate_number",
|
||||
PRECINTO: "seal",
|
||||
"EMPRESA ASEGURADORA": "insurance_company_name",
|
||||
"NUM. ASEGURADORA": "insurance_number",
|
||||
"MONTO ASEGURADO": "insurance_amount",
|
||||
"FECHA DE ASEGURADORA": "insurance_date",
|
||||
// Trailers
|
||||
"NUMERO TRAILER": "trailer_number",
|
||||
"TIPO TRAILER": "trailer_type_key",
|
||||
"CODIGO ENTIDAD": "entity_code",
|
||||
"CLAVE CONTENEDOR": "container_key"
|
||||
};
|
||||
|
||||
const normalizedErrors = d.errors.map((err) => ({
|
||||
field: err.field || (err.col ? colToField[err.col] || err.col : ""),
|
||||
message: err.message || err.msg || "Error de validación"
|
||||
}));
|
||||
|
||||
return {
|
||||
error: d.message || (typeof data.message === 'string' ? data.message : 'Error de validación'),
|
||||
validationErrors: normalizedErrors,
|
||||
status: response.status
|
||||
};
|
||||
}
|
||||
// Errores de validación personalizados (con array errors en raíz)
|
||||
if (data.errors && Array.isArray(data.errors)) {
|
||||
return {
|
||||
error: data.message || 'Error de validación',
|
||||
validationErrors: data.errors as NonNullable<ApiResponse['validationErrors']>,
|
||||
status: response.status
|
||||
};
|
||||
}
|
||||
// Errores de validación de FastAPI (con detail)
|
||||
else if (data.detail) {
|
||||
let errorMessage = 'Error de validación: ';
|
||||
const vErrors: NonNullable<ApiResponse['validationErrors']> = [];
|
||||
|
||||
// FastAPI devuelve errores de validación en data.detail como array
|
||||
if (Array.isArray(data.detail)) {
|
||||
data.detail.forEach((err: any) => {
|
||||
const fieldPath = err.loc ? err.loc.filter((l: any) => l !== 'body').join('.') : 'campo';
|
||||
const msg = humanizeValidationMessage(err.msg || 'error de validación');
|
||||
|
||||
vErrors.push({
|
||||
field: err.loc ? String(err.loc[err.loc.length - 1]) : 'campo',
|
||||
message: msg
|
||||
});
|
||||
});
|
||||
|
||||
errorMessage += data.detail.map((err: any) => {
|
||||
const field = err.loc ? err.loc.join('.') : 'campo desconocido';
|
||||
return `${field}: ${err.msg}`;
|
||||
}).join(', ');
|
||||
} else if (typeof data.detail === 'string') {
|
||||
errorMessage = data.detail;
|
||||
} else {
|
||||
errorMessage += JSON.stringify(data.detail);
|
||||
}
|
||||
|
||||
return {
|
||||
error: errorMessage,
|
||||
validationErrors: vErrors.length ? vErrors : undefined,
|
||||
status: response.status
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
error: data.message || (typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail)) || 'Error en la petición',
|
||||
status: response.status
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
status: response.status
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`❌ [API] Error de conexión en ${endpoint}:`, error);
|
||||
return {
|
||||
error: 'Error de conexión con el servidor',
|
||||
status: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Opciones para subidas CSV (FormData) con progreso de red. */
|
||||
export type CsvFormDataUploadOptions = {
|
||||
onUploadProgress?: (e: { loaded: number; total: number }) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* POST multipart/form-data con XMLHttpRequest para exponer progreso de subida.
|
||||
* Misma semántica de auth/401/403/422 que fetchApi.
|
||||
*/
|
||||
async function fetchApiFormDataPost<T = any>(
|
||||
endpoint: string,
|
||||
formData: FormData,
|
||||
opts: CsvFormDataUploadOptions & { retryCount?: number } = {}
|
||||
): Promise<ApiResponse<T>> {
|
||||
const retryCount = opts.retryCount ?? 0;
|
||||
|
||||
if (isRefreshing && retryCount === 0) {
|
||||
return new Promise((resolve) => {
|
||||
subscribeTokenRefresh(() => {
|
||||
resolve(fetchApiFormDataPost<T>(endpoint, formData, { ...opts, retryCount: 1 }));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const token = getToken();
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', `${API_BASE_URL}${endpoint}`);
|
||||
xhr.withCredentials = true;
|
||||
if (token) {
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
xhr.upload.onprogress = (ev) => {
|
||||
if (!opts.onUploadProgress) return;
|
||||
if (ev.lengthComputable) {
|
||||
opts.onUploadProgress({ loaded: ev.loaded, total: ev.total });
|
||||
} else {
|
||||
opts.onUploadProgress({ loaded: ev.loaded, total: 0 });
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
void (async () => {
|
||||
const status = xhr.status;
|
||||
let data: any = null;
|
||||
if (xhr.responseText) {
|
||||
try {
|
||||
data = JSON.parse(xhr.responseText) as any;
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
}
|
||||
|
||||
if ((status === 401 || status === 403) && !endpoint.includes('/auth/refresh') && retryCount === 0) {
|
||||
if (status === 403) {
|
||||
if (browser) {
|
||||
toast.error('No tienes permisos para realizar esta acción', {
|
||||
duration: 4000,
|
||||
description: 'Contacta a tu administrador si crees que esto es un error'
|
||||
});
|
||||
}
|
||||
resolve({
|
||||
error: data?.detail || 'No tienes permisos para realizar esta acción',
|
||||
status: 403
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
isRefreshing = true;
|
||||
try {
|
||||
const newToken = await refreshToken();
|
||||
if (newToken) {
|
||||
onTokenRefreshed(newToken);
|
||||
isRefreshing = false;
|
||||
resolve(await fetchApiFormDataPost<T>(endpoint, formData, { ...opts, retryCount: 1 }));
|
||||
} else {
|
||||
console.error('❌ [API] No se pudo refrescar el token');
|
||||
isRefreshing = false;
|
||||
resolve({
|
||||
error: 'Sesión expirada. Por favor, inicia sesión nuevamente.',
|
||||
status: 401
|
||||
});
|
||||
}
|
||||
} catch (refreshError) {
|
||||
console.error('❌ [API] Error al refrescar:', refreshError);
|
||||
isRefreshing = false;
|
||||
resolve({
|
||||
error: 'Error al refrescar la sesión',
|
||||
status: 401
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === 204) {
|
||||
resolve({
|
||||
data: null as T,
|
||||
status
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === 0) {
|
||||
resolve({
|
||||
error: 'Error de conexión con el servidor',
|
||||
status: 0
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (status < 200 || status >= 300) {
|
||||
if (status === 422 && data) {
|
||||
if (data.errors && Array.isArray(data.errors)) {
|
||||
resolve({
|
||||
error: data.message || 'Error de validación',
|
||||
validationErrors: data.errors as NonNullable<ApiResponse['validationErrors']>,
|
||||
status: 422
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (data.detail) {
|
||||
let errorMessage = 'Error de validación: ';
|
||||
if (Array.isArray(data.detail)) {
|
||||
const errors = data.detail
|
||||
.map((err: any) => {
|
||||
const field = err.loc ? err.loc.filter((loc: string) => loc !== 'body').join('.') : 'campo desconocido';
|
||||
return `${humanizeFieldPath(field)}: ${humanizeValidationMessage(err.msg || 'error de validación')}`;
|
||||
})
|
||||
.join(', ');
|
||||
errorMessage += errors;
|
||||
} else if (typeof data.detail === 'string') {
|
||||
errorMessage = data.detail;
|
||||
} else {
|
||||
errorMessage += JSON.stringify(data.detail);
|
||||
}
|
||||
resolve({
|
||||
error: errorMessage,
|
||||
status: 422
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
resolve({
|
||||
error:
|
||||
data?.message ||
|
||||
(typeof data?.detail === 'string' ? data.detail : JSON.stringify(data?.detail)) ||
|
||||
'Error en la petición',
|
||||
status
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (data === null && xhr.responseText) {
|
||||
resolve({
|
||||
error: 'Respuesta inválida del servidor',
|
||||
status
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({
|
||||
data,
|
||||
status
|
||||
});
|
||||
})();
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
resolve({
|
||||
error: 'Error de conexión con el servidor',
|
||||
status: 0
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
xhr.send(formData);
|
||||
} catch (error) {
|
||||
console.error(`❌ [API] Error al enviar ${endpoint}:`, error);
|
||||
resolve({
|
||||
error: 'Error de conexión con el servidor',
|
||||
status: 0
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convierte cuerpos de error (JSON o texto) en un mensaje legible para toasts/UX.
|
||||
* Evita mostrar JSON crudo p. ej. `{"error":"HTTP_ERROR","message":"..."}`.
|
||||
*/
|
||||
function messageFromBlobErrorResponse(text: string, status: number): string {
|
||||
const raw = (text || '').trim();
|
||||
if (!raw) {
|
||||
return status === 404
|
||||
? 'No se encontró el recurso. Prueba otro rango o vuelve a intentar.'
|
||||
: `Error ${status} al descargar el archivo.`;
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(raw) as Record<string, unknown>;
|
||||
if (typeof data.message === 'string' && data.message.trim()) {
|
||||
return data.message.trim();
|
||||
}
|
||||
const d = data.detail;
|
||||
if (typeof d === 'string' && d.trim()) {
|
||||
return d.trim();
|
||||
}
|
||||
if (Array.isArray(d) && d[0] && typeof (d[0] as { msg?: string }).msg === 'string') {
|
||||
return String((d[0] as { msg: string }).msg).trim();
|
||||
}
|
||||
} catch {
|
||||
// no es JSON: usar texto plano si es corto y legible
|
||||
}
|
||||
if (raw.length < 500 && !raw.startsWith('{')) {
|
||||
return raw;
|
||||
}
|
||||
if (raw.startsWith('{')) {
|
||||
return status === 404
|
||||
? 'No se encontró información para exportar. Prueba otras fechas o amplía el rango.'
|
||||
: `Error ${status} al descargar el archivo.`;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
async function fetchBlob(
|
||||
endpoint: string,
|
||||
options: RequestInit = {},
|
||||
retryCount = 0
|
||||
): Promise<Blob> {
|
||||
// Si ya estamos refrescando el token, esperar a que termine antes de pegar.
|
||||
if (isRefreshing && retryCount === 0) {
|
||||
return new Promise((resolve, reject) => {
|
||||
subscribeTokenRefresh(() => {
|
||||
fetchBlob(endpoint, options, 1).then(resolve).catch(reject);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const headers = buildAuthHeaders((options.headers as Record<string, string>) || {});
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
// 401: intentar silent refresh y reintentar una vez (mismo flujo que fetchApi).
|
||||
if (response.status === 401 && !endpoint.includes('/auth/refresh') && retryCount === 0) {
|
||||
isRefreshing = true;
|
||||
try {
|
||||
const newToken = await refreshToken();
|
||||
if (newToken) {
|
||||
onTokenRefreshed(newToken);
|
||||
isRefreshing = false;
|
||||
return await fetchBlob(endpoint, options, 1);
|
||||
}
|
||||
isRefreshing = false;
|
||||
throw new Error('Sesión expirada. Por favor, inicia sesión nuevamente.');
|
||||
} catch (refreshError) {
|
||||
isRefreshing = false;
|
||||
if (refreshError instanceof Error) throw refreshError;
|
||||
throw new Error('Error al refrescar la sesión');
|
||||
}
|
||||
}
|
||||
|
||||
// 403: notificar permisos de manera consistente con fetchApi.
|
||||
if (response.status === 403) {
|
||||
if (browser) {
|
||||
toast.error('No tienes permisos para realizar esta acción', {
|
||||
duration: 4000,
|
||||
description: 'Contacta a tu administrador si crees que esto es un error'
|
||||
});
|
||||
}
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(messageFromBlobErrorResponse(text, response.status));
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(messageFromBlobErrorResponse(text, response.status));
|
||||
}
|
||||
return await response.blob();
|
||||
}
|
||||
|
||||
// Métodos HTTP
|
||||
export const api = {
|
||||
get: <T = any>(endpoint: string) => fetchApi<T>(endpoint, { method: 'GET' }),
|
||||
getBlob: (endpoint: string) => fetchBlob(endpoint, { method: 'GET' }),
|
||||
postBlob: (endpoint: string, body: any) =>
|
||||
fetchBlob(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
}),
|
||||
|
||||
post: <T = any>(endpoint: string, body: any, options: RequestInit = {}) =>
|
||||
fetchApi<T>(endpoint, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
...options
|
||||
}),
|
||||
|
||||
put: <T = any>(endpoint: string, body: any, options: RequestInit = {}) =>
|
||||
fetchApi<T>(endpoint, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
...options
|
||||
}),
|
||||
|
||||
patch: <T = any>(endpoint: string, body: any, options: RequestInit = {}) =>
|
||||
fetchApi<T>(endpoint, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
...options
|
||||
}),
|
||||
|
||||
delete: <T = any>(endpoint: string, options: RequestInit = {}) => fetchApi<T>(endpoint, { method: 'DELETE', ...options }),
|
||||
|
||||
auth: {
|
||||
login: (credentials: { username: string; password: string; tenant_slug: string }) =>
|
||||
api.post('/v1/auth/login/', credentials),
|
||||
refresh: (refreshToken: string) =>
|
||||
api.post('/v1/auth/refresh/', { refresh_token: refreshToken }),
|
||||
logout: (data: { refresh_token: string, username?: string }) => api.post('/v1/auth/logout', data, { keepalive: true }),
|
||||
me: () => api.get('/v1/auth/me/'),
|
||||
health: () => api.get('/health'),
|
||||
register: (data: {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
tenant_slug: string;
|
||||
invite_token?: string;
|
||||
}) => api.post('/v1/auth/register', data),
|
||||
},
|
||||
|
||||
tenants: {
|
||||
list: (page = 1, pageSize = 50) =>
|
||||
api.get(`/v1/tenants/?page=${page}&page_size=${pageSize}`),
|
||||
get: (id: number) => api.get(`/v1/tenants/${id}/`),
|
||||
create: (data: any) => api.post('/v1/tenants/', data),
|
||||
update: (id: number, data: any) => api.put(`/v1/tenants/${id}/`, data)
|
||||
},
|
||||
|
||||
licenses: {
|
||||
get: (tenantId: number) => api.get(`/v1/licenses/tenant/${tenantId}/`),
|
||||
myLicense: () => api.get('/v1/licenses/my-license/'),
|
||||
usage: (tenantId: number) => api.get(`/v1/licenses/usage/${tenantId}/`),
|
||||
validate: (tenantId: number) => api.get(`/v1/licenses/validate/${tenantId}/`)
|
||||
},
|
||||
|
||||
// Agrega aquí los endpoints específicos de tu proyecto.
|
||||
|
||||
// Implementa aquí tus endpoints de importación CSV si los necesitas.
|
||||
|
||||
// Generic request for custom needs (like file uploads)
|
||||
request: <T = any>(endpoint: string, options: RequestInit = {}) => fetchApi<T>(endpoint, options)
|
||||
};
|
||||
8
frontend/src/lib/api/dashboard/admin/index.ts
Normal file
8
frontend/src/lib/api/dashboard/admin/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Exportaciones centralizadas de APIs de administración
|
||||
*/
|
||||
|
||||
export * from './permissions';
|
||||
export * from './roles';
|
||||
export * from './role-permissions';
|
||||
export * from './user-roles';
|
||||
110
frontend/src/lib/api/dashboard/admin/permissions.ts
Normal file
110
frontend/src/lib/api/dashboard/admin/permissions.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* API para gestión de permisos del sistema
|
||||
*/
|
||||
|
||||
import { api } from '$lib/api';
|
||||
|
||||
export interface Permission {
|
||||
id: number;
|
||||
code: string;
|
||||
description?: string;
|
||||
module: string;
|
||||
action: string;
|
||||
is_active: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface CreatePermissionData {
|
||||
code?: string;
|
||||
description?: string;
|
||||
module: string;
|
||||
action: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdatePermissionData {
|
||||
code?: string;
|
||||
description?: string;
|
||||
module?: string;
|
||||
action?: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface PermissionListResponse {
|
||||
items: Permission[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export const permissionsAPI = {
|
||||
/**
|
||||
* Listar permisos con filtros
|
||||
*/
|
||||
async list(params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
module?: string;
|
||||
action?: string;
|
||||
is_active?: boolean;
|
||||
search?: string;
|
||||
}): Promise<PermissionListResponse> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.page) queryParams.set('page', params.page.toString());
|
||||
if (params?.page_size) queryParams.set('page_size', params.page_size.toString());
|
||||
if (params?.module) queryParams.set('module', params.module);
|
||||
if (params?.action) queryParams.set('action', params.action);
|
||||
if (params?.search) queryParams.set('search', params.search);
|
||||
const query = queryParams.toString();
|
||||
const response = await api.get(`/v1/core/permissions/${query ? '?' + query : ''}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtener un permiso por ID
|
||||
*/
|
||||
async getById(id: number): Promise<Permission> {
|
||||
const response = await api.get(`/v1/core/permissions/${id}/`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Crear un nuevo permiso
|
||||
*/
|
||||
async create(data: CreatePermissionData): Promise<Permission> {
|
||||
const response = await api.post('/v1/core/permissions/', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Actualizar un permiso
|
||||
*/
|
||||
async update(id: number, data: UpdatePermissionData): Promise<Permission> {
|
||||
const response = await api.put(`/v1/core/permissions/${id}/`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Eliminar un permiso
|
||||
*/
|
||||
async delete(id: number): Promise<void> {
|
||||
await api.delete(`/v1/core/permissions/${id}/`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtener módulos únicos
|
||||
*/
|
||||
async getModules(): Promise<string[]> {
|
||||
const response = await api.get('/v1/core/permissions/modules/');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtener acciones únicas
|
||||
*/
|
||||
async getActions(): Promise<string[]> {
|
||||
const response = await api.get('/v1/core/permissions/actions/');
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
76
frontend/src/lib/api/dashboard/admin/role-permissions.ts
Normal file
76
frontend/src/lib/api/dashboard/admin/role-permissions.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* API para gestión de permisos de roles
|
||||
*/
|
||||
|
||||
import { api } from '$lib/api';
|
||||
|
||||
export interface RolePermission {
|
||||
id: number;
|
||||
company_role_id: number;
|
||||
permission_id: number;
|
||||
granted_at?: string;
|
||||
granted_by?: number;
|
||||
tenant_id: number;
|
||||
permission?: {
|
||||
id: number;
|
||||
code: string;
|
||||
module: string;
|
||||
action: string;
|
||||
description?: string;
|
||||
is_active: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AssignPermissionData {
|
||||
permission_id: number;
|
||||
}
|
||||
|
||||
export interface RolePermissionsResponse {
|
||||
role_id: number;
|
||||
permissions: RolePermission[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export const rolePermissionsAPI = {
|
||||
/**
|
||||
* Listar todos los permisos asignados a un rol
|
||||
*/
|
||||
async listByRole(roleId: number, companyId: number): Promise<RolePermissionsResponse> {
|
||||
const response = await api.get(`/v1/core/permissions/roles/${roleId}/permissions?company_id=${companyId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Asignar un permiso a un rol
|
||||
*/
|
||||
async assign(
|
||||
roleId: number,
|
||||
companyId: number,
|
||||
data: AssignPermissionData
|
||||
): Promise<RolePermission> {
|
||||
const response = await api.post(`/v1/core/permissions/roles/${roleId}/permissions?company_id=${companyId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Remover un permiso de un rol
|
||||
*/
|
||||
async remove(roleId: number, permissionId: number, companyId: number): Promise<void> {
|
||||
await api.delete(`/v1/core/permissions/roles/${roleId}/permissions/${permissionId}?company_id=${companyId}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Asignar múltiples permisos a un rol
|
||||
*/
|
||||
async assignMultiple(
|
||||
roleId: number,
|
||||
companyId: number,
|
||||
permissionIds: number[]
|
||||
): Promise<RolePermission[]> {
|
||||
const response = await api.post(
|
||||
`/v1/core/permissions/roles/${roleId}/permissions/batch?company_id=${companyId}`,
|
||||
{ permission_ids: permissionIds }
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
89
frontend/src/lib/api/dashboard/admin/roles.ts
Normal file
89
frontend/src/lib/api/dashboard/admin/roles.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* API para gestión de roles por compañía
|
||||
*/
|
||||
|
||||
import { api, type ApiResponse } from '$lib/api';
|
||||
|
||||
export interface CompanyRole {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
description?: string;
|
||||
is_active: boolean;
|
||||
company_id: number;
|
||||
tenant_id: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface CreateRoleData {
|
||||
name: string;
|
||||
code: string;
|
||||
description?: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateRoleData {
|
||||
name?: string;
|
||||
code?: string;
|
||||
description?: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface RoleListResponse {
|
||||
items: CompanyRole[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export const rolesAPI = {
|
||||
/**
|
||||
* Listar roles con filtros
|
||||
*/
|
||||
async list(
|
||||
companyId: number,
|
||||
params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
is_active?: boolean;
|
||||
search?: string;
|
||||
}
|
||||
): Promise<ApiResponse<RoleListResponse>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.set('company_id', companyId.toString());
|
||||
if (params?.page) queryParams.set('page', params.page.toString());
|
||||
if (params?.page_size) queryParams.set('page_size', params.page_size.toString());
|
||||
if (params?.is_active !== undefined) queryParams.set('is_active', params.is_active.toString());
|
||||
if (params?.search) queryParams.set('search', params.search);
|
||||
return api.get(`/v1/core/permissions/roles?${queryParams.toString()}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtener un rol por ID
|
||||
*/
|
||||
async getById(id: number, companyId: number): Promise<ApiResponse<CompanyRole>> {
|
||||
return api.get(`/v1/core/permissions/roles/${id}?company_id=${companyId}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Crear un nuevo rol
|
||||
*/
|
||||
async create(companyId: number, data: CreateRoleData): Promise<ApiResponse<CompanyRole>> {
|
||||
return api.post(`/v1/core/permissions/roles?company_id=${companyId}`, data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Actualizar un rol
|
||||
*/
|
||||
async update(id: number, companyId: number, data: UpdateRoleData): Promise<ApiResponse<CompanyRole>> {
|
||||
return api.patch(`/v1/core/permissions/roles/${id}?company_id=${companyId}`, data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Eliminar un rol
|
||||
*/
|
||||
async delete(id: number, companyId: number): Promise<ApiResponse<void>> {
|
||||
return api.delete(`/v1/core/permissions/roles/${id}?company_id=${companyId}`);
|
||||
}
|
||||
};
|
||||
108
frontend/src/lib/api/dashboard/admin/user-permissions.ts
Normal file
108
frontend/src/lib/api/dashboard/admin/user-permissions.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* API para gestión de permisos individuales de usuario
|
||||
*/
|
||||
|
||||
import { api } from '$lib/api';
|
||||
import type { Permission } from './permissions';
|
||||
|
||||
export interface UserPermission {
|
||||
id: number;
|
||||
user_id: string;
|
||||
permission_id: number;
|
||||
company_id: number;
|
||||
tenant_id: number;
|
||||
is_granted: boolean;
|
||||
is_active: boolean;
|
||||
assigned_by?: string;
|
||||
expires_at?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
permission?: Permission;
|
||||
}
|
||||
|
||||
export interface AssignUserPermissionData {
|
||||
user_id: string;
|
||||
permission_id: number;
|
||||
is_granted?: boolean;
|
||||
expires_at?: string;
|
||||
}
|
||||
|
||||
export interface UserPermissionsListResponse {
|
||||
items: UserPermission[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface EffectiveUserPermissions {
|
||||
user_id: string;
|
||||
company_id: number;
|
||||
role_permissions: Permission[];
|
||||
granted_permissions: Permission[];
|
||||
revoked_permissions: Permission[];
|
||||
effective_permissions: Permission[];
|
||||
}
|
||||
|
||||
export const userPermissionsAPI = {
|
||||
/**
|
||||
* Obtener permisos individuales de un usuario
|
||||
*/
|
||||
async getIndividual(userId: string, companyId: number): Promise<UserPermissionsListResponse> {
|
||||
const response = await api.get(
|
||||
`/v1/core/permissions/users/${userId}/permissions?company_id=${companyId}`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtener permisos efectivos de un usuario (roles + individuales - revocados)
|
||||
*/
|
||||
async getEffective(userId: string, companyId: number): Promise<EffectiveUserPermissions> {
|
||||
const response = await api.get(
|
||||
`/v1/core/permissions/users/${userId}/permissions/effective?company_id=${companyId}`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Asignar un permiso individual a un usuario
|
||||
*/
|
||||
async assign(
|
||||
userId: string,
|
||||
companyId: number,
|
||||
data: Omit<AssignUserPermissionData, 'user_id'>
|
||||
): Promise<UserPermission> {
|
||||
const response = await api.post(
|
||||
`/v1/core/permissions/users/${userId}/permissions?company_id=${companyId}`,
|
||||
data
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Conceder un permiso extra a un usuario
|
||||
*/
|
||||
async grant(userId: string, companyId: number, permissionId: number): Promise<UserPermission> {
|
||||
return this.assign(userId, companyId, {
|
||||
permission_id: permissionId,
|
||||
is_granted: true
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Revocar un permiso específico (aunque venga del rol)
|
||||
*/
|
||||
async revoke(userId: string, companyId: number, permissionId: number): Promise<UserPermission> {
|
||||
return this.assign(userId, companyId, {
|
||||
permission_id: permissionId,
|
||||
is_granted: false
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Eliminar un permiso individual
|
||||
*/
|
||||
async remove(userId: string, companyId: number, permissionId: number): Promise<void> {
|
||||
await api.delete(
|
||||
`/v1/core/permissions/users/${userId}/permissions/${permissionId}?company_id=${companyId}`
|
||||
);
|
||||
}
|
||||
};
|
||||
94
frontend/src/lib/api/dashboard/admin/user-roles.ts
Normal file
94
frontend/src/lib/api/dashboard/admin/user-roles.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* API para gestión de roles de usuarios
|
||||
*/
|
||||
|
||||
import { api } from '$lib/api';
|
||||
|
||||
export interface UserRole {
|
||||
id: number;
|
||||
user_id: string;
|
||||
company_id: number;
|
||||
company_role_id: number;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
assigned_by?: string;
|
||||
company_role?: {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
description?: string;
|
||||
};
|
||||
user?: {
|
||||
id: number;
|
||||
username: string;
|
||||
email?: string;
|
||||
full_name?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AssignUserRoleData {
|
||||
user_id: string;
|
||||
company_role_id: number;
|
||||
}
|
||||
|
||||
export interface UserRolesResponse {
|
||||
items: UserRole[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export const userRolesAPI = {
|
||||
/**
|
||||
* Listar todos los roles asignados a usuarios
|
||||
*/
|
||||
async list(
|
||||
companyId: number,
|
||||
params?: {
|
||||
user_id?: string;
|
||||
company_role_id?: number;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
): Promise<UserRolesResponse> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.set('company_id', companyId.toString());
|
||||
if (params?.user_id) queryParams.set('user_id', params.user_id);
|
||||
if (params?.company_role_id) queryParams.set('company_role_id', params.company_role_id.toString());
|
||||
if (params?.page) queryParams.set('page', params.page.toString());
|
||||
if (params?.page_size) queryParams.set('page_size', params.page_size.toString());
|
||||
const response = await api.get(`/v1/core/permissions/user-roles?${queryParams.toString()}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Listar roles de un usuario específico
|
||||
*/
|
||||
async listByUser(userId: number, companyId: number): Promise<UserRolesResponse> {
|
||||
const response = await api.get(`/v1/core/permissions/users/${userId}/roles?company_id=${companyId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Listar usuarios con un rol específico
|
||||
*/
|
||||
async listByRole(roleId: number, companyId: number): Promise<UserRolesResponse> {
|
||||
const response = await api.get(`/v1/core/permissions/roles/${roleId}/users?company_id=${companyId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Asignar un rol a un usuario
|
||||
*/
|
||||
async assign(companyId: number, data: AssignUserRoleData): Promise<UserRole> {
|
||||
const response = await api.post(`/v1/core/permissions/user-roles?company_id=${companyId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Remover un rol de un usuario
|
||||
*/
|
||||
async remove(userRoleId: number, companyId: number): Promise<void> {
|
||||
await api.delete(`/v1/core/permissions/user-roles/${userRoleId}?company_id=${companyId}`);
|
||||
}
|
||||
};
|
||||
1
frontend/src/lib/api/dashboard/index.ts
Normal file
1
frontend/src/lib/api/dashboard/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type { KPIMetric, ActivityItem, ChartDataPoint } from './types';
|
||||
60
frontend/src/lib/api/dashboard/invite-codes.ts
Normal file
60
frontend/src/lib/api/dashboard/invite-codes.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { api } from '$lib/api';
|
||||
|
||||
export interface InviteCode {
|
||||
id: number;
|
||||
code: string;
|
||||
tenant_slug: string;
|
||||
company_id: number | null;
|
||||
role: string;
|
||||
max_uses: number | null;
|
||||
uses_count: number;
|
||||
expires_at: string | null;
|
||||
is_active: boolean;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreateInviteCodeRequest {
|
||||
company_id?: number | null;
|
||||
role: string;
|
||||
max_uses?: number | null;
|
||||
expires_at?: string | null;
|
||||
}
|
||||
|
||||
export interface ValidateInviteCodeResponse {
|
||||
code: string;
|
||||
tenant_slug: string;
|
||||
company_id: number | null;
|
||||
role: string;
|
||||
remaining_uses: number | null;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
export const inviteCodesAPI = {
|
||||
async list(companyId: number, includeInactive = false): Promise<InviteCode[]> {
|
||||
const params = new URLSearchParams({ company_id: companyId.toString() });
|
||||
if (includeInactive) params.set('include_inactive', 'true');
|
||||
const response = await api.get<InviteCode[]>(`/v1/core/invite-codes?${params}`);
|
||||
if (response.error) throw new Error(response.error);
|
||||
return response.data!;
|
||||
},
|
||||
|
||||
async create(data: CreateInviteCodeRequest): Promise<InviteCode> {
|
||||
const response = await api.post<InviteCode>('/v1/core/invite-codes', data);
|
||||
if (response.error) throw new Error(response.error);
|
||||
return response.data!;
|
||||
},
|
||||
|
||||
async revoke(code: string): Promise<void> {
|
||||
const response = await api.delete(`/v1/core/invite-codes/${code}`);
|
||||
if (response.error) throw new Error(response.error);
|
||||
},
|
||||
|
||||
async validate(code: string): Promise<ValidateInviteCodeResponse> {
|
||||
const response = await api.get<ValidateInviteCodeResponse>(
|
||||
`/v1/core/invite-codes/validate/${code}`
|
||||
);
|
||||
if (response.error) throw new Error(response.error);
|
||||
return response.data!;
|
||||
}
|
||||
};
|
||||
24
frontend/src/lib/api/dashboard/types.ts
Normal file
24
frontend/src/lib/api/dashboard/types.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export interface KPIMetric {
|
||||
label: string;
|
||||
value: number;
|
||||
previous_value?: number;
|
||||
percentage_change?: number;
|
||||
trend?: 'up' | 'down' | 'stable';
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export interface ActivityItem {
|
||||
id: number;
|
||||
type: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
timestamp: string;
|
||||
status?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface ChartDataPoint {
|
||||
label: string;
|
||||
value: number;
|
||||
category?: string;
|
||||
}
|
||||
196
frontend/src/lib/api/dashboard/users.ts
Normal file
196
frontend/src/lib/api/dashboard/users.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Cliente API para gestión de usuarios
|
||||
*/
|
||||
|
||||
import { api } from '$lib/api';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
enabled: boolean;
|
||||
email_verified: boolean;
|
||||
created_timestamp?: number;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export interface UserStats {
|
||||
total_users: number;
|
||||
active_users: number;
|
||||
inactive_users: number;
|
||||
max_users_allowed: number;
|
||||
users_available: number;
|
||||
usage_percentage: number;
|
||||
}
|
||||
|
||||
export interface UserListResponse {
|
||||
users: User[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export interface CreateUserRequest {
|
||||
email: string;
|
||||
username: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
password: string;
|
||||
role?: string;
|
||||
enabled?: boolean;
|
||||
email_verified?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
email?: string;
|
||||
enabled?: boolean;
|
||||
email_verified?: boolean;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export interface ChangePasswordRequest {
|
||||
password: string;
|
||||
temporary?: boolean;
|
||||
}
|
||||
|
||||
export interface InviteUserRequest {
|
||||
email: string;
|
||||
company_id: number;
|
||||
role_id: number;
|
||||
}
|
||||
|
||||
export interface InviteUserResponse {
|
||||
id: number;
|
||||
email: string;
|
||||
role: string;
|
||||
expires_at: string;
|
||||
invite_url: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export const usersAPI = {
|
||||
/**
|
||||
* Obtiene estadísticas de usuarios del tenant
|
||||
*/
|
||||
async getStats(companyId: number): Promise<UserStats> {
|
||||
const response = await api.get<UserStats>(`/v1/core/users/stats?company_id=${companyId}`);
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
return response.data!;
|
||||
},
|
||||
|
||||
/**
|
||||
* Lista usuarios del tenant con paginación
|
||||
*/
|
||||
async list(companyId: number, params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
search?: string;
|
||||
}): Promise<UserListResponse> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.set('company_id', companyId.toString());
|
||||
if (params?.page) queryParams.set('page', params.page.toString());
|
||||
if (params?.page_size) queryParams.set('page_size', params.page_size.toString());
|
||||
if (params?.search) queryParams.set('search', params.search);
|
||||
|
||||
const endpoint = `/v1/core/users/?${queryParams}`;
|
||||
const response = await api.get<UserListResponse>(endpoint);
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
return response.data!;
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtiene un usuario específico
|
||||
*/
|
||||
async get(userId: string, companyId: number): Promise<User> {
|
||||
const response = await api.get<User>(`/v1/core/users/${userId}?company_id=${companyId}`);
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
return response.data!;
|
||||
},
|
||||
|
||||
/**
|
||||
* Crea un nuevo usuario
|
||||
*/
|
||||
async create(data: CreateUserRequest, companyId: number): Promise<User> {
|
||||
const response = await api.post<User>(`/v1/core/users/?company_id=${companyId}`, data);
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
return response.data!;
|
||||
},
|
||||
|
||||
/**
|
||||
* Actualiza un usuario existente
|
||||
*/
|
||||
async update(userId: string, data: UpdateUserRequest, companyId: number): Promise<User> {
|
||||
const response = await api.put<User>(`/v1/core/users/${userId}?company_id=${companyId}`, data);
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
return response.data!;
|
||||
},
|
||||
|
||||
/**
|
||||
* Retorna en cuántos tenants está registrado el usuario.
|
||||
*/
|
||||
async getTenantCount(userId: string, companyId: number): Promise<number> {
|
||||
const response = await api.get<{ tenant_count: number }>(
|
||||
`/v1/core/users/${userId}/tenant-count?company_id=${companyId}`
|
||||
);
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
return response.data!.tenant_count;
|
||||
},
|
||||
|
||||
/**
|
||||
* Elimina un usuario
|
||||
*/
|
||||
async delete(
|
||||
userId: string,
|
||||
companyId: number,
|
||||
softDelete: boolean = true,
|
||||
scope: 'current' | 'all' = 'current'
|
||||
): Promise<void> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.set('company_id', companyId.toString());
|
||||
queryParams.set('soft_delete', softDelete.toString());
|
||||
queryParams.set('scope', scope);
|
||||
|
||||
const response = await api.delete(`/v1/core/users/${userId}?${queryParams}`);
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Cambia la contraseña de un usuario
|
||||
*/
|
||||
async changePassword(userId: string, data: ChangePasswordRequest, companyId: number): Promise<void> {
|
||||
const response = await api.post(`/v1/core/users/${userId}/change-password?company_id=${companyId}`, data);
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Genera un token de invitación y envía email al usuario
|
||||
*/
|
||||
async invite(data: InviteUserRequest): Promise<InviteUserResponse> {
|
||||
const response = await api.post<InviteUserResponse>('/v1/core/invites', data);
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
return response.data!;
|
||||
}
|
||||
};
|
||||
128
frontend/src/lib/api/help.ts
Normal file
128
frontend/src/lib/api/help.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { getToken, authStore } from '$lib/auth';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
const api_url = import.meta.env.VITE_API_URL ?? '';
|
||||
const normalizedApiUrl = api_url ? (api_url.endsWith('/') ? api_url : `${api_url}/`) : '/';
|
||||
const BASE_URL = `${normalizedApiUrl}v1/core/help-center`;
|
||||
|
||||
function getAuthToken(): string | null {
|
||||
// 1. First try getToken() which checks Keycloak and localStorage
|
||||
let token = getToken();
|
||||
|
||||
// 2. If somehow empty, explicitly check authStore value
|
||||
if (!token) {
|
||||
const auth = get(authStore);
|
||||
token = auth.token;
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
function getHeaders() {
|
||||
const token = getAuthToken();
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||
};
|
||||
}
|
||||
|
||||
export interface HelpArticle {
|
||||
uuid: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
content: string;
|
||||
updated_at: string;
|
||||
last_editor: string;
|
||||
category?: string;
|
||||
order?: number;
|
||||
content_type: string;
|
||||
file_url?: string;
|
||||
file_size?: number;
|
||||
mime_type?: string;
|
||||
context_path?: string;
|
||||
tags?: string;
|
||||
}
|
||||
|
||||
export const helpApi = {
|
||||
async listArticles(): Promise<HelpArticle[]> {
|
||||
const response = await fetch(`${BASE_URL}/articles/`, { headers: getHeaders() });
|
||||
if (!response.ok) throw new Error('Failed to fetch articles');
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async getArticle(uuid: string): Promise<HelpArticle> {
|
||||
const response = await fetch(`${BASE_URL}/articles/${uuid}/`, { headers: getHeaders() });
|
||||
if (!response.ok) throw new Error('Failed to fetch article');
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async updateArticle(uuid: string, data: Partial<HelpArticle>): Promise<HelpArticle> {
|
||||
const response = await fetch(`${BASE_URL}/articles/${uuid}/`, {
|
||||
method: 'PATCH',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (response.status === 403) throw new Error('No tienes permisos para editar artículos (Requiere rol Admin)');
|
||||
if (!response.ok) throw new Error('Error al guardar cambios');
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async createArticle(data: Partial<HelpArticle>): Promise<HelpArticle> {
|
||||
const response = await fetch(`${BASE_URL}/articles/`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (response.status === 403) throw new Error('No tienes permisos para crear artículos (Requiere rol Admin)');
|
||||
if (!response.ok) throw new Error('Error al crear el artículo');
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async deleteArticle(uuid: string): Promise<void> {
|
||||
const response = await fetch(`${BASE_URL}/articles/${uuid}/`, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders()
|
||||
});
|
||||
if (response.status === 403) throw new Error('No tienes permisos para eliminar (Requiere rol Admin)');
|
||||
if (!response.ok) throw new Error('Error al eliminar');
|
||||
},
|
||||
|
||||
async triggerSync(): Promise<void> {
|
||||
// Opcional: endpoint para forzar sync desde UI si es necesario
|
||||
},
|
||||
|
||||
async uploadImage(file: File): Promise<{ url: string }> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const token = getAuthToken();
|
||||
const response = await fetch(`${BASE_URL}/upload-image/`, {
|
||||
method: 'POST',
|
||||
// No Content-Type header for FormData, browser sets it with boundary
|
||||
headers: {
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
if (response.status === 403) throw new Error('No tienes permisos para subir imágenes (Requiere rol Admin)');
|
||||
if (!response.ok) throw new Error('Error al subir imagen');
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async uploadAsset(file: File): Promise<{ url: string, filename: string, size: number, mime_type: string }> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const token = getAuthToken();
|
||||
const response = await fetch(`${BASE_URL}/upload-asset/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
if (response.status === 403) throw new Error('No tienes permisos para subir archivos (Requiere rol Admin)');
|
||||
if (!response.ok) throw new Error('Error al subir archivo');
|
||||
return response.json();
|
||||
}
|
||||
};
|
||||
1
frontend/src/lib/assets/favicon.svg
Normal file
1
frontend/src/lib/assets/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="none" viewBox="0 0 64 64"><defs><linearGradient id="techGradient" x1="16" x2="48" y1="16" y2="48" gradientUnits="userSpaceOnUse"><stop offset="0%" stop-color="#00f2fe"/><stop offset="100%" stop-color="#4facfe"/></linearGradient></defs><rect width="64" height="64" fill="#0f172a" rx="18"/><path fill="url(#techGradient)" fill-rule="evenodd" d="m32 14 14 12v14L32 52 18 40V26zm0 6.5-9 7.7v7.6l9 7.7 9-7.7v-7.6z" clip-rule="evenodd"/><path stroke="#0f172a" stroke-linecap="round" stroke-width="2" d="M32 20.5V30m0 4v9.5m-9-7.7 9-5.8m9 5.8L32 30"/></svg>
|
||||
|
After Width: | Height: | Size: 619 B |
1068
frontend/src/lib/auth.ts
Normal file
1068
frontend/src/lib/auth.ts
Normal file
File diff suppressed because it is too large
Load Diff
21
frontend/src/lib/backend.test.ts
Normal file
21
frontend/src/lib/backend.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
// Solo en entorno con DNS `backend` (p. ej. red Docker) y con backend levantado; en Jenkins/CI se omite.
|
||||
const skipHttpIntegration = process.env.CI === 'true' || Boolean(process.env.JENKINS_URL)
|
||||
|
||||
describe.skipIf(skipHttpIntegration)('backend — health check', () => {
|
||||
|
||||
it('el backend esta corriendo y responde', async () => {
|
||||
const response = await fetch('http://backend:8000/api/health')
|
||||
expect(response.status).toBe(200)
|
||||
})
|
||||
|
||||
it('el endpoint de facturas responde', async () => {
|
||||
const response = await fetch('http://backend:8000/api/v1/a76/invoices/?company_id=1', {
|
||||
headers: { 'Authorization': 'Bearer test' }
|
||||
})
|
||||
// 200 con datos o 401/403 sin token valido — ambos significan que el backend esta vivo
|
||||
expect([200, 401, 403, 422]).toContain(response.status)
|
||||
})
|
||||
|
||||
})
|
||||
90
frontend/src/lib/components/app-version.svelte
Normal file
90
frontend/src/lib/components/app-version.svelte
Normal file
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
|
||||
/**
|
||||
* Componente para mostrar la versión de la aplicación
|
||||
* Consume el endpoint /api/version del backend para obtener información de versión
|
||||
*/
|
||||
|
||||
interface VersionInfo {
|
||||
service: string;
|
||||
version: string;
|
||||
environment: string;
|
||||
debug: boolean;
|
||||
}
|
||||
|
||||
let versionInfo: VersionInfo | null = $state(null);
|
||||
let loading: boolean = $state(true);
|
||||
let error: string | null = $state(null);
|
||||
|
||||
/**
|
||||
* Obtener información de versión desde el backend
|
||||
*/
|
||||
async function fetchVersion() {
|
||||
try {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
const response = await api.get<VersionInfo>('/version');
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
|
||||
if (response.data) {
|
||||
versionInfo = response.data;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error al obtener versión:', err);
|
||||
error = err instanceof Error ? err.message : 'Error desconocido';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Cargar versión al montar el componente
|
||||
onMount(() => {
|
||||
fetchVersion();
|
||||
});
|
||||
|
||||
/**
|
||||
* Obtener color del badge según el entorno
|
||||
*/
|
||||
function getEnvironmentColor(env: string): string {
|
||||
switch (env?.toLowerCase()) {
|
||||
case 'production':
|
||||
return 'bg-green-600 text-white';
|
||||
case 'development':
|
||||
return 'bg-yellow-600 text-white';
|
||||
case 'staging':
|
||||
return 'bg-blue-600 text-white';
|
||||
default:
|
||||
return 'bg-gray-600 text-white';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Componente de versión -->
|
||||
<div class="flex items-center app-version">
|
||||
{#if loading}
|
||||
<div class="text-xs text-muted-foreground">Cargando versión...</div>
|
||||
{:else if error}
|
||||
<div class="text-xs text-destructive">Error: {error}</div>
|
||||
{:else if versionInfo}
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
|
||||
<!-- Versión -->
|
||||
<span class="font-mono font-semibold text-foreground">
|
||||
v{versionInfo.version}
|
||||
</span>
|
||||
|
||||
<!-- Indicador de debug (solo si está activo) -->
|
||||
{#if versionInfo.debug}
|
||||
<span class="rounded bg-orange-600 px-2 py-0.5 text-xs font-medium text-white">
|
||||
DEBUG
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
80
frontend/src/lib/components/common/Maintenance.svelte
Normal file
80
frontend/src/lib/components/common/Maintenance.svelte
Normal file
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import { Construction } from 'lucide-svelte';
|
||||
import { fade, fly } from 'svelte/transition';
|
||||
import { onMount } from 'svelte';
|
||||
import { m } from '$lib/i18n/messages';
|
||||
|
||||
let visible = false;
|
||||
onMount(() => {
|
||||
visible = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex h-[calc(100vh-200px)] flex-col items-center justify-center overflow-hidden p-8 text-center"
|
||||
>
|
||||
{#if visible}
|
||||
<div
|
||||
class="relative mb-8 rounded-full bg-blue-50 p-8 dark:bg-blue-900/20"
|
||||
in:fly={{ y: -50, duration: 1000, delay: 200 }}
|
||||
>
|
||||
<div class="absolute inset-0 animate-ping rounded-full bg-blue-400 opacity-20"></div>
|
||||
<Construction
|
||||
class="relative z-10 h-16 w-16 animate-bounce text-blue-500 dark:text-blue-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h1
|
||||
class="mb-4 text-4xl font-extrabold tracking-tight text-gray-900 dark:text-white"
|
||||
in:fade={{ duration: 1000, delay: 500 }}
|
||||
>
|
||||
{m['common.maint_title']()}
|
||||
</h1>
|
||||
|
||||
<p
|
||||
class="max-w-md text-xl leading-relaxed text-gray-500 dark:text-gray-400"
|
||||
in:fade={{ duration: 1000, delay: 800 }}
|
||||
>
|
||||
{m['common.maint_desc']()}
|
||||
</p>
|
||||
|
||||
<div class="mt-10" in:fly={{ y: 20, duration: 1000, delay: 1100 }}>
|
||||
<a
|
||||
href="/dashboard"
|
||||
class="group inline-flex items-center justify-center rounded-lg bg-blue-600 px-6 py-3 text-base font-semibold text-white shadow-lg transition-all hover:scale-105 hover:bg-blue-700 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:outline-none active:scale-95"
|
||||
>
|
||||
<span>{m['common.maint_back']()}</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="ml-2 h-5 w-5 transition-transform group-hover:translate-x-1"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(.animate-bounce) {
|
||||
animation: bounce 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(-5%);
|
||||
animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(0);
|
||||
animation-timing-function: cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
81
frontend/src/lib/components/dashboard/chart-card.svelte
Normal file
81
frontend/src/lib/components/dashboard/chart-card.svelte
Normal file
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import type { ChartDataPoint } from '$lib/api/dashboard/types';
|
||||
import { m } from '$lib/i18n/messages';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
data: ChartDataPoint[];
|
||||
type?: 'bar' | 'line' | 'pie';
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { title, description, data, type = 'bar', class: cls = '' }: Props = $props();
|
||||
|
||||
let maxValue = $derived(Math.max(...data.map((d) => d.value), 1));
|
||||
|
||||
const rankColors = [
|
||||
'text-yellow-600 bg-yellow-50 dark:bg-yellow-950/40 border-yellow-200 dark:border-yellow-800',
|
||||
'text-slate-500 bg-slate-50 dark:bg-slate-800 border-slate-200 dark:border-slate-700',
|
||||
'text-orange-600 bg-orange-50 dark:bg-orange-950/40 border-orange-200 dark:border-orange-800',
|
||||
'text-muted-foreground bg-muted border-border',
|
||||
'text-muted-foreground bg-muted border-border'
|
||||
];
|
||||
|
||||
const barGradients = [
|
||||
'from-primary to-primary/60',
|
||||
'from-primary/85 to-primary/50',
|
||||
'from-primary/70 to-primary/40',
|
||||
'from-primary/55 to-primary/30',
|
||||
'from-primary/40 to-primary/20'
|
||||
];
|
||||
</script>
|
||||
|
||||
<Card.Root class={`overflow-hidden ${cls}`}>
|
||||
<Card.Header class="pb-3">
|
||||
<Card.Title class="text-base">{title}</Card.Title>
|
||||
{#if description}
|
||||
<Card.Description>{description}</Card.Description>
|
||||
{/if}
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if data.length === 0}
|
||||
<div class="flex flex-col items-center justify-center py-8 gap-2 text-muted-foreground">
|
||||
<p class="text-sm">{m.dashboard_no_data_available_short()}</p>
|
||||
</div>
|
||||
{:else if type === 'bar'}
|
||||
<div class="space-y-3">
|
||||
{#each data as item, i}
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span
|
||||
class={`inline-flex items-center justify-center w-5 h-5 rounded text-[10px] font-bold border shrink-0 ${rankColors[i] ?? rankColors[3]}`}
|
||||
>{i + 1}</span>
|
||||
<span class="font-medium truncate flex-1">{item.label}</span>
|
||||
<span class="text-muted-foreground font-medium tabular-nums">{item.value.toLocaleString()}</span>
|
||||
</div>
|
||||
<div class="h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
class={`h-full rounded-full bg-gradient-to-r transition-all duration-700 ${barGradients[i] ?? barGradients[4]}`}
|
||||
style="width: {(item.value / maxValue) * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if type === 'pie'}
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
{#each data as item, i}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2.5 h-2.5 rounded-full bg-primary" style="opacity: {1 - i * 0.15}"></div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium truncate">{item.label}</div>
|
||||
<div class="text-xs text-muted-foreground">{item.value.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
158
frontend/src/lib/components/dashboard/common/error-state.svelte
Normal file
158
frontend/src/lib/components/dashboard/common/error-state.svelte
Normal file
@@ -0,0 +1,158 @@
|
||||
<script lang="ts">
|
||||
import { ShieldAlert, AlertTriangle, RefreshCw, ArrowLeft, Home } from 'lucide-svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { fade, fly } from 'svelte/transition';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
|
||||
let {
|
||||
status = 0,
|
||||
error = '',
|
||||
onRetry = () => { if (typeof window !== 'undefined') window.location.reload(); },
|
||||
onBack = () => { if (typeof window !== 'undefined') window.history.back(); }
|
||||
}: {
|
||||
status?: number,
|
||||
error?: string,
|
||||
onRetry?: () => void,
|
||||
onBack?: () => void
|
||||
} = $props();
|
||||
|
||||
// Determinar si es un error de permisos (403)
|
||||
let isForbidden = $derived(status === 403 || error.toLowerCase().includes('permission') || error.toLowerCase().includes('acceso denegado') || error.includes('403'));
|
||||
|
||||
// Determinar si es un error de servidor (500)
|
||||
let isServerError = $derived(status >= 500 || (error && (error.toLowerCase().includes('server error') || error.toLowerCase().includes('error interno'))));
|
||||
|
||||
// Extraer el código del permiso si viene en el error
|
||||
let permissionCode = $derived.by(() => {
|
||||
if (!isForbidden) return null;
|
||||
// Buscar patrones como "cat_example.view" o "Missing required permissions: cat_example.view"
|
||||
const missing = error.match(/Missing required permissions?:\s*([a-z0-9_.]+)/i);
|
||||
if (missing) return missing[1];
|
||||
const legacy = error.match(/([a-z0-9_.]+\.[a-z0-9_.]+)/i);
|
||||
if (legacy) return legacy[0];
|
||||
const simple = error.match(/(cat_|settings_)[a-z0-9_.]+/i);
|
||||
return simple ? simple[0] : null;
|
||||
});
|
||||
|
||||
let title = $derived(isForbidden ? 'Acceso Restringido' : isServerError ? 'Error del Servidor' : 'Algo salió mal');
|
||||
|
||||
let displayError = $derived.by(() => {
|
||||
if (isForbidden) {
|
||||
return 'No tienes los permisos necesarios para acceder a esta sección de la plataforma.';
|
||||
}
|
||||
if (isServerError) {
|
||||
return 'Estamos experimentando dificultades técnicas en nuestros servidores.';
|
||||
}
|
||||
return error || 'Ocurrió un error inesperado al intentar procesar tu solicitud.';
|
||||
});
|
||||
|
||||
const activeCompany = $derived(companyStore.activeCompany);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-center p-6 min-h-[450px] w-full"
|
||||
in:fade={{ duration: 400 }}
|
||||
>
|
||||
<Card.Root class="max-w-md w-full border-2 bg-card/60 backdrop-blur-md shadow-2xl overflow-hidden {isForbidden ? 'border-dashed' : 'border-destructive/20'}">
|
||||
<!-- Barra de progreso decorativa -->
|
||||
<div class="h-1.5 w-full bg-gradient-to-r {isForbidden ? 'from-primary/80 via-primary/40 to-primary/80' : 'from-destructive/80 via-destructive/40 to-destructive/80'} animate-gradient-x"></div>
|
||||
|
||||
<Card.Header class="flex flex-col items-center gap-5 pt-10 text-center">
|
||||
<div class="relative">
|
||||
<div
|
||||
class="relative"
|
||||
in:fly={{ y: 20, duration: 600, delay: 100 }}
|
||||
>
|
||||
<div class="absolute -inset-6 {isForbidden ? 'bg-primary/15' : 'bg-destructive/15'} rounded-full blur-2xl animate-pulse"></div>
|
||||
<div class="relative bg-background p-5 rounded-full border shadow-xl">
|
||||
{#if isForbidden}
|
||||
<ShieldAlert class="h-14 w-14 text-primary" />
|
||||
{:else}
|
||||
<AlertTriangle class="h-14 w-14 text-destructive" />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3" in:fade={{ delay: 300 }}>
|
||||
<Card.Title class="text-3xl font-black tracking-tight {isForbidden ? 'text-foreground' : 'text-destructive'}">
|
||||
{title}
|
||||
</Card.Title>
|
||||
<Card.Description class="text-base text-muted-foreground px-6 leading-relaxed">
|
||||
{displayError}
|
||||
</Card.Description>
|
||||
</div>
|
||||
</Card.Header>
|
||||
|
||||
<Card.Content class="flex flex-col items-center gap-8 pb-10 pt-4">
|
||||
<div
|
||||
class="flex flex-col items-center gap-7 w-full"
|
||||
in:fade={{ delay: 500 }}
|
||||
>
|
||||
{#if isForbidden && permissionCode}
|
||||
<div class="flex flex-col items-center gap-2.5">
|
||||
<span class="text-[10px] uppercase font-bold tracking-[0.1em] text-muted-foreground/80">Identificador de Permiso</span>
|
||||
<Badge variant="outline" class="font-mono text-xs bg-muted/70 border-primary/30 text-primary px-4 py-1.5 shadow-sm">
|
||||
{permissionCode}
|
||||
</Badge>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isServerError && error && !isForbidden}
|
||||
<div class="w-full px-6">
|
||||
<div class="rounded-lg bg-destructive/5 border border-destructive/10 p-4">
|
||||
<p class="text-[11px] font-mono text-destructive/70 break-all text-center leading-tight">
|
||||
{error.length > 150 ? error.substring(0, 150) + '...' : error}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col sm:flex-row items-center justify-center gap-3 w-full px-6">
|
||||
{#if isServerError}
|
||||
<Button variant="default" class="w-full sm:w-auto min-w-[140px] gap-2 shadow-lg hover:scale-105 transition-transform" onclick={onRetry}>
|
||||
<RefreshCw class="h-4 w-4" />
|
||||
Reintentar
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<Button variant="outline" class="w-full sm:w-auto min-w-[140px] gap-2 shadow-sm" onclick={onBack}>
|
||||
<ArrowLeft class="h-4 w-4" />
|
||||
Regresar
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<a href="/dashboard" class="text-xs text-muted-foreground hover:text-primary transition-colors flex items-center gap-1.5">
|
||||
<Home class="h-3 w-3" />
|
||||
Ir al Inicio del Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</Card.Content>
|
||||
|
||||
<Card.Footer class="bg-muted/40 border-t py-5 justify-center">
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<p class="text-[10px] text-muted-foreground/80 text-center max-w-[280px]">
|
||||
Si consideras que esto es un error o el problema persiste, contacta al soporte técnico.
|
||||
</p>
|
||||
{#if activeCompany}
|
||||
<p class="text-[9px] text-muted-foreground/40 font-mono">
|
||||
CID: {activeCompany.id} | TS: {new Date().toISOString()}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes gradient-x {
|
||||
0%, 100% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
}
|
||||
.animate-gradient-x {
|
||||
background-size: 200% 200%;
|
||||
animation: gradient-x 5s ease infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,253 @@
|
||||
<script lang="ts" generics="TData, TValue">
|
||||
import { onMount } from 'svelte';
|
||||
import { type ColumnDef, getCoreRowModel } from '@tanstack/table-core';
|
||||
import { createSvelteTable, FlexRender } from '$lib/components/ui/data-table/index.js';
|
||||
import * as Table from '$lib/components/ui/table/index.js';
|
||||
|
||||
type InfiniteDataTableProps<TData, TValue> = {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
loading: boolean;
|
||||
hasMore: boolean;
|
||||
loadMore: () => void;
|
||||
emptyMessage?: string;
|
||||
selectedIds?: (number | string)[];
|
||||
onSelectedIdsChange?: (ids: (number | string)[]) => void;
|
||||
getRowId?: (row: TData) => string | number;
|
||||
onRowClick?: (row: TData) => void;
|
||||
onRowDoubleClick?: (row: TData) => void;
|
||||
};
|
||||
|
||||
let {
|
||||
data,
|
||||
columns,
|
||||
loading,
|
||||
hasMore,
|
||||
loadMore,
|
||||
emptyMessage = 'No hay resultados.',
|
||||
selectedIds = [],
|
||||
onSelectedIdsChange,
|
||||
getRowId,
|
||||
onRowClick,
|
||||
onRowDoubleClick,
|
||||
}: InfiniteDataTableProps<TData, TValue> = $props();
|
||||
|
||||
const table = createSvelteTable({
|
||||
get data() {
|
||||
return data;
|
||||
},
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId: (row: any, index: number) => {
|
||||
if (getRowId) return getRowId(row).toString();
|
||||
const id = row.id ?? row.transporter_key ?? row.driver_id ?? row.trailer_number ?? row.vehicle_key;
|
||||
return id != null ? id.toString() : index.toString();
|
||||
},
|
||||
state: {
|
||||
get rowSelection() {
|
||||
const selection: Record<string, boolean> = {};
|
||||
selectedIds.forEach((id) => {
|
||||
selection[id.toString()] = true;
|
||||
});
|
||||
return selection;
|
||||
}
|
||||
},
|
||||
onStateChange: (updater: any) => {
|
||||
if (!onSelectedIdsChange) return;
|
||||
|
||||
const currentState = table.getState();
|
||||
const nextState = typeof updater === 'function' ? updater(currentState) : updater;
|
||||
const rowSelection = nextState?.rowSelection;
|
||||
|
||||
if (!rowSelection) {
|
||||
onSelectedIdsChange([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSelectedIds = Object.entries(rowSelection)
|
||||
.filter(([, selected]) => Boolean(selected))
|
||||
.map(([id]) => (isNaN(Number(id)) ? id : Number(id)));
|
||||
|
||||
onSelectedIdsChange(nextSelectedIds);
|
||||
},
|
||||
enableRowSelection: true,
|
||||
enableMultiRowSelection: true
|
||||
});
|
||||
|
||||
let scrollContainer = $state<HTMLDivElement>();
|
||||
let loadingTrigger = $state<HTMLDivElement>();
|
||||
|
||||
function findRowActionTrigger(tr: HTMLTableRowElement): HTMLElement | null {
|
||||
const lastTd = tr.querySelector('td:last-of-type');
|
||||
if (!lastTd) return null;
|
||||
return lastTd.querySelector<HTMLElement>(
|
||||
'[data-slot="dropdown-menu-trigger"], button[aria-haspopup="menu"], button[aria-haspopup="dialog"]'
|
||||
);
|
||||
}
|
||||
|
||||
function handleCatalogTableKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (!target || !scrollContainer?.contains(target)) return;
|
||||
if (
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.tagName === 'SELECT' ||
|
||||
target.isContentEditable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const row = target.closest<HTMLTableRowElement>('tr[data-slot="table-row"]');
|
||||
if (!row || row.closest('[role="dialog"]')) return;
|
||||
const lastCell = row.querySelector('td:last-of-type');
|
||||
if (!lastCell || !lastCell.contains(target)) return;
|
||||
|
||||
const tbody = row.closest('tbody');
|
||||
if (!tbody || !scrollContainer.contains(tbody)) return;
|
||||
const rows = Array.from(
|
||||
tbody.querySelectorAll<HTMLTableRowElement>('tr[data-slot="table-row"]')
|
||||
);
|
||||
const idx = rows.indexOf(row);
|
||||
if (idx < 0) return;
|
||||
const delta = event.key === 'ArrowDown' ? 1 : -1;
|
||||
const nextIdx = idx + delta;
|
||||
if (nextIdx < 0 || nextIdx >= rows.length) return;
|
||||
const nextTrigger = findRowActionTrigger(rows[nextIdx]);
|
||||
if (!nextTrigger) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
nextTrigger.focus();
|
||||
rows[nextIdx].scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const [entry] = entries;
|
||||
if (entry.isIntersecting && hasMore && !loading) {
|
||||
loadMore();
|
||||
}
|
||||
},
|
||||
{
|
||||
root: scrollContainer,
|
||||
threshold: 0.1
|
||||
}
|
||||
);
|
||||
|
||||
if (loadingTrigger) {
|
||||
observer.observe(loadingTrigger);
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex h-full w-full flex-col overflow-hidden">
|
||||
<div
|
||||
class="catalog-table-scroll min-h-[320px] max-h-[calc(100svh-280px)]"
|
||||
bind:this={scrollContainer}
|
||||
onkeydown={handleCatalogTableKeydown}
|
||||
>
|
||||
<Table.Root>
|
||||
<Table.Header class="catalog-table-header">
|
||||
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
|
||||
{@const headerList = headerGroup.headers}
|
||||
{@const lastHeaderColId = headerList[headerList.length - 1]?.column.id}
|
||||
<Table.Row inTabOrder={false}>
|
||||
{#each headerList as header (header.id)}
|
||||
{@const colId = header.column.id}
|
||||
<Table.Head
|
||||
class={[
|
||||
'catalog-table-head-cell',
|
||||
colId === 'select' && 'catalog-table-sticky-left z-40 min-w-[2.75rem]',
|
||||
colId === lastHeaderColId &&
|
||||
'catalog-table-sticky-right z-30'
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{#if !header.isPlaceholder}
|
||||
<FlexRender
|
||||
content={header.column.columnDef.header}
|
||||
context={header.getContext()}
|
||||
/>
|
||||
{/if}
|
||||
</Table.Head>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if table.getRowModel().rows.length}
|
||||
{#each table.getRowModel().rows as row (row.id)}
|
||||
{@const visibleCells = row.getVisibleCells()}
|
||||
{@const lastCellColId = visibleCells[visibleCells.length - 1]?.column.id}
|
||||
<Table.Row
|
||||
inTabOrder={false}
|
||||
data-state={row.getIsSelected() ? 'selected' : undefined}
|
||||
class={[
|
||||
row.getIsSelected() ? 'catalog-table-row-selected' : 'catalog-table-row',
|
||||
onRowClick && 'cursor-pointer'
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onclick={() => onRowClick?.(row.original)}
|
||||
ondblclick={() => onRowDoubleClick?.(row.original)}
|
||||
>
|
||||
{#each visibleCells as cell (cell.id)}
|
||||
{@const colId = cell.column.id}
|
||||
<Table.Cell
|
||||
class={[
|
||||
'whitespace-nowrap',
|
||||
colId === 'select' && 'catalog-table-sticky-left z-30 min-w-[2.75rem]',
|
||||
colId === lastCellColId &&
|
||||
'catalog-table-sticky-right z-10',
|
||||
row.getIsSelected()
|
||||
? 'catalog-table-sticky-row-selected'
|
||||
: 'catalog-table-sticky-row-hover'
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<FlexRender
|
||||
content={cell.column.columnDef.cell}
|
||||
context={cell.getContext()}
|
||||
/>
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{:else}
|
||||
<Table.Row inTabOrder={false}>
|
||||
<Table.Cell colspan={columns.length} class="h-24 text-center text-sm text-muted-foreground">
|
||||
{emptyMessage}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/if}
|
||||
|
||||
{#if hasMore}
|
||||
<Table.Row inTabOrder={false}>
|
||||
<Table.Cell colspan={columns.length} class="h-24 text-center p-0">
|
||||
<div bind:this={loadingTrigger} class="flex h-full w-full items-center justify-center">
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center gap-3 rounded-full border bg-muted/30 px-6 py-2 shadow-sm">
|
||||
<div class="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent text-primary"></div>
|
||||
<span class="text-sm font-medium text-foreground">Cargando más registros...</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span class="h-px w-8 bg-border"></span>
|
||||
<span>Desplázate para cargar más</span>
|
||||
<span class="h-px w-8 bg-border"></span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script lang="ts">
|
||||
import { ShieldAlert, ArrowLeft, RefreshCw } from 'lucide-svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { fade, fly } from 'svelte/transition';
|
||||
|
||||
let {
|
||||
error = '',
|
||||
onRetry = () => {},
|
||||
onBack = () => history.back()
|
||||
} = $props();
|
||||
|
||||
// Extraer el código del permiso si viene en el error (ej: "Missing required permissions: cat_packages.view")
|
||||
let permissionCode = $derived.by(() => {
|
||||
const missing = error.match(/Missing required permissions?:\s*([a-z0-9_.]+)/i);
|
||||
if (missing) return missing[1];
|
||||
const legacy = error.match(/(cat_|settings_)[a-z0-9_.]+/i);
|
||||
return legacy ? legacy[0] : null;
|
||||
});
|
||||
|
||||
let displayError = $derived(
|
||||
error.toLowerCase().includes('permission') || error.toLowerCase().includes('acceso denegado') || error.includes('403')
|
||||
? 'No tienes los permisos necesarios para acceder a esta información.'
|
||||
: error || 'Ocurrió un error inesperado al cargar los datos.'
|
||||
);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-center p-8 min-h-[400px] w-full"
|
||||
in:fade={{ duration: 300 }}
|
||||
>
|
||||
<Card.Root class="max-w-md w-full border-dashed border-2 bg-card/50 backdrop-blur-sm shadow-xl overflow-hidden">
|
||||
<div class="h-1.5 w-full bg-gradient-to-r from-primary via-destructive/50 to-primary/30 animate-gradient-x"></div>
|
||||
|
||||
<Card.Header class="flex flex-col items-center gap-4 pt-8 text-center">
|
||||
<div class="relative">
|
||||
<div
|
||||
class="relative"
|
||||
in:fly={{ y: 20, duration: 500, delay: 200 }}
|
||||
>
|
||||
<div class="absolute -inset-4 bg-primary/10 rounded-full blur-xl animate-pulse"></div>
|
||||
<div class="relative bg-background p-4 rounded-full border shadow-inner">
|
||||
<ShieldAlert class="h-12 w-12 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div in:fade={{ delay: 400 }}>
|
||||
<div class="space-y-2">
|
||||
<Card.Title class="text-2xl font-bold tracking-tight">Acceso Restringido</Card.Title>
|
||||
<Card.Description class="text-sm text-muted-foreground px-4">
|
||||
{displayError}
|
||||
</Card.Description>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Header>
|
||||
|
||||
<Card.Content class="flex flex-col items-center gap-6 pb-8 pt-2">
|
||||
<div
|
||||
class="flex flex-col items-center gap-6 w-full"
|
||||
in:fade={{ delay: 600 }}
|
||||
>
|
||||
{#if permissionCode}
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<span class="text-[10px] uppercase tracking-wider text-muted-foreground font-semibold">Identificador de Permiso</span>
|
||||
<Badge variant="outline" class="font-mono text-[11px] bg-muted/50 border-primary/20 text-primary px-3 py-1">
|
||||
{permissionCode}
|
||||
</Badge>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center justify-center w-full px-6 text-center">
|
||||
<Button variant="outline" class="w-full max-w-[200px] gap-2 shadow-sm" onclick={() => onBack()}>
|
||||
<ArrowLeft class="h-4 w-4" />
|
||||
Regresar al Dashboard
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
|
||||
<Card.Footer class="bg-muted/30 border-t py-4 justify-center">
|
||||
<p class="text-[11px] text-muted-foreground text-center">
|
||||
Si consideras que esto es un error, contacta al administrador del sistema.
|
||||
</p>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes gradient-x {
|
||||
0%, 100% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
}
|
||||
.animate-gradient-x {
|
||||
background-size: 200% 200%;
|
||||
animation: gradient-x 5s ease infinite;
|
||||
}
|
||||
</style>
|
||||
137
frontend/src/lib/components/dashboard/donut-chart.svelte
Normal file
137
frontend/src/lib/components/dashboard/donut-chart.svelte
Normal file
@@ -0,0 +1,137 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { PieChart } from 'lucide-svelte';
|
||||
import type { ChartDataPoint } from '$lib/api/dashboard/types';
|
||||
import { m } from '$lib/i18n/messages';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
data: ChartDataPoint[];
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { title, data, class: cls = '' }: Props = $props();
|
||||
|
||||
// Palette: azul → índigo → cyan → violeta → teal → sky → amber → emerald
|
||||
const PALETTE = [
|
||||
{ bg: '#3b82f6', light: '#eff6ff', text: '#1d4ed8' },
|
||||
{ bg: '#6366f1', light: '#eef2ff', text: '#4338ca' },
|
||||
{ bg: '#06b6d4', light: '#ecfeff', text: '#0e7490' },
|
||||
{ bg: '#8b5cf6', light: '#f5f3ff', text: '#6d28d9' },
|
||||
{ bg: '#14b8a6', light: '#f0fdfa', text: '#0f766e' },
|
||||
{ bg: '#0ea5e9', light: '#f0f9ff', text: '#0369a1' },
|
||||
{ bg: '#f59e0b', light: '#fffbeb', text: '#b45309' },
|
||||
{ bg: '#10b981', light: '#ecfdf5', text: '#047857' },
|
||||
];
|
||||
|
||||
let total = $derived(data.reduce((sum, d) => sum + d.value, 0));
|
||||
let sorted = $derived([...data].sort((a, b) => b.value - a.value));
|
||||
|
||||
let segments = $derived(
|
||||
sorted.map((d, i) => ({
|
||||
...d,
|
||||
pct: total > 0 ? (d.value / total) * 100 : 0,
|
||||
color: PALETTE[i % PALETTE.length]
|
||||
}))
|
||||
);
|
||||
|
||||
// SVG donut
|
||||
const R = 72;
|
||||
const SW = 22;
|
||||
const CIRC = 2 * Math.PI * R;
|
||||
const GAP = 0.018 * CIRC;
|
||||
|
||||
let donutSlices = $derived(() => {
|
||||
let cum = 0;
|
||||
return segments.map((s) => {
|
||||
const arc = Math.max((s.pct / 100) * CIRC - GAP, 0);
|
||||
const dashArray = `${arc} ${CIRC - arc}`;
|
||||
const dashOffset = -(cum * CIRC / 100);
|
||||
cum += s.pct;
|
||||
return { ...s, dashArray, dashOffset };
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<Card.Root class={cls}>
|
||||
<Card.Header class="pb-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<Card.Title class="flex items-center gap-2 text-base">
|
||||
<PieChart class="h-4 w-4 text-blue-500" />
|
||||
{title}
|
||||
</Card.Title>
|
||||
<Card.Description class="mt-1">{m.dashboard_operations_breakdown()}</Card.Description>
|
||||
</div>
|
||||
{#if total > 0}
|
||||
<span class="text-sm font-semibold tabular-nums text-muted-foreground">{total.toLocaleString()} {m.dashboard_ops_short()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</Card.Header>
|
||||
|
||||
<Card.Content class="pt-4">
|
||||
{#if data.length === 0}
|
||||
<div class="flex flex-col items-center justify-center py-14 gap-3 text-muted-foreground">
|
||||
<div class="rounded-full bg-muted p-4">
|
||||
<PieChart class="h-8 w-8 opacity-30" />
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-sm font-medium">{m.dashboard_no_data_available()}</p>
|
||||
<p class="text-xs text-muted-foreground/70 mt-0.5">{m.dashboard_operations_will_appear_here()}</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col sm:flex-row items-center gap-6">
|
||||
|
||||
<!-- Donut SVG -->
|
||||
<div class="relative shrink-0">
|
||||
<svg width="176" height="176" viewBox="0 0 176 176" class="-rotate-90">
|
||||
<circle cx="88" cy="88" r={R} fill="none"
|
||||
stroke="currentColor" stroke-width={SW} stroke-opacity="0.06" />
|
||||
{#each donutSlices() as s}
|
||||
<circle
|
||||
cx="88" cy="88" r={R}
|
||||
fill="none"
|
||||
stroke={s.color.bg}
|
||||
stroke-width={SW}
|
||||
stroke-dasharray={s.dashArray}
|
||||
stroke-dashoffset={s.dashOffset}
|
||||
stroke-linecap="butt"
|
||||
class="transition-all duration-500"
|
||||
/>
|
||||
{/each}
|
||||
</svg>
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span class="text-2xl font-bold tabular-nums leading-none">{total.toLocaleString()}</span>
|
||||
<span class="text-[11px] text-muted-foreground mt-0.5">{m.dashboard_total()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legend with progress bars -->
|
||||
<div class="flex-1 w-full space-y-3 min-w-0">
|
||||
{#each segments as s}
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1 gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="inline-block h-2.5 w-2.5 rounded-full shrink-0" style="background:{s.color.bg}"></span>
|
||||
<span class="text-xs font-medium truncate leading-snug">{s.label}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 shrink-0">
|
||||
<span class="text-xs font-semibold tabular-nums">{s.value.toLocaleString()}</span>
|
||||
<span class="text-[11px] text-muted-foreground w-10 text-right">{s.pct.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-1.5 w-full rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-700"
|
||||
style="width:{s.pct}%; background:{s.color.bg}; opacity:0.75"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
54
frontend/src/lib/components/dashboard/kpi-card.svelte
Normal file
54
frontend/src/lib/components/dashboard/kpi-card.svelte
Normal file
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import { TrendingUp, TrendingDown, Minus } from 'lucide-svelte';
|
||||
import type { KPIMetric } from '$lib/api/dashboard/types';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
metric: KPIMetric;
|
||||
icon?: any;
|
||||
iconColor?: string;
|
||||
}
|
||||
|
||||
let { metric, icon: Icon, iconColor = 'text-primary' }: Props = $props();
|
||||
|
||||
const iconBgMap: Record<string, string> = {
|
||||
'text-blue-600': 'bg-blue-50 dark:bg-blue-950/40',
|
||||
'text-green-600': 'bg-green-50 dark:bg-green-950/40',
|
||||
'text-purple-600': 'bg-purple-50 dark:bg-purple-950/40',
|
||||
'text-orange-600': 'bg-orange-50 dark:bg-orange-950/40',
|
||||
'text-cyan-600': 'bg-cyan-50 dark:bg-cyan-950/40',
|
||||
'text-yellow-600': 'bg-yellow-50 dark:bg-yellow-950/40'
|
||||
};
|
||||
|
||||
const trendColors = {
|
||||
up: 'text-emerald-600',
|
||||
down: 'text-red-500',
|
||||
stable: 'text-slate-400'
|
||||
};
|
||||
|
||||
const TrendIcon = metric.trend ? { up: TrendingUp, down: TrendingDown, stable: Minus }[metric.trend] : null;
|
||||
let iconBg = $derived(iconBgMap[iconColor] ?? 'bg-primary/10');
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-4 rounded-xl border bg-card px-4 py-3.5 hover:shadow-sm transition-shadow">
|
||||
{#if Icon}
|
||||
<div class={cn('flex h-9 w-9 shrink-0 items-center justify-center rounded-lg', iconBg)}>
|
||||
<Icon class={cn('h-4 w-4', iconColor)} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs text-muted-foreground truncate">{metric.label}</p>
|
||||
<div class="flex items-baseline gap-2 mt-0.5">
|
||||
<span class="text-xl font-bold tabular-nums leading-none">
|
||||
{metric.value.toLocaleString()}{#if metric.unit}<span class="text-sm font-normal text-muted-foreground ml-0.5">{metric.unit}</span>{/if}
|
||||
</span>
|
||||
{#if metric.percentage_change !== undefined && TrendIcon && metric.trend}
|
||||
<span class={cn('flex items-center gap-0.5 text-xs font-medium', trendColors[metric.trend])}>
|
||||
<TrendIcon class="h-3 w-3" />
|
||||
{Math.abs(metric.percentage_change).toFixed(1)}%
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script lang="ts">
|
||||
import { SETTINGS_METADATA } from './settings-metadata';
|
||||
import SettingFormField from './SettingFormField.svelte';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Search, FilterX } from 'lucide-svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
export let category: string;
|
||||
export let currentData: any = {};
|
||||
export let onUpdate: (data: any) => void;
|
||||
|
||||
let searchQuery = '';
|
||||
|
||||
// Local state to manage edits before final save
|
||||
let formData: any = { ...currentData };
|
||||
|
||||
// Update local state when category changes
|
||||
$: {
|
||||
formData = { ...currentData };
|
||||
}
|
||||
|
||||
function handleFieldChange(key: string, value: any) {
|
||||
formData[key] = value;
|
||||
onUpdate(formData); // Propagate changes upward
|
||||
}
|
||||
|
||||
// Filter fields based on metadata and search query
|
||||
$: availableFields = SETTINGS_METADATA[category] || Object.keys(currentData);
|
||||
$: filteredFields = availableFields.filter(f =>
|
||||
f.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
// Derived grouped fields for UI organization
|
||||
$: hasResults = filteredFields.length > 0;
|
||||
</script>
|
||||
|
||||
<div class="space-y-6 flex flex-col h-full overflow-hidden">
|
||||
<!-- Search/Filter Bar -->
|
||||
<div class="relative group mx-2">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/20 group-focus-within:text-primary transition-colors" />
|
||||
<Input
|
||||
placeholder="Buscar parámetro en {category}..."
|
||||
bind:value={searchQuery}
|
||||
class="pl-10 bg-black/40 border-white/5 h-10 focus-visible:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Fields Grid -->
|
||||
<div class="flex-1 overflow-y-auto px-2 custom-scrollbar pr-4">
|
||||
{#if hasResults}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 pb-8">
|
||||
{#each filteredFields as field (field)}
|
||||
<div in:fade={{ duration: 150 }}>
|
||||
<SettingFormField
|
||||
key={field}
|
||||
value={formData[field]}
|
||||
onChange={(val) => handleFieldChange(field, val)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col items-center justify-center h-48 text-white/20 border-2 border-dashed border-white/5 rounded-2xl mx-2">
|
||||
<FilterX class="w-12 h-12 mb-4 opacity-50" />
|
||||
<p class="text-xs uppercase tracking-widest">No se encontraron parámetros</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,526 @@
|
||||
<script lang="ts">
|
||||
import * as Tabs from '$lib/components/ui/tabs';
|
||||
import SettingFormField from './SettingFormField.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { AlertTriangle, CreditCard, FileEdit, RefreshCw } from 'lucide-svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
currentData: any;
|
||||
onUpdate: (data: any) => void;
|
||||
}
|
||||
|
||||
let { currentData = {}, onUpdate }: Props = $props();
|
||||
|
||||
// Labels — mismos que SCAII más los específicos de SCAF
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
// General (heredados de SsisGen)
|
||||
factoriva: 'Factor IVA (Aplica en compras)',
|
||||
dta: 'DTA',
|
||||
actseguridad: 'Activar la seguridad del sistema',
|
||||
// SIN mensajevenc / diavencimiento (no aplica en SCAF)
|
||||
calvalbasetcped: 'Cálculo en base al TC fecha pago (Importación)',
|
||||
calvalbasetcpedexpo: 'Cálculo en base al TC fecha pago (Exportación)',
|
||||
controldes: 'Asignar fecha límite para desactualizar facturas',
|
||||
diadesactual: 'Días para desactualizar facturas (Impo/Expo)',
|
||||
decimalespeso: 'Peso neto y bruto',
|
||||
decimalescant: 'Cantidades',
|
||||
decimalesvalor: 'Valores y costos',
|
||||
filtrocantidad: 'Omitir Cantidades con balance menor a',
|
||||
muestraarchcodbarras: 'Muestra archivo TXT al generar factura/remesa',
|
||||
// SCAF extra en código de barras
|
||||
codigobarrasesp: 'Imprimir 3 en código de barras',
|
||||
|
||||
// Archivos electrónicos
|
||||
patharch: 'Generación de los archivos PDF en',
|
||||
patharchtransmision: 'Generación de archivos electrónicos para transmitir en',
|
||||
pathrespuesta: 'Dirección de espera de la respuesta del broker americano',
|
||||
patharchped: 'Generación de archivos electrónicos para pedimentos en',
|
||||
|
||||
// Continuación 1
|
||||
pathgenimpotemp: 'Ruta de los archivos CSV para importación temporal',
|
||||
pathgenexpo: 'Ruta de los archivos CSV para exportación',
|
||||
tipovenro: 'Tipo de validación para regla octava',
|
||||
cantvenro: 'Cantidad (Porcentaje/Días)',
|
||||
calcdepreciacion: 'Cálculo de la depreciación',
|
||||
firmapacking: 'Esconder la Firma en el Packing List',
|
||||
escondamepacking: 'Esconde el A. A. Mexicano en Packing List',
|
||||
advertenciatm: 'Advertencia el Tipo de Moneda en Facturas',
|
||||
valmanifusado: 'Activar Control de Manifiesto',
|
||||
repdescargolineal: 'Generar Reporte de Descargo por Orden de factura',
|
||||
muestra_copias_codbarras: 'Imprimir Código de Barras en las demás copias, aparte de la copia del transportista',
|
||||
|
||||
// Continuación 2
|
||||
validadecencant: 'Invalida la Captura de Decimales en Cantidades con Unidad de Medida Pieza',
|
||||
mostraradvertenciaro: 'Mostrar Advertencia de Valores de Regla Octava en Partidas',
|
||||
usartranspamedocame: 'Utilizar Transportista Americano en el Packing List y en Bill Of Lading de Exportación',
|
||||
cantvscantseries: 'Validar la Cantidad Contra la Cantidad de Series',
|
||||
covefechaemision: 'Tomar la Fecha de Emision para la Generación del COVE',
|
||||
usarcoveenarchsaaim3: 'Usar E-Document en Lugar del Número de Factura en el Archivo de Transferencia SAAIM3',
|
||||
asignainfoparte: 'Asigna en Impo. Temporal, Definitiva y Compras Mex. la información completa del número de parte',
|
||||
interfaceaatcfpff: 'En Interfaces (WINSAAI, SAAIM3), usar tipo de cambio de:',
|
||||
incluirobscoveobsimpo: 'Incluir las Observaciones del COVE en las Observaciones en Importación',
|
||||
agregarincreimpo: 'Agregar Incrementables, Fecha de Emisión y Factor de Multimoneda en las Facturas de Importación',
|
||||
|
||||
// Continuación 3
|
||||
identificadornodoseriecove: 'Omitir datos de identificación en COVE (Regla 7.3.3 OEA)',
|
||||
pathnaftaccs: 'Ruta NAFTA CSS',
|
||||
actpdfreportes: 'Activa para que se generen los PDFs al momento de Actualización',
|
||||
patharchpdfimpo: 'Generar Archivos PDF de Importación en',
|
||||
patharchpdfexpo: 'Generar Archivos PDF de Exportación en',
|
||||
mensajesvurfc: 'Desactivar mensajes de RFC en Transmision de VU',
|
||||
mostrarpackinglistingles: 'Mostrar Packing List en Inglés',
|
||||
enviarsubpartidascove: 'Enviar Sub Partidas a XML COVE',
|
||||
utilizarfechapagopeddeundiaanterior: 'Utilizar Tipo de Cambio de la Fecha de Pago de Pedimento De Un Dia Anterior',
|
||||
utilizartitulosalternativosimpresionfactura: 'Utilizar Títulos Alternativos en la Impresión de las Facturas',
|
||||
|
||||
// Continuación 4
|
||||
usartcdelafechapagopedimpoendescarga: 'Usar Tipo de Cambio de la Fecha de Pago de Pedimento de Importación en Impresion de la Descarga',
|
||||
usarvude128o256: 'Utilizar Ventanilla Unica con Encriptacion de 256',
|
||||
agregarnumeroembarque: 'Agregar Numero Embarque',
|
||||
afostrofe: 'Imprimir Caracter Especial en Columna Clase / Num. Parte (Exportar a Excel)',
|
||||
utilizarumdeexistenciaentransmisionvu: 'Utilizar UM de Existencia En Transmision VU',
|
||||
utilizarcodigodebrokerdeclienteenmainx30: 'Utilizar Codigo De Broker De Cliente En Main X 30',
|
||||
hojacalculosepararincrementablesanexo3: 'En la Hoja de Calculo Separar los Incrementables en el Anexo 3',
|
||||
hojacalculodesglosefacturaanexo3: 'En la Hoja de Calculo Desglosar las Facturas en el Anexo 3',
|
||||
utilizarnombregenericomainx30: 'Utilizar Nombre Generico Mainx 30',
|
||||
utilizartcrespectoatipoped: 'Utilizar TC Respecto Al Tipo de Pedimento',
|
||||
utilizarsolopartesnaftaenco: 'Utilizar Solo Partes Nafta En CO',
|
||||
cambiarpesosporcostounitario: 'Cambiar Pesos Por Costo Unitario En Exportación',
|
||||
bloqueodeediciondefacturas: 'Bloqueo De Edición De Facturas',
|
||||
impresionfacturaalterna: 'Imprimir Factura Alterna En Hoja De Calculo Y Manifestación Al Valor',
|
||||
|
||||
// Continuación 5
|
||||
transmitirfacalterna: 'Transmitir Factura Alterna en VU',
|
||||
validarseries: 'Omitir validación series en facturas',
|
||||
emailas: 'Activar ventana EmailAS',
|
||||
};
|
||||
|
||||
const canonicalize = (data: any) => {
|
||||
const result: any = {};
|
||||
const recognizedKeys = Object.keys(FIELD_LABELS);
|
||||
for (const rawKey in data) {
|
||||
const canonicalKey = recognizedKeys.find((k) => k.toLowerCase() === rawKey.toLowerCase());
|
||||
if (canonicalKey) {
|
||||
result[canonicalKey] = data[rawKey];
|
||||
} else {
|
||||
result[rawKey] = data[rawKey];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
let formData: any = $state({
|
||||
downloadftp: 'No',
|
||||
descargarftpolocal: 'FTP',
|
||||
minsdownlftp: 0,
|
||||
usarfechaemisionfactura: 0,
|
||||
campo18valsaaim3: 0,
|
||||
...canonicalize(currentData)
|
||||
});
|
||||
|
||||
// Guarda la última versión recibida del padre para detectar cambios reales vs. echo circular
|
||||
let lastExternalData: string = JSON.stringify({ ...currentData });
|
||||
|
||||
$effect(() => {
|
||||
const newData = { ...currentData };
|
||||
const newDataStr = JSON.stringify(newData);
|
||||
// Solo sincroniza si el dato cambió externamente (no es un echo de nuestro propio onUpdate)
|
||||
if (newDataStr === lastExternalData) return;
|
||||
lastExternalData = newDataStr;
|
||||
|
||||
const mapped = canonicalize(newData);
|
||||
for (const key in mapped) {
|
||||
formData[key] = mapped[key];
|
||||
}
|
||||
});
|
||||
|
||||
// Notifica al padre del estado completo al montar (incluyendo defaults)
|
||||
onMount(() => {
|
||||
lastExternalData = JSON.stringify({ ...currentData });
|
||||
onUpdate({ ...formData });
|
||||
});
|
||||
|
||||
function handleFieldChange(key: string, value: any) {
|
||||
formData[key] = value;
|
||||
onUpdate({ ...formData });
|
||||
}
|
||||
|
||||
// Tab General — igual que SCAII pero SIN mensajevenc/diavencimiento, más codigobarrasEsp
|
||||
const GENERAL_SECTIONS = [
|
||||
{
|
||||
title: 'Parámetros del Sistema',
|
||||
fields: ['factoriva', 'dta', 'actseguridad']
|
||||
},
|
||||
{
|
||||
title: 'Tipos de Cambio',
|
||||
fields: ['calvalbasetcped', 'calvalbasetcpedexpo']
|
||||
},
|
||||
{
|
||||
title: 'Desactualización de Facturas',
|
||||
fields: ['controldes', 'diadesactual']
|
||||
},
|
||||
{
|
||||
title: 'Número de decimales en reportes generales en',
|
||||
fields: ['decimalespeso', 'decimalescant', 'decimalesvalor']
|
||||
},
|
||||
{
|
||||
title: 'Filtrar por la cantidad',
|
||||
fields: ['filtrocantidad']
|
||||
},
|
||||
{
|
||||
title: 'Código de barras',
|
||||
fields: ['muestraarchcodbarras', 'codigobarrasesp']
|
||||
}
|
||||
];
|
||||
|
||||
// Tab Archivos — solo 4 rutas (sin pathgenimpotemp/pathgenexpo/patharchpedconsm)
|
||||
const ARCHIVOS_SECTIONS = [
|
||||
{
|
||||
title: 'Gestión de Archivos PDF y Electrónicos',
|
||||
fields: [
|
||||
'patharch',
|
||||
'patharchtransmision',
|
||||
'pathrespuesta',
|
||||
'patharchped',
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// Continuación 1
|
||||
const CONTINUACION_SECTIONS = [
|
||||
{
|
||||
title: 'Rutas CSV',
|
||||
fields: ['pathgenimpotemp', 'pathgenexpo']
|
||||
},
|
||||
{
|
||||
title: 'Tipo de validación para regla octava',
|
||||
fields: ['tipovenro', 'cantvenro']
|
||||
},
|
||||
{
|
||||
title: 'Cálculo de la depreciación',
|
||||
fields: ['calcdepreciacion']
|
||||
},
|
||||
{
|
||||
title: 'Parámetros Adicionales y Visualización',
|
||||
fields: [
|
||||
'firmapacking',
|
||||
'escondamepacking',
|
||||
'advertenciatm',
|
||||
'valmanifusado',
|
||||
'repdescargolineal',
|
||||
'muestra_copias_codbarras',
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// Continuación 2
|
||||
const CONT2_SECTIONS = [
|
||||
{
|
||||
title: 'Configuraciones de Pantalla y Validación',
|
||||
fields: [
|
||||
'validadecencant',
|
||||
'mostraradvertenciaro',
|
||||
'usartranspamedocame',
|
||||
'cantvscantseries',
|
||||
'covefechaemision',
|
||||
'usarcoveenarchsaaim3',
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'SCAF.INI',
|
||||
fields: ['asignainfoparte']
|
||||
},
|
||||
{
|
||||
title: 'En Interfaces (WINSAAI, SAAIM3), usar tipo de cambio de:',
|
||||
fields: ['interfaceaatcfpff']
|
||||
},
|
||||
{
|
||||
title: 'COVE e Importación',
|
||||
fields: ['incluirobscoveobsimpo', 'agregarincreimpo']
|
||||
}
|
||||
];
|
||||
|
||||
// Continuación 3
|
||||
const CONT3_SECTIONS = [
|
||||
{
|
||||
id: 'rutas_pdf',
|
||||
title: 'Rutas y Archivos PDF',
|
||||
fields: [
|
||||
'identificadornodoseriecove',
|
||||
'pathnaftaccs',
|
||||
'actpdfreportes',
|
||||
'patharchpdfimpo',
|
||||
'patharchpdfexpo',
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'opciones',
|
||||
title: 'Opciones',
|
||||
fields: [
|
||||
'mensajesvurfc',
|
||||
'mostrarpackinglistingles',
|
||||
'enviarsubpartidascove',
|
||||
'utilizarfechapagopeddeundiaanterior',
|
||||
'utilizartitulosalternativosimpresionfactura',
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// Continuación 4
|
||||
const CONT4_FIELDS = [
|
||||
'usartcdelafechapagopedimpoendescarga',
|
||||
'usarvude128o256',
|
||||
'agregarnumeroembarque',
|
||||
'afostrofe',
|
||||
'utilizarumdeexistenciaentransmisionvu',
|
||||
'utilizarcodigodebrokerdeclienteenmainx30',
|
||||
'hojacalculosepararincrementablesanexo3',
|
||||
'hojacalculodesglosefacturaanexo3',
|
||||
'utilizarnombregenericomainx30',
|
||||
'utilizartcrespectoatipoped',
|
||||
'utilizarsolopartesnaftaenco',
|
||||
'cambiarpesosporcostounitario',
|
||||
'bloqueodeediciondefacturas',
|
||||
'impresionfacturaalterna',
|
||||
];
|
||||
|
||||
// Continuación 5
|
||||
const CONT5_FIELDS = [
|
||||
'transmitirfacalterna',
|
||||
'validarseries',
|
||||
'emailas',
|
||||
];
|
||||
|
||||
const TABS = [
|
||||
{ id: 'general', label: 'General' },
|
||||
{ id: 'archivos', label: 'Archivos electrónicos' },
|
||||
{ id: 'continuacion', label: 'Continuación' },
|
||||
{ id: 'cont2', label: 'Cont 2' },
|
||||
{ id: 'cont3', label: 'Cont 3' },
|
||||
{ id: 'cont4', label: 'Cont 4' },
|
||||
{ id: 'cont5', label: 'Cont 5' },
|
||||
];
|
||||
|
||||
const RULE_VALIDATOR_OPTIONS = [
|
||||
{ value: 'Porcentaje', label: 'Porcentaje' },
|
||||
{ value: 'Dias', label: 'Días' },
|
||||
{ value: 'No aplica', label: 'No aplica' }
|
||||
];
|
||||
|
||||
const DEPRECIACION_OPTIONS = [
|
||||
{ value: 'MES', label: 'MES' },
|
||||
{ value: 'DIA', label: 'DÍA' }
|
||||
];
|
||||
|
||||
const INTERFACE_TC_OPTIONS = [
|
||||
{ value: 'P', label: 'Fecha de Pago' },
|
||||
{ value: 'F', label: 'Fecha de Factura' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="w-full">
|
||||
<Tabs.Root value="general" class="flex flex-col h-full">
|
||||
<Tabs.List class="grid w-full grid-cols-3 md:grid-cols-4 lg:grid-cols-7 bg-muted rounded-md p-1 mb-4">
|
||||
{#each TABS as tab}
|
||||
<Tabs.Trigger
|
||||
value={tab.id}
|
||||
class="w-full px-3 py-1.5 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm"
|
||||
>
|
||||
{tab.label}
|
||||
</Tabs.Trigger>
|
||||
{/each}
|
||||
</Tabs.List>
|
||||
|
||||
<!-- GENERAL -->
|
||||
<Tabs.Content value="general" class="space-y-6">
|
||||
<div class="space-y-6 py-4">
|
||||
{#each GENERAL_SECTIONS as section}
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<h3 class="font-medium text-lg">{section.title}</h3>
|
||||
<div class="h-px w-full bg-border mt-2"></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{#each section.fields as field}
|
||||
<SettingFormField
|
||||
key={field}
|
||||
label={FIELD_LABELS[field] || field}
|
||||
value={formData[field]}
|
||||
onChange={(val) => handleFieldChange(field, val)}
|
||||
type={field === 'DTA' ? 'number' : undefined}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- ARCHIVOS ELECTRÓNICOS — solo 4 rutas -->
|
||||
<Tabs.Content value="archivos" class="space-y-6">
|
||||
<div class="space-y-6 py-4">
|
||||
{#each ARCHIVOS_SECTIONS as section}
|
||||
<div class="space-y-6 max-w-3xl mx-auto">
|
||||
<div class="flex items-center gap-4">
|
||||
<h3 class="font-medium text-lg">{section.title}</h3>
|
||||
<div class="h-px w-full bg-border mt-2"></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
{#each section.fields as field}
|
||||
<SettingFormField
|
||||
key={field}
|
||||
label={FIELD_LABELS[field] || field}
|
||||
value={formData[field]}
|
||||
onChange={(val) => handleFieldChange(field, val)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- CONTINUACIÓN 1 -->
|
||||
<Tabs.Content value="continuacion" class="space-y-6">
|
||||
<div class="space-y-6 py-4">
|
||||
{#each CONTINUACION_SECTIONS as section}
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<h3 class="font-medium text-lg">{section.title}</h3>
|
||||
<div class="h-px w-full bg-border mt-2"></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{#each section.fields as field}
|
||||
<SettingFormField
|
||||
key={field}
|
||||
label={FIELD_LABELS[field] || field}
|
||||
value={formData[field]}
|
||||
onChange={(val) => handleFieldChange(field, val)}
|
||||
options={
|
||||
field === 'TipoVenRO' ? RULE_VALIDATOR_OPTIONS :
|
||||
field === 'CalcDepreciacion' ? DEPRECIACION_OPTIONS :
|
||||
undefined
|
||||
}
|
||||
type={
|
||||
field === 'TipoVenRO' ? 'select' :
|
||||
field === 'CalcDepreciacion' ? 'radio' :
|
||||
field === 'CantVenRO' ? 'number' :
|
||||
undefined
|
||||
}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- CONTINUACIÓN 2 -->
|
||||
<Tabs.Content value="cont2" class="space-y-6">
|
||||
<div class="space-y-6 py-4">
|
||||
{#each CONT2_SECTIONS as section}
|
||||
<div class="space-y-6 max-w-4xl mx-auto">
|
||||
<div class="flex items-center gap-4">
|
||||
<h3 class="font-medium text-lg">{section.title}</h3>
|
||||
<div class="h-px w-full bg-border mt-2"></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
{#each section.fields as field}
|
||||
<SettingFormField
|
||||
key={field}
|
||||
label={FIELD_LABELS[field] || field}
|
||||
value={formData[field]}
|
||||
onChange={(val) => handleFieldChange(field, val)}
|
||||
type="switch"
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- CONTINUACIÓN 3 -->
|
||||
<Tabs.Content value="cont3" class="space-y-6">
|
||||
<div class="space-y-6 py-4">
|
||||
{#each CONT3_SECTIONS as section}
|
||||
<div class="space-y-6 max-w-4xl mx-auto">
|
||||
<div class="flex items-center gap-4">
|
||||
<h3 class="font-medium text-lg">{section.title}</h3>
|
||||
<div class="h-px w-full bg-border mt-2"></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
{#each section.fields as field}
|
||||
<SettingFormField
|
||||
key={field}
|
||||
label={FIELD_LABELS[field] || field}
|
||||
value={formData[field]}
|
||||
onChange={(val) => handleFieldChange(field, val)}
|
||||
type={
|
||||
field.toLowerCase().includes('path') || field.toLowerCase().includes('nafta')
|
||||
? undefined
|
||||
: 'switch'
|
||||
}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- CONTINUACIÓN 4 -->
|
||||
<Tabs.Content value="cont4" class="space-y-6">
|
||||
<div class="space-y-6 py-4 max-w-4xl mx-auto">
|
||||
<div class="flex items-center gap-4">
|
||||
<h3 class="font-medium text-lg">Opciones Adicionales</h3>
|
||||
<div class="h-px w-full bg-border mt-2"></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
{#each CONT4_FIELDS as field}
|
||||
<SettingFormField
|
||||
key={field}
|
||||
label={FIELD_LABELS[field] || field}
|
||||
value={formData[field]}
|
||||
onChange={(val) => handleFieldChange(field, val)}
|
||||
type="switch"
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- CONTINUACIÓN 5 -->
|
||||
<Tabs.Content value="cont5" class="space-y-6">
|
||||
<div class="space-y-6 py-4 max-w-4xl mx-auto">
|
||||
<div class="flex items-center gap-4">
|
||||
<h3 class="font-medium text-lg">Configuraciones Adicionales</h3>
|
||||
<div class="h-px w-full bg-border mt-2"></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
{#each CONT5_FIELDS as field}
|
||||
<SettingFormField
|
||||
key={field}
|
||||
label={FIELD_LABELS[field] || field}
|
||||
value={formData[field]}
|
||||
onChange={(val) => handleFieldChange(field, val)}
|
||||
type="switch"
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
</Tabs.Root>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global([data-radix-scroll-area-viewport]) {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
:global([data-radix-scroll-area-viewport]::-webkit-scrollbar) {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Settings, Package, Truck, Construction,
|
||||
TrendingUp, FileText, ClipboardList, Database,
|
||||
ChevronRight, Layers
|
||||
} from 'lucide-svelte';
|
||||
import { fade, slide } from 'svelte/transition';
|
||||
|
||||
export let activeCategory: string;
|
||||
export let onSelect: (cat: string) => void;
|
||||
|
||||
const groups = [
|
||||
{
|
||||
title: 'SCAII (Inventarios)',
|
||||
icon: Package,
|
||||
items: [
|
||||
{ id: 'ssisgen', label: 'Parámetros Generales', icon: Settings },
|
||||
{ id: 'ssisgen2', label: 'Valor Agregado', icon: TrendingUp },
|
||||
{ id: 'ssisgen3', label: 'Otros Parámetros', icon: ClipboardList },
|
||||
{ id: 'ssismex', label: 'Nacional (MEX)', icon: FileText },
|
||||
{ id: 'ssisdef', label: 'Imp. Definitiva', icon: Truck },
|
||||
{ id: 'ssisimpo', label: 'Imp. Temporal', icon: Database },
|
||||
{ id: 'ssisexpo', label: 'Exportación', icon: Construction },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'SCAF (Activos Fijos)',
|
||||
icon: Layers,
|
||||
items: [
|
||||
{ id: 'qsisgen', label: 'General Activos', icon: Settings },
|
||||
{ id: 'qsiscmex', label: 'Mantenimiento EX', icon: FileText },
|
||||
{ id: 'qsisdef', label: 'Imp. Definitiva', icon: Truck },
|
||||
{ id: 'qsisimpo', label: 'Imp. Temporal', icon: Database },
|
||||
{ id: 'qsisexpo', label: 'Exportación', icon: Construction },
|
||||
{ id: 'qsisimporep', label: 'Rep. Importación', icon: ClipboardList },
|
||||
{ id: 'qsisexporep', label: 'Rep. Exportación', icon: ClipboardList },
|
||||
]
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
{#each groups as group}
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground px-4 flex items-center gap-2">
|
||||
<svelte:component this={group.icon} class="w-3 h-3" />
|
||||
{group.title}
|
||||
</h3>
|
||||
<div class="space-y-1">
|
||||
{#each group.items as item}
|
||||
<button
|
||||
class="w-full flex items-center justify-between gap-3 px-4 py-2.5 rounded-lg transition-all group
|
||||
{activeCategory === item.id
|
||||
? 'bg-primary/10 text-primary border border-primary/20 shadow-lg shadow-primary/5'
|
||||
: 'text-white/60 hover:bg-white/5 border border-transparent hover:border-white/5'}"
|
||||
onclick={() => onSelect(item.id)}
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<svelte:component this={item.icon} class="w-4 h-4 {activeCategory === item.id ? 'text-primary' : 'text-white/40 group-hover:text-white/60'}" />
|
||||
<span class="text-sm font-medium">{item.label}</span>
|
||||
</div>
|
||||
{#if activeCategory === item.id}
|
||||
<div in:fade>
|
||||
<ChevronRight class="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,176 @@
|
||||
<script lang="ts">
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import * as RadioGroup from '$lib/components/ui/radio-group';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { FolderOpen } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
key: string;
|
||||
label?: string;
|
||||
value: any;
|
||||
onChange: (val: any) => void;
|
||||
options?: { value: any, label: string }[];
|
||||
type?: 'switch' | 'input' | 'select' | 'path' | 'radio' | 'password' | 'number';
|
||||
hint?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { key, label, value, onChange, options, type, hint, disabled }: Props = $props();
|
||||
|
||||
// Heuristic to detect toggleable (boolean-like) legacy fields (0/1)
|
||||
const isToggleable = (k: string, v: any) => {
|
||||
const toggles = [
|
||||
'act', 'valid', 'mostr', 'usar', 'utiliz', 'restring', 'bloqueo',
|
||||
'omiti', 'oculta', 'resaltar', 'cal', 'mues', 'mens', 'cont', 'firm',
|
||||
'esc', 'adv', 'val', 'tom', 'nom', 'rev', 'imp', 'temp', 'costo',
|
||||
'des', 'asig', 'calc', 'param', 'fracc', 'valor', 'cove', 'loc', 'inter', 'inclu', 'actvalor', 'trans', 'cambiar', 'reasigna'
|
||||
];
|
||||
// Be more lenient: if it has the prefix and is 0, 1, or null, it's likely a toggle
|
||||
return (v === 0 || v === 1 || v === null || v === undefined) &&
|
||||
toggles.some(t => k.toLowerCase().startsWith(t));
|
||||
}
|
||||
|
||||
const isPath = $derived(
|
||||
key.toLowerCase().includes('path') ||
|
||||
key.toLowerCase().includes('arch') ||
|
||||
key.toLowerCase().includes('ruta')
|
||||
);
|
||||
|
||||
// Heuristic or explicit type
|
||||
let mode = $derived(type || (options ? 'select' : (isToggleable(key, value) ? 'switch' : (isPath ? 'path' : 'input'))));
|
||||
|
||||
function handleBrowse() {
|
||||
try {
|
||||
if (typeof window !== 'undefined' && 'showDirectoryPicker' in window) {
|
||||
// @ts-ignore
|
||||
window.showDirectoryPicker().then((handle: any) => {
|
||||
onChange(handle.name);
|
||||
}).catch((err: any) => console.log("Browse cancelled:", err));
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("Browse failed:", err);
|
||||
}
|
||||
}
|
||||
let selectValue = $state(String(value));
|
||||
$effect(() => {
|
||||
selectValue = String(value);
|
||||
});
|
||||
|
||||
function handleSelectChange(v: string) {
|
||||
selectValue = v;
|
||||
onChange(v);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<div class="flex flex-col gap-2 {mode === 'switch' ? 'flex-row items-center justify-between' : ''}">
|
||||
<Label for={key} class="text-sm font-medium {disabled ? 'opacity-50 cursor-not-allowed' : ''} {mode === 'switch' ? 'cursor-pointer' : ''}">
|
||||
{label || key}
|
||||
</Label>
|
||||
|
||||
{#if mode === 'select' && options}
|
||||
<Select.Root
|
||||
type="single"
|
||||
bind:value={selectValue}
|
||||
onValueChange={handleSelectChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Select.Trigger class="w-full">
|
||||
<span class="truncate">
|
||||
{options.find(opt => String(opt.value) === selectValue)?.label || "Seleccionar..."}
|
||||
</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each options as opt}
|
||||
<Select.Item value={String(opt.value)} label={opt.label}>
|
||||
{opt.label}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{:else if mode === 'radio'}
|
||||
<RadioGroup.Root
|
||||
value={String(value)}
|
||||
onValueChange={onChange}
|
||||
disabled={disabled}
|
||||
class="flex flex-col gap-3"
|
||||
>
|
||||
{#each options || [] as opt}
|
||||
<div class="flex items-center space-x-3">
|
||||
<RadioGroup.Item value={String(opt.value)} id={`${key}-${opt.value}`} />
|
||||
<Label for={`${key}-${opt.value}`} class="text-sm font-medium cursor-pointer {disabled ? 'cursor-not-allowed' : ''}">
|
||||
{opt.label}
|
||||
</Label>
|
||||
</div>
|
||||
{/each}
|
||||
</RadioGroup.Root>
|
||||
{:else if mode === 'switch'}
|
||||
<Switch
|
||||
id={key}
|
||||
checked={value === 1 || value === '1' || value === true || value === 'true'}
|
||||
onCheckedChange={(val) => onChange(val ? 1 : 0)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{:else}
|
||||
<div class="space-y-1.5">
|
||||
{#if mode === 'path'}
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
id={key}
|
||||
value={value || ""}
|
||||
oninput={(e: any) => onChange(e.target.value)}
|
||||
placeholder="Ruta del directorio..."
|
||||
disabled={disabled}
|
||||
class="font-mono text-xs text-foreground bg-background"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
onclick={handleBrowse}
|
||||
class="shrink-0"
|
||||
>
|
||||
<FolderOpen class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{:else if mode === 'password'}
|
||||
<Input
|
||||
id={key}
|
||||
type="password"
|
||||
value={value || ""}
|
||||
oninput={(e: any) => onChange(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
disabled={disabled}
|
||||
class="text-foreground bg-background"
|
||||
/>
|
||||
{:else if mode === 'number'}
|
||||
<Input
|
||||
id={key}
|
||||
type="number"
|
||||
value={value || ""}
|
||||
oninput={(e: any) => onChange(Number(e.target.value))}
|
||||
disabled={disabled}
|
||||
class="text-right text-foreground bg-background"
|
||||
/>
|
||||
{:else}
|
||||
<Input
|
||||
id={key}
|
||||
value={value || ""}
|
||||
oninput={(e: any) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
class="text-foreground bg-background"
|
||||
/>
|
||||
{/if}
|
||||
{#if hint}
|
||||
<p class="text-[10px] text-muted-foreground ml-1 font-medium italic">
|
||||
{hint}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { Globe, Database, Layers } from 'lucide-svelte';
|
||||
|
||||
export let level: 'global' | 'tenant' | 'company';
|
||||
export let size: 'sm' | 'md' = 'md';
|
||||
|
||||
const config = {
|
||||
global: {
|
||||
label: 'Global',
|
||||
icon: Globe,
|
||||
color: 'text-primary bg-primary/10 border-primary/20'
|
||||
},
|
||||
tenant: {
|
||||
label: 'Tenant',
|
||||
icon: Database,
|
||||
color: 'text-blue-400 bg-blue-400/10 border-blue-400/20'
|
||||
},
|
||||
company: {
|
||||
label: 'Company',
|
||||
icon: Layers,
|
||||
color: 'text-purple-400 bg-purple-400/10 border-purple-400/20'
|
||||
}
|
||||
};
|
||||
|
||||
$: current = config[level];
|
||||
</script>
|
||||
|
||||
<div class="inline-flex items-center gap-1.5 px-2 py-1 rounded-md border {current.color} {size === 'sm' ? 'text-[10px]' : 'text-xs'} font-medium">
|
||||
<svelte:component this={current.icon} class={size === 'sm' ? 'w-3 h-3' : 'w-3.5 h-3.5'} />
|
||||
{current.label.toUpperCase()}
|
||||
</div>
|
||||
@@ -0,0 +1,884 @@
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
import * as Tabs from '$lib/components/ui/tabs';
|
||||
import SettingFormField from './SettingFormField.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import {
|
||||
AlertTriangle,
|
||||
CreditCard,
|
||||
FileEdit,
|
||||
RefreshCw,
|
||||
Layers
|
||||
} from 'lucide-svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
currentData: any;
|
||||
onUpdate: (data: any) => void;
|
||||
}
|
||||
|
||||
let { currentData = {}, onUpdate }: Props = $props();
|
||||
|
||||
// Translation Mapping for SSisGen
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
consecutivo: "Número Consecutivo",
|
||||
dta: "DTA (Derecho Trámite Aduanero)",
|
||||
dtaexpo: "DTA Exportación",
|
||||
subempresa: "Sub-Empresa / División",
|
||||
patharch: "Generación de los archivos PDF por",
|
||||
patharchtransmision: "Generación de archivos electrónicos por transmitir en",
|
||||
pathtransexpo: "Ruta: Transmisión Exportación",
|
||||
pathrespuesta: "Ruta: Respuestas SAAI",
|
||||
patharchped: "Generación de archivos electrónicos para pedimentos en",
|
||||
patharchpedconsm: "Ruta de los archivos TXT de los archivos previos para pedimentos consolidados",
|
||||
pathgenimpotemp: "Ruta de los archivos CSV para importaciones",
|
||||
pathgenexpo: "Ruta de los archivos CSV para exportación",
|
||||
actseguridad: "Activar la seguridad del sistema",
|
||||
controldes: "Asignar fecha límite para desactualizar facturas",
|
||||
diadesactual: "Días para desactualizar facturas (Impo/Expo)",
|
||||
diavencimiento: "Días de anticipación (vencimiento)",
|
||||
mensajevenc: "Activar la advertencia de saldos vencidos",
|
||||
fechades: "Fecha de Desactualización Automática",
|
||||
factoriva: "Factor IVA (Aplica en compras)",
|
||||
validasifra: "Validar Fracciones con SIFRA",
|
||||
decimalespeso: "Peso neto y bruto",
|
||||
decimalescant: "Cantidades",
|
||||
decimalesvalor: "Valores y costos",
|
||||
calvalbasetcped: "Cálculo en base al TC fecha pago (Importación)",
|
||||
calvalbasetcpedexpo: "Cálculo en base al TC fecha pago (Exportación)",
|
||||
filtrocantidad: "Omitir Cantidades con balance menor a",
|
||||
muestraarchcodbarras: "Muestra archivo TXT al generar factura/remesa",
|
||||
datoshist: "Permitir Datos Históricos",
|
||||
tipovenro: "Tipo de validación para regla octava",
|
||||
cantvenro: "Cantidad para validación (Porcentaje/Días)",
|
||||
costoplanta: "Costo Directo en Planta",
|
||||
firmapacking: "Esconder la firma en el packing list",
|
||||
escondaamexpacking: "Esconde el agente aduanal mexicano en el packing list",
|
||||
advertenciatm: "Advertencia del uso del tipo de moneda en facturas",
|
||||
costoimpofijo: "Valida como inconsistencia si el costo de la parte es diferente al costo de la partida al actualizar la factura de importación",
|
||||
tomarsaldosvenc: "Activar si desea tomar saldos temporales o vencidos para descargar",
|
||||
valmanifusado: "Activar control de asignación de manifiestos en factura",
|
||||
valparteexiste: "No mostrar ventanas de auto selección de partes y marcar error en caso de que no exista",
|
||||
temporalfechapago: "Tomar temporalidad en base a la fecha de pago del pedimento",
|
||||
muestra_copias_codbarras: "Imprimir código de barras en las demas copias, aparte de la copia del transportista",
|
||||
mostrarpackinglistingles: "Mostrar packing list en inglés",
|
||||
activar_revision_fracciones: "Activar revisión de fracciones",
|
||||
transmitirfacalterna: "Transmitir Factura Alterna en VU",
|
||||
deshabilitardescparte: "Deshabilitar la Descripción en Español del Número de Parte",
|
||||
deshabilitardescparteing: "Deshabilitar la Descripción en Inglés del Número de Parte",
|
||||
asignafracameparte: "Asignar Automáticamente Fracción Americana de Expo US Dutible en Base a la información de la Clase y Parte",
|
||||
validadecencant: "En las Partidas de Factura se Restringe la Captura de Decimales en el Campo de Unidad de Medida Pza",
|
||||
mostraradvertenciaro: "Mostrar Advertencia si el Costo del Permiso de Regla Octava es Diferente al Costo de la Partida al Momento de Guardarla",
|
||||
mostraradvertenciarovalor: "Mostrar Advertencia si el Valor del Permiso de Regla Octava es Diferente al Valor de la Partida al Momento de Guardarla",
|
||||
usartranspamedocame: "Mostrar Transportista Americano en vez del Mexicano en Packing List y Bill Of Lading de Exportación",
|
||||
calcdutypacking: "Agregar el Valor de US Packing como Complemento del Valor Dutible para Exportación.",
|
||||
parammultiples: "Activar Uso de Parámetros Multiples en Catálogos de Facturas Impo/Expo.",
|
||||
fraccnivelpais: "Activar la Asignación de Fracción a Nivel Parte-Pais.",
|
||||
valordllstcfacturaexpo: "Mostrar Valor en dolares conforme al Tipo de Cambio de Exportación (Facturas, Códigos de Barra y COVE)",
|
||||
covefechaemision: "Tomar la fecha de Emision para la generación del COVE.",
|
||||
usarcoveenarchsaaim3: "Usar COVE en Lugar del Número de Factura en el Archivo de Transferencia SAAIM3.",
|
||||
interfaceaaconsolidada: "Generar la Interface Consolidada por Clase para el Agente Aduanal.",
|
||||
incluirobscoveobsimpo: "Incluir las Observaciones del COVE en las Observaciones en Importación.",
|
||||
activarcatalogofraccionesamericanassifra: "Activar Catálogo Fracciones Americanas Sifra",
|
||||
mostrarprogramaimmexprosec: "Mostrar Autorización Prosec en Reportes de Saldos y Descargos",
|
||||
costounitarioporempaquefac: "Mostrar Costo Unitario Por Empaque en Impresión de Facturas de Exportación",
|
||||
actvaloragre: "Activar Valor Agregado General",
|
||||
valoragregadogen: "Valor Agregado General",
|
||||
interfaceaatcfpff: "En Interfaces (WINSAAI, SAAIM3), Usar Tipo de Cambio de:",
|
||||
agregarincreimpo: "Agregar Incrementables, Fecha de Emisión y Factor Tipo de Cambio en Facturas de Importación",
|
||||
componentebom: "No Permitir Capturar Componentes Diferentes en BOMs (Materias Primas)",
|
||||
limitesubensamble: "Límite Sub Ensamble",
|
||||
partesypedimentosporcliente: "Agregar y Filtrar Catálogo de Partes y Pedimentos por Cliente",
|
||||
mensajesvurfc: "Desactivar Mensajes de RFC en transmisión de VU",
|
||||
omitirempaqueencodigobarras: "Omitir en el Código de Barras el Valor del Empaque",
|
||||
actpdfreportes: "Generar PDFs al momento de Actualización",
|
||||
patharchpdfimpo: "Generar Archivos PDF de Importación en:",
|
||||
patharchpdfexpo: "Generar Archivos PDF de Exportación en:",
|
||||
downloadftp: "¿Desea Activar Descarga Desde Sitio FTP o Localmente?",
|
||||
minsdownlftp: "En Intervalos de (Minutos)",
|
||||
downloadftppath: "Descargar Archivos en",
|
||||
descargarftpolocal: "Descargar Archivos Desde",
|
||||
serverftp: "Servidor",
|
||||
userftp: "Usuario",
|
||||
passwordftp: "Contraseña",
|
||||
directorioftp: "Directorio en FTP",
|
||||
pathlocalparadescde: "Ruta Local",
|
||||
agregarremplazarautomatico: "Agregar/Remplazar Aut",
|
||||
activarprocesovequipment: "Activar/Desactivar proceso para JD Edwards",
|
||||
activarprocesodesperdiciojdedwards: "Activar proceso de desperdicio para JD Edwards",
|
||||
utilizarnombregenericomainx30: "Utilizar Nombre Genérico Mainx 30",
|
||||
utilizartcrespectoatipoped: "Utilizar TC Respecto Al Tipo de Pedimento",
|
||||
tomardecimalescompletos: "Tomar Decimales Completos en las Impresiones de Facturas de Importación y Exportación",
|
||||
incluirremesaeninterfazaawinsaai: "Incluir Remesa en Interfaz AA (WINSAAI)",
|
||||
cambiarpesosporcostounitario: "Cambiar Pesos Por Costo Unitario en Exportaciones Mexicanas y Bilingües",
|
||||
utilizarsolopartesnaftaenco: "Utilizar Solo Partes Nafta En CO",
|
||||
bloqueodeediciondefacturas: "Bloqueo De Edición De Facturas",
|
||||
reasignafraccionclase: "Reasigna Fracción en Reporte de Descargos",
|
||||
calcularcostounitarioenbaseavalortotal: "Calcula Costo Unitario en Base a Valor Total Capturado",
|
||||
parametroauxiliar: "Seleccionar versión de B.O.M a Descargar",
|
||||
usarcontroldefechasdeversion: "Control de Versiones de Bom por Fechas",
|
||||
desactivaciondemodulos: "Activar/Desactivar Módulos",
|
||||
restringepaisimpo: "Restringir país de Korea para operaciones de Importación.",
|
||||
restringpaisexpo: "Restringir país de Korea para operaciones de Exportación.",
|
||||
bloqueoaldesactivarnumerodeparte: "Bloqueo para Desactivación y Activación de Número De Parte",
|
||||
geninformeanexo31: "Inventario Inicial en Base al Sistema",
|
||||
usarfechaemisionfactura: "Usar Fecha de Emision Factura en Manifestacion al Valor",
|
||||
utilizarfechapagopeddeundiaanterior: "Utilizar Tipo de Cambio de la Fecha de Pago de Pedimento De Un Dia Anterior",
|
||||
utilizartitulosalternativosimpresionfactura: "Utilizar Títulos Alternativos en la Impresión de las Facturas",
|
||||
utilizarequivalenciasdeumpornumerodeparte: "Utilizar factor de conversión en número de parte para Importación y exportación.",
|
||||
usarfactorconversionpornumerodeparte: "Usar Factor de Conversión Por Número De Parte",
|
||||
usartcdelafechapagopedimpoendescarga: "Usar Tipo de Cambio de la Fecha de Pago Pedimento de Importación en Descarga",
|
||||
usarvude128o256: "Utilizar Ventanilla Unica con Encriptacion de 256",
|
||||
agregarnumeroembarque: "Agregar Número Embarque",
|
||||
utilizarumdeexistenciaentransmisionvu: "Utilizar UM de Existencia En Transmision VU",
|
||||
utilizarcodigodebrokerdeclienteenmainx30: "Utilizar Codigo De Broker De Cliente En Main X 30",
|
||||
hojacalculosepararincrementablesanexo3: "En la Hoja de Calculo Separar Incrementables en el Anexo 3",
|
||||
hojacalculodesglosefacturaanexo3: "En la Hoja de Calculo Desglosar Facturas en el Anexo 3",
|
||||
usarvaloragregadoenfacturaamericana: "Usar Valor Agregado En Factura Americana",
|
||||
ocultarinformacionfraccion: "Ocultar Información Fracción",
|
||||
resaltarsaldostempconcolor: "Resaltar Saldos Temporales con Color",
|
||||
validarsectorprosecr8: "Validar Sector Prosec R8",
|
||||
agregarsubtotalinterfazaa: "Agregar Sub Total al archivo TXT para la Interfaz del Agente Aduanal",
|
||||
imprimirfacturaalterna: "Imprimir factura alterna en manifestación al valor y hoja de calculo",
|
||||
informacionamericanasubtotal: "Mostrar Información Extra En Factura Americana SubTotal",
|
||||
activarexpedienteelectronico: "Activar Expediente Electrónico",
|
||||
campo18valsaaim3: "Mostrar Campo 18 en 551 Valsaaim3"
|
||||
};
|
||||
|
||||
const canonicalize = (data: any) => {
|
||||
const result: any = {};
|
||||
const recognizedKeys = Object.keys(FIELD_LABELS);
|
||||
for (const rawKey in data) {
|
||||
const canonicalKey = recognizedKeys.find((k) => k.toLowerCase() === rawKey.toLowerCase());
|
||||
if (canonicalKey) {
|
||||
result[canonicalKey] = data[rawKey];
|
||||
} else {
|
||||
result[rawKey] = data[rawKey];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// Initialize local/loc fields with defaults and keep in sync with currentData prop
|
||||
let formData: any = $state({
|
||||
downloadftp: 'No',
|
||||
descargarftpolocal: 'FTP',
|
||||
minsdownlftp: 0,
|
||||
usarfechaemisionfactura: 0,
|
||||
campo18valsaaim3: 0,
|
||||
...canonicalize(currentData)
|
||||
});
|
||||
|
||||
// Guarda la última versión recibida del padre para detectar cambios reales vs. echo circular
|
||||
let lastExternalData: string = JSON.stringify({ ...currentData });
|
||||
|
||||
$effect(() => {
|
||||
// Update state when currentData prop changes from outside
|
||||
const newData = { ...currentData };
|
||||
const newDataStr = JSON.stringify(newData);
|
||||
// Solo sincroniza si el dato cambió externamente (no es un echo de nuestro propio onUpdate)
|
||||
if (newDataStr === lastExternalData) return;
|
||||
lastExternalData = newDataStr;
|
||||
|
||||
const mapped = canonicalize(newData);
|
||||
for (const key in mapped) {
|
||||
formData[key] = mapped[key];
|
||||
}
|
||||
});
|
||||
|
||||
// Notifica al padre del estado completo al montar (incluyendo defaults)
|
||||
onMount(() => {
|
||||
lastExternalData = JSON.stringify({ ...currentData });
|
||||
onUpdate({ ...formData });
|
||||
});
|
||||
|
||||
function handleFieldChange(key: string, value: any) {
|
||||
formData[key] = value;
|
||||
onUpdate({ ...formData });
|
||||
}
|
||||
|
||||
// Grouped sections for the "General" tab
|
||||
const GENERAL_SECTIONS = [
|
||||
{
|
||||
title: 'Parámetros del Sistema',
|
||||
fields: ['factoriva', 'actseguridad']
|
||||
},
|
||||
{
|
||||
title: 'Advertencias de Vencimiento',
|
||||
fields: ['mensajevenc', 'diavencimiento']
|
||||
},
|
||||
{
|
||||
title: 'Tipos de Cambio',
|
||||
fields: ['calvalbasetcped', 'calvalbasetcpedexpo']
|
||||
},
|
||||
{
|
||||
title: 'Desactualización de Facturas',
|
||||
fields: ['controldes', 'diadesactual']
|
||||
},
|
||||
{
|
||||
title: 'Número de decimales en reportes generales en',
|
||||
fields: ['decimalespeso', 'decimalescant', 'decimalesvalor']
|
||||
},
|
||||
{
|
||||
title: 'Filtrar por la cantidad',
|
||||
fields: ['filtrocantidad']
|
||||
},
|
||||
{
|
||||
title: 'Código de barras',
|
||||
fields: ['muestraarchcodbarras']
|
||||
}
|
||||
];
|
||||
|
||||
// Grouped sections for "Archivos electrónicos" tab
|
||||
const ARCHIVOS_SECTIONS = [
|
||||
{
|
||||
title: 'Gestión de Archivos PDF y Electrónicos',
|
||||
fields: [
|
||||
'patharch',
|
||||
'patharchtransmision',
|
||||
'patharchped',
|
||||
'pathgenimpotemp',
|
||||
'pathgenexpo',
|
||||
'patharchpedconsm',
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// Grouped sections for "Continuación" tab
|
||||
const CONTINUACION_SECTIONS = [
|
||||
{
|
||||
title: 'Tipo de validación para regla octava',
|
||||
fields: ['tipovenro', 'cantvenro']
|
||||
},
|
||||
{
|
||||
title: 'Parámetros Adicionales y Visualización',
|
||||
fields: [
|
||||
'firmapacking',
|
||||
'escondaamexpacking',
|
||||
'advertenciatm',
|
||||
'costoimpofijo',
|
||||
'tomarsaldosvenc',
|
||||
'valmanifusado',
|
||||
'valparteexiste',
|
||||
'temporalfechapago',
|
||||
'muestra_copias_codbarras',
|
||||
'mostrarpackinglistingles',
|
||||
'activar_revision_fracciones'
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// Grouped sections for "Cont 2" tab
|
||||
const CONT2_SECTIONS = [
|
||||
{
|
||||
title: 'Configuraciones de Pantalla y Validación Avanzada',
|
||||
fields: [
|
||||
'deshabilitardescparte',
|
||||
'deshabilitardescparteing',
|
||||
'asignafracameparte',
|
||||
'validadecencant',
|
||||
'mostraradvertenciaro',
|
||||
'mostraradvertenciarovalor',
|
||||
'usartranspamedocame',
|
||||
'calcdutypacking',
|
||||
'parammultiples',
|
||||
'fraccnivelpais',
|
||||
'valordllstcfacturaexpo',
|
||||
'covefechaemision',
|
||||
'usarcoveenarchsaaim3',
|
||||
'interfaceaaconsolidada',
|
||||
'incluirobscoveobsimpo'
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// Sections for "Cont 3" tab
|
||||
const CONT3_SECTIONS = [
|
||||
{
|
||||
id: 'interface_tc',
|
||||
title: 'Tipo de Cambio en Interfaces',
|
||||
fields: ['interfaceaatcfpff']
|
||||
},
|
||||
{
|
||||
id: 'config_general',
|
||||
title: 'Opciones de Configuración General',
|
||||
fields: [
|
||||
'agregarincreimpo',
|
||||
'componentebom',
|
||||
'limitesubensamble',
|
||||
'partesypedimentosporcliente',
|
||||
'mensajesvurfc',
|
||||
'omitirempaqueencodigobarras'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'rutas_pdf',
|
||||
title: 'Rutas y Archivos PDF',
|
||||
fields: [
|
||||
'actpdfreportes',
|
||||
'patharchpdfimpo',
|
||||
'patharchpdfexpo'
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// Groups for Cont 4
|
||||
const CONT4_SECTIONS = [
|
||||
{
|
||||
id: 'ftp_master',
|
||||
title: 'Activación y Frecuencia',
|
||||
fields: ['downloadftp', 'minsdownlftp']
|
||||
},
|
||||
{
|
||||
id: 'ftp_destino',
|
||||
title: 'Destino de Descarga',
|
||||
fields: ['downloadftppath']
|
||||
},
|
||||
{
|
||||
id: 'ftp_origen',
|
||||
title: 'Origen de Descarga',
|
||||
fields: ['descargarftpolocal']
|
||||
},
|
||||
{
|
||||
id: 'ftp_config',
|
||||
title: 'Configuración FTP',
|
||||
fields: ['serverftp', 'userftp', 'passwordftp', 'directorioftp']
|
||||
},
|
||||
{
|
||||
id: 'local_config',
|
||||
title: 'Configuración de Ruta Local',
|
||||
fields: ['pathlocalparadescde']
|
||||
},
|
||||
{
|
||||
id: 'additional_options',
|
||||
title: 'Opciones Adicionales',
|
||||
fields: ['agregarremplazarautomatico', 'activarprocesovequipment', 'activarprocesodesperdiciojdedwards']
|
||||
}
|
||||
];
|
||||
|
||||
// Sections for redefined "Cont 5" tab
|
||||
const CONT5_SECTIONS = [
|
||||
{
|
||||
id: 'restricciones',
|
||||
title: 'Restricciones y Bloqueos Iniciales',
|
||||
fields: ['restringepaisimpo', 'restringpaisexpo', 'bloqueoaldesactivarnumerodeparte']
|
||||
},
|
||||
{
|
||||
id: 'inv_inicial',
|
||||
title: 'Inventario Inicial (Agrupación)',
|
||||
fields: ['geninformeanexo31']
|
||||
},
|
||||
{
|
||||
id: 'config_adicionales',
|
||||
title: 'Configuraciones Adicionales',
|
||||
fields: [
|
||||
'usarfechaemisionfactura',
|
||||
'utilizarfechapagopeddeundiaanterior',
|
||||
'utilizartitulosalternativosimpresionfactura',
|
||||
'utilizarequivalenciasdeumpornumerodeparte',
|
||||
'usarfactorconversionpornumerodeparte',
|
||||
'usartcdelafechapagopedimpoendescarga',
|
||||
'usarvude128o256',
|
||||
'agregarnumeroembarque',
|
||||
'utilizarumdeexistenciaentransmisionvu',
|
||||
'utilizarcodigodebrokerdeclienteenmainx30',
|
||||
'hojacalculosepararincrementablesanexo3',
|
||||
'hojacalculodesglosefacturaanexo3'
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// Sections for redefined "Cont 6" tab
|
||||
const CONT6_SECTIONS = [
|
||||
{
|
||||
id: 'configuracion',
|
||||
title: 'Opciones de Configuración',
|
||||
fields: [
|
||||
'usarvaloragregadoenfacturaamericana',
|
||||
'ocultarinformacionfraccion',
|
||||
'resaltarsaldostempconcolor',
|
||||
'validarsectorprosecr8',
|
||||
'agregarsubtotalinterfazaa',
|
||||
'utilizarnombregenericomainx30',
|
||||
'utilizartcrespectoatipoped',
|
||||
'tomardecimalescompletos',
|
||||
'cambiarpesosporcostounitario',
|
||||
'utilizarsolopartesnaftaenco',
|
||||
'bloqueodeediciondefacturas',
|
||||
'reasignafraccionclase',
|
||||
'calcularcostounitarioenbaseavalortotal',
|
||||
'parametroauxiliar',
|
||||
'usarcontroldefechasdeversion',
|
||||
'imprimirfacturaalterna',
|
||||
'informacionamericanasubtotal'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'acciones_modulos',
|
||||
title: 'Acciones y Módulos',
|
||||
fields: [
|
||||
'desactivaciondemodulos',
|
||||
'activarexpedienteelectronico',
|
||||
'campo18valsaaim3'
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// Grouped sections for "Cont 7" tab
|
||||
const CONT7_SECTIONS = [
|
||||
{
|
||||
title: 'Configuraciones de Fracciones y Transmisión',
|
||||
fields: [
|
||||
'activarcatalogofraccionesamericanassifra',
|
||||
'mostrarprogramaimmexprosec',
|
||||
'costounitarioporempaquefac',
|
||||
'transmitirfacalterna'
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const TABS = [
|
||||
{ id: 'general', label: 'General' },
|
||||
{ id: 'archivos', label: 'Archivos electrónicos' },
|
||||
{ id: 'continuacion', label: 'Continuación' },
|
||||
{ id: 'cont2', label: 'Cont 2' },
|
||||
{ id: 'cont3', label: 'Cont 3' },
|
||||
{ id: 'cont4', label: 'Cont 4' },
|
||||
{ id: 'cont5', label: 'Cont 5' },
|
||||
{ id: 'cont6', label: 'Cont 6' },
|
||||
{ id: 'cont7', label: 'cont 7' }
|
||||
];
|
||||
|
||||
// Options for the rule validator select
|
||||
const RULE_VALIDATOR_OPTIONS = [
|
||||
{ value: 'Porcentaje', label: 'Porcentaje' },
|
||||
{ value: 'Dias', label: 'Días' },
|
||||
{ value: 'No aplica', label: 'No aplica' }
|
||||
];
|
||||
|
||||
// Options for the interface TC radio buttons
|
||||
const INTERFACE_TC_OPTIONS = [
|
||||
{ value: 'P', label: 'Fecha de Pago' },
|
||||
{ value: 'F', label: 'Fecha de Factura' }
|
||||
];
|
||||
|
||||
// Options for radio groups in Cont 4
|
||||
const YES_NO_OPTIONS = [
|
||||
{ value: 'Si', label: 'Si' },
|
||||
{ value: 'No', label: 'No' }
|
||||
];
|
||||
|
||||
const FTP_LOCAL_OPTIONS = [
|
||||
{ value: 'FTP', label: 'FTP' },
|
||||
{ value: 'Ruta Local', label: 'Ruta Local' }
|
||||
];
|
||||
|
||||
// Options for Inventario Inicial in Cont 5
|
||||
const INICIAL_INV_OPTIONS = [
|
||||
{ value: 'AMBOS', label: 'AMBOS' },
|
||||
{ value: 'SCAII', label: 'SCAII' },
|
||||
{ value: 'SCAF', label: 'SCAF' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="w-full">
|
||||
<Tabs.Root value="general" class="flex flex-col h-full">
|
||||
<Tabs.List class="grid w-full grid-cols-3 md:grid-cols-5 lg:grid-cols-9 bg-muted rounded-md p-1 mb-4">
|
||||
{#each TABS as tab}
|
||||
<Tabs.Trigger
|
||||
value={tab.id}
|
||||
class="w-full px-3 py-1.5 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm"
|
||||
>
|
||||
{tab.label}
|
||||
</Tabs.Trigger>
|
||||
{/each}
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="general" class="space-y-6">
|
||||
<div class="space-y-6 py-4">
|
||||
{#each GENERAL_SECTIONS as section}
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<h3 class="font-medium text-lg">
|
||||
{section.title}
|
||||
</h3>
|
||||
<div class="h-px w-full bg-border mt-2"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{#each section.fields as field}
|
||||
<SettingFormField
|
||||
key={field}
|
||||
label={FIELD_LABELS[field] || field}
|
||||
value={formData[field]}
|
||||
onChange={(val) => handleFieldChange(field, val)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="archivos" class="space-y-6">
|
||||
<div class="space-y-6 py-4">
|
||||
{#each ARCHIVOS_SECTIONS as section}
|
||||
<div class="space-y-6 max-w-3xl mx-auto">
|
||||
<div class="flex items-center gap-4">
|
||||
<h3 class="font-medium text-lg">
|
||||
{section.title}
|
||||
</h3>
|
||||
<div class="h-px w-full bg-border mt-2"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
{#each section.fields as field}
|
||||
<SettingFormField
|
||||
key={field}
|
||||
label={FIELD_LABELS[field] || field}
|
||||
value={formData[field]}
|
||||
onChange={(val) => handleFieldChange(field, val)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="continuacion" class="space-y-6">
|
||||
<div class="space-y-6 py-4">
|
||||
{#each CONTINUACION_SECTIONS as section}
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<h3 class="font-medium text-lg">
|
||||
{section.title}
|
||||
</h3>
|
||||
<div class="h-px w-full bg-border mt-2"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{#each section.fields as field}
|
||||
<SettingFormField
|
||||
key={field}
|
||||
label={FIELD_LABELS[field] || field}
|
||||
value={formData[field]}
|
||||
onChange={(val) => handleFieldChange(field, val)}
|
||||
options={field === 'tipovenro' ? RULE_VALIDATOR_OPTIONS : undefined}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="cont2" class="space-y-6">
|
||||
<div class="space-y-6 py-4">
|
||||
{#each CONT2_SECTIONS as section}
|
||||
<div class="space-y-6 max-w-4xl mx-auto">
|
||||
<div class="flex items-center gap-4">
|
||||
<h3 class="font-medium text-lg">
|
||||
{section.title}
|
||||
</h3>
|
||||
<div class="h-px w-full bg-border mt-2"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
{#each section.fields as field}
|
||||
<SettingFormField
|
||||
key={field}
|
||||
label={FIELD_LABELS[field] || field}
|
||||
value={formData[field]}
|
||||
onChange={(val) => handleFieldChange(field, val)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="cont3" class="space-y-6">
|
||||
<div class="space-y-6 py-4">
|
||||
{#each CONT3_SECTIONS as section}
|
||||
<div class="space-y-6 max-w-4xl mx-auto">
|
||||
<div class="flex items-center gap-4">
|
||||
<h3 class="font-medium text-lg">
|
||||
{section.title}
|
||||
</h3>
|
||||
<div class="h-px w-full bg-border mt-2"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
{#each section.fields as field}
|
||||
<SettingFormField
|
||||
key={field}
|
||||
label={FIELD_LABELS[field] || field}
|
||||
value={formData[field]}
|
||||
onChange={(val) => handleFieldChange(field, val)}
|
||||
type={
|
||||
field === 'interfaceaatcfpff' ? 'radio' :
|
||||
(field === 'limitesubensamble' ? 'input' :
|
||||
(['agregarincreimpo', 'componentebom', 'partesypedimentosporcliente'].includes(field) ? 'switch' : undefined))
|
||||
}
|
||||
options={field === 'interfaceaatcfpff' ? INTERFACE_TC_OPTIONS : undefined}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="cont4" class="space-y-6">
|
||||
<div class="space-y-6 py-4">
|
||||
<div class="max-w-4xl mx-auto space-y-6">
|
||||
<div class="space-y-6 p-6 rounded-2xl border border-border bg-muted/20">
|
||||
|
||||
{#each CONT4_SECTIONS.filter(s => s.id !== 'additional_options') as section}
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<h3 class="font-medium text-lg">
|
||||
{section.title}
|
||||
</h3>
|
||||
<div class="h-px w-full bg-border mt-2"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{#each section.fields as field}
|
||||
<SettingFormField
|
||||
key={field}
|
||||
label={FIELD_LABELS[field] || field}
|
||||
value={formData[field]}
|
||||
onChange={(val) => handleFieldChange(field, val)}
|
||||
disabled={formData.downloadftp !== 'Si' && field !== 'downloadftp'}
|
||||
type={
|
||||
field === 'downloadftp' || field === 'descargarftpolocal' ? 'radio' :
|
||||
field === 'minsdownlftp' ? 'number' :
|
||||
field === 'passwordftp' ? 'password' :
|
||||
undefined
|
||||
}
|
||||
options={
|
||||
field === 'downloadftp' ? YES_NO_OPTIONS :
|
||||
field === 'descargarftpolocal' ? FTP_LOCAL_OPTIONS :
|
||||
undefined
|
||||
}
|
||||
hint={
|
||||
field === 'directorioftp' ? 'Ejemplo: /Folder 1/SubFolder' :
|
||||
field === 'pathlocalparadescde' ? 'Ejemplo: C:\\Aduanas\\SCAIISQL' :
|
||||
undefined
|
||||
}
|
||||
{...(section.id === 'ftp_config' && formData.descargarftpolocal === 'Ruta Local' ? { disabled: true } : {})}
|
||||
{...(section.id === 'local_config' && formData.descargarftpolocal === 'FTP' ? { disabled: true } : {})}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Outside Options -->
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<h3 class="font-medium text-lg">
|
||||
Opciones Adicionales
|
||||
</h3>
|
||||
<div class="h-px w-full bg-border mt-2"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{#each CONT4_SECTIONS.find(s => s.id === 'additional_options')?.fields || [] as field}
|
||||
<SettingFormField
|
||||
key={field}
|
||||
label={FIELD_LABELS[field] || field}
|
||||
value={formData[field]}
|
||||
onChange={(val) => handleFieldChange(field, val)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="cont5" class="space-y-6">
|
||||
<div class="space-y-6 py-4">
|
||||
<div class="max-w-4xl mx-auto space-y-6">
|
||||
{#each CONT5_SECTIONS as section}
|
||||
<div class="space-y-6 p-6 rounded-2xl border border-border bg-muted/20">
|
||||
<div class="flex items-center gap-4">
|
||||
<h3 class="font-medium text-lg">
|
||||
{section.title}
|
||||
</h3>
|
||||
<div class="h-px w-full bg-border mt-2"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 {section.id === 'inv_inicial' ? '' : 'md:grid-cols-2 lg:grid-cols-3'} gap-6">
|
||||
{#each section.fields as field}
|
||||
<SettingFormField
|
||||
key={field}
|
||||
label={FIELD_LABELS[field] || field}
|
||||
value={formData[field]}
|
||||
onChange={(val) => handleFieldChange(field, val)}
|
||||
type={
|
||||
field === 'geninformeanexo31' ? 'radio' :
|
||||
(['usarfechaemisionfactura', 'agregarnumeroembarque', 'hojacalculosepararincrementablesanexo3', 'hojacalculodesglosefacturaanexo3'].includes(field) ? 'switch' : undefined)
|
||||
}
|
||||
options={field === 'geninformeanexo31' ? INICIAL_INV_OPTIONS : undefined}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="cont6" class="space-y-6">
|
||||
<div class="space-y-6 py-4">
|
||||
<div class="max-w-5xl mx-auto grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||
<!-- Columna Izquierda: Opciones de Configuración -->
|
||||
<div class="space-y-8">
|
||||
<div class="flex items-center gap-4">
|
||||
<h3 class="font-medium text-lg">
|
||||
Opciones de Configuración
|
||||
</h3>
|
||||
<div class="h-px w-full bg-border mt-2"></div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
{#each CONT6_SECTIONS.find(s => s.id === 'configuracion')?.fields || [] as field}
|
||||
<div class="{field === 'usarcontroldefechasdeversion' ? 'ml-8 opacity-60' : ''} transition-opacity">
|
||||
<SettingFormField
|
||||
key={field}
|
||||
label={FIELD_LABELS[field] || field}
|
||||
value={formData[field]}
|
||||
onChange={(val) => handleFieldChange(field, val)}
|
||||
disabled={field === 'usarcontroldefechasdeversion' && !formData.parametroauxiliar}
|
||||
type="switch"
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Columna Derecha: Acciones y Módulos -->
|
||||
<div class="space-y-6">
|
||||
<!-- Módulos -->
|
||||
<div class="space-y-8">
|
||||
<div class="flex items-center gap-4">
|
||||
<h3 class="font-medium text-lg">
|
||||
Módulos del Sistema
|
||||
</h3>
|
||||
<div class="h-px w-full bg-border mt-2"></div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
{#each CONT6_SECTIONS.find(s => s.id === 'acciones_modulos')?.fields || [] as field}
|
||||
<SettingFormField
|
||||
key={field}
|
||||
label={FIELD_LABELS[field] || field}
|
||||
value={formData[field]}
|
||||
onChange={(val) => handleFieldChange(field, val)}
|
||||
type={field === 'campo18valsaaim3' ? 'switch' : undefined}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Acciones -->
|
||||
<div class="space-y-8">
|
||||
<div class="flex items-center gap-4">
|
||||
<h3 class="font-medium text-lg">
|
||||
Acciones de Gestión
|
||||
</h3>
|
||||
<div class="h-px w-full bg-border mt-2"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<Button variant="outline" class="w-full flex items-center justify-start gap-4 h-12 rounded-xl border-border bg-background hover:bg-accent text-foreground group">
|
||||
<AlertTriangle class="w-5 h-5 text-red-500 group-hover:scale-110 transition-transform" />
|
||||
Eliminar Accesos Abiertos
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" class="w-full flex items-center justify-start gap-4 h-12 rounded-xl border-border bg-background hover:bg-accent text-foreground group">
|
||||
<CreditCard class="w-5 h-5 text-primary group-hover:scale-110 transition-transform" />
|
||||
Asignar Formas de Pago Para Anexo 31
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" class="w-full flex items-center justify-start gap-4 h-12 rounded-xl border-border bg-background hover:bg-accent text-foreground group">
|
||||
<FileEdit class="w-5 h-5 text-primary group-hover:scale-110 transition-transform" />
|
||||
Corrección de partidas expo
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" class="w-full flex items-center justify-start gap-4 h-12 rounded-xl border-border bg-background hover:bg-accent text-foreground group">
|
||||
<RefreshCw class="w-5 h-5 text-primary group-hover:scale-110 transition-transform" />
|
||||
Actualizar Tarifa
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="cont7" class="space-y-6">
|
||||
<div class="space-y-6 py-4">
|
||||
{#each CONT7_SECTIONS as section}
|
||||
<div class="space-y-6 max-w-4xl mx-auto">
|
||||
<div class="flex items-center gap-4">
|
||||
<h3 class="font-medium text-lg">
|
||||
{section.title}
|
||||
</h3>
|
||||
<div class="h-px w-full bg-border mt-2"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<!-- Especial Row: Activar Valor Agregado Gral + Numeric Input -->
|
||||
<div class="flex flex-col md:flex-row items-center gap-4 p-4 rounded-xl border border-border bg-background">
|
||||
<div class="flex-1">
|
||||
<SettingFormField
|
||||
key="actvaloragre"
|
||||
label={FIELD_LABELS.actvaloragre}
|
||||
value={formData.actvaloragre}
|
||||
onChange={(val) => handleFieldChange('actvaloragre', val)}
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full md:w-32">
|
||||
<SettingFormField
|
||||
key="valoragregadogen"
|
||||
label={FIELD_LABELS.valoragregadogen}
|
||||
value={formData.valoragregadogen}
|
||||
onChange={(val) => handleFieldChange('valoragregadogen', val)}
|
||||
type="input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#each section.fields as field}
|
||||
<SettingFormField
|
||||
key={field}
|
||||
label={FIELD_LABELS[field] || field}
|
||||
value={formData[field]}
|
||||
onChange={(val) => handleFieldChange(field, val)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
</Tabs.Root>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global([data-radix-scroll-area-viewport]) {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
:global([data-radix-scroll-area-viewport]::-webkit-scrollbar) {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,35 @@
|
||||
export const SETTINGS_METADATA: Record<string, string[]> = {
|
||||
ssisgen: [
|
||||
"consecutivo", "dta", "dtaexpo", "subempresa", "patharch", "patharchtransmision", "pathtransexpo", "pathrespuesta",
|
||||
"patharchped", "patharchpedconsm", "pathgenimpotemp", "pathgenexpo", "actseguridad", "controldes", "diadesactual",
|
||||
"diavencimiento", "mensajevenc", "fechades", "factoriva", "validasifra", "decimalespeso", "decimalescant",
|
||||
"decimalesvalor", "calvalbasetcped", "calvalbasetcpedexpo", "filtrocantidad", "muestraarchcodbarras", "datoshist",
|
||||
"tipovenro", "cantvenro", "costoplanta", "firmapacking", "advertenciatm", "tomarsaldosvenc", "costoimpofijo",
|
||||
"valparteexiste", "valmanifusado", "temporalfechapago", "asignadiasantdesc", "diasantdesc", "deshabilitardescparte",
|
||||
"deshabilitardescparteing", "asignafracameparte", "validadecencant", "usartranspamedocame", "mostraradvertenciaro",
|
||||
"escondaamexpacking", "calcdutypacking", "parammultiples", "fraccnivelpais", "covefechaemision", "valordllstcfacturaexpo",
|
||||
"interfaceaaconsolidada", "interfaceaatcfpff", "incluirobscoveobsimpo", "agregarincreimpo", "componentebom",
|
||||
"limitesubensamble", "actpdfreportes", "patharchpdfimpo", "patharchpdfexpo", "noimprimircons", "mostraradvertenciarovalor",
|
||||
"partesypedimentosporcliente", "mensajesvurfc", "mostrarpackinglistingles", "omitirempaqueencodigobarras", "restringepaisimpo",
|
||||
"bloqueoaldesactivarnumerodeparte", "restringpaisexpo", "geninformeanexo31", "utilizarfechapagopeddeundiaanterior",
|
||||
"utilizarequivalenciasdeumpornumerodeparte", "utilizartitulosalternativosimpresionfactura", "usarfactorconversionpornumerodeparte",
|
||||
"usarvude128o256", "usartcdelafechapagopedimpoendescarga", "solicitarcontrasenaadministrador", "agregarnumeroembarque",
|
||||
"utilizarumdeexistenciaentransmisionvu", "utilizarcodigodebrokerdeclienteenmainx30", "hojacalculosepararincrementablesanexo3",
|
||||
"hojacalculodesglosefacturaanexo3", "usarvaloragregadoenfacturaamericana", "ocultarinformacionfraccion",
|
||||
"resaltarsaldostempconcolor", "valoragregadoenfacturamexicana", "validarsectorprosecr8", "agregarsubtotalinterfazaa"
|
||||
],
|
||||
ssismex: [
|
||||
"consecutivo", "prefijocm", "consecutivocm", "porparteclasemex", "porparteclaseame", "proveedor", "vendidoconsignado",
|
||||
"vendidoa", "enviadotransferido", "enviadoa", "flete", "paisorigenmex", "numpartemex", "firmafmex", "fraccionimp",
|
||||
"tipofraccmex", "tasafraccmex", "umequivalentemex", "numparteame", "fraccioname", "paisorigename", "umequivalenteame",
|
||||
"firmafame", "impordencomp", "decimalespeso", "decimalescant", "decimalesvalor", "decimalescosto", "tipomoneda",
|
||||
"clavemoneda", "transportista", "conductor", "transporte", "numtransporte", "observacione", "observacioni",
|
||||
"leyendamex", "leyendaame", "firmaaamex", "firmapmex", "firmaaaame", "firmapame", "firmaeamex", "firmaeame",
|
||||
"firmasamex", "firmasame", "claveregimen", "claveregimename", "numfactura", "fechafactura", "numeropedimento",
|
||||
"fechapedimento", "pedimentomex", "clientemex", "claveregmexo", "claveregmexd", "usarvalorameric", "consecutivoas",
|
||||
"consecutivops", "usatranspfactu", "ocultarfechahora"
|
||||
],
|
||||
qsisgen: [
|
||||
"consecutivo", "actvaloragre", "valoragregadogen"
|
||||
]
|
||||
};
|
||||
@@ -0,0 +1,149 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Search, Loader2, MapPin } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import {
|
||||
customsSectionsApi,
|
||||
type CustomsSection
|
||||
} from '$lib/api/dashboard/reference_data/customs_sections';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
onSelect,
|
||||
onClear
|
||||
}: {
|
||||
open: boolean;
|
||||
onSelect: (item: CustomsSection) => void;
|
||||
onClear?: () => void;
|
||||
} = $props();
|
||||
|
||||
let items = $state<CustomsSection[]>([]);
|
||||
let loading = $state(false);
|
||||
let searchTerm = $state('');
|
||||
let loaded = $state(false);
|
||||
|
||||
let filteredItems = $derived(
|
||||
items.filter((i) => {
|
||||
const search = searchTerm.toLowerCase();
|
||||
return (
|
||||
!searchTerm ||
|
||||
(i.section_name?.toLowerCase() || '').includes(search) ||
|
||||
(i.customs_code?.toLowerCase() || '').includes(search)
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (open && !loaded) {
|
||||
loadItems();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadItems() {
|
||||
loading = true;
|
||||
try {
|
||||
// Customs sections are public reference data
|
||||
// Reduced page_size to 100 to comply with backend constraints (le=100)
|
||||
const res = await customsSectionsApi.list(1, 100);
|
||||
const data = (res.data || res) as any;
|
||||
if (data) {
|
||||
// Handle both direct array and object-with-items wrapper
|
||||
items = Array.isArray(data) ? data : data.items || [];
|
||||
loaded = true;
|
||||
} else if (res.error) {
|
||||
console.error('API Error loading customs sections:', res.error);
|
||||
toast.error('Error al cargar secciones: ' + res.error);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error loading customs sections:', e);
|
||||
toast.error('Error de conexión al cargar las secciones');
|
||||
// Even on error, mark as loaded to prevent infinite loops, or handle with a retry button
|
||||
loaded = true;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelect(item: CustomsSection) {
|
||||
if (onSelect) onSelect(item);
|
||||
open = false;
|
||||
}
|
||||
|
||||
function handleRowKeydown(event: KeyboardEvent, item: CustomsSection) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleSelect(item);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="flex max-h-[80vh] flex-col sm:max-w-[700px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Seleccionar Sección Aduanera</Dialog.Title>
|
||||
<Dialog.Description>Catálogo general de aduanas y secciones.</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="relative my-2 w-full">
|
||||
<Search class="absolute top-2.5 left-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Buscar por descripción o código..."
|
||||
class="pl-9"
|
||||
bind:value={searchTerm}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="min-h-[300px] flex-1 overflow-y-auto rounded-md border">
|
||||
{#if loading}
|
||||
<div class="flex h-48 flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<Loader2 class="h-8 w-8 animate-spin text-primary" />
|
||||
<p>Cargando catálogo...</p>
|
||||
</div>
|
||||
{:else if filteredItems.length === 0}
|
||||
<div class="flex h-48 flex-col items-center justify-center text-muted-foreground">
|
||||
<p>No se encontraron registros.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-muted/50">
|
||||
<tr class="border-b text-left">
|
||||
<th class="w-[100px] p-3 font-medium text-muted-foreground">Código</th>
|
||||
<th class="p-3 font-medium text-muted-foreground">Descripción</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each filteredItems as item}
|
||||
<tr
|
||||
class="cursor-pointer border-b transition-colors hover:bg-accent/50"
|
||||
tabindex="0"
|
||||
onclick={() => handleSelect(item)}
|
||||
onkeydown={(event) => handleRowKeydown(event, item)}
|
||||
>
|
||||
<td class="p-3 font-mono font-bold text-primary">{item.customs_code}</td>
|
||||
<td class="p-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<MapPin class="h-3 w-3 text-muted-foreground" />
|
||||
{item.section_name || '-'}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<div class="mr-auto self-center text-xs text-muted-foreground">
|
||||
Mostrando {filteredItems.length} registros
|
||||
</div>
|
||||
{#if onClear}
|
||||
<Button variant="ghost" onclick={() => { onClear(); open = false; }}>Quitar selección</Button>
|
||||
{/if}
|
||||
<Button variant="outline" onclick={() => (open = false)}>Cancelar</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,230 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { Search, Loader2, Factory } from 'lucide-svelte';
|
||||
import { sectorsApi, type Sector } from '$lib/api/dashboard/reference_data/sectors';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
// --- PROPS ---
|
||||
let {
|
||||
open = $bindable(false),
|
||||
onSelect,
|
||||
onClear
|
||||
}: {
|
||||
open: boolean;
|
||||
onSelect: (item: Sector) => void;
|
||||
onClear?: () => void;
|
||||
} = $props();
|
||||
|
||||
// --- ESTADO ---
|
||||
let items = $state<Sector[]>([]);
|
||||
let loading = $state(false);
|
||||
let loadingMore = $state(false);
|
||||
let searchTerm = $state('');
|
||||
let previousSearchTerm = '';
|
||||
let page = $state(1);
|
||||
let pageSize = 50;
|
||||
let hasMore = $state(true);
|
||||
let totalItems = $state(0);
|
||||
let observer: IntersectionObserver | null = null;
|
||||
let bottomSentinel: HTMLElement | null = $state(null);
|
||||
let searchTimeout: any;
|
||||
let isInitialized = false;
|
||||
|
||||
// Cargar datos iniciales al abrir
|
||||
$effect(() => {
|
||||
if (open && !isInitialized) {
|
||||
isInitialized = true;
|
||||
previousSearchTerm = searchTerm;
|
||||
resetAndLoad();
|
||||
} else if (!open) {
|
||||
isInitialized = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Manejar búsqueda con debouncing
|
||||
$effect(() => {
|
||||
const term = searchTerm;
|
||||
if (isInitialized && term !== previousSearchTerm) {
|
||||
if (searchTimeout) clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
previousSearchTerm = term;
|
||||
resetAndLoad();
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Configurar IntersectionObserver para infinite scroll
|
||||
$effect(() => {
|
||||
if (bottomSentinel && hasMore && !loading && !loadingMore && open) {
|
||||
if (observer) observer.disconnect();
|
||||
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasMore && !loading && !loadingMore) {
|
||||
loadMore();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
observer.observe(bottomSentinel);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (observer) observer.disconnect();
|
||||
};
|
||||
});
|
||||
|
||||
async function resetAndLoad() {
|
||||
page = 1;
|
||||
items = [];
|
||||
hasMore = true;
|
||||
await loadSectors(true);
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (!hasMore || loading || loadingMore) return;
|
||||
page += 1;
|
||||
await loadSectors(false);
|
||||
}
|
||||
|
||||
async function loadSectors(isInitial: boolean) {
|
||||
if (isInitial) {
|
||||
loading = true;
|
||||
} else {
|
||||
loadingMore = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) {
|
||||
toast.error('No hay empresa activa seleccionada');
|
||||
hasMore = false;
|
||||
return;
|
||||
}
|
||||
const response = await sectorsApi.list(page, pageSize, companyId, searchTerm || undefined);
|
||||
|
||||
if (response.error) {
|
||||
toast.error(`Error: ${response.error}`);
|
||||
hasMore = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const newItems = response.data?.items || [];
|
||||
totalItems = response.data?.total || 0;
|
||||
|
||||
if (isInitial) {
|
||||
items = newItems;
|
||||
} else {
|
||||
items = [...items, ...newItems];
|
||||
}
|
||||
|
||||
hasMore = items.length < totalItems && newItems.length > 0;
|
||||
} catch (e: any) {
|
||||
console.error('Error loading sectors:', e);
|
||||
toast.error('Error al conectar con el servidor');
|
||||
hasMore = false;
|
||||
} finally {
|
||||
loading = false;
|
||||
loadingMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelect(item: Sector) {
|
||||
if (onSelect) onSelect(item);
|
||||
open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="flex max-h-[90vh] flex-col sm:max-w-[800px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Seleccionar Sector PROSEC</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Seleccione el sector del catálogo. Escrolea para ver más.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="relative my-2 w-full">
|
||||
<Search class="absolute top-2.5 left-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Filtrar por clave o descripción..."
|
||||
class="pl-9"
|
||||
bind:value={searchTerm}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="min-h-[300px] flex-1 overflow-y-auto rounded-md border">
|
||||
{#if loading && items.length === 0}
|
||||
<div class="flex h-48 flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<Loader2 class="h-8 w-8 animate-spin text-primary" />
|
||||
<p>Cargando catálogo...</p>
|
||||
</div>
|
||||
{:else if items.length === 0}
|
||||
<div class="flex h-48 flex-col items-center justify-center text-muted-foreground">
|
||||
<p>No se encontraron sectores.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-[100px]">Clave</Table.Head>
|
||||
<Table.Head>Descripción</Table.Head>
|
||||
<Table.Head class="w-[100px]">Autorizado</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each items as item}
|
||||
<Table.Row
|
||||
class="cursor-pointer transition-colors hover:bg-accent/50"
|
||||
onclick={() => handleSelect(item)}
|
||||
>
|
||||
<Table.Cell>
|
||||
<div class="flex items-center gap-1">
|
||||
<Factory class="h-3 w-3 text-orange-500" />
|
||||
<span class="font-mono text-xs font-bold">
|
||||
{item.key}
|
||||
</span>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-sm font-medium">
|
||||
{item.description}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs {item.authorized
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-red-100 text-red-700'}"
|
||||
>
|
||||
{item.authorized ? 'Sí' : 'No'}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
|
||||
<div bind:this={bottomSentinel} class="flex h-10 items-center justify-center">
|
||||
{#if loadingMore}
|
||||
<Loader2 class="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<div class="mr-auto self-center text-xs text-muted-foreground">
|
||||
{items.length} de {totalItems} registros
|
||||
</div>
|
||||
{#if onClear}
|
||||
<Button variant="ghost" onclick={() => { onClear(); open = false; }}>Quitar selección</Button>
|
||||
{/if}
|
||||
<Button variant="outline" onclick={() => (open = false)}>Cancelar</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,231 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { Search, Loader2, MapPin } from 'lucide-svelte';
|
||||
import { statesApi, type State } from '$lib/api/dashboard/reference_data/states';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
// --- PROPS ---
|
||||
let {
|
||||
open = $bindable(false),
|
||||
onSelect,
|
||||
onClear
|
||||
}: {
|
||||
open: boolean;
|
||||
onSelect: (item: State) => void;
|
||||
onClear?: () => void;
|
||||
} = $props();
|
||||
|
||||
// --- ESTADO ---
|
||||
let items = $state<State[]>([]);
|
||||
let loading = $state(false);
|
||||
let loadingMore = $state(false);
|
||||
let searchTerm = $state('');
|
||||
let previousSearchTerm = '';
|
||||
let page = $state(1);
|
||||
let pageSize = 50;
|
||||
let hasMore = $state(true);
|
||||
let totalItems = $state(0);
|
||||
let observer: IntersectionObserver | null = null;
|
||||
let bottomSentinel: HTMLElement | null = $state(null);
|
||||
let searchTimeout: any;
|
||||
let isInitialized = false;
|
||||
|
||||
// Cargar datos iniciales al abrir
|
||||
$effect(() => {
|
||||
if (open && !isInitialized) {
|
||||
isInitialized = true;
|
||||
previousSearchTerm = searchTerm;
|
||||
resetAndLoad();
|
||||
} else if (!open) {
|
||||
isInitialized = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Manejar búsqueda con debouncing
|
||||
$effect(() => {
|
||||
const term = searchTerm;
|
||||
if (isInitialized && term !== previousSearchTerm) {
|
||||
if (searchTimeout) clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
previousSearchTerm = term;
|
||||
resetAndLoad();
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Configurar IntersectionObserver para infinite scroll
|
||||
$effect(() => {
|
||||
if (bottomSentinel && hasMore && !loading && !loadingMore && open) {
|
||||
if (observer) observer.disconnect();
|
||||
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasMore && !loading && !loadingMore) {
|
||||
loadMore();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
observer.observe(bottomSentinel);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (observer) observer.disconnect();
|
||||
};
|
||||
});
|
||||
|
||||
async function resetAndLoad() {
|
||||
page = 1;
|
||||
items = [];
|
||||
hasMore = true;
|
||||
await loadStates(true);
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (!hasMore || loading || loadingMore) return;
|
||||
page += 1;
|
||||
await loadStates(false);
|
||||
}
|
||||
|
||||
async function loadStates(isInitial: boolean) {
|
||||
if (isInitial) {
|
||||
loading = true;
|
||||
} else {
|
||||
loadingMore = true;
|
||||
}
|
||||
|
||||
try {
|
||||
// Note: statesApi.list takes page, pageSize, and searchTerm?
|
||||
// Wait, let me check statesApi.list signature again.
|
||||
// It only takes page and pageSize! I need to check if it supports search.
|
||||
const companyId = companyStore.activeCompany?.id || 1;
|
||||
const response = await statesApi.list(companyId, page, pageSize);
|
||||
|
||||
if (response.error) {
|
||||
toast.error(`Error: ${response.error}`);
|
||||
hasMore = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Local filtering if search term exists (temporary workaround if API doesn't support it)
|
||||
let newItems = response.data?.items || [];
|
||||
totalItems = response.data?.total || 0;
|
||||
|
||||
if (searchTerm) {
|
||||
newItems = newItems.filter(
|
||||
(item) =>
|
||||
item.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.m3_key.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
if (isInitial) {
|
||||
items = newItems;
|
||||
} else {
|
||||
items = [...items, ...newItems];
|
||||
}
|
||||
|
||||
hasMore = items.length < totalItems && newItems.length > 0;
|
||||
} catch (e: any) {
|
||||
console.error('Error loading states:', e);
|
||||
toast.error('Error al conectar con el servidor');
|
||||
hasMore = false;
|
||||
} finally {
|
||||
loading = false;
|
||||
loadingMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelect(item: State) {
|
||||
if (onSelect) onSelect(item);
|
||||
open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="flex max-h-[90vh] flex-col sm:max-w-[800px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Seleccionar Estado</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Seleccione el estado del catálogo. Escrolea para ver más.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="relative my-2 w-full">
|
||||
<Search class="absolute top-2.5 left-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Filtrar por clave o descripción..."
|
||||
class="pl-9"
|
||||
bind:value={searchTerm}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="min-h-[300px] flex-1 overflow-y-auto rounded-md border">
|
||||
{#if loading && items.length === 0}
|
||||
<div class="flex h-48 flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<Loader2 class="h-8 w-8 animate-spin text-primary" />
|
||||
<p>Cargando catálogo...</p>
|
||||
</div>
|
||||
{:else if items.length === 0}
|
||||
<div class="flex h-48 flex-col items-center justify-center text-muted-foreground">
|
||||
<p>No se encontraron estados.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-[100px]">Clave M3</Table.Head>
|
||||
<Table.Head>Descripción</Table.Head>
|
||||
<Table.Head class="w-[80px]">MEX</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each items as item}
|
||||
<Table.Row
|
||||
class="cursor-pointer transition-colors hover:bg-accent/50"
|
||||
onclick={() => handleSelect(item)}
|
||||
>
|
||||
<Table.Cell>
|
||||
<div class="flex items-center gap-1">
|
||||
<MapPin class="h-3 w-3 text-red-500" />
|
||||
<span class="font-mono text-xs font-bold">
|
||||
{item.m3_key}
|
||||
</span>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-sm font-medium">
|
||||
{item.description}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="font-mono text-xs">
|
||||
{item.mex_key || '-'}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
|
||||
<div bind:this={bottomSentinel} class="flex h-10 items-center justify-center">
|
||||
{#if loadingMore}
|
||||
<Loader2 class="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<div class="mr-auto self-center text-xs text-muted-foreground">
|
||||
{items.length} de {totalItems} registros
|
||||
</div>
|
||||
{#if onClear}
|
||||
<Button variant="ghost" onclick={() => { onClear(); open = false; }}>Quitar selección</Button>
|
||||
{/if}
|
||||
<Button variant="outline" onclick={() => (open = false)}>Cancelar</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
219
frontend/src/lib/components/dashboard/trend-chart.svelte
Normal file
219
frontend/src/lib/components/dashboard/trend-chart.svelte
Normal file
@@ -0,0 +1,219 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { TrendingUp, TrendingDown, Minus, LineChart } from 'lucide-svelte';
|
||||
import type { ChartDataPoint } from '$lib/api/dashboard/types';
|
||||
import { onDestroy, tick } from 'svelte';
|
||||
import { m } from '$lib/i18n/messages';
|
||||
|
||||
interface Props {
|
||||
monthlyData: ChartDataPoint[];
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { monthlyData, class: cls = '' }: Props = $props();
|
||||
|
||||
let total = $derived(monthlyData.reduce((sum, d) => sum + d.value, 0));
|
||||
let average = $derived(monthlyData.length > 0 ? Math.round(total / monthlyData.length) : 0);
|
||||
let maxValue = $derived(Math.max(...monthlyData.map((d) => d.value), 1));
|
||||
|
||||
// Trend: compare last two months
|
||||
let trendPct = $derived(() => {
|
||||
if (monthlyData.length < 2) return 0;
|
||||
const last = monthlyData[monthlyData.length - 1].value;
|
||||
const prev = monthlyData[monthlyData.length - 2].value;
|
||||
if (prev === 0) return 0;
|
||||
return Math.round(((last - prev) / prev) * 100);
|
||||
});
|
||||
|
||||
let canvas: HTMLCanvasElement | undefined = $state();
|
||||
let chartInstance: any;
|
||||
|
||||
async function buildChart() {
|
||||
if (!canvas || monthlyData.length < 2) return;
|
||||
|
||||
const {
|
||||
Chart,
|
||||
LineController,
|
||||
LineElement, PointElement,
|
||||
CategoryScale, LinearScale,
|
||||
Tooltip, Filler
|
||||
} = await import('chart.js');
|
||||
|
||||
Chart.register(LineController, LineElement, PointElement, CategoryScale, LinearScale, Tooltip, Filler);
|
||||
|
||||
chartInstance?.destroy();
|
||||
|
||||
// Blue palette
|
||||
const blue = 'rgb(59, 130, 246)'; // blue-500
|
||||
const blueDark = 'rgb(37, 99, 235)'; // blue-600
|
||||
const blueLight = 'rgba(59, 130, 246, 0.12)';
|
||||
const blueMid = 'rgba(59, 130, 246, 0.5)';
|
||||
const muted = '#94a3b8'; // slate-400
|
||||
const gridColor = 'rgba(148, 163, 184, 0.12)';
|
||||
const foreground = '#0f172a'; // slate-900
|
||||
const popover = '#ffffff';
|
||||
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
const h = canvas.clientHeight || 260;
|
||||
|
||||
// Gradient fill under the area
|
||||
const areaGrad = ctx.createLinearGradient(0, 0, 0, h);
|
||||
areaGrad.addColorStop(0, blueMid);
|
||||
areaGrad.addColorStop(0.6, blueLight);
|
||||
areaGrad.addColorStop(1, 'rgba(59, 130, 246, 0)');
|
||||
|
||||
chartInstance = new Chart(canvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: monthlyData.map((d) => d.label),
|
||||
datasets: [
|
||||
{
|
||||
label: m.dashboard_operations(),
|
||||
data: monthlyData.map((d) => d.value),
|
||||
borderColor: blue,
|
||||
borderWidth: 2.5,
|
||||
pointBackgroundColor: blueDark,
|
||||
pointBorderColor: popover,
|
||||
pointBorderWidth: 2.5,
|
||||
pointRadius: 5,
|
||||
pointHoverRadius: 7,
|
||||
pointHoverBorderWidth: 2.5,
|
||||
pointHoverBackgroundColor: blueDark,
|
||||
pointHoverBorderColor: popover,
|
||||
tension: 0.45,
|
||||
fill: true,
|
||||
backgroundColor: areaGrad
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: { duration: 800, easing: 'easeOutCubic' },
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: popover,
|
||||
titleColor: foreground,
|
||||
titleFont: { weight: 'bold', size: 12 },
|
||||
bodyColor: muted,
|
||||
borderColor: 'rgba(59,130,246,0.25)',
|
||||
borderWidth: 1,
|
||||
padding: { x: 14, y: 10 },
|
||||
cornerRadius: 10,
|
||||
boxPadding: 4,
|
||||
callbacks: {
|
||||
label: (ctx: any) => ` ${Number(ctx.raw).toLocaleString()} ${m.dashboard_operations()}`
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { display: false },
|
||||
border: { display: false },
|
||||
ticks: { color: muted, font: { size: 11 }, maxRotation: 0 }
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
border: { display: false, dash: [4, 4] },
|
||||
grid: { color: gridColor },
|
||||
ticks: {
|
||||
color: muted,
|
||||
font: { size: 11 },
|
||||
maxTicksLimit: 5,
|
||||
callback: (v: any) => Number(v).toLocaleString()
|
||||
}
|
||||
}
|
||||
}
|
||||
} as any
|
||||
} as any);
|
||||
}
|
||||
|
||||
onDestroy(() => { chartInstance?.destroy(); });
|
||||
|
||||
$effect(() => {
|
||||
const data = monthlyData;
|
||||
const el = canvas;
|
||||
if (el && data.length >= 2) {
|
||||
tick().then(() => buildChart());
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Card.Root class={cls}>
|
||||
<Card.Header class="pb-0">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<Card.Title class="flex items-center gap-2 text-base">
|
||||
<LineChart class="h-4 w-4 text-blue-500" />
|
||||
{m.dashboard_operations_trend()}
|
||||
</Card.Title>
|
||||
<Card.Description class="mt-1">{m.dashboard_monthly_evolution()}</Card.Description>
|
||||
</div>
|
||||
|
||||
{#if monthlyData.length >= 2}
|
||||
{@const pct = trendPct()}
|
||||
<div class="flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium
|
||||
{pct > 0 ? 'bg-green-50 text-green-600' : pct < 0 ? 'bg-red-50 text-red-600' : 'bg-muted text-muted-foreground'}">
|
||||
{#if pct > 0}
|
||||
<TrendingUp class="h-3 w-3" />+{pct}%
|
||||
{:else if pct < 0}
|
||||
<TrendingDown class="h-3 w-3" />{pct}%
|
||||
{:else}
|
||||
<Minus class="h-3 w-3" />0%
|
||||
{/if}
|
||||
<span class="opacity-60 ml-0.5">vs {m.dashboard_previous_month()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Card.Header>
|
||||
|
||||
<Card.Content class="pt-4 pb-5">
|
||||
{#if monthlyData.length === 0}
|
||||
<div class="flex flex-col items-center justify-center py-14 text-muted-foreground gap-3">
|
||||
<div class="rounded-full bg-blue-50 p-4">
|
||||
<LineChart class="h-8 w-8 text-blue-300" />
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-sm font-medium">{m.dashboard_no_data_available()}</p>
|
||||
<p class="text-xs text-muted-foreground/70 mt-0.5">{m.dashboard_data_will_appear_here()}</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if monthlyData.length === 1}
|
||||
<div class="flex flex-col items-center justify-center py-8 gap-5">
|
||||
<div class="text-center">
|
||||
<div class="text-5xl font-bold text-blue-600 tabular-nums">{monthlyData[0].value.toLocaleString()}</div>
|
||||
<div class="text-sm text-muted-foreground mt-2">
|
||||
{m.dashboard_operations_in()} <span class="font-semibold text-foreground">{monthlyData[0].label}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground bg-blue-50 text-blue-600 rounded-full px-4 py-2">
|
||||
<LineChart class="h-3.5 w-3.5 shrink-0" />
|
||||
{m.dashboard_more_than_one_month_needed()}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Chart -->
|
||||
<div class="h-56">
|
||||
<canvas bind:this={canvas}></canvas>
|
||||
</div>
|
||||
|
||||
<!-- Stats row -->
|
||||
<div class="grid grid-cols-3 divide-x border-t mt-4 pt-4">
|
||||
<div class="px-4 first:pl-0 last:pr-0">
|
||||
<div class="text-lg font-semibold tabular-nums text-blue-600">{total.toLocaleString()}</div>
|
||||
<div class="text-xs text-muted-foreground mt-0.5">{m.dashboard_total()}</div>
|
||||
</div>
|
||||
<div class="px-4">
|
||||
<div class="text-lg font-semibold tabular-nums">{average.toLocaleString()}</div>
|
||||
<div class="text-xs text-muted-foreground mt-0.5">{m.dashboard_average_per_month()}</div>
|
||||
</div>
|
||||
<div class="px-4 text-right">
|
||||
<div class="text-lg font-semibold tabular-nums">{maxValue.toLocaleString()}</div>
|
||||
<div class="text-xs text-muted-foreground mt-0.5">{m.dashboard_maximum()}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
504
frontend/src/lib/components/help/HelpDrawer.svelte
Normal file
504
frontend/src/lib/components/help/HelpDrawer.svelte
Normal file
@@ -0,0 +1,504 @@
|
||||
<script lang="ts">
|
||||
import { currentUser, userIsAdmin } from '$lib/auth';
|
||||
import * as Sheet from '$lib/components/ui/sheet';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import {
|
||||
HelpCircle,
|
||||
Edit2,
|
||||
Save,
|
||||
X,
|
||||
ChevronLeft,
|
||||
Plus,
|
||||
BookOpen,
|
||||
ExternalLink,
|
||||
Search,
|
||||
Sparkles,
|
||||
FileCode,
|
||||
FileText,
|
||||
Upload
|
||||
} from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { helpApi, type HelpArticle } from '$lib/api/help';
|
||||
import { helpStore } from '$lib/stores/help.svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { page } from '$app/state';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
|
||||
let articles = $state<HelpArticle[]>([]);
|
||||
let selectedArticle = $state<HelpArticle | null>(null);
|
||||
let isEditing = $state(false);
|
||||
let editContent = $state('');
|
||||
let editTitle = $state('');
|
||||
let isLoading = $state(false);
|
||||
let isCreating = $state(false);
|
||||
let searchTerm = $state('');
|
||||
let hasError = $state(false);
|
||||
let isLoaded = $state(false);
|
||||
|
||||
const isAdmin = $derived(userIsAdmin($currentUser));
|
||||
const currentPath = $derived(page.url.pathname + page.url.search);
|
||||
|
||||
const synonyms: Record<string, string[]> = {
|
||||
'users': ['usuario', 'colaborador', 'acceso', 'perfil', 'permiso', 'roles'],
|
||||
'company': ['empresa', 'compania', 'negocio', 'fiscal', 'emisor', 'razon social', 'rfc'],
|
||||
'invoices': ['factura', 'cobro', 'gasto', 'cxc', 'cxp', 'comprobante', 'imp', 'exp', 'facturacion'],
|
||||
'packages': ['bulto', 'embalaje', 'empaque', 'pallet', 'contenedor', 'packing'],
|
||||
'audit': ['auditoria', 'bitacora', 'log', 'historial', 'evento', 'monitoreo'],
|
||||
'pedimentos': ['pedimento', 'aduana', 'valida', 'despacho', 'pedimentacion'],
|
||||
'parts': ['parte', 'mercancia', 'producto', 'fraccion', 'item', 'articulo', 'numero de parte'],
|
||||
'goods': ['mercancia', 'producto', 'bienes', 'parte', 'item'],
|
||||
'manifest': ['manifiesto', 'salida', 'embarque', 'transporte', 'carga'],
|
||||
'clients': ['cliente', 'proveedor', 'vendor', 'provider', 'comercial', 'socio', 'proveedores', 'clientes'],
|
||||
'transporters': ['transportista', 'fletera', 'chofer', 'conductor', 'camion', 'vehiculo', 'transporte'],
|
||||
'settings': ['configuracion', 'ajuste', 'preferencia', 'perfil', 'cuenta'],
|
||||
'digitalizacion': ['documento', 'archivo', 'digital', 'expediente', 'pdf', 'xml', 'e-document'],
|
||||
'reports': ['reporte', 'estadistica', 'grafica', 'consulta', 'descarga', 'excel', 'kpi'],
|
||||
'fractions': ['fraccion', 'arancel', 'nico', 'tarifa', 'impuesto', 'tigie'],
|
||||
'reference': ['catalogo', 'fijo', 'referencia', 'maestro', 'base']
|
||||
};
|
||||
|
||||
function getKeywords(str: string): string[] {
|
||||
const isEdit = str.toLowerCase().includes('/edit') || str.toLowerCase().includes('/editor');
|
||||
const isCreate = str.toLowerCase().includes('/create') || str.toLowerCase().includes('/new');
|
||||
|
||||
const baseKeywords = str
|
||||
.toLowerCase()
|
||||
.normalize("NFD").replace(/[\u0300-\u036f]/g, "") // Quitar acentos
|
||||
.replace(/[/_-]/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter(w => w.length > 2 && !['dashboard', 'general', 'catalogs', 'information', 'management'].includes(w));
|
||||
|
||||
if (isEdit) baseKeywords.push('edicion', 'editar', 'modificar', 'actualizar');
|
||||
if (isCreate) baseKeywords.push('crear', 'nuevo', 'registro', 'alta');
|
||||
|
||||
// Expandir con sinónimos
|
||||
const expanded = [...baseKeywords];
|
||||
baseKeywords.forEach(kw => {
|
||||
if (synonyms[kw]) expanded.push(...synonyms[kw]);
|
||||
// Buscar si la palabra clave es un sinónimo de alguna categoría
|
||||
Object.entries(synonyms).forEach(([key, values]) => {
|
||||
if (values.includes(kw)) expanded.push(key);
|
||||
});
|
||||
});
|
||||
return [...new Set(expanded)];
|
||||
}
|
||||
|
||||
// Filtrar artículos contextuales basados en la ruta actual o coincidencias inteligentes
|
||||
const contextualArticles = $derived(
|
||||
articles.filter((a) => {
|
||||
// 1. Prioridad: Ruta explícita (si existe)
|
||||
if (a.context_path) {
|
||||
try {
|
||||
if (a.context_path.startsWith('/')) {
|
||||
if (currentPath === a.context_path || currentPath.startsWith(a.context_path + '/')) return true;
|
||||
} else {
|
||||
const regex = new RegExp(a.context_path);
|
||||
if (regex.test(currentPath)) return true;
|
||||
}
|
||||
} catch {
|
||||
if (currentPath.includes(a.context_path)) return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Inteligencia de Coincidencia (Fuzzy match por palabras clave)
|
||||
const editIntents = ['edicion', 'editar', 'modificar', 'actualizar', 'corregir', 'edit', 'editor'];
|
||||
const createIntents = ['crear', 'nuevo', 'registro', 'alta', 'create', 'new'];
|
||||
const intentionWords = [...editIntents, ...createIntents, 'baja', 'cambio'];
|
||||
|
||||
const pathKeywords = getKeywords(currentPath);
|
||||
const moduleKeywords = pathKeywords.filter(pk => !intentionWords.includes(pk));
|
||||
const pathIntentKeywords = pathKeywords.filter(pk => intentionWords.includes(pk));
|
||||
|
||||
const titleKeywords = getKeywords(a.title);
|
||||
const contentKeywords = getKeywords(a.content.substring(0, 100));
|
||||
|
||||
// Si estamos en un módulo específico (ej. Pedimentos), DEBE coincidir el módulo
|
||||
const matchesModule = moduleKeywords.length === 0 || moduleKeywords.some(pk =>
|
||||
titleKeywords.some(tk => tk.includes(pk) || pk.includes(tk)) ||
|
||||
contentKeywords.some(ck => ck.includes(pk) || pk.includes(ck))
|
||||
);
|
||||
|
||||
if (!matchesModule) return false;
|
||||
|
||||
// Si hay una intención clara en la URL (crear/editar), filtramos los artículos
|
||||
// que sean explícitamente de la intención OPUESTA.
|
||||
if (pathIntentKeywords.length > 0) {
|
||||
const pathIsEdit = pathIntentKeywords.some(pk => editIntents.includes(pk));
|
||||
const pathIsCreate = pathIntentKeywords.some(pk => createIntents.includes(pk));
|
||||
|
||||
const articleIsEdit = titleKeywords.some(tk => editIntents.includes(tk));
|
||||
const articleIsCreate = titleKeywords.some(tk => createIntents.includes(tk));
|
||||
|
||||
// Bloqueo cruzado: Si estoy creando, no me des manuales que son SOLO de editar.
|
||||
// (Si el manual sirve para ambos o es general, pasará)
|
||||
if (pathIsCreate && articleIsEdit && !articleIsCreate) return false;
|
||||
if (pathIsEdit && articleIsCreate && !articleIsEdit) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
);
|
||||
|
||||
// Filtrar búsqueda manual
|
||||
const filteredArticles = $derived(
|
||||
articles.filter(
|
||||
(a) =>
|
||||
a.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(a.category || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(a.tags || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(a.content || '').toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
);
|
||||
|
||||
async function loadArticles() {
|
||||
isLoading = true;
|
||||
try {
|
||||
hasError = false;
|
||||
articles = await helpApi.listArticles();
|
||||
isLoaded = true;
|
||||
} catch (e) {
|
||||
hasError = true;
|
||||
toast.error('Error al cargar artículos de ayuda');
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectArticle(article: HelpArticle) {
|
||||
selectedArticle = article;
|
||||
isEditing = false;
|
||||
isCreating = false;
|
||||
}
|
||||
|
||||
function startEdit() {
|
||||
if (!selectedArticle) return;
|
||||
editContent = selectedArticle.content;
|
||||
editTitle = selectedArticle.title;
|
||||
isEditing = true;
|
||||
isCreating = false;
|
||||
}
|
||||
|
||||
function startCreate() {
|
||||
editContent = '# Nuevo Artículo\nEscribe el contenido aquí...';
|
||||
editTitle = 'Nueva Guía de Ayuda';
|
||||
isEditing = true;
|
||||
isCreating = true;
|
||||
selectedArticle = {
|
||||
uuid: '',
|
||||
slug: '',
|
||||
title: '',
|
||||
content: '',
|
||||
updated_at: '',
|
||||
last_editor: '',
|
||||
content_type: 'markdown'
|
||||
} as any;
|
||||
}
|
||||
|
||||
async function saveChanges() {
|
||||
try {
|
||||
if (isCreating) {
|
||||
const newArticle = await helpApi.createArticle({
|
||||
title: editTitle,
|
||||
content: editContent,
|
||||
context_path: currentPath, // Captura automática de la ruta real
|
||||
last_editor: $currentUser?.username || 'unknown'
|
||||
});
|
||||
selectedArticle = newArticle;
|
||||
toast.success('Artículo creado con éxito');
|
||||
} else if (selectedArticle) {
|
||||
const updated = await helpApi.updateArticle(selectedArticle.uuid, {
|
||||
content: editContent,
|
||||
title: editTitle,
|
||||
last_editor: $currentUser?.username || 'unknown'
|
||||
});
|
||||
selectedArticle = updated;
|
||||
toast.success('Cambios guardados');
|
||||
}
|
||||
isEditing = false;
|
||||
isCreating = false;
|
||||
loadArticles();
|
||||
} catch (e) {
|
||||
toast.error('Error al guardar cambios');
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (helpStore.isOpen && !isLoaded && !isLoading && !hasError) {
|
||||
loadArticles();
|
||||
}
|
||||
|
||||
// Reset error when drawer closes to allow retry next time it opens
|
||||
if (!helpStore.isOpen) {
|
||||
hasError = false;
|
||||
}
|
||||
});
|
||||
|
||||
function renderMarkdown(content: string) {
|
||||
// Fallback simple pero limpio
|
||||
return content
|
||||
.replace(/^# (.*$)/gim, '<h1 class="text-2xl font-bold mb-4">$1</h1>')
|
||||
.replace(/^## (.*$)/gim, '<h2 class="text-xl font-bold mb-3 mt-6">$1</h2>')
|
||||
.replace(/^### (.*$)/gim, '<h3 class="text-lg font-bold mb-2 mt-4">$1</h3>')
|
||||
.replace(/\*\*(.*)\*\*/gim, '<strong>$1</strong>')
|
||||
.replace(/\*(.*)\*/gim, '<em>$1</em>')
|
||||
.replace(/\n/gim, '<br />');
|
||||
}
|
||||
|
||||
function highlightText(text: string, term: string) {
|
||||
if (!term || term.length < 2) return text;
|
||||
const escapedTerm = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const regex = new RegExp(`(${escapedTerm})`, 'gi');
|
||||
return text.replace(regex, '<mark class="bg-primary/20 text-primary-foreground font-bold rounded-sm px-0.5">$1</mark>');
|
||||
}
|
||||
</script>
|
||||
|
||||
<Sheet.Root bind:open={helpStore.isOpen}>
|
||||
<Sheet.Content side="right" class="w-[450px] sm:w-[600px] border-l bg-background/95 backdrop-blur-md p-0 shadow-2xl flex flex-col">
|
||||
<!-- Header con Glassmorphism -->
|
||||
<div class="px-6 py-8 border-b bg-primary/5">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if selectedArticle}
|
||||
<Button variant="ghost" size="icon" onclick={() => (selectedArticle = null)} class="mr-1 h-8 w-8 rounded-full">
|
||||
<ChevronLeft size={20} />
|
||||
</Button>
|
||||
{/if}
|
||||
<div class="p-2 rounded-lg bg-primary/10 text-primary">
|
||||
<Sparkles size={20} />
|
||||
</div>
|
||||
<h2 class="text-xl font-bold tracking-tight">
|
||||
{selectedArticle ? 'Artículo de Ayuda' : 'Centro de Ayuda'}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">Manuales y guías interactivas del sistema.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 flex flex-col overflow-hidden">
|
||||
{#if !selectedArticle}
|
||||
<!-- Vista de Lista -->
|
||||
<div class="p-6 flex-1 overflow-y-auto space-y-8">
|
||||
<!-- Search Box -->
|
||||
<div class="relative group">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground group-focus-within:text-primary transition-colors" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar ayuda..."
|
||||
bind:value={searchTerm}
|
||||
class="w-full pl-10 pr-4 py-2.5 rounded-xl border bg-muted/50 focus:bg-background focus:ring-2 focus:ring-primary/20 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Sección Contextual (Recomendaciones) -->
|
||||
{#if contextualArticles.length > 0 && !searchTerm}
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2 px-1">
|
||||
<div class="flex items-center gap-1.5 text-xs font-semibold text-primary uppercase tracking-wider">
|
||||
<Sparkles size={14} /> Recomendado para ti
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-3">
|
||||
{#each contextualArticles as article}
|
||||
<button
|
||||
onclick={() => selectArticle(article)}
|
||||
class="flex items-start gap-4 p-4 rounded-xl border bg-primary/5 border-primary/20 hover:bg-primary/10 hover:border-primary/30 transition-all text-left shadow-sm"
|
||||
>
|
||||
<div class="p-2 rounded-lg bg-background flex-shrink-0 shadow-sm">
|
||||
{#if article.content_type === 'pdf'}
|
||||
<FileText size={18} class="text-primary" />
|
||||
{:else}
|
||||
<BookOpen size={18} class="text-primary" />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<h4 class="font-semibold text-sm leading-tight">{article.title}</h4>
|
||||
<p class="text-xs text-muted-foreground line-clamp-1">{article.category || 'General'}</p>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Catálogo Completo / Búsqueda -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between px-1">
|
||||
<h3 class="text-xs font-bold text-muted-foreground uppercase tracking-widest">
|
||||
{searchTerm ? 'Resultados de búsqueda' : 'Manuales del Sistema'}
|
||||
</h3>
|
||||
{#if articles.length > 0}
|
||||
<span class="text-[10px] text-muted-foreground bg-muted px-2 py-0.5 rounded-full">{articles.length} artículos</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="flex flex-col items-center justify-center py-12 space-y-4">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
<p class="text-xs text-muted-foreground animate-pulse">Cargando base de conocimientos...</p>
|
||||
</div>
|
||||
{:else if articles.length === 0}
|
||||
<div class="flex flex-col items-center justify-center py-12 px-4 text-center space-y-3 bg-muted/20 rounded-2xl border border-dashed border-muted-foreground/20">
|
||||
<div class="p-3 rounded-full bg-background shadow-sm">
|
||||
<BookOpen size={24} class="text-muted-foreground/40" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium">
|
||||
{hasError ? 'Error de conexión' : 'Biblioteca vacía'}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{hasError
|
||||
? 'No pudimos conectar con el servidor de ayuda.'
|
||||
: 'No hay artículos registrados para esta sección aún.'}
|
||||
</p>
|
||||
</div>
|
||||
{#if hasError}
|
||||
<Button variant="outline" size="sm" onclick={loadArticles} class="mt-2 h-8">
|
||||
Reintentar
|
||||
</Button>
|
||||
{:else if isAdmin}
|
||||
<Button variant="outline" size="sm" onclick={startCreate} class="mt-2 h-8">
|
||||
<Plus size={14} class="mr-1" /> Crear primero
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
{@const displayList = searchTerm ? filteredArticles : articles}
|
||||
{#if displayList.length === 0}
|
||||
<div class="text-center py-12 px-4 rounded-xl border border-dashed bg-muted/10">
|
||||
<Search size={32} class="mx-auto mb-3 text-muted-foreground opacity-20" />
|
||||
<p class="text-sm text-muted-foreground">No encontramos nada para "{searchTerm}"</p>
|
||||
<Button variant="link" size="sm" onclick={() => searchTerm = ''}>Limpiar búsqueda</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid gap-2">
|
||||
{#each displayList as article}
|
||||
<button
|
||||
onclick={() => selectArticle(article)}
|
||||
class="flex items-center justify-between p-3.5 rounded-xl border border-transparent hover:border-border hover:bg-muted/50 transition-all text-left group"
|
||||
>
|
||||
<div class="flex items-center gap-3 overflow-hidden">
|
||||
<div class="w-9 h-9 rounded-lg bg-muted flex items-center justify-center text-muted-foreground group-hover:text-primary group-hover:bg-primary/10 transition-all shrink-0">
|
||||
{#if article.content_type === 'pdf'}
|
||||
<FileText size={16} />
|
||||
{:else}
|
||||
<BookOpen size={16} />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-col overflow-hidden">
|
||||
<span class="text-sm font-medium truncate group-hover:text-primary transition-colors">
|
||||
{@html highlightText(article.title, searchTerm)}
|
||||
</span>
|
||||
<span class="text-[10px] text-muted-foreground uppercase">{article.category || 'General'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronLeft class="rotate-180 opacity-0 group-hover:opacity-40 transition-all w-4 h-4 shrink-0" />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer Actions -->
|
||||
<div class="p-6 border-t bg-muted/20">
|
||||
<div class="flex flex-col gap-3">
|
||||
<Button href="/dashboard/help-center" variant="outline" class="w-full bg-background rounded-xl h-11 border-primary/20 text-primary hover:bg-primary/5">
|
||||
<ExternalLink size={16} class="mr-2" /> Ir al Catálogo de Manuales
|
||||
</Button>
|
||||
{#if isAdmin}
|
||||
<Button onclick={startCreate} variant="ghost" class="w-full rounded-xl h-11 text-muted-foreground hover:text-foreground">
|
||||
<Plus size={16} class="mr-2" /> Crear nuevo artículo
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Vista de Artículo -->
|
||||
<div class="flex-1 flex flex-col overflow-hidden">
|
||||
<div class="px-6 py-4 flex items-center justify-between border-b bg-background">
|
||||
<Button variant="ghost" size="sm" onclick={() => (selectedArticle = null)} class="rounded-lg h-9">
|
||||
<ChevronLeft size={18} class="mr-1" /> Volver
|
||||
</Button>
|
||||
{#if isAdmin && !isEditing}
|
||||
<Button variant="outline" size="sm" onclick={startEdit} class="h-9 rounded-lg">
|
||||
<Edit2 size={16} class="mr-2" /> Editar
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-8">
|
||||
{#if isEditing}
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs uppercase tracking-widest text-muted-foreground">Título</Label>
|
||||
<input
|
||||
bind:value={editTitle}
|
||||
class="w-full bg-transparent border-b border-muted py-2 text-2xl font-bold outline-none focus:border-primary transition-colors"
|
||||
placeholder="Título del artículo"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs uppercase tracking-widest text-muted-foreground">Contenido (Markdown)</Label>
|
||||
<textarea
|
||||
bind:value={editContent}
|
||||
class="min-h-[500px] w-full bg-muted/20 rounded-xl border p-6 font-mono text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all"
|
||||
placeholder="Escribe el contenido..."
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<Button variant="outline" onclick={() => {
|
||||
isEditing = false;
|
||||
if (isCreating) selectedArticle = null;
|
||||
isCreating = false;
|
||||
}} class="rounded-xl">Cancelar</Button>
|
||||
<Button onclick={saveChanges} class="rounded-xl px-8 shadow-lg shadow-primary/20">Guardar Cambios</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="max-w-2xl mx-auto space-y-6">
|
||||
<div class="space-y-2">
|
||||
<Badge variant="outline" class="bg-primary/5 text-primary border-primary/20">{selectedArticle.category || 'General'}</Badge>
|
||||
<h1 class="text-4xl font-extrabold tracking-tight">{selectedArticle.title}</h1>
|
||||
<div class="flex items-center gap-4 text-xs text-muted-foreground pt-1 border-b pb-6">
|
||||
<span>Por <strong>{selectedArticle.last_editor}</strong></span>
|
||||
<span>•</span>
|
||||
<span>Actualizado: {new Date(selectedArticle.updated_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="prose prose-sm dark:prose-invert max-w-none prose-headings:text-foreground prose-p:text-muted-foreground prose-strong:text-foreground leading-relaxed">
|
||||
{#if selectedArticle.content_type === 'pdf' && selectedArticle.file_url}
|
||||
<div class="rounded-2xl overflow-hidden border bg-muted/30 h-[calc(100vh-350px)] min-h-[500px] shadow-sm">
|
||||
<iframe
|
||||
src={selectedArticle.file_url}
|
||||
class="w-full h-full border-none"
|
||||
title={selectedArticle.title}
|
||||
></iframe>
|
||||
</div>
|
||||
{:else}
|
||||
{#if browser}
|
||||
{@html renderMarkdown(selectedArticle.content)}
|
||||
{:else}
|
||||
<div class="whitespace-pre-wrap">{selectedArticle.content}</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
|
||||
<style>
|
||||
@reference "../../../app.css";
|
||||
|
||||
:global(.prose h1) { @apply text-3xl font-bold mb-6 text-foreground; }
|
||||
:global(.prose h2) { @apply text-2xl font-semibold mb-4 mt-8 text-foreground; }
|
||||
:global(.prose p) { @apply mb-4 text-muted-foreground; }
|
||||
:global(.prose strong) { @apply font-bold text-foreground; }
|
||||
</style>
|
||||
681
frontend/src/lib/components/keyboard/KeyboardManager.svelte
Normal file
681
frontend/src/lib/components/keyboard/KeyboardManager.svelte
Normal file
@@ -0,0 +1,681 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { browser } from '$app/environment';
|
||||
import { GLOBAL_NAV } from '$lib/config/shortcuts';
|
||||
import { hasAccessTokenInDocument } from '$lib/access-token-cookie-browser';
|
||||
import { shortcutStore, activeShortcutsList } from '$lib/stores/shortcut-store';
|
||||
import { focusStore, interactionMode } from '$lib/stores/focus-store';
|
||||
import { helpStore } from '$lib/stores/help.svelte';
|
||||
import ShortcutsHelpModal from './ShortcutsHelpModal.svelte';
|
||||
|
||||
let showHelp = $state(false);
|
||||
/** Element to restore focus when closing the F1 shortcuts help modal */
|
||||
let helpFocusRestore: HTMLElement | null = null;
|
||||
let lastShortcutTime = 0;
|
||||
|
||||
function restoreHelpFocus() {
|
||||
const el = helpFocusRestore;
|
||||
helpFocusRestore = null;
|
||||
if (el && document.contains(el) && typeof el.focus === 'function') {
|
||||
try {
|
||||
el.focus();
|
||||
} catch {
|
||||
/* ignore e.g. disconnected nodes */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verificar si el usuario está autenticado
|
||||
function isAuthenticated(): boolean {
|
||||
if (!browser) return false;
|
||||
const currentPath = $page?.url?.pathname || '';
|
||||
const isAuthenticatedRoute = currentPath.startsWith('/dashboard');
|
||||
const hasAccessToken =
|
||||
hasAccessTokenInDocument() || localStorage.getItem('access_token');
|
||||
return isAuthenticatedRoute && !!hasAccessToken;
|
||||
}
|
||||
|
||||
function isVisibleInMainContent(htmlEl: HTMLElement): boolean {
|
||||
if (htmlEl.closest('[data-sidebar="sidebar"]')) return false;
|
||||
return !!(htmlEl.offsetWidth || htmlEl.offsetHeight || htmlEl.getClientRects().length);
|
||||
}
|
||||
|
||||
function inputIsSearchCandidate(el: HTMLInputElement): boolean {
|
||||
const t = (el.type || 'text').toLowerCase();
|
||||
return !['hidden', 'checkbox', 'radio', 'file', 'button', 'submit', 'reset'].includes(t);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alt+B: focus the current view's primary filter/search when possible.
|
||||
* Order: [data-view-search] → placeholder hints (buscar/search/…) →
|
||||
* first visible text-like input before the main table / catalog scroll → global sr-only fallback.
|
||||
*/
|
||||
function focusViewSearchOrGlobal() {
|
||||
const main =
|
||||
document.getElementById('dashboard-main-content') ||
|
||||
document.getElementById('main-form-content');
|
||||
|
||||
const tryFocus = (el: HTMLElement | null | undefined): boolean => {
|
||||
if (!el || !isVisibleInMainContent(el)) return false;
|
||||
if (el.closest('[role="dialog"]')) return false;
|
||||
try {
|
||||
el.focus();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
setTimeout(() => {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
|
||||
}, 50);
|
||||
return true;
|
||||
};
|
||||
|
||||
if (main) {
|
||||
const explicit = main.querySelector<HTMLElement>('[data-view-search]:not([disabled])');
|
||||
if (tryFocus(explicit)) return;
|
||||
|
||||
const phPattern = /buscar|search|filtrar|filter|búsqueda|busqueda/i;
|
||||
const inputs = Array.from(
|
||||
main.querySelectorAll<HTMLInputElement>('input:not([disabled])')
|
||||
).filter(inputIsSearchCandidate);
|
||||
|
||||
for (const input of inputs) {
|
||||
if (!isVisibleInMainContent(input)) continue;
|
||||
if (input.closest('[role="dialog"]')) continue;
|
||||
if (phPattern.test(input.placeholder || '')) {
|
||||
if (tryFocus(input)) return;
|
||||
}
|
||||
}
|
||||
|
||||
const boundary = main.querySelector(
|
||||
'table, tbody, .catalog-table-scroll, [data-slot="table-container"]'
|
||||
);
|
||||
if (boundary) {
|
||||
for (const input of inputs) {
|
||||
if (!isVisibleInMainContent(input)) continue;
|
||||
if (input.closest('[role="dialog"]')) continue;
|
||||
if (input.compareDocumentPosition(boundary) & Node.DOCUMENT_POSITION_FOLLOWING) {
|
||||
if (tryFocus(input)) return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('global-search-input')?.focus();
|
||||
}
|
||||
|
||||
/** First meaningful control in main area (forms); optional delay for post-navigation paint. */
|
||||
function focusMainContentPrimary(options?: { delay?: number }) {
|
||||
const delay = options?.delay ?? 350;
|
||||
setTimeout(() => {
|
||||
const container =
|
||||
document.getElementById('main-form-content') ||
|
||||
document.getElementById('dashboard-main-content') ||
|
||||
document.body;
|
||||
|
||||
const activePanels = container.querySelectorAll('[role="tabpanel"][data-state="active"]');
|
||||
const searchableAreas =
|
||||
activePanels.length > 0 ? Array.from(activePanels).reverse() : [container];
|
||||
|
||||
for (const area of searchableAreas) {
|
||||
const focusables = area.querySelectorAll(
|
||||
'input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]):not([role="tab"])'
|
||||
);
|
||||
const firstVisible = Array.from(focusables).find((el) =>
|
||||
isVisibleInMainContent(el as HTMLElement)
|
||||
) as HTMLElement | undefined;
|
||||
|
||||
if (firstVisible) {
|
||||
firstVisible.focus();
|
||||
setTimeout(() => {
|
||||
firstVisible.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const fallbacks = container.querySelectorAll(
|
||||
'tr[tabindex="0"], [data-slot="table-row"][tabindex="0"], a[href]:not([tabindex="-1"]), button:not([disabled]):not([role="tab"]):not([tabindex="-1"])'
|
||||
);
|
||||
const fb = Array.from(fallbacks).find((el) =>
|
||||
isVisibleInMainContent(el as HTMLElement)
|
||||
) as HTMLElement | undefined;
|
||||
if (fb) {
|
||||
fb.focus();
|
||||
setTimeout(() => {
|
||||
fb.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
|
||||
}, 100);
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function focusSidebarNav() {
|
||||
window.dispatchEvent(new CustomEvent('app:expand-sidebar-for-nav'));
|
||||
setTimeout(() => {
|
||||
const nav = document.getElementById('dashboard-sidebar-nav');
|
||||
if (!nav) return;
|
||||
const candidates = nav.querySelectorAll(
|
||||
'a[href]:not([tabindex="-1"]), button:not([disabled]):not([tabindex="-1"])'
|
||||
);
|
||||
const first = Array.from(candidates).find(
|
||||
(el) => !(el as HTMLElement).closest('[data-slot="sidebar-rail"]')
|
||||
) as HTMLElement | undefined;
|
||||
if (first) {
|
||||
first.focus();
|
||||
setTimeout(() => {
|
||||
first.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}, 50);
|
||||
}
|
||||
}, 350);
|
||||
}
|
||||
|
||||
function focusTriggerByDescription(description: string) {
|
||||
// Small delay to allow Svelte to update the DOM
|
||||
setTimeout(() => {
|
||||
// Extract a meaningful keyword from the description (e.g., "Go to DTA")
|
||||
const words = description.split(' ');
|
||||
// Look for words that are uppercase or the last word
|
||||
const keyword =
|
||||
words.find((w) => w.length >= 3 && w === w.toUpperCase()) || words[words.length - 1];
|
||||
|
||||
if (!keyword) return;
|
||||
|
||||
// Find buttons or tabs containing that text
|
||||
const elements = document.querySelectorAll('button, [role="tab"], a');
|
||||
const target = Array.from(elements).find((el) => {
|
||||
const text = el.textContent?.trim() || '';
|
||||
// Exclude sidebar
|
||||
if (el.closest('[data-sidebar="sidebar"]')) return false;
|
||||
|
||||
return text.toLowerCase().includes(keyword.toLowerCase());
|
||||
}) as HTMLElement;
|
||||
|
||||
if (target) {
|
||||
target.focus();
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// FOCUS POLICY DICTIONARY
|
||||
// Decides what to focus after a shortcut/navigation based on context
|
||||
const FOCUS_POLICY: Record<string, 'trigger' | 'first-input'> = {
|
||||
// Pedimentos
|
||||
'General Tab Navigation': 'trigger',
|
||||
'Pedimento Edit Main Tabs': 'first-input',
|
||||
'Otros Tab Navigation': 'first-input',
|
||||
'Contribuciones Tab Navigation': 'first-input',
|
||||
|
||||
// Dashboard forms with Alt+Digit tab navigation
|
||||
'Edit Broker Tabs': 'first-input',
|
||||
'Edit Client Provider': 'first-input',
|
||||
'Formulario Empresa': 'first-input',
|
||||
'Formulario DODA': 'first-input',
|
||||
'Invoice Edit': 'first-input',
|
||||
'Part Form': 'first-input',
|
||||
'Invoice Item Form (Inventory)': 'first-input',
|
||||
'Invoice Item Form (Fixed Asset)': 'first-input',
|
||||
'Invoice Items Tab': 'first-input',
|
||||
'Customs Brokers List': 'trigger',
|
||||
'Clients Providers List': 'trigger'
|
||||
};
|
||||
|
||||
/** When focus is inside the line-item sheet, only these contexts may handle shortcuts (overrides Invoice Edit). */
|
||||
const INVOICE_ITEM_SHEET_CONTEXTS = new Set([
|
||||
'Invoice Item Form (Fixed Asset)',
|
||||
'Invoice Item Form (Inventory)'
|
||||
]);
|
||||
|
||||
const INVOICE_ITEM_SHEET_CONTEXT_BY_ATTR: Record<string, string> = {
|
||||
fixed_asset: 'Invoice Item Form (Fixed Asset)',
|
||||
inventory: 'Invoice Item Form (Inventory)'
|
||||
};
|
||||
|
||||
function filterShortcutsWhenInItemSheet<
|
||||
T extends { context: string; key: string; description: string; action: () => void }
|
||||
>(pool: T[], eventTarget: EventTarget | null): T[] {
|
||||
const el = eventTarget as HTMLElement | null;
|
||||
const sheet = el?.closest?.('[data-invoice-item-sheet]') as HTMLElement | null;
|
||||
if (!sheet) return pool;
|
||||
const kind = sheet.getAttribute('data-invoice-item-sheet') || '';
|
||||
const onlyContext = INVOICE_ITEM_SHEET_CONTEXT_BY_ATTR[kind];
|
||||
if (onlyContext) return pool.filter((s) => s.context === onlyContext);
|
||||
return pool.filter((s) => INVOICE_ITEM_SHEET_CONTEXTS.has(s.context));
|
||||
}
|
||||
|
||||
// Listen for remote focus requests (e.g., from mouse navigation)
|
||||
$effect(() => {
|
||||
if ($focusStore) {
|
||||
// If a shortcut was just pressed, ignore remote focus requests for 250ms
|
||||
// to allow the shortcut's specific focus strategy to prevail
|
||||
if (Date.now() - lastShortcutTime < 250) return;
|
||||
|
||||
if ($focusStore.strategy === 'first-input') {
|
||||
focusMainContentPrimary();
|
||||
} else if ($focusStore.strategy === 'trigger' && $focusStore.description) {
|
||||
focusTriggerByDescription($focusStore.description);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
// Verificar autenticación al inicio
|
||||
const authenticated = isAuthenticated();
|
||||
|
||||
const { key, altKey, ctrlKey, metaKey, shiftKey } = event;
|
||||
if (!key) return;
|
||||
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;
|
||||
|
||||
// 0. SEARCH TO TABLE FLOW: ArrowDown from search input to first table row
|
||||
if (isInput && key === 'ArrowDown') {
|
||||
const dialog = target.closest('[role="dialog"]');
|
||||
if (dialog) {
|
||||
const firstRow = dialog.querySelector(
|
||||
'tr[tabindex="0"], [data-slot$="-item"][tabindex="0"]'
|
||||
) as HTMLElement;
|
||||
if (firstRow) {
|
||||
event.preventDefault();
|
||||
firstRow.focus();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Invoices list (and similar): jump from a filter field to first data row
|
||||
const main =
|
||||
document.getElementById('dashboard-main-content') ||
|
||||
document.getElementById('main-form-content');
|
||||
if (main?.contains(target)) {
|
||||
const firstDataRow = main.querySelector(
|
||||
'[data-invoice-list-table] tbody tr[data-invoice-data-row][tabindex="0"]'
|
||||
) as HTMLElement | null;
|
||||
if (firstDataRow) {
|
||||
event.preventDefault();
|
||||
firstDataRow.focus();
|
||||
setTimeout(() => {
|
||||
firstDataRow.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}, 50);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. HELP: F1
|
||||
if (key === 'F1') {
|
||||
// Solo permitir F1 si está autenticado
|
||||
if (!isAuthenticated()) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (showHelp) {
|
||||
showHelp = false;
|
||||
restoreHelpFocus();
|
||||
} else {
|
||||
const active = document.activeElement;
|
||||
if (active instanceof HTMLElement) {
|
||||
helpFocusRestore = active;
|
||||
}
|
||||
showHelp = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 1.5 HELP DRAWER: F12
|
||||
if (key === 'F12') {
|
||||
if (!authenticated) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
helpStore.toggle();
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. ESCAPE
|
||||
if (key === 'Escape') {
|
||||
if (showHelp) {
|
||||
showHelp = false;
|
||||
restoreHelpFocus();
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
// specific search for Escape in REVERSE order (LIFO); sheet partida overrides Invoice Edit
|
||||
const escapePool = filterShortcutsWhenInItemSheet(
|
||||
[...$activeShortcutsList].reverse(),
|
||||
target
|
||||
);
|
||||
const match = escapePool.find((s) => s.key === 'Escape');
|
||||
if (match) {
|
||||
event.preventDefault();
|
||||
match.action();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. PRIORITY: LOCAL SHORTCUTS
|
||||
// Solo permitir atajos locales si está autenticado
|
||||
if (authenticated) {
|
||||
// Match exact combo string against registered shortcuts
|
||||
// Search in REVERSE order (LIFO) so recent contexts (modals) override earlier ones (pages)
|
||||
const reversedShortcuts = filterShortcutsWhenInItemSheet(
|
||||
[...$activeShortcutsList].reverse(),
|
||||
target
|
||||
);
|
||||
let match = reversedShortcuts.find((s) => s.key === combo);
|
||||
|
||||
// Fallback: If no match found by 'key' (e.g. Shift+1 produces 'Alt+Shift+!'),
|
||||
// try matching by 'code' (e.g. 'Alt+Shift+Digit1')
|
||||
if (!match) {
|
||||
const codeCombo = [...modifiers, event.code].join('+');
|
||||
match = reversedShortcuts.find((s) => s.key === codeCombo);
|
||||
}
|
||||
|
||||
if (match) {
|
||||
// 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
|
||||
lastShortcutTime = Date.now();
|
||||
match.action();
|
||||
|
||||
// Hybrid focus strategy (skip when shortcut manages focus itself)
|
||||
if (combo.includes('Alt+') && match && !match.skipDefaultFocusAfter) {
|
||||
const isDigit = combo.includes('Digit') || (event.code && event.code.startsWith('Digit'));
|
||||
const strategy = FOCUS_POLICY[match.context] || 'first-input';
|
||||
|
||||
if (isDigit && strategy === 'trigger') {
|
||||
// Focus the trigger for specific views (General sub-tabs)
|
||||
focusTriggerByDescription(match.description);
|
||||
} else {
|
||||
// Default: Focus the first input (Main tabs, non-trigger sub-tabs)
|
||||
focusMainContentPrimary();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
} // Fin del bloque authenticated
|
||||
|
||||
// 4. GLOBAL NAV: Alt + Key (Legacy/Default behavior for global nav)
|
||||
// Solo permitir si está autenticado
|
||||
if (authenticated && altKey && modifiers.length === 1) {
|
||||
// Exactly Alt + Key (no shift/ctrl)
|
||||
// Special: Alt+B -> Focus view search / filters or global fallback
|
||||
if (lowerKey === 'b') {
|
||||
event.preventDefault();
|
||||
lastShortcutTime = Date.now();
|
||||
focusViewSearchOrGlobal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (lowerKey === 'n') {
|
||||
event.preventDefault();
|
||||
lastShortcutTime = Date.now();
|
||||
focusSidebarNav();
|
||||
return;
|
||||
}
|
||||
|
||||
if (lowerKey === 'j') {
|
||||
event.preventDefault();
|
||||
lastShortcutTime = Date.now();
|
||||
focusMainContentPrimary({ delay: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
const route = GLOBAL_NAV[lowerKey as keyof typeof GLOBAL_NAV];
|
||||
if (
|
||||
route &&
|
||||
route !== 'SEARCH_FOCUS' &&
|
||||
route !== 'SIDEBAR_FOCUS' &&
|
||||
route !== 'MAIN_CONTENT_FOCUS'
|
||||
) {
|
||||
event.preventDefault();
|
||||
lastShortcutTime = Date.now();
|
||||
void goto(route).then(() => {
|
||||
focusMainContentPrimary();
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. GLOBAL TABLE & SELECT NAVIGATION: Arrows / Enter / Tab
|
||||
const isRowOrItem =
|
||||
target.tagName === 'TR' ||
|
||||
target.getAttribute('data-slot') === 'table-row' ||
|
||||
target.getAttribute('data-slot') === 'select-item' ||
|
||||
target.closest('[data-slot="table-row"]') ||
|
||||
target.closest('[data-slot="select-item"]');
|
||||
|
||||
if (isRowOrItem) {
|
||||
const element = (
|
||||
target.getAttribute('data-slot')?.includes('-item') || target.tagName === 'TR'
|
||||
? target
|
||||
: target.closest('[data-slot$="-row"], [data-slot$="-item"]')
|
||||
) as HTMLElement;
|
||||
|
||||
if (!element) return;
|
||||
|
||||
const inInvoiceListDataBody =
|
||||
element.tagName === 'TR' &&
|
||||
element.hasAttribute('data-invoice-data-row') &&
|
||||
!!element.closest('[data-invoice-list-table] tbody');
|
||||
|
||||
const inInvoiceItemsDataBody =
|
||||
element.tagName === 'TR' &&
|
||||
element.hasAttribute('data-item-line-row') &&
|
||||
!!element.closest('[data-invoice-items-table] tbody');
|
||||
|
||||
// Logic for UP/DOWN arrows
|
||||
if (key === 'ArrowDown') {
|
||||
if (inInvoiceItemsDataBody) {
|
||||
let next = element.nextElementSibling as HTMLElement | null;
|
||||
while (next) {
|
||||
if (next.hasAttribute('data-item-line-row')) {
|
||||
event.preventDefault();
|
||||
next.focus();
|
||||
setTimeout(() => {
|
||||
next.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}, 50);
|
||||
return;
|
||||
}
|
||||
next = next.nextElementSibling as HTMLElement | null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (inInvoiceListDataBody) {
|
||||
let next = element.nextElementSibling as HTMLElement | null;
|
||||
while (next) {
|
||||
if (next.hasAttribute('data-invoice-data-row')) {
|
||||
event.preventDefault();
|
||||
next.focus();
|
||||
setTimeout(() => {
|
||||
next.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}, 50);
|
||||
return;
|
||||
}
|
||||
next = next.nextElementSibling as HTMLElement | null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const next = element.nextElementSibling as HTMLElement;
|
||||
if (next) {
|
||||
event.preventDefault();
|
||||
next.focus();
|
||||
}
|
||||
} else if (key === 'ArrowUp') {
|
||||
if (inInvoiceItemsDataBody) {
|
||||
let prev = element.previousElementSibling as HTMLElement | null;
|
||||
while (prev) {
|
||||
if (prev.hasAttribute('data-item-line-row')) {
|
||||
event.preventDefault();
|
||||
prev.focus();
|
||||
setTimeout(() => {
|
||||
prev.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}, 50);
|
||||
return;
|
||||
}
|
||||
prev = prev.previousElementSibling as HTMLElement | null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (inInvoiceListDataBody) {
|
||||
let prev = element.previousElementSibling as HTMLElement | null;
|
||||
while (prev) {
|
||||
if (prev.hasAttribute('data-invoice-data-row')) {
|
||||
event.preventDefault();
|
||||
prev.focus();
|
||||
setTimeout(() => {
|
||||
prev.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}, 50);
|
||||
return;
|
||||
}
|
||||
prev = prev.previousElementSibling as HTMLElement | null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const prev = element.previousElementSibling as HTMLElement;
|
||||
if (prev) {
|
||||
event.preventDefault();
|
||||
prev.focus();
|
||||
}
|
||||
} else if (key === 'Enter' || key === ' ') {
|
||||
if (target.tagName !== 'INPUT' && target.tagName !== 'TEXTAREA') {
|
||||
event.preventDefault();
|
||||
if (element.hasAttribute('data-item-line-row')) {
|
||||
const editBtn = element.querySelector('button') as HTMLButtonElement | null;
|
||||
editBtn?.click();
|
||||
} else {
|
||||
element.click();
|
||||
}
|
||||
}
|
||||
} else if (key === 'Tab') {
|
||||
// Only hijack Tab inside dialogs (selection lists). In main content, preserve natural tab order.
|
||||
if (!element.closest('[role="dialog"]')) {
|
||||
return;
|
||||
}
|
||||
const siblings = Array.from(element.parentElement?.children || []);
|
||||
const index = siblings.indexOf(element);
|
||||
|
||||
if (shiftKey) {
|
||||
if (index > 0) {
|
||||
event.preventDefault();
|
||||
(siblings[index - 1] as HTMLElement).focus();
|
||||
}
|
||||
} else {
|
||||
if (index < siblings.length - 1) {
|
||||
event.preventDefault();
|
||||
(siblings[index + 1] as HTMLElement).focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleFocusIn(event: FocusEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target) return;
|
||||
|
||||
// Exclude sidebar from any magic scrolling
|
||||
if (target.closest('[data-sidebar="sidebar"]')) return;
|
||||
|
||||
// Exclude checkboxes inside tables (e.g. row selection) - prevents unwanted scroll when selecting
|
||||
if (
|
||||
target.tagName === 'INPUT' &&
|
||||
(target as HTMLInputElement).type === 'checkbox' &&
|
||||
target.closest('table')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Exclude table rows (TR) - they have tabindex for keyboard nav but clicking to select shouldn't scroll
|
||||
if (target.tagName === 'TR' && target.closest('table')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if it's an interactive element we care about
|
||||
const isInteractive =
|
||||
['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON', 'A'].includes(target.tagName) ||
|
||||
target.role === 'tab';
|
||||
|
||||
if (isInteractive) {
|
||||
// Force scroll to center after a delay to override browser default behavior
|
||||
setTimeout(() => {
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
// --- DYNAMIC INTERACTIVE OBSERVER ---
|
||||
// Automatically make any clickable row or item focusable
|
||||
$effect(() => {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node instanceof HTMLElement) {
|
||||
// Find TRs that look like they are clickable/interactive
|
||||
const trs = node.tagName === 'TR' ? [node] : Array.from(node.querySelectorAll('tr'));
|
||||
trs.forEach((tr) => {
|
||||
// If it has a cursor-pointer class or an onclick handler (complex to detect in Svelte,
|
||||
// so we use a broad heuristic: any TR inside a dialog's scroll area)
|
||||
const isInSelectionDialog =
|
||||
!!tr.closest('[role="dialog"]') &&
|
||||
!!tr.closest('.overflow-y-auto, .overflow-auto');
|
||||
if (isInSelectionDialog && !tr.hasAttribute('tabindex')) {
|
||||
tr.setAttribute('tabindex', '0');
|
||||
// Add focus styles if it's a native TR
|
||||
if (!tr.getAttribute('data-slot')) {
|
||||
tr.classList.add(
|
||||
'focus-visible:outline-none',
|
||||
'focus-visible:bg-accent',
|
||||
'focus-visible:ring-1',
|
||||
'focus-visible:ring-ring'
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
onkeydown={(e) => {
|
||||
interactionMode.set('keyboard');
|
||||
handleKeydown(e);
|
||||
}}
|
||||
onmousedown={() => interactionMode.set('mouse')}
|
||||
onfocusin={handleFocusIn}
|
||||
/>
|
||||
|
||||
{#if showHelp}
|
||||
<ShortcutsHelpModal
|
||||
open={true}
|
||||
onClose={() => {
|
||||
showHelp = false;
|
||||
restoreHelpFocus();
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
195
frontend/src/lib/components/keyboard/ShortcutsHelpModal.svelte
Normal file
195
frontend/src/lib/components/keyboard/ShortcutsHelpModal.svelte
Normal file
@@ -0,0 +1,195 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
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);
|
||||
|
||||
let globalList = $state<HTMLDivElement | undefined>();
|
||||
let localList = $state<HTMLDivElement | undefined>();
|
||||
let modalRef = $state<HTMLDivElement | undefined>();
|
||||
|
||||
const SHORTCUTS_HELP_TITLE_ID = 'shortcuts-help-dialog-title';
|
||||
|
||||
function isFocusableVisible(el: HTMLElement): boolean {
|
||||
if (el.hasAttribute('disabled')) return false;
|
||||
if (el.tabIndex === -1) return false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
tick().then(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (cancelled) return;
|
||||
globalList?.focus();
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
function handleArrowScroll(event: KeyboardEvent, target: HTMLDivElement | undefined) {
|
||||
if (!target) return;
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
const delta = event.key === 'ArrowDown' ? 32 : -32;
|
||||
target.scrollTop += delta;
|
||||
}
|
||||
}
|
||||
|
||||
function handleFocusTrap(event: KeyboardEvent) {
|
||||
if (event.key !== 'Tab' || !modalRef) return;
|
||||
const focusable = modalRef.querySelectorAll<HTMLElement>(
|
||||
'button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
const focusables = Array.from(focusable).filter((el) => isFocusableVisible(el));
|
||||
if (focusables.length === 0) return;
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
const active = document.activeElement as HTMLElement;
|
||||
|
||||
if (!event.shiftKey && active === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
} else if (event.shiftKey && active === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={SHORTCUTS_HELP_TITLE_ID}
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="max-h-[80vh] w-full max-w-2xl overflow-y-auto rounded-xl bg-white p-6 text-gray-900 shadow-2xl dark:bg-gray-900 dark:text-gray-100"
|
||||
role="document"
|
||||
bind:this={modalRef}
|
||||
onkeydown={handleFocusTrap}
|
||||
>
|
||||
<div
|
||||
class="mb-6 flex items-center justify-between border-b border-gray-200 pb-4 dark:border-gray-800"
|
||||
>
|
||||
<div>
|
||||
<h2 id={SHORTCUTS_HELP_TITLE_ID} 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 tracking-wider text-gray-500 uppercase dark:text-gray-400"
|
||||
>
|
||||
Global Navigation (Alt)
|
||||
</h3>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="max-h-64 space-y-2 overflow-y-auto rounded pr-1 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
role="region"
|
||||
aria-label="Global Navigation shortcuts"
|
||||
bind:this={globalList}
|
||||
tabindex="0"
|
||||
onkeydown={(event) => handleArrowScroll(event, globalList)}
|
||||
>
|
||||
{#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 === 'SIDEBAR_FOCUS'
|
||||
? 'Sidebar menu (focus)'
|
||||
: route === 'MAIN_CONTENT_FOCUS'
|
||||
? 'Main content (focus first control)'
|
||||
: route === '/'
|
||||
? 'Home'
|
||||
: 'Go to ' + String(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 tracking-wider text-gray-500 uppercase dark:text-gray-400"
|
||||
>
|
||||
Active Actions (Alt+Shift)
|
||||
</h3>
|
||||
{#if localShortcuts.length === 0}
|
||||
<p class="text-sm text-gray-400 italic">No specific actions for this view.</p>
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="max-h-64 space-y-2 overflow-y-auto rounded pr-1 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
role="region"
|
||||
aria-label="Active Actions shortcuts"
|
||||
bind:this={localList}
|
||||
tabindex="0"
|
||||
onkeydown={(event) => handleArrowScroll(event, localList)}
|
||||
>
|
||||
{#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}
|
||||
100
frontend/src/lib/components/license-error-screen.svelte
Normal file
100
frontend/src/lib/components/license-error-screen.svelte
Normal file
@@ -0,0 +1,100 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { logout } from '$lib/auth';
|
||||
|
||||
interface LicenseError {
|
||||
type: string;
|
||||
message: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
let { error }: { error: LicenseError } = $props();
|
||||
|
||||
const isHubOffline = error.type === 'HUB_OFFLINE' || error.type === 'HUB_ERROR';
|
||||
|
||||
function handleLogout() {
|
||||
void logout();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-[calc(100vh-4rem)] flex-col items-center justify-center p-8">
|
||||
<div class="flex max-w-md flex-col items-center gap-6 text-center">
|
||||
<!-- Icon -->
|
||||
{#if isHubOffline}
|
||||
<div class="flex h-20 w-20 items-center justify-center rounded-full bg-yellow-100 dark:bg-yellow-900/30">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-10 w-10 text-yellow-600 dark:text-yellow-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex h-20 w-20 items-center justify-center rounded-full bg-destructive/10">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-10 w-10 text-destructive"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Heading -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-foreground">
|
||||
{#if isHubOffline}
|
||||
Servicio de licencias no disponible
|
||||
{:else}
|
||||
Acceso suspendido
|
||||
{/if}
|
||||
</h1>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{error.message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Help text -->
|
||||
<div class="rounded-lg border bg-muted/50 px-4 py-3 text-sm text-muted-foreground">
|
||||
{#if isHubOffline}
|
||||
El servidor de licencias no está disponible en este momento. Por favor, inténtalo de nuevo
|
||||
en unos minutos o contacta a soporte si el problema persiste.
|
||||
{:else if error.type === 'LICENSE_ERROR'}
|
||||
Tu organización no cuenta con una licencia activa para acceder al sistema. Contacta a tu
|
||||
administrador o al equipo de soporte para regularizar tu suscripción.
|
||||
{:else}
|
||||
No tienes permisos para acceder al sistema. Contacta a tu administrador.
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3">
|
||||
{#if isHubOffline}
|
||||
<Button variant="outline" onclick={() => window.location.reload()}>
|
||||
Reintentar
|
||||
</Button>
|
||||
{/if}
|
||||
<Button variant="destructive" onclick={handleLogout}>
|
||||
Cerrar sesión
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
128
frontend/src/lib/components/session-timeout-warning.svelte
Normal file
128
frontend/src/lib/components/session-timeout-warning.svelte
Normal file
@@ -0,0 +1,128 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import {
|
||||
SESSION_WARNING_EVENT,
|
||||
SESSION_EXPIRED_EVENT,
|
||||
SESSION_EXTENDED_EVENT,
|
||||
getSessionManager
|
||||
} from '$lib/session-manager';
|
||||
import type { SessionWarningDetail } from '$lib/session-manager';
|
||||
|
||||
// ─── State ────────────────────────────────────────────────────────────────
|
||||
let open = $state(false);
|
||||
let remainingSeconds = $state(300);
|
||||
let countdownId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||
function formatTime(secs: number): string {
|
||||
const m = Math.floor(secs / 60);
|
||||
const s = secs % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function clearCountdown() {
|
||||
if (countdownId !== null) {
|
||||
clearInterval(countdownId);
|
||||
countdownId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startCountdown() {
|
||||
clearCountdown();
|
||||
countdownId = setInterval(() => {
|
||||
remainingSeconds = Math.max(0, remainingSeconds - 1);
|
||||
if (remainingSeconds === 0) clearCountdown();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// ─── Event handlers ───────────────────────────────────────────────────────
|
||||
function onWarning(e: Event) {
|
||||
const { remainingMs } = (e as CustomEvent<SessionWarningDetail>).detail;
|
||||
remainingSeconds = Math.floor(remainingMs / 1000);
|
||||
open = true;
|
||||
startCountdown();
|
||||
}
|
||||
|
||||
function onExpired() {
|
||||
open = false;
|
||||
clearCountdown();
|
||||
}
|
||||
|
||||
function onExtended() {
|
||||
open = false;
|
||||
clearCountdown();
|
||||
}
|
||||
|
||||
// ─── User actions ─────────────────────────────────────────────────────────
|
||||
function continueSession() {
|
||||
const mgr = getSessionManager();
|
||||
mgr?.extendSession();
|
||||
open = false;
|
||||
clearCountdown();
|
||||
}
|
||||
|
||||
function logoutNow() {
|
||||
open = false;
|
||||
clearCountdown();
|
||||
// Dispara el evento de sesión expirada para que el layout gestione el logout
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(SESSION_EXPIRED_EVENT, { detail: { reason: 'manual' } })
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Lifecycle ────────────────────────────────────────────────────────────
|
||||
onMount(() => {
|
||||
if (!browser) return;
|
||||
window.addEventListener(SESSION_WARNING_EVENT, onWarning);
|
||||
window.addEventListener(SESSION_EXPIRED_EVENT, onExpired);
|
||||
window.addEventListener(SESSION_EXTENDED_EVENT, onExtended);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (!browser) return;
|
||||
clearCountdown();
|
||||
window.removeEventListener(SESSION_WARNING_EVENT, onWarning);
|
||||
window.removeEventListener(SESSION_EXPIRED_EVENT, onExpired);
|
||||
window.removeEventListener(SESSION_EXTENDED_EVENT, onExtended);
|
||||
});
|
||||
</script>
|
||||
|
||||
<!--
|
||||
session-timeout-warning.svelte
|
||||
Diálogo que avisa al usuario cuando su sesión está a punto de expirar
|
||||
por inactividad. Se controla completamente a través de eventos DOM.
|
||||
-->
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="fixed inset-0 z-[9998] bg-black/40 backdrop-blur-sm" />
|
||||
<Dialog.Content
|
||||
class="fixed left-1/2 top-1/2 z-[9999] w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg bg-background p-6 shadow-xl"
|
||||
>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2 text-lg font-semibold">
|
||||
⚠️ Sesión por expirar
|
||||
</Dialog.Title>
|
||||
<Dialog.Description class="mt-2 text-sm text-muted-foreground">
|
||||
Tu sesión cerrará automáticamente por inactividad en
|
||||
<span class="font-mono font-bold text-foreground">
|
||||
{formatTime(remainingSeconds)}
|
||||
</span>.
|
||||
<br />
|
||||
¿Deseas continuar trabajando?
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<Dialog.Footer class="mt-6 flex gap-3">
|
||||
<Button variant="outline" class="flex-1" onclick={logoutNow}>
|
||||
Cerrar sesión
|
||||
</Button>
|
||||
<Button class="flex-1" onclick={continueSession}>
|
||||
Continuar sesión
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
81
frontend/src/lib/components/sidebar/app-launcher.svelte
Normal file
81
frontend/src/lib/components/sidebar/app-launcher.svelte
Normal file
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||
import LayoutGridIcon from '@lucide/svelte/icons/layout-grid';
|
||||
import { workspaceAppsStore, type WorkspaceApp } from '$lib/stores/workspace-apps.svelte';
|
||||
|
||||
// Abrir la app seleccionada:
|
||||
// - Mismo origin (es esta misma app): si trae `active_system`, cambio de sistema local
|
||||
// recargando la página actual con ese parámetro (sin rebotar al Workspace). Si no, ir al dashboard.
|
||||
// - Otro origin: navegar a su entrada (login_url); su propio login resuelve el SSO vía Workspace
|
||||
// y aterriza en la app.
|
||||
function openApp(app: WorkspaceApp) {
|
||||
let target: URL;
|
||||
try {
|
||||
target = new URL(app.url);
|
||||
} catch {
|
||||
window.location.assign(app.url);
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.origin !== window.location.origin) {
|
||||
window.location.assign(app.url);
|
||||
return;
|
||||
}
|
||||
|
||||
const system = target.searchParams.get('active_system');
|
||||
if (system) {
|
||||
const current = new URL(window.location.href);
|
||||
current.searchParams.set('active_system', system);
|
||||
window.location.assign(current.pathname + current.search);
|
||||
} else {
|
||||
window.location.assign('/dashboard');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<button
|
||||
{...props}
|
||||
class="inline-flex size-10 shrink-0 select-none items-center justify-center rounded-md
|
||||
text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground
|
||||
transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring
|
||||
data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
title="Aplicaciones"
|
||||
aria-label="Abrir selector de aplicaciones"
|
||||
>
|
||||
<LayoutGridIcon class="size-5" />
|
||||
</button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Content
|
||||
class="w-64 rounded-xl p-3"
|
||||
align="end"
|
||||
side="bottom"
|
||||
sideOffset={8}
|
||||
>
|
||||
<p class="mb-3 px-1 text-xs font-medium text-muted-foreground">Tus aplicaciones</p>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{#each workspaceAppsStore.apps as app (app.id)}
|
||||
<button
|
||||
onclick={() => openApp(app)}
|
||||
class="flex flex-col items-center gap-1.5 rounded-lg p-3 text-center transition-colors hover:bg-accent"
|
||||
>
|
||||
<div class="flex size-10 items-center justify-center overflow-hidden rounded-xl bg-primary/10 text-primary">
|
||||
{#if app.iconUrl}
|
||||
<img src={app.iconUrl} alt={app.name} class="size-full object-cover" />
|
||||
{:else}
|
||||
<LayoutGridIcon class="size-5" />
|
||||
{/if}
|
||||
</div>
|
||||
<span class="text-sm font-medium leading-tight">{app.name}</span>
|
||||
{#if app.slug}
|
||||
<span class="text-xs text-muted-foreground">{app.slug}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
103
frontend/src/lib/components/sidebar/app-sidebar.svelte
Normal file
103
frontend/src/lib/components/sidebar/app-sidebar.svelte
Normal file
@@ -0,0 +1,103 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { page } from "$app/state";
|
||||
import { useSidebar } from "$lib/components/ui/sidebar/context.svelte.js";
|
||||
import { getSidebarData } from "$lib/components/sidebar/modules";
|
||||
import { currentUser } from "$lib/auth";
|
||||
import NavMain from "./nav-main.svelte";
|
||||
import NavProjects from "./nav-projects.svelte";
|
||||
import NavUser from "./nav-user.svelte";
|
||||
import TeamSwitcher from "./team-switcher.svelte";
|
||||
import AppLauncher from "./app-launcher.svelte";
|
||||
import { systemStore } from "$lib/stores/system.svelte";
|
||||
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
|
||||
import type { ComponentProps } from "svelte";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
collapsible = "icon",
|
||||
...restProps
|
||||
}: ComponentProps<typeof Sidebar.Root> = $props();
|
||||
|
||||
// Leer siempre de page.data para que el sidebar reaccione tras invalidateAll()
|
||||
const userTenants = $derived((page.data.userTenants as { id: number; name: string; slug: string }[]) ?? []);
|
||||
|
||||
// Obtener datos del sidebar con traducciones
|
||||
const sidebarData = getSidebarData();
|
||||
const mergedUser = $derived((page.data.user as any) || $currentUser || null);
|
||||
|
||||
// Combinar los datos estáticos del sidebar con los datos del usuario de Keycloak
|
||||
const data = $derived({
|
||||
...sidebarData,
|
||||
user: mergedUser
|
||||
? {
|
||||
name: _displayName(mergedUser),
|
||||
email: mergedUser.email || "",
|
||||
username: mergedUser.preferred_username || mergedUser.username || "",
|
||||
firstName: mergedUser.first_name || mergedUser.firstName || mergedUser.given_name || null,
|
||||
lastName: mergedUser.last_name || mergedUser.lastName || mergedUser.family_name || null,
|
||||
displayName:
|
||||
mergedUser.displayName ||
|
||||
_displayName(mergedUser) ||
|
||||
mergedUser.preferred_username ||
|
||||
mergedUser.username ||
|
||||
"",
|
||||
avatarUrl:
|
||||
mergedUser.workspaceAvatarUrl ||
|
||||
mergedUser.workspace_avatar_url ||
|
||||
mergedUser.avatarUrl ||
|
||||
mergedUser.avatar_url ||
|
||||
mergedUser.legacyAvatarUrl ||
|
||||
mergedUser.legacy_avatar_url ||
|
||||
null,
|
||||
workspaceAvatarUrl:
|
||||
mergedUser.workspaceAvatarUrl ||
|
||||
mergedUser.workspace_avatar_url ||
|
||||
null,
|
||||
legacyAvatarUrl:
|
||||
mergedUser.legacyAvatarUrl ||
|
||||
mergedUser.legacy_avatar_url ||
|
||||
mergedUser.avatar_url ||
|
||||
null,
|
||||
}
|
||||
: sidebarData.user,
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (mergedUser) {
|
||||
console.debug('[avatar][sidebar] avatar final en page.data.user:', data.user?.avatarUrl ?? '(null)');
|
||||
}
|
||||
});
|
||||
|
||||
function _displayName(u: any): string {
|
||||
const first = u.first_name || u.given_name || "";
|
||||
const last = u.last_name || u.family_name || "";
|
||||
return (first + " " + last).trim() || u.name || u.preferred_username || "";
|
||||
}
|
||||
|
||||
const sidebar = useSidebar();
|
||||
|
||||
onMount(() => {
|
||||
const expandForKeyboardNav = () => {
|
||||
sidebar.setOpen(true);
|
||||
if (sidebar.isMobile) sidebar.setOpenMobile(true);
|
||||
};
|
||||
window.addEventListener("app:expand-sidebar-for-nav", expandForKeyboardNav);
|
||||
return () =>
|
||||
window.removeEventListener("app:expand-sidebar-for-nav", expandForKeyboardNav);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Sidebar.Root {collapsible} {...restProps}>
|
||||
<Sidebar.Header>
|
||||
<TeamSwitcher {userTenants} />
|
||||
</Sidebar.Header>
|
||||
<Sidebar.Content>
|
||||
<NavMain items={data.navMain} />
|
||||
<NavProjects projects={data.projects} />
|
||||
</Sidebar.Content>
|
||||
<Sidebar.Footer>
|
||||
<NavUser user={data.user} tenants={userTenants} />
|
||||
</Sidebar.Footer>
|
||||
<Sidebar.Rail />
|
||||
</Sidebar.Root>
|
||||
75
frontend/src/lib/components/sidebar/modules.ts
Normal file
75
frontend/src/lib/components/sidebar/modules.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Settings2,
|
||||
Users,
|
||||
Shield,
|
||||
} from '@lucide/svelte';
|
||||
|
||||
export type SystemContext = 'fixed_asset' | 'inventory';
|
||||
|
||||
export interface NavItem {
|
||||
title: string;
|
||||
url: string;
|
||||
permission?: string;
|
||||
systemContext?: SystemContext;
|
||||
}
|
||||
|
||||
export interface NavMainItem {
|
||||
title: string;
|
||||
url: string;
|
||||
icon: any;
|
||||
isActive?: boolean;
|
||||
permission?: string;
|
||||
items?: NavItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Navegación principal del dashboard.
|
||||
* Agrega aquí los módulos de tu proyecto.
|
||||
*/
|
||||
export function getNavMain(): NavMainItem[] {
|
||||
return [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
url: '/dashboard',
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
{
|
||||
title: 'Usuarios',
|
||||
url: '/dashboard/users',
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
title: 'Roles y permisos',
|
||||
url: '/dashboard/roles',
|
||||
icon: Shield,
|
||||
},
|
||||
{
|
||||
title: 'Configuración',
|
||||
url: '/dashboard/settings/general',
|
||||
icon: Settings2,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Datos completos del sidebar (navegación + usuario fallback + proyectos).
|
||||
* El usuario real se inyecta desde page.data en app-sidebar.svelte.
|
||||
*/
|
||||
export function getSidebarData() {
|
||||
return {
|
||||
navMain: getNavMain(),
|
||||
projects: [] as { name: string; url: string; icon: any }[],
|
||||
user: {
|
||||
name: '',
|
||||
email: '',
|
||||
username: '',
|
||||
firstName: null,
|
||||
lastName: null,
|
||||
displayName: '',
|
||||
avatarUrl: null,
|
||||
workspaceAvatarUrl: null,
|
||||
legacyAvatarUrl: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
337
frontend/src/lib/components/sidebar/nav-main.svelte
Normal file
337
frontend/src/lib/components/sidebar/nav-main.svelte
Normal file
@@ -0,0 +1,337 @@
|
||||
<script lang="ts">
|
||||
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||
import { useSidebar } from '$lib/components/ui/sidebar/context.svelte.js';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import { authStore, permissionsRefreshing, permissionsHydrated, userHasPermission } from '$lib/auth';
|
||||
import { systemStore } from '$lib/stores/system.svelte';
|
||||
import { page } from '$app/state';
|
||||
|
||||
function resolveVisibleNavItems<T>(
|
||||
filtered: T[],
|
||||
lastNonEmpty: T[],
|
||||
hasAuthenticatedUser: boolean
|
||||
): T[] {
|
||||
if (filtered.length > 0) return filtered;
|
||||
if (hasAuthenticatedUser && lastNonEmpty.length > 0) return lastNonEmpty;
|
||||
return filtered;
|
||||
}
|
||||
|
||||
let {
|
||||
items
|
||||
}: {
|
||||
items: {
|
||||
title: string;
|
||||
url: string;
|
||||
icon?: any;
|
||||
isActive?: boolean;
|
||||
permission?: string;
|
||||
systemContext?: 'fixed_asset' | 'inventory';
|
||||
items?: {
|
||||
title: string;
|
||||
url: string;
|
||||
permission?: string;
|
||||
systemContext?: 'fixed_asset' | 'inventory';
|
||||
}[];
|
||||
}[];
|
||||
} = $props();
|
||||
|
||||
function isUrlActive(url: string): boolean {
|
||||
const pathname = page.url.pathname;
|
||||
return pathname === url || pathname.startsWith(url + '/');
|
||||
}
|
||||
|
||||
function matchesSystem(ctx: 'fixed_asset' | 'inventory' | undefined): boolean {
|
||||
if (!ctx) return true;
|
||||
if (!systemStore.activeSystem) return false;
|
||||
return ctx === systemStore.activeSystem;
|
||||
}
|
||||
|
||||
// Filtrar items según permisos y sistema activo
|
||||
const filteredItems = $derived(
|
||||
items
|
||||
.map((item) => ({
|
||||
...item,
|
||||
items: item.items?.filter((subItem) => {
|
||||
if (subItem.permission && !userHasPermission($authStore.user, subItem.permission)) return false;
|
||||
if (!matchesSystem(subItem.systemContext)) return false;
|
||||
return true;
|
||||
})
|
||||
}))
|
||||
.filter((item) => {
|
||||
// 1. Filtrar por permiso explícito del item principal
|
||||
if (item.permission && !userHasPermission($authStore.user, item.permission)) return false;
|
||||
|
||||
// 2. Filtrar por sistema activo
|
||||
if (!matchesSystem(item.systemContext)) return false;
|
||||
|
||||
// 3. Ocultar categorías (url="#") que se quedaron sin sub-items visibles
|
||||
if (item.url === '#' && item.items && item.items.length === 0) return false;
|
||||
|
||||
return true;
|
||||
})
|
||||
);
|
||||
|
||||
type NavMainItem = (typeof items)[number];
|
||||
|
||||
const NAV_SNAPSHOT_KEY = 'app:sidebar:nav-main:v1';
|
||||
|
||||
function loadNavSnapshot(): NavMainItem[] {
|
||||
if (typeof window === 'undefined') return [];
|
||||
try {
|
||||
const raw = sessionStorage.getItem(NAV_SNAPSHOT_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? (parsed as NavMainItem[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveNavSnapshot(items: NavMainItem[]): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
sessionStorage.setItem(NAV_SNAPSHOT_KEY, JSON.stringify(items));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot del último menú no vacío: evita parpadeo si filteredItems queda [] durante revalidación.
|
||||
let lastNonEmptyItems = $state<NavMainItem[]>(loadNavSnapshot());
|
||||
|
||||
$effect(() => {
|
||||
if (filteredItems.length > 0) {
|
||||
lastNonEmptyItems = filteredItems;
|
||||
saveNavSnapshot(filteredItems);
|
||||
}
|
||||
});
|
||||
|
||||
const visibleItems = $derived(
|
||||
resolveVisibleNavItems(
|
||||
filteredItems,
|
||||
lastNonEmptyItems,
|
||||
Boolean($authStore.user)
|
||||
)
|
||||
);
|
||||
|
||||
const sidebar = useSidebar();
|
||||
|
||||
// Estado del Sistema Híbrido controlado por hover estricto (sin timers)
|
||||
let activeTitle = $state<string | null>(null);
|
||||
|
||||
function shouldKeepOpen(event: PointerEvent, title: string) {
|
||||
const next = event.relatedTarget as Node | null;
|
||||
if (!next) return false;
|
||||
|
||||
const trigger = document.getElementById(`trigger-${title}`);
|
||||
const content = document.getElementById(`content-${title}`);
|
||||
|
||||
// Mantenemos abierto si el puntero se mueve hacia el trigger o el contenido del menú
|
||||
return (trigger && trigger.contains(next)) || (content && content.contains(next));
|
||||
}
|
||||
|
||||
function handleTriggerEnter(title: string) {
|
||||
if (sidebar.state !== 'collapsed') return;
|
||||
activeTitle = title;
|
||||
}
|
||||
|
||||
function handleTriggerLeave(event: PointerEvent, title: string) {
|
||||
if (sidebar.state !== 'collapsed') return;
|
||||
|
||||
// Si nos movemos al contenido (o nos quedamos en el trigger), no cerramos
|
||||
if (shouldKeepOpen(event, title)) return;
|
||||
|
||||
activeTitle = null;
|
||||
}
|
||||
|
||||
function handleContentEnter(title: string) {
|
||||
if (sidebar.state !== 'collapsed') return;
|
||||
activeTitle = title;
|
||||
}
|
||||
|
||||
function handleContentLeave(event: PointerEvent, title: string) {
|
||||
if (sidebar.state !== 'collapsed') return;
|
||||
|
||||
// Si nos movemos de vuelta al trigger (o dentro del contenido), no cerramos
|
||||
if (shouldKeepOpen(event, title)) return;
|
||||
|
||||
activeTitle = null;
|
||||
}
|
||||
|
||||
function onOpenChange(open: boolean, title: string) {
|
||||
// Sincronización base
|
||||
if (open) {
|
||||
activeTitle = title;
|
||||
} else {
|
||||
if (activeTitle === title) {
|
||||
activeTitle = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Sidebar.Group>
|
||||
<Sidebar.GroupLabel class="flex items-center gap-2">
|
||||
<span>Anexo-76</span>
|
||||
{#if $permissionsRefreshing}
|
||||
<span
|
||||
class="size-1.5 shrink-0 animate-pulse rounded-full bg-primary"
|
||||
title="Actualizando permisos"
|
||||
aria-label="Actualizando permisos"
|
||||
></span>
|
||||
{/if}
|
||||
</Sidebar.GroupLabel>
|
||||
<Sidebar.Menu
|
||||
id="dashboard-sidebar-nav"
|
||||
aria-label="Navegación principal"
|
||||
class={visibleItems.length === 0 && !$permissionsHydrated ? 'opacity-0' : ''}
|
||||
>
|
||||
{#each visibleItems as item (item.title)}
|
||||
{#if item.items && item.items.length > 0}
|
||||
{#if sidebar.state === 'collapsed'}
|
||||
<!-- Sidebar Colapsado: Dropdown controlado por eventos estrictos -->
|
||||
<Sidebar.MenuItem>
|
||||
<DropdownMenu.Root
|
||||
open={activeTitle === item.title}
|
||||
onOpenChange={(v) => onOpenChange(v, item.title)}
|
||||
>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<div
|
||||
id={`trigger-${item.title}`}
|
||||
class="relative z-30 flex w-full justify-center"
|
||||
onpointerenter={() => handleTriggerEnter(item.title)}
|
||||
onpointerleave={(e) => handleTriggerLeave(e, item.title)}
|
||||
>
|
||||
<Sidebar.MenuButton
|
||||
{...props}
|
||||
tooltipContent={undefined}
|
||||
class="justify-center"
|
||||
>
|
||||
{#if item.icon}
|
||||
<item.icon />
|
||||
{:else}
|
||||
<div class="size-4"></div>
|
||||
{/if}
|
||||
<!-- Ocultamos el texto en modo colapsado para asegurar que solo sea el icono -->
|
||||
<span class="sr-only">{item.title}</span>
|
||||
</Sidebar.MenuButton>
|
||||
</div>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
side="right"
|
||||
align="start"
|
||||
sideOffset={0}
|
||||
class="z-50 w-64 overflow-visible rounded-lg p-0 shadow-lg"
|
||||
id={`content-${item.title}`}
|
||||
onpointerenter={() => handleContentEnter(item.title)}
|
||||
onpointerleave={(e) => handleContentLeave(e, item.title)}
|
||||
>
|
||||
<!--
|
||||
Header "Tab" (Icono Flotante)
|
||||
Posicionado con right-full para estar exactamente donde el trigger termina (offset 0).
|
||||
Usamos w-8 h-8 para coincidir con un botón de tamaño estándar de sidebar.
|
||||
-->
|
||||
<div
|
||||
class="absolute top-0 right-full z-50 flex h-8 w-8 items-center justify-center rounded-l-lg border border-r-0 border-sidebar-border bg-sidebar-accent text-sidebar-accent-foreground shadow-none"
|
||||
>
|
||||
{#if item.icon}
|
||||
<item.icon class="size-4 shrink-0" />
|
||||
{:else}
|
||||
<div class="size-4 shrink-0"></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Panel Principal
|
||||
-->
|
||||
<div
|
||||
class="pointer-events-auto ml-[0px] h-full w-full rounded-lg rounded-tl-none border border-sidebar-border bg-popover p-1"
|
||||
>
|
||||
<!-- Título en el panel principal -->
|
||||
<div
|
||||
class="truncate border-b px-2 py-2 text-sm font-medium text-sidebar-foreground"
|
||||
>
|
||||
{item.title}
|
||||
</div>
|
||||
|
||||
<DropdownMenu.DropdownMenuGroup class="mt-1 max-h-80 overflow-y-auto">
|
||||
{#each item.items as subItem (subItem.title)}
|
||||
<DropdownMenu.Item>
|
||||
{#snippet child({ props })}
|
||||
<a
|
||||
{...props}
|
||||
href={subItem.url}
|
||||
class="flex w-full items-center gap-2 overflow-hidden"
|
||||
>
|
||||
<span class="truncate">{subItem.title}</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
</DropdownMenu.DropdownMenuGroup>
|
||||
</div>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Sidebar.MenuItem>
|
||||
{:else}
|
||||
<!-- Sidebar Expandido: Collapsible original -->
|
||||
<Collapsible.Root
|
||||
open={item.isActive || item.items?.some((sub) => isUrlActive(sub.url))}
|
||||
class="group/collapsible"
|
||||
>
|
||||
{#snippet child({ props })}
|
||||
<Sidebar.MenuItem {...props}>
|
||||
<Collapsible.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Sidebar.MenuButton {...props} tooltipContent={item.title}>
|
||||
{#if item.icon}
|
||||
<item.icon />
|
||||
{/if}
|
||||
<span>{item.title}</span>
|
||||
<ChevronRight
|
||||
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
|
||||
/>
|
||||
</Sidebar.MenuButton>
|
||||
{/snippet}
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<Sidebar.MenuSub>
|
||||
{#each item.items as subItem (subItem.title)}
|
||||
<Sidebar.MenuSubItem>
|
||||
<Sidebar.MenuSubButton isActive={isUrlActive(subItem.url)}>
|
||||
{#snippet child({ props })}
|
||||
<a href={subItem.url} {...props}>
|
||||
<span>{subItem.title}</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuSubButton>
|
||||
</Sidebar.MenuSubItem>
|
||||
{/each}
|
||||
</Sidebar.MenuSub>
|
||||
</Collapsible.Content>
|
||||
</Sidebar.MenuItem>
|
||||
{/snippet}
|
||||
</Collapsible.Root>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Items sin submenú -->
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton tooltipContent={item.title}>
|
||||
{#snippet child({ props })}
|
||||
<a href={item.url} {...props}>
|
||||
{#if item.icon}
|
||||
<item.icon />
|
||||
{/if}
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
{/if}
|
||||
{/each}
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.Group>
|
||||
33
frontend/src/lib/components/sidebar/nav-projects.svelte
Normal file
33
frontend/src/lib/components/sidebar/nav-projects.svelte
Normal file
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||
import { m } from '$lib/i18n/messages';
|
||||
|
||||
let {
|
||||
projects
|
||||
}: {
|
||||
projects: {
|
||||
name: string;
|
||||
url: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
icon: any;
|
||||
}[];
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Sidebar.Group class="group-data-[collapsible=icon]:hidden">
|
||||
<Sidebar.GroupLabel>{m['sidebar.management_label']()}</Sidebar.GroupLabel>
|
||||
<Sidebar.Menu>
|
||||
{#each projects as item (item.name)}
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton>
|
||||
{#snippet child({ props })}
|
||||
<a href={item.url} {...props}>
|
||||
<item.icon />
|
||||
<span>{item.name}</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
{/each}
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.Group>
|
||||
285
frontend/src/lib/components/sidebar/nav-user.svelte
Normal file
285
frontend/src/lib/components/sidebar/nav-user.svelte
Normal file
@@ -0,0 +1,285 @@
|
||||
<script lang="ts">
|
||||
import * as Avatar from '$lib/components/ui/avatar/index.js';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||
import { useSidebar } from '$lib/components/ui/sidebar/index.js';
|
||||
import BadgeCheckIcon from '@lucide/svelte/icons/badge-check';
|
||||
import BellIcon from '@lucide/svelte/icons/bell';
|
||||
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
|
||||
import CreditCardIcon from '@lucide/svelte/icons/credit-card';
|
||||
import LogOutIcon from '@lucide/svelte/icons/log-out';
|
||||
import LanguagesIcon from '@lucide/svelte/icons/languages';
|
||||
import MoonIcon from '@lucide/svelte/icons/moon';
|
||||
import SunIcon from '@lucide/svelte/icons/sun';
|
||||
import ArrowLeftRightIcon from '@lucide/svelte/icons/arrow-left-right';
|
||||
import BuildingIcon from '@lucide/svelte/icons/building';
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
import { logout } from '$lib/auth';
|
||||
import { cookieName } from '$lib/paraglide/runtime';
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { browser } from '$app/environment';
|
||||
import { resolveUserAvatarUrl } from '$lib/utils';
|
||||
import AppVersion from '$lib/components/app-version.svelte';
|
||||
|
||||
let { user, tenants = [] }: {
|
||||
user: {
|
||||
name: string;
|
||||
email: string;
|
||||
username?: string;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
displayName?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
workspaceAvatarUrl?: string | null;
|
||||
legacyAvatarUrl?: string | null;
|
||||
};
|
||||
tenants: { id: number; name: string; slug: string }[];
|
||||
} = $props();
|
||||
const sidebar = useSidebar();
|
||||
|
||||
// Tenant activo (viene en el JWT como atributo tenant_slug)
|
||||
let currentTenantSlug = $derived((page.data.user as any)?.tenant_slug ?? '');
|
||||
|
||||
// State for tenant switching
|
||||
let switchingTenant = $state(false);
|
||||
|
||||
let avatarLoadFailed = $state(false);
|
||||
|
||||
// URL de avatar con prioridad: Workspace -> legado
|
||||
let avatarUrl = $derived(
|
||||
avatarLoadFailed
|
||||
? ''
|
||||
: resolveUserAvatarUrl(
|
||||
user.workspaceAvatarUrl ?? null,
|
||||
user.legacyAvatarUrl ?? user.avatarUrl ?? null
|
||||
)
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
console.debug('[avatar][nav-user] URL final usada por Avatar.Image:', avatarUrl || '(fallback)');
|
||||
});
|
||||
|
||||
// Nombre a mostrar: displayName > firstName + lastName > name > username
|
||||
let displayName = $derived(
|
||||
user.displayName ||
|
||||
(user.firstName && user.lastName ? `${user.firstName} ${user.lastName}`.trim() : null) ||
|
||||
user.name ||
|
||||
user.username ||
|
||||
'User'
|
||||
);
|
||||
|
||||
// Iniciales del usuario (2 primeras letras de displayName)
|
||||
let initials = $derived(displayName.slice(0, 2).toUpperCase());
|
||||
|
||||
function handleAvatarError() {
|
||||
avatarLoadFailed = true;
|
||||
}
|
||||
|
||||
// Estado reactivo del idioma actual
|
||||
let currentLocale = $derived(page.data.locale || 'en');
|
||||
|
||||
// Estado reactivo del tema actual
|
||||
let isDarkMode = $state(false);
|
||||
|
||||
// Inicializar el estado del tema al montar el componente
|
||||
$effect(() => {
|
||||
if (browser) {
|
||||
// Cargar la preferencia guardada o usar el valor actual del HTML
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
if (savedTheme) {
|
||||
isDarkMode = savedTheme === 'dark';
|
||||
if (savedTheme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
} else {
|
||||
// Si no hay preferencia guardada, usar el valor actual
|
||||
isDarkMode = document.documentElement.classList.contains('dark');
|
||||
// Guardar el estado actual
|
||||
localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function handleLogout() {
|
||||
await logout();
|
||||
}
|
||||
|
||||
async function switchTenant(slug: string) {
|
||||
if (slug === currentTenantSlug || switchingTenant) return;
|
||||
switchingTenant = true;
|
||||
try {
|
||||
const res = await fetch('/api-sveltekit/auth/switch-tenant', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tenant_slug: slug })
|
||||
});
|
||||
if (res.ok) {
|
||||
// Recargar el dashboard con los nuevos tokens
|
||||
window.location.href = '/dashboard';
|
||||
} else {
|
||||
console.error('Error al cambiar de organización');
|
||||
}
|
||||
} finally {
|
||||
switchingTenant = false;
|
||||
}
|
||||
}
|
||||
|
||||
function navigateToAccount() {
|
||||
goto('/dashboard/account');
|
||||
}
|
||||
|
||||
function toggleLanguage() {
|
||||
if (!browser) return;
|
||||
|
||||
// Leer la cookie actual para obtener el idioma real
|
||||
const cookies = document.cookie.split(';').map((c) => c.trim());
|
||||
const localeCookie = cookies.find((c) => c.startsWith(`${cookieName}=`));
|
||||
const current = localeCookie ? localeCookie.split('=')[1] : 'en';
|
||||
|
||||
// Alternar el idioma
|
||||
const newLocale = current === 'en' ? 'es' : 'en';
|
||||
|
||||
// Establecer la cookie del idioma
|
||||
document.cookie = `${cookieName}=${newLocale}; path=/; max-age=34560000; SameSite=Lax`;
|
||||
|
||||
// Recargar la página para que el servidor procese el nuevo idioma
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
if (!browser) return;
|
||||
|
||||
const html = document.documentElement;
|
||||
const newTheme = html.classList.contains('dark') ? 'light' : 'dark';
|
||||
|
||||
if (newTheme === 'dark') {
|
||||
html.classList.add('dark');
|
||||
} else {
|
||||
html.classList.remove('dark');
|
||||
}
|
||||
|
||||
// Guardar la preferencia en localStorage
|
||||
localStorage.setItem('theme', newTheme);
|
||||
isDarkMode = newTheme === 'dark';
|
||||
}
|
||||
</script>
|
||||
|
||||
<Sidebar.Menu>
|
||||
<Sidebar.MenuItem>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Sidebar.MenuButton
|
||||
size="lg"
|
||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
{...props}
|
||||
>
|
||||
<Avatar.Root class="size-8 rounded-lg">
|
||||
<Avatar.Image src={avatarUrl} alt={displayName} onerror={handleAvatarError} />
|
||||
<Avatar.Fallback class="rounded-lg">{initials}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-medium">{displayName}</span>
|
||||
<span class="truncate text-xs">{user.email}</span>
|
||||
</div>
|
||||
<ChevronsUpDownIcon class="ml-auto size-4" />
|
||||
</Sidebar.MenuButton>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
class="w-(--bits-dropdown-menu-anchor-width) min-w-56 rounded-lg"
|
||||
side={sidebar.isMobile ? 'bottom' : 'right'}
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenu.Label class="p-0 font-normal">
|
||||
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar.Root class="size-8 rounded-lg">
|
||||
<Avatar.Image src={avatarUrl} alt={displayName} onerror={handleAvatarError} />
|
||||
<Avatar.Fallback class="rounded-lg">{initials}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-medium">{displayName}</span>
|
||||
<span class="truncate text-xs">{user.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.Item onclick={navigateToAccount}>
|
||||
<BadgeCheckIcon />
|
||||
Account
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item>
|
||||
<CreditCardIcon />
|
||||
Billing
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item>
|
||||
<BellIcon />
|
||||
Notifications
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Group>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onclick={toggleLanguage}>
|
||||
<LanguagesIcon />
|
||||
Language: {currentLocale.toUpperCase()}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={toggleTheme}>
|
||||
{#if isDarkMode}
|
||||
<SunIcon />
|
||||
Light Mode
|
||||
{:else}
|
||||
<MoonIcon />
|
||||
Dark Mode
|
||||
{/if}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
{#if tenants.length > 1}
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger class="gap-2">
|
||||
{#if switchingTenant}
|
||||
<svg class="animate-spin size-4 shrink-0" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
||||
</svg>
|
||||
{:else}
|
||||
<ArrowLeftRightIcon class="size-4" />
|
||||
{/if}
|
||||
Cambiar organización
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent class="min-w-44">
|
||||
<DropdownMenu.Label class="text-xs text-muted-foreground pb-1">
|
||||
Mis organizaciones
|
||||
</DropdownMenu.Label>
|
||||
{#each tenants as tenant (tenant.id)}
|
||||
<DropdownMenu.Item
|
||||
class="gap-2 cursor-pointer"
|
||||
disabled={switchingTenant}
|
||||
onclick={() => switchTenant(tenant.slug)}
|
||||
>
|
||||
<BuildingIcon class="size-4 shrink-0 text-muted-foreground" />
|
||||
<span class="flex-1 truncate">{tenant.name}</span>
|
||||
{#if tenant.slug === currentTenantSlug}
|
||||
<CheckIcon class="size-3.5 shrink-0 text-blue-600" />
|
||||
{/if}
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
{/if}
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onclick={handleLogout}>
|
||||
<LogOutIcon />
|
||||
Log out
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<div class="px-2 py-2">
|
||||
<AppVersion />
|
||||
</div>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Sidebar.MenuItem>
|
||||
</Sidebar.Menu>
|
||||
209
frontend/src/lib/components/sidebar/team-switcher.svelte
Normal file
209
frontend/src/lib/components/sidebar/team-switcher.svelte
Normal file
@@ -0,0 +1,209 @@
|
||||
<script lang="ts">
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||
import { useSidebar } from '$lib/components/ui/sidebar/index.js';
|
||||
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
|
||||
import BuildingIcon from '@lucide/svelte/icons/building';
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { getBackendAssetUrl } from '$lib/utils';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
|
||||
interface Tenant {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
let { userTenants = [] }: { userTenants: Tenant[] } = $props();
|
||||
|
||||
const sidebar = useSidebar();
|
||||
|
||||
let switchingTenant = $state(false);
|
||||
|
||||
// Logo de la compañía activa — implementa tu propio endpoint si lo necesitas
|
||||
let activeCompanyLogoUrl = $derived<string | null>(null);
|
||||
|
||||
// Iniciales de la compañía activa (2 primeras letras)
|
||||
let activeCompanyInitials = $derived(
|
||||
companyStore.activeCompany?.name?.slice(0, 2).toUpperCase() || 'CO'
|
||||
);
|
||||
|
||||
function readCookie(name: string): string | null {
|
||||
if (typeof document === 'undefined') return null;
|
||||
const value = `; ${document.cookie}`;
|
||||
const parts = value.split(`; ${name}=`);
|
||||
if (parts.length === 2) return parts.pop()?.split(';').shift() ?? null;
|
||||
return null;
|
||||
}
|
||||
|
||||
let activeTenantPubId = $derived.by<number | null>(() => {
|
||||
const fromCookie = readCookie('sso_tenant_pub');
|
||||
if (fromCookie && !Number.isNaN(Number(fromCookie))) return Number(fromCookie);
|
||||
return null;
|
||||
});
|
||||
|
||||
async function switchTenant(tenant: Tenant) {
|
||||
if (switchingTenant) return;
|
||||
switchingTenant = true;
|
||||
try {
|
||||
const res = await fetch('/api-sveltekit/auth/switch-tenant', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tenant_id: tenant.id }),
|
||||
credentials: 'include',
|
||||
});
|
||||
if (res.ok) {
|
||||
companyStore.clear();
|
||||
await invalidateAll();
|
||||
// Reinicializar el store con las compañías del nuevo tenant
|
||||
// (loadCompanies sin args hace fetch al backend que ya tiene la nueva cookie de tenant)
|
||||
await companyStore.loadCompanies();
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
console.error('[team-switcher] switch-tenant error:', err);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[team-switcher] fetch error:', e);
|
||||
} finally {
|
||||
switchingTenant = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Misma lista que devuelve my-companies; no filtrar por tenant (nombre/slug equivalentes
|
||||
// ocultaban la empresa creada al primer login).
|
||||
let myCompanies = $derived(companyStore.companies);
|
||||
</script>
|
||||
|
||||
<Sidebar.Menu>
|
||||
<Sidebar.MenuItem>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Sidebar.MenuButton
|
||||
{...props}
|
||||
size="lg"
|
||||
class="relative overflow-hidden group-has-data-[state=collapsed]/sidebar-wrapper:aspect-square group-has-data-[state=collapsed]/sidebar-wrapper:h-10 group-has-data-[state=collapsed]/sidebar-wrapper:w-10 group-has-data-[state=collapsed]/sidebar-wrapper:justify-center group-has-data-[state=collapsed]/sidebar-wrapper:gap-0 group-has-data-[state=collapsed]/sidebar-wrapper:rounded-lg data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<div
|
||||
class="flex aspect-square size-8 items-center justify-center overflow-hidden rounded-lg bg-sidebar-primary text-sidebar-primary-foreground group-has-data-[state=collapsed]/sidebar-wrapper:hidden"
|
||||
>
|
||||
{#if activeCompanyLogoUrl}
|
||||
<img
|
||||
src={activeCompanyLogoUrl}
|
||||
alt={companyStore.activeCompany?.name || 'Company'}
|
||||
class="size-full object-cover"
|
||||
onerror={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<span class="text-sm font-semibold text-white">{activeCompanyInitials}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="grid min-w-0 flex-1 text-left text-sm leading-tight group-has-data-[state=collapsed]/sidebar-wrapper:hidden"
|
||||
>
|
||||
<span class="truncate font-medium">
|
||||
{companyStore.activeCompany?.name || 'Seleccionar compañía'}
|
||||
</span>
|
||||
{#if companyStore.activeCompany?.rfc}
|
||||
<span class="truncate text-xs text-muted-foreground">
|
||||
{companyStore.activeCompany.rfc}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<ChevronsUpDownIcon
|
||||
class="ml-auto size-4 group-has-data-[state=collapsed]/sidebar-wrapper:hidden"
|
||||
/>
|
||||
|
||||
<!-- Isotipo compacto visible solo en modo colapsado -->
|
||||
<div
|
||||
class="relative z-10 hidden size-8 items-center justify-center overflow-hidden rounded-md bg-sidebar-primary text-sm font-semibold text-sidebar-foreground shadow-sm ring-1 ring-sidebar-border/40 group-has-data-[state=collapsed]/sidebar-wrapper:flex"
|
||||
>
|
||||
{#if activeCompanyLogoUrl}
|
||||
<img
|
||||
src={activeCompanyLogoUrl}
|
||||
alt={companyStore.activeCompany?.name || 'Company'}
|
||||
class="size-full object-cover"
|
||||
onerror={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
{activeCompanyInitials}
|
||||
{/if}
|
||||
</div>
|
||||
</Sidebar.MenuButton>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
class="w-(--bits-dropdown-menu-anchor-width) min-w-56 rounded-lg"
|
||||
align="start"
|
||||
side={sidebar.isMobile ? 'bottom' : 'right'}
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenu.Label class="text-xs text-muted-foreground">Tenant</DropdownMenu.Label>
|
||||
{#if userTenants.length === 0}
|
||||
<DropdownMenu.Item disabled class="gap-2 p-2">
|
||||
<span class="text-muted-foreground">Sin tenant asignado</span>
|
||||
</DropdownMenu.Item>
|
||||
{:else}
|
||||
{#each userTenants as tenant (tenant.id)}
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => switchTenant(tenant)}
|
||||
class="cursor-pointer gap-2 p-2"
|
||||
disabled={switchingTenant}
|
||||
>
|
||||
<div class="flex size-6 items-center justify-center rounded-md border bg-muted">
|
||||
<BuildingIcon class="size-3.5" />
|
||||
</div>
|
||||
<span class="truncate font-medium">{tenant.name}</span>
|
||||
{#if activeTenantPubId === tenant.id}
|
||||
<CheckIcon class="ml-auto size-4 text-primary" />
|
||||
{/if}
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
{/if}
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<DropdownMenu.Label class="text-xs text-muted-foreground">Mis compañías</DropdownMenu.Label>
|
||||
|
||||
{#if companyStore.loading}
|
||||
<DropdownMenu.Item disabled class="gap-2 p-2">
|
||||
<span class="text-muted-foreground">Cargando...</span>
|
||||
</DropdownMenu.Item>
|
||||
{:else if myCompanies.length === 0}
|
||||
<DropdownMenu.Item disabled class="gap-2 p-2">
|
||||
<span class="text-muted-foreground">No tienes compañías disponibles</span>
|
||||
</DropdownMenu.Item>
|
||||
{:else}
|
||||
{#each myCompanies as company, index (company.id)}
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => companyStore.setActiveCompany(company)}
|
||||
class="cursor-pointer gap-2 p-2"
|
||||
>
|
||||
<div
|
||||
class="flex size-6 items-center justify-center overflow-hidden rounded-md border"
|
||||
>
|
||||
<span class="text-xs font-semibold">{company.name.slice(0, 2).toUpperCase()}</span>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<span class="truncate font-medium">{company.name}</span>
|
||||
{#if company.rfc}
|
||||
<span class="truncate text-xs text-muted-foreground">{company.rfc}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if companyStore.activeCompany?.id === company.id}
|
||||
<CheckIcon class="ml-auto size-4 text-primary" />
|
||||
{/if}
|
||||
{#if index < 9}
|
||||
<DropdownMenu.Shortcut>⌘{index + 1}</DropdownMenu.Shortcut>
|
||||
{/if}
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
{/if}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Sidebar.MenuItem>
|
||||
</Sidebar.Menu>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AlertDialogPrimitive.ActionProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Action
|
||||
bind:ref
|
||||
data-slot="alert-dialog-action"
|
||||
class={cn(buttonVariants(), className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AlertDialogPrimitive.CancelProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Cancel
|
||||
bind:ref
|
||||
data-slot="alert-dialog-cancel"
|
||||
class={cn(buttonVariants({ variant: "outline" }), className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||
import AlertDialogOverlay from "./alert-dialog-overlay.svelte";
|
||||
import { cn, type WithoutChild, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
portalProps,
|
||||
onInteractOutside: userOnInteractOutside,
|
||||
...restProps
|
||||
}: WithoutChild<AlertDialogPrimitive.ContentProps> & {
|
||||
portalProps?: WithoutChildrenOrChild<AlertDialogPrimitive.PortalProps>;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Portal {...portalProps}>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="alert-dialog-content"
|
||||
class={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
onInteractOutside={userOnInteractOutside}
|
||||
{...restProps}
|
||||
/>
|
||||
</AlertDialogPrimitive.Portal>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AlertDialogPrimitive.DescriptionProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Description
|
||||
bind:ref
|
||||
data-slot="alert-dialog-description"
|
||||
class={cn("text-muted-foreground text-sm", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="alert-dialog-footer"
|
||||
class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="alert-dialog-header"
|
||||
class={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AlertDialogPrimitive.OverlayProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Overlay
|
||||
bind:ref
|
||||
data-slot="alert-dialog-overlay"
|
||||
class={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AlertDialogPrimitive.TitleProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Title
|
||||
bind:ref
|
||||
data-slot="alert-dialog-title"
|
||||
class={cn("text-lg font-semibold", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: AlertDialogPrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Trigger bind:ref data-slot="alert-dialog-trigger" {...restProps} />
|
||||
40
frontend/src/lib/components/ui/alert-dialog/index.ts
Normal file
40
frontend/src/lib/components/ui/alert-dialog/index.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { AlertDialog } from "bits-ui";
|
||||
const AlertDialogPrimitive = AlertDialog;
|
||||
import Trigger from "./alert-dialog-trigger.svelte";
|
||||
import Title from "./alert-dialog-title.svelte";
|
||||
import Action from "./alert-dialog-action.svelte";
|
||||
import Cancel from "./alert-dialog-cancel.svelte";
|
||||
import Footer from "./alert-dialog-footer.svelte";
|
||||
import Header from "./alert-dialog-header.svelte";
|
||||
import Overlay from "./alert-dialog-overlay.svelte";
|
||||
import Content from "./alert-dialog-content.svelte";
|
||||
import Description from "./alert-dialog-description.svelte";
|
||||
|
||||
const Root = AlertDialogPrimitive.Root;
|
||||
const Portal = AlertDialogPrimitive.Portal;
|
||||
|
||||
export {
|
||||
Root,
|
||||
Title,
|
||||
Action,
|
||||
Cancel,
|
||||
Portal,
|
||||
Footer,
|
||||
Header,
|
||||
Trigger,
|
||||
Overlay,
|
||||
Content,
|
||||
Description,
|
||||
//
|
||||
Root as AlertDialog,
|
||||
Title as AlertDialogTitle,
|
||||
Action as AlertDialogAction,
|
||||
Cancel as AlertDialogCancel,
|
||||
Portal as AlertDialogPortal,
|
||||
Footer as AlertDialogFooter,
|
||||
Header as AlertDialogHeader,
|
||||
Trigger as AlertDialogTrigger,
|
||||
Overlay as AlertDialogOverlay,
|
||||
Content as AlertDialogContent,
|
||||
Description as AlertDialogDescription,
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="alert-description"
|
||||
class={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
20
frontend/src/lib/components/ui/alert/alert-title.svelte
Normal file
20
frontend/src/lib/components/ui/alert/alert-title.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="alert-title"
|
||||
class={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
44
frontend/src/lib/components/ui/alert/alert.svelte
Normal file
44
frontend/src/lib/components/ui/alert/alert.svelte
Normal file
@@ -0,0 +1,44 @@
|
||||
<script lang="ts" module>
|
||||
import { type VariantProps, tv } from "tailwind-variants";
|
||||
|
||||
export const alertVariants = tv({
|
||||
base: "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
});
|
||||
|
||||
export type AlertVariant = VariantProps<typeof alertVariants>["variant"];
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
variant = "default",
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||
variant?: AlertVariant;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="alert"
|
||||
class={cn(alertVariants({ variant }), className)}
|
||||
{...restProps}
|
||||
role="alert"
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
14
frontend/src/lib/components/ui/alert/index.ts
Normal file
14
frontend/src/lib/components/ui/alert/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import Root from "./alert.svelte";
|
||||
import Description from "./alert-description.svelte";
|
||||
import Title from "./alert-title.svelte";
|
||||
export { alertVariants, type AlertVariant } from "./alert.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Description,
|
||||
Title,
|
||||
//
|
||||
Root as Alert,
|
||||
Description as AlertDescription,
|
||||
Title as AlertTitle,
|
||||
};
|
||||
17
frontend/src/lib/components/ui/avatar/avatar-fallback.svelte
Normal file
17
frontend/src/lib/components/ui/avatar/avatar-fallback.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AvatarPrimitive.FallbackProps = $props();
|
||||
</script>
|
||||
|
||||
<AvatarPrimitive.Fallback
|
||||
bind:ref
|
||||
data-slot="avatar-fallback"
|
||||
class={cn("bg-muted flex size-full items-center justify-center rounded-full", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
17
frontend/src/lib/components/ui/avatar/avatar-image.svelte
Normal file
17
frontend/src/lib/components/ui/avatar/avatar-image.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AvatarPrimitive.ImageProps = $props();
|
||||
</script>
|
||||
|
||||
<AvatarPrimitive.Image
|
||||
bind:ref
|
||||
data-slot="avatar-image"
|
||||
class={cn("aspect-square size-full", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
19
frontend/src/lib/components/ui/avatar/avatar.svelte
Normal file
19
frontend/src/lib/components/ui/avatar/avatar.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
loadingStatus = $bindable("loading"),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AvatarPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<AvatarPrimitive.Root
|
||||
bind:ref
|
||||
bind:loadingStatus
|
||||
data-slot="avatar"
|
||||
class={cn("relative flex size-8 shrink-0 overflow-hidden rounded-full", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
13
frontend/src/lib/components/ui/avatar/index.ts
Normal file
13
frontend/src/lib/components/ui/avatar/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import Root from "./avatar.svelte";
|
||||
import Image from "./avatar-image.svelte";
|
||||
import Fallback from "./avatar-fallback.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Image,
|
||||
Fallback,
|
||||
//
|
||||
Root as Avatar,
|
||||
Image as AvatarImage,
|
||||
Fallback as AvatarFallback,
|
||||
};
|
||||
50
frontend/src/lib/components/ui/badge/badge.svelte
Normal file
50
frontend/src/lib/components/ui/badge/badge.svelte
Normal file
@@ -0,0 +1,50 @@
|
||||
<script lang="ts" module>
|
||||
import { type VariantProps, tv } from "tailwind-variants";
|
||||
|
||||
export const badgeVariants = tv({
|
||||
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-full border px-2 py-0.5 text-xs font-medium transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent",
|
||||
destructive:
|
||||
"bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white",
|
||||
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
});
|
||||
|
||||
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { HTMLAnchorAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
href,
|
||||
class: className,
|
||||
variant = "default",
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAnchorAttributes> & {
|
||||
variant?: BadgeVariant;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<svelte:element
|
||||
this={href ? "a" : "span"}
|
||||
bind:this={ref}
|
||||
data-slot="badge"
|
||||
{href}
|
||||
class={cn(badgeVariants({ variant }), className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</svelte:element>
|
||||
2
frontend/src/lib/components/ui/badge/index.ts
Normal file
2
frontend/src/lib/components/ui/badge/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as Badge } from "./badge.svelte";
|
||||
export { badgeVariants, type BadgeVariant } from "./badge.svelte";
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import EllipsisIcon from "@lucide/svelte/icons/ellipsis";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLSpanElement>>> = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
bind:this={ref}
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
class={cn("flex size-9 items-center justify-center", className)}
|
||||
{...restProps}
|
||||
>
|
||||
<EllipsisIcon class="size-4" />
|
||||
<span class="sr-only">More</span>
|
||||
</span>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLLiAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLLiAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<li
|
||||
bind:this={ref}
|
||||
data-slot="breadcrumb-item"
|
||||
class={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</li>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAnchorAttributes } from "svelte/elements";
|
||||
import type { Snippet } from "svelte";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
href = undefined,
|
||||
child,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAnchorAttributes> & {
|
||||
child?: Snippet<[{ props: HTMLAnchorAttributes }]>;
|
||||
} = $props();
|
||||
|
||||
const attrs = $derived({
|
||||
"data-slot": "breadcrumb-link",
|
||||
class: cn("hover:text-foreground transition-colors", className),
|
||||
href,
|
||||
...restProps,
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: attrs })}
|
||||
{:else}
|
||||
<a bind:this={ref} {...attrs}>
|
||||
{@render children?.()}
|
||||
</a>
|
||||
{/if}
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLOlAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLOlAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<ol
|
||||
bind:this={ref}
|
||||
data-slot="breadcrumb-list"
|
||||
class={cn(
|
||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 break-words text-sm sm:gap-2.5",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</ol>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
bind:this={ref}
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
class={cn("text-foreground font-normal", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</span>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLLiAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLLiAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<li
|
||||
bind:this={ref}
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
class={cn("[&>svg]:size-3.5", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{#if children}
|
||||
{@render children?.()}
|
||||
{:else}
|
||||
<ChevronRightIcon />
|
||||
{/if}
|
||||
</li>
|
||||
21
frontend/src/lib/components/ui/breadcrumb/breadcrumb.svelte
Normal file
21
frontend/src/lib/components/ui/breadcrumb/breadcrumb.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import type { WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||
</script>
|
||||
|
||||
<nav
|
||||
bind:this={ref}
|
||||
data-slot="breadcrumb"
|
||||
class={className}
|
||||
aria-label="breadcrumb"
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</nav>
|
||||
25
frontend/src/lib/components/ui/breadcrumb/index.ts
Normal file
25
frontend/src/lib/components/ui/breadcrumb/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import Root from "./breadcrumb.svelte";
|
||||
import Ellipsis from "./breadcrumb-ellipsis.svelte";
|
||||
import Item from "./breadcrumb-item.svelte";
|
||||
import Separator from "./breadcrumb-separator.svelte";
|
||||
import Link from "./breadcrumb-link.svelte";
|
||||
import List from "./breadcrumb-list.svelte";
|
||||
import Page from "./breadcrumb-page.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Ellipsis,
|
||||
Item,
|
||||
Separator,
|
||||
Link,
|
||||
List,
|
||||
Page,
|
||||
//
|
||||
Root as Breadcrumb,
|
||||
Ellipsis as BreadcrumbEllipsis,
|
||||
Item as BreadcrumbItem,
|
||||
Separator as BreadcrumbSeparator,
|
||||
Link as BreadcrumbLink,
|
||||
List as BreadcrumbList,
|
||||
Page as BreadcrumbPage,
|
||||
};
|
||||
82
frontend/src/lib/components/ui/button/button.svelte
Normal file
82
frontend/src/lib/components/ui/button/button.svelte
Normal file
@@ -0,0 +1,82 @@
|
||||
<script lang="ts" module>
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
|
||||
import { type VariantProps, tv } from "tailwind-variants";
|
||||
|
||||
export const buttonVariants = tv({
|
||||
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white",
|
||||
outline:
|
||||
"bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border",
|
||||
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
});
|
||||
|
||||
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
|
||||
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
|
||||
|
||||
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
|
||||
WithElementRef<HTMLAnchorAttributes> & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
let {
|
||||
class: className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
ref = $bindable(null),
|
||||
href = undefined,
|
||||
type = "button",
|
||||
disabled,
|
||||
children,
|
||||
...restProps
|
||||
}: ButtonProps = $props();
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a
|
||||
bind:this={ref}
|
||||
data-slot="button"
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
href={disabled ? undefined : href}
|
||||
aria-disabled={disabled}
|
||||
role={disabled ? "link" : undefined}
|
||||
tabindex={disabled ? -1 : undefined}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
bind:this={ref}
|
||||
data-slot="button"
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
{type}
|
||||
{disabled}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
{/if}
|
||||
17
frontend/src/lib/components/ui/button/index.ts
Normal file
17
frontend/src/lib/components/ui/button/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import Root, {
|
||||
type ButtonProps,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
buttonVariants,
|
||||
} from "./button.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
type ButtonProps as Props,
|
||||
//
|
||||
Root as Button,
|
||||
buttonVariants,
|
||||
type ButtonProps,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
};
|
||||
20
frontend/src/lib/components/ui/card/card-action.svelte
Normal file
20
frontend/src/lib/components/ui/card/card-action.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-action"
|
||||
class={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
15
frontend/src/lib/components/ui/card/card-content.svelte
Normal file
15
frontend/src/lib/components/ui/card/card-content.svelte
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div bind:this={ref} data-slot="card-content" class={cn("px-6", className)} {...restProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
20
frontend/src/lib/components/ui/card/card-description.svelte
Normal file
20
frontend/src/lib/components/ui/card/card-description.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
|
||||
</script>
|
||||
|
||||
<p
|
||||
bind:this={ref}
|
||||
data-slot="card-description"
|
||||
class={cn("text-muted-foreground text-sm", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</p>
|
||||
20
frontend/src/lib/components/ui/card/card-footer.svelte
Normal file
20
frontend/src/lib/components/ui/card/card-footer.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-footer"
|
||||
class={cn("[.border-t]:pt-6 flex items-center px-6", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
23
frontend/src/lib/components/ui/card/card-header.svelte
Normal file
23
frontend/src/lib/components/ui/card/card-header.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-header"
|
||||
class={cn(
|
||||
"@container/card-header has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6 grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
20
frontend/src/lib/components/ui/card/card-title.svelte
Normal file
20
frontend/src/lib/components/ui/card/card-title.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-title"
|
||||
class={cn("font-semibold leading-none", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
23
frontend/src/lib/components/ui/card/card.svelte
Normal file
23
frontend/src/lib/components/ui/card/card.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card"
|
||||
class={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
25
frontend/src/lib/components/ui/card/index.ts
Normal file
25
frontend/src/lib/components/ui/card/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import Root from "./card.svelte";
|
||||
import Content from "./card-content.svelte";
|
||||
import Description from "./card-description.svelte";
|
||||
import Footer from "./card-footer.svelte";
|
||||
import Header from "./card-header.svelte";
|
||||
import Title from "./card-title.svelte";
|
||||
import Action from "./card-action.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
Description,
|
||||
Footer,
|
||||
Header,
|
||||
Title,
|
||||
Action,
|
||||
//
|
||||
Root as Card,
|
||||
Content as CardContent,
|
||||
Description as CardDescription,
|
||||
Footer as CardFooter,
|
||||
Header as CardHeader,
|
||||
Title as CardTitle,
|
||||
Action as CardAction,
|
||||
};
|
||||
36
frontend/src/lib/components/ui/checkbox/checkbox.svelte
Normal file
36
frontend/src/lib/components/ui/checkbox/checkbox.svelte
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { Checkbox as CheckboxPrimitive } from "bits-ui";
|
||||
import CheckIcon from "@lucide/svelte/icons/check";
|
||||
import MinusIcon from "@lucide/svelte/icons/minus";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
checked = $bindable(false),
|
||||
indeterminate = $bindable(false),
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<CheckboxPrimitive.RootProps> = $props();
|
||||
</script>
|
||||
|
||||
<CheckboxPrimitive.Root
|
||||
bind:ref
|
||||
data-slot="checkbox"
|
||||
class={cn(
|
||||
"border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive peer flex size-4 shrink-0 items-center justify-center rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
bind:checked
|
||||
bind:indeterminate
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ checked, indeterminate })}
|
||||
<div data-slot="checkbox-indicator" class="text-current transition-none">
|
||||
{#if checked}
|
||||
<CheckIcon class="size-3.5" />
|
||||
{:else if indeterminate}
|
||||
<MinusIcon class="size-3.5" />
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</CheckboxPrimitive.Root>
|
||||
6
frontend/src/lib/components/ui/checkbox/index.ts
Normal file
6
frontend/src/lib/components/ui/checkbox/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import Root from "./checkbox.svelte";
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Checkbox,
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: CollapsiblePrimitive.ContentProps = $props();
|
||||
</script>
|
||||
|
||||
<CollapsiblePrimitive.Content bind:ref data-slot="collapsible-content" {...restProps} />
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: CollapsiblePrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<CollapsiblePrimitive.Trigger bind:ref data-slot="collapsible-trigger" {...restProps} />
|
||||
@@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
open = $bindable(false),
|
||||
...restProps
|
||||
}: CollapsiblePrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<CollapsiblePrimitive.Root bind:ref bind:open data-slot="collapsible" {...restProps} />
|
||||
13
frontend/src/lib/components/ui/collapsible/index.ts
Normal file
13
frontend/src/lib/components/ui/collapsible/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import Root from "./collapsible.svelte";
|
||||
import Trigger from "./collapsible-trigger.svelte";
|
||||
import Content from "./collapsible-content.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
Trigger,
|
||||
//
|
||||
Root as Collapsible,
|
||||
Content as CollapsibleContent,
|
||||
Trigger as CollapsibleTrigger,
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user