feat: plantilla base workspace SaaS
Some checks failed
Build Producción & Push a Harbor / test (push) Failing after 3s
Build Producción & Push a Harbor / build (push) Has been skipped
Aduanasoft/plantillas-proyectos/pipeline/head There was a failure building this commit

This commit is contained in:
2026-07-21 13:59:00 -05:00
commit bdd089954b
470 changed files with 70022 additions and 0 deletions

188
frontend/src/app.css Normal file
View 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
View 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
View 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>

View 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);
});
});

View 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
View 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;

View 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*

View 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}`;
});
}

View 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;

View 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
View 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)
};

View 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';

View 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;
}
};

View 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;
}
};

View 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}`);
}
};

View 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}`
);
}
};

View 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}`);
}
};

View File

@@ -0,0 +1 @@
export type { KPIMetric, ActivityItem, ChartDataPoint } from './types';

View 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!;
}
};

View 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;
}

View 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!;
}
};

View 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();
}
};

View 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

File diff suppressed because it is too large Load Diff

View 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)
})
})

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"
]
};

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View 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}

View 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}

View 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>

View 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>

View 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>

View 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>

View 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,
},
};
}

View 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>

View 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>

View 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>

View 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>

View File

@@ -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}
/>

View File

@@ -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}
/>

View File

@@ -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>

View File

@@ -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}
/>

View 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="alert-dialog-footer"
class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...restProps}
>
{@render children?.()}
</div>

View 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-dialog-header"
class={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -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}
/>

View File

@@ -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}
/>

View File

@@ -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} />

View 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,
};

View 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="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>

View 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>

View 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>

View 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,
};

View 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}
/>

View 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}
/>

View 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}
/>

View 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,
};

View 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>

View File

@@ -0,0 +1,2 @@
export { default as Badge } from "./badge.svelte";
export { badgeVariants, type BadgeVariant } from "./badge.svelte";

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>

View 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<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>

View File

@@ -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>

View 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>

View 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,
};

View 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}

View 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,
};

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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,
};

View 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>

View File

@@ -0,0 +1,6 @@
import Root from "./checkbox.svelte";
export {
Root,
//
Root as Checkbox,
};

View File

@@ -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} />

View File

@@ -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} />

View File

@@ -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} />

View 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