chore: baseline plantilla-proyectos como base del CRM

This commit is contained in:
Aduanasoft
2026-07-14 09:03:52 -06:00
commit c3d0eedc8d
469 changed files with 69739 additions and 0 deletions

45
frontend/.env.example Normal file
View File

@@ -0,0 +1,45 @@
# ─── Copia este archivo a .env y ajusta los valores para dev local ─────────────
# ─── Para PRODUCCIÓN ver el bloque al final de este archivo ──────────────────
# Auth local (sin Keycloak/Hub) — pon true para desarrollo standalone
# El backend también necesita DEV_LOCAL_AUTH=True
DEV_LOCAL_AUTH=false
# URL interna del backend (usada por el servidor SvelteKit en SSR, no por el browser)
BACKEND_URL=http://backend:8000
# API de Mi Aplicación (frontend y SSR)
VITE_API_URL=http://localhost:8000/api/
INTERNAL_API_URL=http://localhost:8000/api/
# Hub Workspace
VITE_HUB_URL=http://localhost:3001
HUB_URL=http://localhost:3001
INTERNAL_HUB_URL=http://localhost:8001
# Keycloak — URL pública que el BROWSER usará (se bakea en el build)
VITE_KEYCLOAK_URL=http://localhost:8085/kcauth
VITE_KEYCLOAK_REALM=master
VITE_KEYCLOAK_CLIENT_ID=app-frontend
# Keycloak — URL interna que el SERVIDOR usará (no va al browser)
KEYCLOAK_URL=http://localhost:8085/kcauth
KEYCLOAK_REALM=master
KEYCLOAK_CLIENT_ID=app-frontend
# KEYCLOAK_CLIENT_SECRET= # solo si el cliente KC no es público
# SvelteKit — necesario para cookies secure y URLs SSR correctas.
# ⚠️ En producción DEBE apuntar al dominio público real, no a localhost.
# Si este valor es localhost, url.origin en los load functions será localhost
# y los redirect_uri de Keycloak apuntarán a localhost (bug de login).
ORIGIN=http://localhost:5173
# ─── PRODUCCIÓN: vars adicionales críticas ────────────────────────────────────
# SITE_URL es el fallback de seguridad cuando ORIGIN no se pudo corregir a tiempo.
# El código lo usa para construir redirect_uri cuando url.origin es localhost.
# Recomendado: definir TANTO ORIGIN como SITE_URL con el mismo valor en prod.
#
# SITE_URL=https://anexo76-dev.aduanasoft.com
# ORIGIN=https://anexo76-dev.aduanasoft.com
# VITE_HUB_URL=https://hub-dev.aduanasoft.com (o la URL del Hub en prod)
# HUB_URL=https://hub-dev.aduanasoft.com
# VITE_KEYCLOAK_URL= # vacío → se deriva del hostname del browser automáticamente
# KEYCLOAK_URL=http://keycloak:8080 # URL interna del contenedor KC (si en Docker)

35
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,35 @@
test-results
# Playwright E2E: no versionar sesión, reportes ni estado compartido generado
e2e/.auth/user.json
e2e/.e2e-*.json
playwright-report/
blob-report/
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
.vite/
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Paraglide
src/lib/paraglide
frontend/project.inlang/cache/

1
frontend/.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

9
frontend/.prettierignore Normal file
View File

@@ -0,0 +1,9 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb
# Miscellaneous
/static/

16
frontend/.prettierrc Normal file
View File

@@ -0,0 +1,16 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
],
"tailwindStylesheet": "./src/app.css"
}

34
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,34 @@
FROM node:20-alpine
WORKDIR /app
# Instalar dependencias del sistema necesarias para healthchecks
RUN apk update && apk add --no-cache ca-certificates wget && update-ca-certificates
# Copiar package files
COPY package.json pnpm-lock.yaml ./
# Instalar pnpm
RUN npm config set strict-ssl false
RUN npm install -g pnpm
# Instalar dependencias
RUN pnpm install
# Entrypoint (espera API backend en dev; no montar desde el host)
COPY docker-entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Copiar código
COPY . .
# Build (para producción)
# RUN pnpm run build
# Exponer puerto
EXPOSE 5173
ENTRYPOINT ["/entrypoint.sh"]
# Comando por defecto (desarrollo)
CMD ["pnpm", "run", "dev", "--", "--host", "0.0.0.0"]

89
frontend/Dockerfile.prod Normal file
View File

@@ -0,0 +1,89 @@
# ==========================
# Etapa de build
# ==========================
FROM node:22-alpine AS build
# Directorio de trabajo
WORKDIR /app
# Configurar npm para trabajar con certificados autofirmados e instalar pnpm
RUN npm config set strict-ssl false && \
npm install -g pnpm
# Copiar archivos de dependencias
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
# Instalar dependencias con pnpm
RUN pnpm install --frozen-lockfile
ARG VITE_API_URL
ENV VITE_API_URL=${VITE_API_URL}
ARG VITE_KEYCLOAK_URL
ENV VITE_KEYCLOAK_URL=${VITE_KEYCLOAK_URL}
ARG VITE_KEYCLOAK_REALM=master
ENV VITE_KEYCLOAK_REALM=${VITE_KEYCLOAK_REALM}
ARG VITE_KEYCLOAK_CLIENT_ID=anexo76-frontend
ENV VITE_KEYCLOAK_CLIENT_ID=${VITE_KEYCLOAK_CLIENT_ID}
# URL pública del Hub Workspace (bakeada en build para el browser)
ARG VITE_HUB_URL=https://workspace.aduanasoft.com
ENV VITE_HUB_URL=${VITE_HUB_URL}
ARG INTERNAL_API_URL
ENV INTERNAL_API_URL=${INTERNAL_API_URL}
# Copiar el resto del código
COPY . .
# Construir el proyecto
RUN pnpm run build
# ==========================
# Etapa de ejecución con Node.js
# ==========================
FROM node:22-alpine AS runtime
WORKDIR /app
RUN apk add --no-cache wget
RUN npm config set strict-ssl false && \
npm install -g pnpm
# Crear usuario no-root para seguridad antes de copiar con --chown
RUN addgroup -g 1001 -S nodejs
RUN adduser -S svelte -u 1001
COPY docker-entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Copiar solo archivos necesarios para producción y aplicar propietario en la copia
COPY --from=build --chown=svelte:nodejs /app/build ./build
COPY --from=build --chown=svelte:nodejs /app/package.json ./
COPY --from=build --chown=svelte:nodejs /app/node_modules ./node_modules
# WORKDIR /app queda owned por root; pnpm necesita crear _tmp_* en el cwd al ejecutar scripts.
RUN chown svelte:nodejs /app
USER svelte
# Puerto para SvelteKit con adapter-node
EXPOSE 5173
# Variables de entorno
ENV NODE_ENV=production
ENV PORT=5173
ENV HOST=0.0.0.0
# IMPORTANTE: Estas variables se pueden sobrescribir en docker-compose
# pero necesitamos valores por defecto para el build
ENV INTERNAL_API_URL=http://backend:8000/api/
ENTRYPOINT ["/entrypoint.sh"]
# Ejecutar aplicación con Node.js
CMD ["pnpm", "start"]

41
frontend/README.md Normal file
View File

@@ -0,0 +1,41 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project in the current directory
npx sv create
# create a new project in my-app
npx sv create my-app
# compile paraglide
cd frontend && sudo rm -rf src/lib/paraglide && pnpm paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

16
frontend/components.json Normal file
View File

@@ -0,0 +1,16 @@
{
"$schema": "https://shadcn-svelte.com/schema.json",
"tailwind": {
"css": "src/app.css",
"baseColor": "zinc"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks",
"lib": "$lib"
},
"typescript": true,
"registry": "https://shadcn-svelte.com/registry"
}

View File

@@ -0,0 +1,44 @@
#!/bin/sh
set -e
# Arranque del contenedor: opcionalmente espera al health del backend, luego ejecuta CMD (pnpm dev / start, …)
wait_for_backend() {
local url=$1
local max_attempts=30
local attempt=1
echo "Esperando a que el backend esté disponible en ${url}..."
while [ $attempt -le $max_attempts ]; do
# NOTA: usar la URL tal cual la pasa el caller. Antes esta función agregaba
# un "/health" extra al final (resultando en /api/health/health → 404), lo
# que provocaba que el entrypoint esperara 30 intentos en vano antes de
# arrancar Vite, sumando ~90 s muertos al arranque del frontend.
if wget -q -O /dev/null "${url}" 2>/dev/null; then
echo "✓ Backend está listo"
return 0
fi
echo "Backend no está listo aún... (intento $attempt/$max_attempts)"
attempt=$((attempt + 1))
sleep 3
done
echo "⚠ WARNING: Backend no estuvo disponible, continuando de todas formas"
return 0
}
# compose usa INTERNAL_API_URL; alias opcional BACKEND_INTERNAL_URL
_api_base="${INTERNAL_API_URL:-${BACKEND_INTERNAL_URL:-http://backend:8000/api}}"
_api_base="${_api_base%/}"
wait_for_backend "${_api_base}/health"
# En desarrollo (volumen montado) reinstala deps si cambia package.json
if [ "$NODE_ENV" = "development" ]; then
echo "Instalando dependencias (development)..."
CI=true pnpm install
fi
echo "Iniciando: $*"
exec "$@"

View File

@@ -0,0 +1,188 @@
import { test, expect, type Page } from '@playwright/test'
import * as fs from 'fs'
import { fileURLToPath } from 'url'
import * as path from 'path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const SHARED_FILE = path.join(__dirname, '.e2e-catalog.json')
function saveCatalog(data: Record<string, string>) {
fs.writeFileSync(SHARED_FILE, JSON.stringify(data))
}
// Clase y parte con fraccion valida del catalogo SITAR
// 8471.30.01 es una fraccion comun para equipos de computo — existe en SITAR
const CLASS_CODE = 'E2E01'
const CLASS_FRACTION = '12787'
// HTS-style code resolvable via SITAR fracciones-usa (8 or 10 digit patterns used in UI)
const CLASS_US_FRACTION = '8471300100'
const CLASS_UM = 'KGS'
const CLASS_MATERIAL_KEY = 'MP'
const CLASS_DESC_ES = 'Clase E2E Test'
const CLASS_DESC_EN = 'E2E Test Class'
const PART_NUMBER = 'E2E-PART-001'
const PART_DESC_ES = 'Parte E2E Test'
async function fillInput(page: Page, selector: string, value: string) {
await page.locator(selector).click()
await page.evaluate(({ sel, val }: { sel: string; val: string }) => {
const el = document.querySelector(sel) as HTMLInputElement
if (!el) return
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
setter?.call(el, val)
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
}, { sel: selector, val: value })
await page.waitForTimeout(300)
}
test.describe('Setup — Catalogos para pruebas E2E', () => {
test('1. crear clase valida', async ({ page }) => {
await page.goto('/dashboard/goods/fixed-asset-classes')
await page.waitForLoadState('networkidle')
// Abrir dialog de nueva clase
await page.getByRole('button', { name: /Insertar|Nueva Clase|Nuevo/ }).first().click()
await page.waitForTimeout(800)
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 })
// Llenar campos
await page.locator('#class_code').scrollIntoViewIfNeeded()
await fillInput(page, '#class_code', CLASS_CODE)
await page.locator('#material_key').scrollIntoViewIfNeeded()
await fillInput(page, '#material_key', CLASS_MATERIAL_KEY)
await page.locator('#description_es').scrollIntoViewIfNeeded()
await fillInput(page, '#description_es', CLASS_DESC_ES)
await page.locator('#description_en').scrollIntoViewIfNeeded()
await fillInput(page, '#description_en', CLASS_DESC_EN)
await page.locator('#unit_of_measure').scrollIntoViewIfNeeded()
await fillInput(page, '#unit_of_measure', CLASS_UM)
await page.locator('#fraction').scrollIntoViewIfNeeded()
await fillInput(page, '#fraction', CLASS_FRACTION)
// El input #fraction abre TariffFractionSelector (SITAR) como dialog anidado.
// El onclick está en el <tr> — hacer click forzado en la primera fila lo cierra.
await page.waitForTimeout(1500)
const sitarDialog = page.getByRole('dialog', { name: /SITAR/i })
if (await sitarDialog.isVisible().catch(() => false)) {
await sitarDialog.locator('tbody tr').first()
.click({ force: true, timeout: 5000 })
.catch(async () => {
// Fallback: cerrar con Close si no podemos clickear fila
await sitarDialog.getByRole('button', { name: /Close|Cerrar/ })
.click({ force: true }).catch(() => {})
})
await sitarDialog.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {})
}
await page.locator('#us_fraction').scrollIntoViewIfNeeded()
await fillInput(page, '#us_fraction', CLASS_US_FRACTION)
await page.waitForTimeout(1500)
// Mismo patrón para catálogo de fracciones americanas si abre.
const usDialog = page.getByRole('dialog', { name: /AMERICANA|US/i })
if (await usDialog.isVisible().catch(() => false)) {
await usDialog.locator('tbody tr').first()
.click({ force: true, timeout: 5000 })
.catch(async () => {
await usDialog.getByRole('button', { name: /Close|Cerrar/ })
.click({ force: true }).catch(() => {})
})
await usDialog.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {})
}
// Guardar
await page.getByRole('button', { name: /^Guardar$/ }).scrollIntoViewIfNeeded()
await page.getByRole('button', { name: /^Guardar$/ }).click()
await page.waitForTimeout(3000)
// Si aparece un error visible, mostrarlo para diagnóstico (no toleramos silencioso).
const errorEl = page.locator('.text-destructive, [role="alert"]').first()
if (await errorEl.isVisible({ timeout: 500 }).catch(() => false)) {
const txt = await errorEl.textContent()
console.log('[DEBUG][crear clase] Error visible:', txt)
}
saveCatalog({ CLASS_CODE, PART_NUMBER })
await expect(page.locator('main')).toBeVisible()
})
test('2. verificar clase en tabla', async ({ page }) => {
await page.goto('/dashboard/goods/fixed-asset-classes')
await page.waitForLoadState('networkidle')
// Buscar la clase creada en la tabla
const classRow = page.locator('tbody').getByText(CLASS_CODE)
if (await classRow.isVisible({ timeout: 5000 }).catch(() => false)) {
await expect(classRow.first()).toBeVisible()
} else {
// La clase puede no aparecer si ya existia — OK
console.log('Clase no encontrada en tabla — puede ya existir con otro nombre')
}
})
test('3. crear parte valida', async ({ page }) => {
// Las partes se crean en /dashboard/goods/parts/edit/new
await page.goto('/dashboard/goods/parts/edit/new')
await page.waitForLoadState('networkidle')
await page.waitForTimeout(800)
// PrerequisitesModal: AlertDialog "Aviso" que abre cuando la DB no tiene
// Agentes aduanales o Clientes registrados (caso típico en E2E con DB limpia).
// Bloquea pointer events con un overlay sobre todo el form. Cancelar te saca
// del editor; Aceptar lo cierra y permite continuar.
// Ver routes/dashboard/goods/parts/edit/[[id]]/+page.svelte:29-38.
const prerequisitesDialog = page.getByRole('alertdialog', { name: /Aviso/i })
if (await prerequisitesDialog.isVisible({ timeout: 2000 }).catch(() => false)) {
await prerequisitesDialog.getByRole('button', { name: /^Aceptar$/ }).click()
await prerequisitesDialog.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {})
}
// Llenar campos del formulario de nueva parte
// Numero de parte
const partInput = page.locator('#part_number, input[placeholder*="parte"], input[placeholder*="número"], input[name="part_number"]').first()
if (await partInput.isVisible({ timeout: 3000 }).catch(() => false)) {
await partInput.scrollIntoViewIfNeeded()
await fillInput(page, '#part_number', PART_NUMBER).catch(async () => {
await partInput.fill(PART_NUMBER)
})
}
// Descripcion en español
const descInput = page.locator('#description_es, textarea[placeholder*="español"], input[placeholder*="escripción"]').first()
if (await descInput.isVisible({ timeout: 2000 }).catch(() => false)) {
await descInput.scrollIntoViewIfNeeded()
await descInput.fill(PART_DESC_ES).catch(() => {})
}
// Clase — puede ser un input o select
const claseInput = page.locator('#class_code, #clase, input[placeholder*="Clase"], input[placeholder*="clase"]').first()
if (await claseInput.isVisible({ timeout: 2000 }).catch(() => false)) {
await claseInput.scrollIntoViewIfNeeded()
await claseInput.fill(CLASS_CODE).catch(() => {})
await page.waitForTimeout(500)
const option = page.getByRole('option').first()
if (await option.isVisible({ timeout: 1000 }).catch(() => false)) {
await option.click()
}
}
// Guardar
const saveBtn = page.getByRole('button', { name: /Guardar|Crear|Insertar/ }).first()
if (await saveBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
await saveBtn.scrollIntoViewIfNeeded()
await saveBtn.click()
await page.waitForTimeout(1000)
}
await expect(page.locator('main')).toBeVisible()
})
})

View File

@@ -0,0 +1,248 @@
# Reporte de Pruebas E2E — Flujo de Factura
**Proyecto:** Anexo 76 — Sistema de Control de Operaciones Aduaneras
**Herramienta:** Playwright
**Archivo:** `frontend/e2e/invoice-flow.spec.ts`
**Fecha:** Abril 2026
**Estado:** 12/12 pruebas pasando ✅
**Tiempo de ejecución:** ~4.4 minutos
---
## Resumen
| Categoría | Pruebas | Estado |
|-----------|---------|--------|
| Prerrequisitos (proveedor, cliente, agente, TC) | 4 | ✅ |
| Pedimento | 1 | ✅ |
| Factura TEM | 3 | ✅ |
| Partidas | 2 | ✅ |
| Actualización final | 1 | ✅ |
| **Total** | **11** | **✅** |
> Nota: el test de setup de autenticación (`auth.setup.ts`) suma 1 prueba adicional, totalizando 12 en el runner.
---
## Flujo completo
### 1. Crear proveedor
Navega a `/dashboard/clients_and_providers`, abre el formulario de nuevo socio, llena RFC y nombre, selecciona tipo "Proveedor" y guarda. Verifica redirección a la lista y que el nombre aparece en la tabla.
### 2. Crear cliente
Mismo flujo que el proveedor pero con tipo "Cliente".
### 3. Crear agente aduanal
Navega a `/dashboard/customs_brokers`, abre el formulario, llena clave, patente y nombre. Verifica toast de éxito y redirección.
### 4. Crear tipo de cambio
Navega a `/dashboard/general_catalogs/exchange-rate`, abre el modal de nuevo tipo de cambio, llena fecha de hoy y valor `17.5`, confirma. Verifica toast de éxito.
### 5. Crear pedimento
Navega a `/dashboard/pedimentos/edit/new`, llena año (`26`), selecciona Aduana, Patente, Clave, Tipo de Operación y Régimen via bits-ui Select. Llena número de pedimento. Guarda y verifica redirección a `/dashboard/pedimentos`.
### 6. Crear factura de importación TEM
Navega a `/dashboard/invoices/edit/new?operation_type=imp&invoice_type=TEM`. Llena número de factura con `pressSequentially`, fecha, y en la pestaña General selecciona proveedor, sold-to, shipped-to, agente aduanal, aduana y tipo de documento. Guarda y verifica toast de éxito. Al finalizar guarda el número de factura en `.e2e-shared.json` para los tests posteriores.
### 7. Factura aparece en la lista
Filtra por número de factura en la lista de importación y verifica que la fila es visible.
### 8. Agregar partida a la factura
Abre la factura desde la lista, navega a la pestaña Partidas, abre el sheet de nueva partida. Selecciona Clase, U.M. y País de Origen (cada uno abre un dialog con tabla). Llena cantidad (`10`), costo unitario (`100`), peso neto (`5`), peso bruto (`6`) y descripción en español. Hace click en el botón "Crear" del sheet. Guarda la factura completa.
### 9. Editar factura existente
Lee el número de factura desde `.e2e-shared.json`, la busca en la lista, la abre en modo edición. En la pestaña General vuelve a seleccionar agente aduanal, aduana y tipo de documento. Guarda y verifica toast de éxito.
### 10. Editar partida existente
Lee el número desde shared, abre la factura, va a pestaña Partidas. Hace click en el ícono Pencil de la primera fila para abrir el sheet de edición. Modifica cantidad (`20`) y costo unitario (`200`). Hace click en "Actualizar" del sheet. Guarda la factura.
### 11. Actualizar factura — verificación final
Lee el número desde shared, busca la factura en la lista, selecciona la fila, hace click en el botón "Actualizar" del footer (ícono RefreshCw, clase `h-8`). Verifica el resultado con toast de éxito.
---
## Patrones técnicos establecidos
### fillInput — inputs reactivos de Svelte 5
Los inputs de Svelte 5 no responden a `page.fill()` ni `pressSequentially` de forma confiable. La solución es usar el native setter del prototipo:
```typescript
async function fillInput(page: Page, selector: string, value: string) {
await page.locator(selector).click()
await page.evaluate(({ sel, val }) => {
const el = document.querySelector(sel) as HTMLInputElement
const setter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype, 'value'
)?.set
setter?.call(el, val)
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
}, { sel: selector, val: value })
await page.waitForTimeout(2000)
}
```
La excepción es `#invoice_number`, que sí responde a `pressSequentially` con delay:
```typescript
await page.locator('#invoice_number').click()
await page.keyboard.press('Control+A')
await page.locator('#invoice_number').pressSequentially(INVOICE_NUMBER, { delay: 1000 })
```
### bits-ui Select — selects con IDs dinámicos
Los selects de bits-ui generan IDs como `bits-s65` que cambian en cada render. La estrategia es seleccionarlos por el atributo `data-select-trigger` y posición:
```typescript
const triggers = page.locator('[data-select-trigger]')
await triggers.nth(0).click() // Aduana
await page.getByRole('option').first().click()
```
Para selects con IDs estables (facturas) se usa directamente:
```typescript
await page.locator('#provider_id').click()
await page.getByRole('option').first().click()
```
### Dialogs anidados — clase, U.M., país de origen
Los campos Clase, U.M. y País de Origen abren un dialog de búsqueda encima del sheet. Para evitar que el sheet intercepte los clicks, se scopea al último dialog abierto:
```typescript
await page.locator('#clase').click()
await page.waitForTimeout(3000)
const claseDialog = page.locator('[data-dialog-content]').last()
await claseDialog.locator('tbody tr').first().click()
```
### Botones dentro del sheet
El botón de guardar partida está dentro del sheet y puede ser interceptado. Se scopea explícitamente:
```typescript
const sheet = page.locator('[data-slot="sheet-content"]')
await sheet.getByRole('button', { name: /Crear/ }).click() // nueva partida
await sheet.getByRole('button', { name: /Actualizar/ }).click() // editar partida
```
### Distinguir botones ambiguos por clase CSS
Cuando hay múltiples botones con el mismo texto o ícono, se distinguen por clases CSS únicas:
```typescript
// Botón Actualizar del footer (tiene h-8, border, RefreshCw)
await page.locator('button.h-8:has([class*="lucide-refresh"])').click()
```
### Compartir estado entre tests
Playwright corre cada test en un worker separado, por lo que `Date.now()` se reevalúa. Para compartir el número de factura entre tests se usa un archivo JSON:
```typescript
// Al final del test 6
saveShared({ INVOICE_NUMBER })
// En tests 7-11
const shared = loadShared()
const invoiceNumber = shared.INVOICE_NUMBER || INVOICE_NUMBER
```
El archivo se guarda en `frontend/e2e/.e2e-shared.json`.
---
## Selectores de referencia
| Campo | Selector | Tipo |
|-------|----------|------|
| RFC | `#rfc` | input normal |
| Nombre | `#name` | input normal |
| Tipo de socio | `#type` | bits-ui Select |
| Año pedimento | `#year` | input normal |
| Número pedimento | `#pedimento_number` | input normal |
| Aduana pedimento | `[data-select-trigger]` nth(0) | bits-ui Select |
| Patente pedimento | `[data-select-trigger]` nth(1) | bits-ui Select |
| Clave pedimento | `[data-select-trigger]` nth(2) | bits-ui Select |
| Número factura | `#invoice_number` | input (pressSequentially) |
| Fecha factura | `#invoice_date` | date input |
| Proveedor | `#provider_id` | bits-ui Select |
| Sold-to | `#sold_to_id` | bits-ui Select |
| Shipped-to | `#shipped_to_id` | bits-ui Select |
| Agente aduanal | `#customs_broker_id` | bits-ui Select |
| Aduana factura | `#aduana` | bits-ui Select |
| Tipo documento | `#document_type` | bits-ui Select |
| Clase partida | `#clase` | input readonly → dialog |
| U.M. | `#um` | input readonly → dialog |
| País origen | `#pais_origen` | input readonly → dialog |
| Cantidad | `#cantidad` | input number |
| Costo unitario | `#costo_unitario` | input number |
| Peso neto | `#peso_neto` | input number |
| Peso bruto | `#peso_bruto` | input number |
| Descripción ES | `#desc_espanol` | textarea |
| Filtro número | `#filter-invoice-number` | input normal |
---
## Comandos
```bash
# Flujo completo
pnpm test:e2e --grep "Flujo completo"
# Test individual
pnpm test:e2e --grep "5. crear pedimento"
pnpm test:e2e --grep "8. agregar partida"
# Modo visual para debug
pnpm test:e2e --grep "Flujo completo" --headed --timeout 120000
```
---
## Estructura de archivos
```
frontend/e2e/
├── .auth/
│ └── user.json sesion de autenticacion
├── .e2e-shared.json estado compartido entre tests (generado)
├── auth.setup.ts 1 test — login y guardado de sesion
├── invoice-flow.spec.ts 11 tests — flujo completo de factura
├── full-flow.spec.ts 4 tests
├── login.spec.ts 3 tests
├── navigation.spec.ts 10 tests
└── modules.spec.ts 10 tests
```
---
## Conteo total actualizado
| Suite | Pruebas |
|-------|---------|
| auth.setup.ts | 1 |
| invoice-flow.spec.ts | 11 |
| full-flow.spec.ts | 4 |
| login.spec.ts | 3 |
| navigation.spec.ts | 10 |
| modules.spec.ts | 10 |
| **Total Playwright** | **39** |
---
*Anexo 76 — Reporte de Pruebas E2E v4.0 — invoice-flow — Abril 2026*

View File

@@ -0,0 +1,93 @@
/**
* Setup de autenticación para tests E2E de Playwright.
*
* El flujo de login de Anexo76 pasa por Workspace (workspace.aduanasoft.com):
* 1. Navegar a /login → 303 redirect al formulario de Workspace
* 2. Llenar el form de Workspace con el usuario de prueba (creado en globalSetup)
* 3. Workspace/Keycloak redirige de vuelta a /auth/callback → /dashboard
* 4. Guardar storageState para todos los tests dependientes
*
* El usuario de prueba es creado y eliminado por globalSetup / globalTeardown.
* Patrón idéntico al de aduanasoft-hub.
*/
import { test as setup, expect } from '@playwright/test'
import { mkdirSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const authFile = path.join(__dirname, '.auth/user.json')
setup('autenticacion', async ({ page }) => {
const username = process.env.E2E_TEST_USER ?? ''
const password = process.env.E2E_TEST_PASSWORD ?? ''
if (!username) throw new Error('E2E_TEST_USER no está configurado')
if (!password) throw new Error('E2E_TEST_PASSWORD no está configurado')
// ── 1. Navegar a /login — Workspace intercepta y muestra su formulario ──
await page.goto('/login')
// ── 2. Llenar el formulario de Workspace (mismos selectores que hub/login.spec.ts) ──
await page.getByRole('textbox', { name: /usuario o email/i }).fill(username)
await page.getByLabel(/contraseña/i).fill(password)
await page.getByRole('button', { name: /continuar/i }).click()
// ── 3. Si Workspace muestra el app-launcher (usuario con múltiples apps),
// elegir anexo76-dev — la app del launcher cuya URL apunta al frontend bajo prueba.
// Cuando hay match directo de dominio, Workspace salta el launcher y este paso no ejecuta.
try {
await page.waitForURL(/\/(app-launcher|dashboard|auth\/callback)/, { timeout: 30_000 })
} catch (e) {
// Si seguimos en /login de Workspace, las credenciales fueron rechazadas o el form no aceptó.
// Imprimir diagnóstico antes de fallar: URL actual + posibles mensajes de error visibles.
const currentUrl = page.url()
const errorMessages = await page.locator('.text-destructive, [role="alert"], .error, .invalid-feedback')
.allTextContents()
.catch(() => [] as string[])
const visibleText = await page.locator('body').innerText().catch(() => '')
console.log(`[auth.setup][DIAG] URL: ${currentUrl}`)
console.log(`[auth.setup][DIAG] Mensajes de error: ${JSON.stringify(errorMessages)}`)
console.log(`[auth.setup][DIAG] Body (primeros 800 chars): ${visibleText.slice(0, 800)}`)
throw e
}
if (/\/app-launcher/.test(page.url())) {
// Nuevo botón de Fixed Assets en el launcher de Workspace.
// Intentar primero el botón de Fixed Asset y, si no existe (compatibilidad),
// caer al botón legacy de anexo76-dev.
const fixedAssetButton = page.getByRole('button', {
name: /fixed asset|activo fijo/i
})
if (await fixedAssetButton.isVisible().catch(() => false)) {
await fixedAssetButton.click()
} else {
await page.getByRole('button', { name: /^anexo76-dev/i }).click()
}
}
// ── 4. Esperar redirect SSO con active_system=fixed_asset y luego /dashboard ──
await page.waitForURL(/\/auth\/sso\?.*active_system=fixed_asset/, { timeout: 30_000 })
await page.waitForURL(/\/dashboard/, { timeout: 30_000 })
// ── 5. ExchangeRateGuard abre un dialog modal en cada carga del dashboard
// si no existe un tipo de cambio para hoy. El guard espera a que
// companyStore.activeCompany?.id esté disponible (API async) antes de
// consultar la DB y abrir el dialog. Esperar networkidle para que ese
// flujo se complete antes de buscar el dialog.
await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {})
const tcDialog = page.getByRole('dialog', { name: /Nuevo Tipo de Cambio/i })
if (await tcDialog.isVisible({ timeout: 10_000 }).catch(() => false)) {
const valueInput = tcDialog.getByRole('spinbutton', { name: /Tipo de Cambio/i })
await tcDialog.getByRole('button', { name: /Consultar DOF/i }).click()
// El backend consulta el DOF y llena el campo. Esperar a que tenga un valor.
await expect(valueInput).not.toHaveValue('', { timeout: 15_000 })
// "Ok" abre un AlertDialog de confirmación; el TC se crea hasta "Confirmar".
await tcDialog.getByRole('button', { name: /^Ok$/ }).click()
await page.getByRole('button', { name: /^Confirmar$/ }).click()
await tcDialog.waitFor({ state: 'hidden', timeout: 10_000 })
}
// ── 6. Guardar estado para los tests dependientes ─────────────────────────
mkdirSync(path.dirname(authFile), { recursive: true })
await page.context().storageState({ path: authFile })
})

View File

@@ -0,0 +1,6 @@
import { expect, test } from '@playwright/test';
test('home page has expected h1', async ({ page }) => {
await page.goto('/');
await expect(page.locator('h1')).toBeVisible();
});

View File

@@ -0,0 +1,529 @@
import { test, expect, type Page } from '@playwright/test'
import * as fs from 'fs'
import { fileURLToPath } from 'url'
import * as path from 'path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const SHARED_FILE = path.join(__dirname, '.e2e-shared-exp.json')
function saveShared(data: Record<string, string>) {
fs.writeFileSync(SHARED_FILE, JSON.stringify(data))
}
function loadShared(): Record<string, string> {
try {
return JSON.parse(fs.readFileSync(SHARED_FILE, 'utf-8'))
} catch {
return {}
}
}
const SUFFIX = 'X' + Date.now().toString().slice(-5) // prefijo X para exportacion
const TODAY = new Date().toISOString().split('T')[0]
const HOMOCLAVE = SUFFIX.slice(-3).toUpperCase()
const PROVEEDOR_RFC = `XAXX010101${HOMOCLAVE}`
const PROVEEDOR_NOMBRE = `Proveedor E2E ${SUFFIX}`
const CLIENTE_RFC = `XBXX010101${HOMOCLAVE}`
const CLIENTE_NOMBRE = `Cliente E2E ${SUFFIX}`
const BROKER_KEY = `T${SUFFIX.slice(-4)}`
const BROKER_LICENSE = SUFFIX.slice(-4).replace(/^0/, '1')
const INVOICE_NUMBER = `E2E-${SUFFIX}`
const PEDIMENTO_YEAR = '26'
const PEDIMENTO_OFFICE = '240'
const PEDIMENTO_LICENSE = '3101'
const PEDIMENTO_NUMBER = SUFFIX.slice(-5) // 5 digitos para pedimento
async function fillInput(page: Page, selector: string, value: string) {
await page.locator(selector).click()
await page.evaluate(({ sel, val }: { sel: string; val: string }) => {
const el = document.querySelector(sel) as HTMLInputElement
if (!el) return
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
setter?.call(el, val)
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
}, { sel: selector, val: value })
await page.waitForTimeout(300)
}
async function fillTextarea(page: Page, selector: string, value: string) {
await page.locator(selector).click()
await page.evaluate(({ sel, val }: { sel: string; val: string }) => {
const el = document.querySelector(sel) as HTMLTextAreaElement
if (!el) return
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set
setter?.call(el, val)
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
}, { sel: selector, val: value })
await page.waitForTimeout(500)
}
test.describe('Flujo completo — creacion y actualizacion de factura EXPORTACION', () => {
test('1. crear proveedor', async ({ page }) => {
await page.goto('/dashboard/clients_and_providers')
await page.waitForLoadState('networkidle')
await page.getByRole('link', { name: /Nuevo/ }).click()
await expect(page).toHaveURL(/edit/, { timeout: 10000 })
await page.waitForLoadState('networkidle')
await fillInput(page, '#rfc', PROVEEDOR_RFC)
await fillInput(page, '#name', PROVEEDOR_NOMBRE)
await page.locator('#type').click()
await page.getByRole('option', { name: 'Proveedor' }).click()
await page.waitForTimeout(500)
await page.getByRole('button', { name: /Guardar Socio|Actualizar Socio/ }).click()
await expect(page).toHaveURL(/clients_and_providers$/, { timeout: 15000 })
await expect(page.getByText(PROVEEDOR_NOMBRE)).toBeVisible()
})
test('2. crear cliente', async ({ page }) => {
await page.goto('/dashboard/clients_and_providers')
await page.waitForLoadState('networkidle')
await page.getByRole('link', { name: /Nuevo/ }).click()
await expect(page).toHaveURL(/edit/, { timeout: 10000 })
await page.waitForLoadState('networkidle')
await fillInput(page, '#rfc', CLIENTE_RFC)
await fillInput(page, '#name', CLIENTE_NOMBRE)
await page.locator('#type').click()
await page.getByRole('option', { name: 'Cliente' }).click()
await page.waitForTimeout(500)
await page.getByRole('button', { name: /Guardar Socio|Actualizar Socio/ }).click()
await expect(page).toHaveURL(/clients_and_providers$/, { timeout: 15000 })
await expect(page.getByText(CLIENTE_NOMBRE)).toBeVisible()
})
test('3. crear agente aduanal', async ({ page }) => {
await page.goto('/dashboard/customs_brokers')
await page.waitForLoadState('networkidle')
await page.getByRole('link', { name: /Nuevo/ }).click()
await expect(page).toHaveURL(/edit/, { timeout: 20000 })
await page.waitForLoadState('networkidle')
await fillInput(page, 'input[placeholder="Ej. 550"]', BROKER_KEY)
await fillInput(page, 'input[placeholder="Ej. 3421"]', BROKER_LICENSE)
await fillInput(page, 'input[placeholder="Nombre oficial"]', `Agente E2E ${SUFFIX}`)
await page.getByRole('button', { name: /Guardar Agente|Actualizar Agente/ }).click()
await expect(page.getByText(/Agente creado|Agente actualizado/i)).toBeVisible({ timeout: 20000 })
await expect(page).toHaveURL(/customs_brokers$/, { timeout: 25000 })
})
test('4. crear tipo de cambio', async ({ page }) => {
await page.goto('/dashboard/general_catalogs/exchange-rate')
await page.waitForLoadState('networkidle')
// Si ya existe TC del día (creado por auth.setup.ts), no intentar duplicar.
const todayCell = page.locator('tbody').getByText(new Date().toLocaleDateString('es-MX'))
if (await todayCell.first().isVisible({ timeout: 2000 }).catch(() => false)) {
return
}
// El botón se llama "Nuevo Registro"; antes era "Nuevo Tipo de Cambio".
await page.getByRole('button', { name: /Nuevo Registro|Nuevo Tipo de Cambio/ }).click()
await expect(page.getByRole('heading', { name: 'Nuevo Tipo de Cambio' })).toBeVisible()
await page.locator('#date').fill(TODAY)
await page.waitForTimeout(500)
await fillInput(page, '#value', '17.5')
await page.getByRole('button', { name: /^Ok$/ }).click()
await page.getByRole('button', { name: 'Confirmar' }).click()
await expect(page.getByText(/tipo de cambio creado|tipo de cambio actualizado/i)).toBeVisible({ timeout: 20000 })
})
test('5. crear pedimento', async ({ page }) => {
await page.goto('/dashboard/pedimentos/edit/new')
await page.waitForLoadState('networkidle')
await expect(page.getByText('Nuevo Pedimento')).toBeVisible({ timeout: 20000 })
const prereqModal = page.getByRole('button', { name: /Continuar|Aceptar/ })
if (await prereqModal.isVisible()) await prereqModal.click()
await page.waitForTimeout(500)
// Año — input con id estable
await fillInput(page, '#year', PEDIMENTO_YEAR)
// Aduana — bits-ui Select, 1er trigger de la fila superior
const triggers = page.locator('[data-select-trigger]')
await triggers.nth(0).click()
await page.waitForTimeout(800)
await page.getByRole('option').first().click()
await page.waitForTimeout(500)
// Patente — bits-ui Select, 2do trigger
await triggers.nth(1).click()
await page.waitForTimeout(800)
await page.getByRole('option').first().click()
await page.waitForTimeout(500)
// Número de pedimento — input con id estable
await fillInput(page, '#pedimento_number', PEDIMENTO_NUMBER)
// Clave — bits-ui Select, 3er trigger
await triggers.nth(2).click()
await page.waitForTimeout(800)
await page.getByRole('option').first().click()
await page.waitForTimeout(500)
// Tipo de Operación — bits-ui Select, 4to trigger
await triggers.nth(3).click()
await page.waitForTimeout(800)
await page.getByRole('option').first().click()
await page.waitForTimeout(500)
// Régimen — bits-ui Select, 5to trigger
await triggers.nth(4).click()
await page.waitForTimeout(800)
await page.getByRole('option').first().click()
await page.waitForTimeout(800)
await page.getByRole('button', { name: /Guardar Todos los Cambios/ }).click()
await expect(page).toHaveURL(/pedimentos$/, { timeout: 20000 })
})
test('6. crear factura de exportacion', async ({ page }) => {
await page.goto('/dashboard/invoices/edit/new?operation_type=exp')
await page.waitForLoadState('networkidle')
await expect(page.getByText('Nueva Factura')).toBeVisible({ timeout: 20000 })
const prereqModal = page.getByRole('button', { name: /Continuar|Aceptar/ })
if (await prereqModal.isVisible()) await prereqModal.click()
await page.waitForTimeout(1500)
await page.locator('#invoice_number').click()
await page.keyboard.press('Control+A')
await page.locator('#invoice_number').pressSequentially(INVOICE_NUMBER, { delay: 50 })
await page.waitForTimeout(1500)
await page.locator('#invoice_date').fill(TODAY)
await page.waitForTimeout(500)
// Helper: click en un botón-selector por su placeholder y elige la 1ra opción.
const selectFirstByName = async (btnName: RegExp) => {
await page.getByRole('button', { name: btnName }).first().click()
await page.waitForTimeout(800)
await page.getByRole('option').first().click({ force: true })
await page.waitForTimeout(600)
}
// Tipo de factura — sin ID, identificar por placeholder del botón
await selectFirstByName(/^Tipo de factura$/)
await page.getByRole('tab', { name: /General/ }).click()
await page.waitForTimeout(1500)
// Selectores con IDs estables (siguen existiendo)
await page.locator('#provider_id').click()
await page.waitForTimeout(500)
await page.getByRole('option').first().click()
await page.locator('#sold_to_id').click()
await page.waitForTimeout(500)
await page.getByRole('option').first().click()
await page.locator('#shipped_to_id').click()
await page.waitForTimeout(500)
await page.getByRole('option').first().click()
// Estos ya no tienen ID — usar el placeholder del botón
await selectFirstByName(/Agente Aduanal Mex/)
await selectFirstByName(/Aduana y Secci[oó]n de Despacho/)
await selectFirstByName(/Clave de R[eé]gimen Aduanero/)
await page.getByRole('button', { name: /Guardar Todos los Cambios/ }).click()
await expect(page.getByText('Todos los cambios se guardaron correctamente')).toBeVisible({ timeout: 15000 })
// Guardar numero de factura para tests posteriores
saveShared({ INVOICE_NUMBER })
})
test('7. factura aparece en la lista de exportacion', async ({ page }) => {
const shared = loadShared()
const invoiceNumber = shared.INVOICE_NUMBER || INVOICE_NUMBER
await page.goto('/dashboard/invoices?operation_type=exp')
await page.waitForLoadState('networkidle')
await expect(page.locator('main')).toBeVisible()
await fillInput(page, '#filter-invoice-number, input[placeholder="No. Factura"]', invoiceNumber)
await page.waitForTimeout(2000)
await expect(page.locator('tbody').getByText(invoiceNumber).first()).toBeVisible({ timeout: 10000 })
})
// TODO(AS-export-partida): El sheet "Nueva Partida" en exportación abre y se cierra
// automáticamente antes de que el test pueda interactuar. Comportamiento estable en
// importación. Probable causa: $effect que cierra el sheet cuando la factura de
// exportación no tiene vinculación a una factura impo previa.
// Requiere fix en item-sheet-fa.svelte; mientras tanto, 8, 10 y 11 quedan en fixme.
test.fixme('8. agregar partida a la factura', async ({ page }) => {
const shared = loadShared()
const invoiceNumber = shared.INVOICE_NUMBER || INVOICE_NUMBER
await page.goto('/dashboard/invoices?operation_type=exp')
await page.waitForLoadState('networkidle')
await fillInput(page, '#filter-invoice-number, input[placeholder="No. Factura"]', invoiceNumber)
await expect(page.locator('tbody').getByText(invoiceNumber).first()).toBeVisible({ timeout: 20000 })
await page.locator('tbody').getByText(invoiceNumber).first().click()
await page.getByRole('button', { name: /Editar/ }).click()
await expect(page).toHaveURL(/invoices\/edit\/\d+/, { timeout: 10000 })
await page.waitForLoadState('networkidle')
// Ir a pestaña Partidas. La página de export necesita tiempo extra para
// hidratar todas las relaciones (factura, items existentes) antes de Agregar.
await page.getByRole('tab', { name: /Partidas/ }).click()
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {})
await page.waitForTimeout(5000)
// Abrir sheet de nueva partida. En exportación click+focus a veces no dispara;
// usar Enter key tras focus. Reintentar si el sheet no aparece.
const addBtn = page.getByRole('button', { name: /^Agregar Partidas$/ }).first()
const sheetHeading = page.getByRole('heading', { name: /Nueva Partida/i })
for (let i = 0; i < 5; i++) {
await addBtn.scrollIntoViewIfNeeded()
await addBtn.focus()
await page.keyboard.press('Enter')
if (await sheetHeading.isVisible({ timeout: 4000 }).catch(() => false)) break
await page.waitForTimeout(1500)
}
await sheetHeading.waitFor({ state: 'visible', timeout: 15000 })
await page.waitForTimeout(2000)
// Helper para seleccionar en dialog y esperar cierre
async function selectFromDialog(selector: string) {
await page.locator(selector).click()
await page.waitForTimeout(800)
const dialogsBefore = await page.locator('[data-dialog-content]').count()
await page.locator('[data-dialog-content]').last().locator('tbody tr').first().click()
await page.waitForFunction(
(count) => document.querySelectorAll('[data-dialog-content]').length < count,
dialogsBefore,
{ timeout: 10000 }
).catch(() => {})
await page.waitForTimeout(500)
}
// Exportacion requiere vincular a una factura de importacion
// El bloque "Factura Impo" aparece en el sheet — click en el input readonly
const sheet = page.locator('[data-slot="sheet-content"]')
const facturaImpoInput = sheet.locator('input[placeholder="Seleccionar factura..."]').first()
if (await facturaImpoInput.isVisible({ timeout: 3000 }).catch(() => false)) {
await facturaImpoInput.click()
await page.waitForTimeout(500)
// InvoiceSelectorModal abre con campo de busqueda
const modalDialog = page.locator('[data-dialog-content]').last()
if (await modalDialog.isVisible({ timeout: 5000 }).catch(() => false)) {
// Buscar la factura de importacion por numero para encontrarla
const searchInput = modalDialog.locator('input[placeholder*="número"], input[placeholder*="numero"], input[type="search"], input[type="text"]').first()
if (await searchInput.isVisible({ timeout: 2000 }).catch(() => false)) {
// Leer numero desde shared file del flujo de importacion
const sharedImp = loadShared()
await searchInput.fill(sharedImp.INVOICE_NUMBER || '')
// Click en buscar si hay boton
const buscarBtn = modalDialog.getByRole('button', { name: /Buscar/i })
if (await buscarBtn.isVisible({ timeout: 1000 }).catch(() => false)) {
await buscarBtn.click()
}
await page.waitForTimeout(800)
}
// Seleccionar primera fila visible
const firstRow = modalDialog.locator('tbody tr').first()
if (await firstRow.isVisible({ timeout: 5000 }).catch(() => false)) {
await firstRow.click()
await page.waitForTimeout(800)
} else {
// No hay facturas procesadas — cerrar modal y continuar sin vincular
await page.keyboard.press('Escape')
await page.waitForTimeout(500)
}
}
// Seleccionar linea si el input esta habilitado
await page.waitForTimeout(500)
const lineaInput = sheet.locator('#fa_search_line')
if (await lineaInput.isEnabled({ timeout: 3000 }).catch(() => false)) {
await lineaInput.click()
await page.waitForTimeout(800)
const lineDialog = page.locator('[data-dialog-content]').last()
if (await lineDialog.isVisible({ timeout: 3000 }).catch(() => false)) {
const lineBtn = lineDialog.getByRole('button').first()
if (await lineBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
await lineBtn.click()
await page.waitForTimeout(500)
} else {
await page.keyboard.press('Escape')
}
}
}
}
await selectFromDialog('#clase')
await selectFromDialog('#um')
await selectFromDialog('#pais_origen')
await page.keyboard.press('Escape')
await page.waitForTimeout(500)
// Cantidad
await fillInput(page, '#cantidad', '10')
// Costo unitario
await fillInput(page, '#costo_unitario', '100')
// Peso neto y bruto — requeridos por el backend
await fillInput(page, '#peso_neto', '5')
await fillInput(page, '#peso_bruto', '6')
// Descripción en español (textarea)
await fillTextarea(page, '#desc_espanol', `Partida E2E ${SUFFIX}`)
// Guardar partida — botón "Crear" dentro del sheet
await sheet.getByRole('button', { name: /^(Crear|Guardar)$/i }).click()
await page.waitForTimeout(500)
// Verificar que la partida aparece en la tabla
await expect(page.locator('tbody').first()).toBeVisible({ timeout: 10000 })
// Guardar factura completa
await page.getByRole('button', { name: /Guardar Todos los Cambios/ }).click()
await expect(page.getByText('Todos los cambios se guardaron correctamente')).toBeVisible({ timeout: 15000 })
})
test('9. editar factura existente', async ({ page }) => {
await page.goto('/dashboard/invoices?operation_type=exp')
await page.waitForLoadState('networkidle')
const shared9 = loadShared()
const invoiceNumber9 = shared9.INVOICE_NUMBER || INVOICE_NUMBER
await fillInput(page, '#filter-invoice-number, input[placeholder="No. Factura"]', invoiceNumber9)
await expect(page.locator('tbody').getByText(invoiceNumber9).first()).toBeVisible({ timeout: 20000 })
await page.locator('tbody').getByText(invoiceNumber9).first().click()
await page.getByRole('button', { name: /Editar/ }).click()
await expect(page).toHaveURL(/invoices\/edit\/\d+/, { timeout: 10000 })
await page.waitForLoadState('networkidle')
await expect(page.getByText(/Factura #/)).toBeVisible()
await page.getByRole('tab', { name: /General/ }).click()
await page.waitForTimeout(1500)
await page.locator('#customs_broker_id').click()
await page.waitForTimeout(500)
await page.getByRole('option').first().click()
await page.locator('#aduana').click()
await page.waitForTimeout(500)
await page.getByRole('option').first().click()
await page.locator('#document_type').click()
await page.waitForTimeout(500)
await page.getByRole('option').first().click()
await page.getByRole('button', { name: /Guardar Todos los Cambios/ }).click()
await expect(page.getByText('Todos los cambios se guardaron correctamente')).toBeVisible({ timeout: 15000 })
})
test.fixme('10. editar partida existente', async ({ page }) => {
// Depende de test 8 (sheet inestable en exportación) — ver TODO arriba.
const shared10 = loadShared()
const invoiceNumber10 = shared10.INVOICE_NUMBER || INVOICE_NUMBER
await page.goto('/dashboard/invoices?operation_type=exp')
await page.waitForLoadState('networkidle')
await fillInput(page, '#filter-invoice-number, input[placeholder="No. Factura"]', invoiceNumber10)
await expect(page.locator('tbody').getByText(invoiceNumber10).first()).toBeVisible({ timeout: 20000 })
await page.locator('tbody').getByText(invoiceNumber10).first().click()
await page.getByRole('button', { name: /Editar/ }).click()
await expect(page).toHaveURL(/invoices\/edit\/\d+/, { timeout: 10000 })
await page.waitForLoadState('networkidle')
// Ir a pestaña Partidas
await page.getByRole('tab', { name: /Partidas/ }).click()
await page.waitForTimeout(500)
// Abrir sheet de edición — la fila de la partida contiene "E2E01"; el primer botón
// de la cell de acciones es editar.
const partidaRow = page.locator('tbody tr').filter({ hasText: 'E2E01' }).first()
await partidaRow.locator('button').first().click({ force: true, timeout: 10000 })
await page.waitForTimeout(500)
// Modificar cantidad
await fillInput(page, '#cantidad', '20')
// Modificar costo unitario
await fillInput(page, '#costo_unitario', '200')
// Guardar partida editada — botón "Actualizar" dentro del sheet
const sheetEdit = page.locator('[data-slot="sheet-content"]')
await sheetEdit.getByRole('button', { name: /^(Actualizar|Guardar)$/i }).click()
await page.waitForTimeout(500)
// Guardar factura completa
await page.getByRole('button', { name: /Guardar Todos los Cambios/ }).click()
await expect(page.getByText('Todos los cambios se guardaron correctamente')).toBeVisible({ timeout: 15000 })
})
test.fixme('11. actualizar factura — verificacion final', async ({ page }) => {
// Depende de tests 8 y 10 (sheet inestable en exportación) — ver TODO arriba.
const shared11 = loadShared()
const invoiceNumber11 = shared11.INVOICE_NUMBER || INVOICE_NUMBER
await page.goto('/dashboard/invoices?operation_type=exp')
await page.waitForLoadState('networkidle')
await fillInput(page, '#filter-invoice-number, input[placeholder="No. Factura"]', invoiceNumber11)
await expect(page.locator('tbody').getByText(invoiceNumber11).first()).toBeVisible({ timeout: 20000 })
// Seleccionar la fila
await page.locator('tbody tr').first().click()
await page.waitForTimeout(800)
// Click en boton Actualizar del footer — tiene h-8, gap-1.5 y border
await page.locator('button.h-8:has([class*="lucide-refresh"])').click()
await page.waitForTimeout(1500)
// Verificar que el proceso se ejecuto — puede ser exito o error de validacion de datos
// El test verifica que el flujo llega hasta el procesamiento, no que los datos sean correctos
await expect(
page.getByText(/actualiz|procesad|exito|validaci|error/i).first()
).toBeVisible({ timeout: 20000 })
})
})

View File

@@ -0,0 +1,453 @@
import { test, expect, type Page } from '@playwright/test'
import * as fs from 'fs'
import { fileURLToPath } from 'url'
import * as path from 'path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const SHARED_FILE = path.join(__dirname, '.e2e-shared.json')
function saveShared(data: Record<string, string>) {
fs.writeFileSync(SHARED_FILE, JSON.stringify(data))
}
function loadShared(): Record<string, string> {
try {
return JSON.parse(fs.readFileSync(SHARED_FILE, 'utf-8'))
} catch {
return {}
}
}
const SUFFIX = Date.now().toString().slice(-6)
const TODAY = new Date().toISOString().split('T')[0]
const HOMOCLAVE = SUFFIX.slice(-3).toUpperCase()
const PROVEEDOR_RFC = `XAXX010101${HOMOCLAVE}`
const PROVEEDOR_NOMBRE = `Proveedor E2E ${SUFFIX}`
const CLIENTE_RFC = `XBXX010101${HOMOCLAVE}`
const CLIENTE_NOMBRE = `Cliente E2E ${SUFFIX}`
const BROKER_KEY = `T${SUFFIX.slice(-4)}`
const BROKER_LICENSE = SUFFIX.slice(-4).replace(/^0/, '1')
const INVOICE_NUMBER = `E2E-${SUFFIX}`
const PEDIMENTO_YEAR = '26'
const PEDIMENTO_OFFICE = '240'
const PEDIMENTO_LICENSE = '3101'
const PEDIMENTO_NUMBER = `${SUFFIX}`
async function fillInput(page: Page, selector: string, value: string) {
await page.locator(selector).click()
await page.evaluate(({ sel, val }: { sel: string; val: string }) => {
const el = document.querySelector(sel) as HTMLInputElement
if (!el) return
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
setter?.call(el, val)
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
}, { sel: selector, val: value })
await page.waitForTimeout(300)
}
async function fillTextarea(page: Page, selector: string, value: string) {
await page.locator(selector).click()
await page.evaluate(({ sel, val }: { sel: string; val: string }) => {
const el = document.querySelector(sel) as HTMLTextAreaElement
if (!el) return
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set
setter?.call(el, val)
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
}, { sel: selector, val: value })
await page.waitForTimeout(500)
}
test.describe('Flujo completo — creacion y actualizacion de factura', () => {
test('1. crear proveedor', async ({ page }) => {
await page.goto('/dashboard/clients_and_providers')
await page.waitForLoadState('networkidle')
await page.getByRole('link', { name: /Nuevo/ }).click()
await expect(page).toHaveURL(/edit/, { timeout: 10000 })
await page.waitForLoadState('networkidle')
await fillInput(page, '#rfc', PROVEEDOR_RFC)
await fillInput(page, '#name', PROVEEDOR_NOMBRE)
await page.locator('#type').click()
await page.getByRole('option', { name: 'Proveedor' }).click()
await page.waitForTimeout(500)
await page.getByRole('button', { name: /Guardar Socio|Actualizar Socio/ }).click()
await expect(page).toHaveURL(/clients_and_providers$/, { timeout: 15000 })
await expect(page.getByText(PROVEEDOR_NOMBRE)).toBeVisible()
})
test('2. crear cliente', async ({ page }) => {
await page.goto('/dashboard/clients_and_providers')
await page.waitForLoadState('networkidle')
await page.getByRole('link', { name: /Nuevo/ }).click()
await expect(page).toHaveURL(/edit/, { timeout: 10000 })
await page.waitForLoadState('networkidle')
await fillInput(page, '#rfc', CLIENTE_RFC)
await fillInput(page, '#name', CLIENTE_NOMBRE)
await page.locator('#type').click()
await page.getByRole('option', { name: 'Cliente' }).click()
await page.waitForTimeout(500)
await page.getByRole('button', { name: /Guardar Socio|Actualizar Socio/ }).click()
await expect(page).toHaveURL(/clients_and_providers$/, { timeout: 15000 })
await expect(page.getByText(CLIENTE_NOMBRE)).toBeVisible()
})
test('3. crear agente aduanal', async ({ page }) => {
await page.goto('/dashboard/customs_brokers')
await page.waitForLoadState('networkidle')
await page.getByRole('link', { name: /Nuevo/ }).click()
await expect(page).toHaveURL(/edit/, { timeout: 20000 })
await page.waitForLoadState('networkidle')
await fillInput(page, 'input[placeholder="Ej. 550"]', BROKER_KEY)
await fillInput(page, 'input[placeholder="Ej. 3421"]', BROKER_LICENSE)
await fillInput(page, 'input[placeholder="Nombre oficial"]', `Agente E2E ${SUFFIX}`)
await page.getByRole('button', { name: /Guardar Agente|Actualizar Agente/ }).click()
await expect(page.getByText(/Agente creado|Agente actualizado/i)).toBeVisible({ timeout: 20000 })
await expect(page).toHaveURL(/customs_brokers$/, { timeout: 25000 })
})
test('4. crear tipo de cambio', async ({ page }) => {
await page.goto('/dashboard/general_catalogs/exchange-rate')
await page.waitForLoadState('networkidle')
// Si ya existe TC del día (creado por auth.setup.ts), no intentar duplicar.
const todayCell = page.locator('tbody').getByText(new Date().toLocaleDateString('es-MX'))
if (await todayCell.first().isVisible({ timeout: 2000 }).catch(() => false)) {
return
}
// El botón se llama "Nuevo Registro"; antes era "Nuevo Tipo de Cambio".
await page.getByRole('button', { name: /Nuevo Registro|Nuevo Tipo de Cambio/ }).click()
await expect(page.getByRole('heading', { name: 'Nuevo Tipo de Cambio' })).toBeVisible()
await page.locator('#date').fill(TODAY)
await page.waitForTimeout(500)
await fillInput(page, '#value', '17.5')
await page.getByRole('button', { name: /^Ok$/ }).click()
await page.getByRole('button', { name: 'Confirmar' }).click()
await expect(page.getByText(/tipo de cambio creado|tipo de cambio actualizado/i)).toBeVisible({ timeout: 20000 })
})
test('5. crear pedimento', async ({ page }) => {
await page.goto('/dashboard/pedimentos/edit/new')
await page.waitForLoadState('networkidle')
await expect(page.getByText('Nuevo Pedimento')).toBeVisible({ timeout: 20000 })
const prereqModal = page.getByRole('button', { name: /Continuar|Aceptar/ })
if (await prereqModal.isVisible()) await prereqModal.click()
await page.waitForTimeout(500)
// Año — input con id estable
await fillInput(page, '#year', PEDIMENTO_YEAR)
// Aduana — bits-ui Select, 1er trigger de la fila superior
const triggers = page.locator('[data-select-trigger]')
await triggers.nth(0).click()
await page.waitForTimeout(800)
await page.getByRole('option').first().click()
await page.waitForTimeout(500)
// Patente — bits-ui Select, 2do trigger
await triggers.nth(1).click()
await page.waitForTimeout(800)
await page.getByRole('option').first().click()
await page.waitForTimeout(500)
// Número de pedimento — input con id estable
await fillInput(page, '#pedimento_number', PEDIMENTO_NUMBER)
// Clave — bits-ui Select, 3er trigger
await triggers.nth(2).click()
await page.waitForTimeout(800)
await page.getByRole('option').first().click()
await page.waitForTimeout(500)
// Tipo de Operación — bits-ui Select, 4to trigger
await triggers.nth(3).click()
await page.waitForTimeout(800)
await page.getByRole('option').first().click()
await page.waitForTimeout(500)
// Régimen — bits-ui Select, 5to trigger
await triggers.nth(4).click()
await page.waitForTimeout(800)
await page.getByRole('option').first().click()
await page.waitForTimeout(800)
await page.getByRole('button', { name: /Guardar Todos los Cambios/ }).click()
await expect(page).toHaveURL(/pedimentos$/, { timeout: 20000 })
await expect(page.getByText(/Pedimento creado/i)).toBeVisible({ timeout: 15000 })
})
test('6. crear factura de importacion TEM', async ({ page }) => {
await page.goto('/dashboard/invoices/edit/new?operation_type=imp&invoice_type=TEM')
await page.waitForLoadState('networkidle')
await expect(page.getByText('Nueva Factura')).toBeVisible({ timeout: 20000 })
const prereqModal = page.getByRole('button', { name: /Continuar|Aceptar/ })
if (await prereqModal.isVisible()) await prereqModal.click()
await page.waitForTimeout(1500)
await page.locator('#invoice_number').click()
await page.keyboard.press('Control+A')
await page.locator('#invoice_number').pressSequentially(INVOICE_NUMBER, { delay: 50 })
await page.waitForTimeout(1500)
await page.locator('#invoice_date').fill(TODAY)
await page.waitForTimeout(500)
await page.getByRole('tab', { name: /General/ }).click()
await page.waitForTimeout(1500)
await page.locator('#provider_id').click()
await page.waitForTimeout(500)
await page.getByRole('option').first().click()
await page.locator('#sold_to_id').click()
await page.waitForTimeout(500)
await page.getByRole('option').first().click()
await page.locator('#shipped_to_id').click()
await page.waitForTimeout(500)
await page.getByRole('option').first().click()
await page.locator('#customs_broker_id').click()
await page.waitForTimeout(500)
await page.getByRole('option').first().click()
await page.locator('#aduana').click()
await page.waitForTimeout(500)
await page.getByRole('option').first().click()
await page.locator('#document_type').click()
await page.waitForTimeout(500)
await page.getByRole('option').first().click()
await page.getByRole('button', { name: /Guardar Todos los Cambios/ }).click()
await expect(page.getByText('Todos los cambios se guardaron correctamente')).toBeVisible({ timeout: 15000 })
// Guardar numero de factura para tests posteriores
saveShared({ INVOICE_NUMBER })
})
test('7. factura aparece en la lista de importacion', async ({ page }) => {
const shared = loadShared()
const invoiceNumber = shared.INVOICE_NUMBER || INVOICE_NUMBER
await page.goto('/dashboard/invoices?operation_type=imp&invoice_type=TEM')
await page.waitForLoadState('networkidle')
await expect(page.locator('main')).toBeVisible()
await fillInput(page, '#filter-invoice-number, input[placeholder="No. Factura"]', invoiceNumber)
await page.waitForTimeout(2000)
await expect(page.locator('tbody').getByText(invoiceNumber).first()).toBeVisible({ timeout: 10000 })
})
test('8. agregar partida a la factura', async ({ page }) => {
const shared = loadShared()
const invoiceNumber = shared.INVOICE_NUMBER || INVOICE_NUMBER
await page.goto('/dashboard/invoices?operation_type=imp&invoice_type=TEM')
await page.waitForLoadState('networkidle')
await fillInput(page, '#filter-invoice-number, input[placeholder="No. Factura"]', invoiceNumber)
await expect(page.locator('tbody').getByText(invoiceNumber).first()).toBeVisible({ timeout: 20000 })
await page.locator('tbody').getByText(invoiceNumber).first().click()
await page.getByRole('button', { name: /Editar/ }).click()
await expect(page).toHaveURL(/invoices\/edit\/\d+/, { timeout: 10000 })
await page.waitForLoadState('networkidle')
// Ir a pestaña Partidas
await page.getByRole('tab', { name: /Partidas/ }).click()
await page.waitForTimeout(800)
// Abrir sheet de nueva partida
await page.getByRole('button', { name: /Agregar Partidas/ }).click()
await page.waitForTimeout(1000)
// Clase — abre un dialog de búsqueda con tabla, scopear al dialog activo
await page.locator('#clase').click()
await page.waitForTimeout(800)
// El dialog de clase tiene data-nested y está encima del sheet
// Scopear al último dialog abierto para evitar que el sheet intercepte
const claseDialog = page.locator('[data-dialog-content]').last()
await claseDialog.locator('tbody tr').first().click()
await page.waitForTimeout(500)
// Unidad de medida — mismo patron
await page.locator('#um').click()
await page.waitForTimeout(800)
const umDialog = page.locator('[data-dialog-content]').last()
await umDialog.locator('tbody tr').first().click()
await page.waitForTimeout(500)
// País de origen — abre dialog con tabla igual que clase y UM
await page.locator('#pais_origen').click()
await page.waitForTimeout(800)
const paisDialog = page.locator('[data-dialog-content]').last()
await paisDialog.locator('tbody tr').first().click()
await page.waitForTimeout(500)
// Cantidad
await fillInput(page, '#cantidad', '10')
// Costo unitario
await fillInput(page, '#costo_unitario', '100')
// Peso neto y bruto — requeridos por el backend
await fillInput(page, '#peso_neto', '5')
await fillInput(page, '#peso_bruto', '6')
// Descripción en español (textarea)
await fillTextarea(page, '#desc_espanol', `Partida E2E ${SUFFIX}`)
// Guardar partida — botón "Crear" dentro del sheet
const sheet = page.locator('[data-slot="sheet-content"]')
await sheet.getByRole('button', { name: /^(Crear|Guardar)$/i }).click()
await page.waitForTimeout(800)
// Verificar que la partida aparece en la tabla
await expect(page.locator('tbody').first()).toBeVisible({ timeout: 10000 })
// Guardar factura completa
await page.getByRole('button', { name: /Guardar Todos los Cambios/ }).click()
await expect(page.getByText('Todos los cambios se guardaron correctamente')).toBeVisible({ timeout: 15000 })
})
test('9. editar factura existente', async ({ page }) => {
await page.goto('/dashboard/invoices?operation_type=imp&invoice_type=TEM')
await page.waitForLoadState('networkidle')
const shared9 = loadShared()
const invoiceNumber9 = shared9.INVOICE_NUMBER || INVOICE_NUMBER
await fillInput(page, '#filter-invoice-number, input[placeholder="No. Factura"]', invoiceNumber9)
await expect(page.locator('tbody').getByText(invoiceNumber9).first()).toBeVisible({ timeout: 20000 })
await page.locator('tbody').getByText(invoiceNumber9).first().click()
await page.getByRole('button', { name: /Editar/ }).click()
await expect(page).toHaveURL(/invoices\/edit\/\d+/, { timeout: 10000 })
await page.waitForLoadState('networkidle')
await expect(page.getByText(/Factura #/)).toBeVisible()
await page.getByRole('tab', { name: /General/ }).click()
await page.waitForTimeout(1500)
await page.locator('#customs_broker_id').click()
await page.waitForTimeout(500)
await page.getByRole('option').first().click()
await page.locator('#aduana').click()
await page.waitForTimeout(500)
await page.getByRole('option').first().click()
await page.locator('#document_type').click()
await page.waitForTimeout(500)
await page.getByRole('option').first().click()
await page.getByRole('button', { name: /Guardar Todos los Cambios/ }).click()
await expect(page.getByText('Todos los cambios se guardaron correctamente')).toBeVisible({ timeout: 15000 })
})
test('10. editar partida existente', async ({ page }) => {
const shared10 = loadShared()
const invoiceNumber10 = shared10.INVOICE_NUMBER || INVOICE_NUMBER
await page.goto('/dashboard/invoices?operation_type=imp&invoice_type=TEM')
await page.waitForLoadState('networkidle')
await fillInput(page, '#filter-invoice-number, input[placeholder="No. Factura"]', invoiceNumber10)
await expect(page.locator('tbody').getByText(invoiceNumber10).first()).toBeVisible({ timeout: 20000 })
await page.locator('tbody').getByText(invoiceNumber10).first().click()
await page.getByRole('button', { name: /Editar/ }).click()
await expect(page).toHaveURL(/invoices\/edit\/\d+/, { timeout: 10000 })
await page.waitForLoadState('networkidle')
// Ir a pestaña Partidas
await page.getByRole('tab', { name: /Partidas/ }).click()
await page.waitForTimeout(800)
// Abrir sheet de edición — la fila de la partida contiene "E2E01"; el primer botón
// de la cell de acciones es editar.
const partidaRow = page.locator('tbody tr').filter({ hasText: 'E2E01' }).first()
await partidaRow.locator('button').first().click({ force: true, timeout: 10000 })
await page.waitForTimeout(1000)
// Modificar cantidad
await fillInput(page, '#cantidad', '20')
// Modificar costo unitario
await fillInput(page, '#costo_unitario', '200')
// Guardar partida editada — botón "Actualizar" dentro del sheet
const sheetEdit = page.locator('[data-slot="sheet-content"]')
await sheetEdit.getByRole('button', { name: /^(Actualizar|Guardar)$/i }).click()
await page.waitForTimeout(800)
// Guardar factura completa
await page.getByRole('button', { name: /Guardar Todos los Cambios/ }).click()
await expect(page.getByText('Todos los cambios se guardaron correctamente')).toBeVisible({ timeout: 15000 })
})
test('11. actualizar factura — verificacion final', async ({ page }) => {
const shared11 = loadShared()
const invoiceNumber11 = shared11.INVOICE_NUMBER || INVOICE_NUMBER
await page.goto('/dashboard/invoices?operation_type=imp&invoice_type=TEM')
await page.waitForLoadState('networkidle')
await fillInput(page, '#filter-invoice-number, input[placeholder="No. Factura"]', invoiceNumber11)
await expect(page.locator('tbody').getByText(invoiceNumber11).first()).toBeVisible({ timeout: 20000 })
// Seleccionar la fila
await page.locator('tbody tr').first().click()
await page.waitForTimeout(800)
// Click en boton Actualizar del footer — tiene h-8, gap-1.5 y border
await page.locator('button.h-8:has([class*="lucide-refresh"])').click()
await page.waitForTimeout(1500)
// Verificar resultado
await expect(page.getByText(/actualiz|procesad|exito/i).first()).toBeVisible({ timeout: 20000 })
})
})

View File

@@ -0,0 +1,17 @@
import { test, expect } from '@playwright/test'
// El login local fue reemplazado por SSO de Workspace; auth.setup.ts cubre el flujo de auth.
// Este spec mantiene solo verificaciones del dashboard tras el setup.
test.describe('Login', () => {
test('sesión válida lleva al dashboard', async ({ page }) => {
await page.goto('/dashboard')
await expect(page).toHaveURL(/dashboard/, { timeout: 15000 })
})
test('dashboard muestra encabezado', async ({ page }) => {
await page.goto('/dashboard')
await expect(page.locator('h1')).toBeVisible({ timeout: 15000 })
})
})

View File

@@ -0,0 +1,108 @@
import { test, expect } from '@playwright/test'
test.describe('Modulos', () => {
test.describe('Import Invoices', () => {
test('factura TEM carga sin error', async ({ page }) => {
await page.goto('/dashboard/invoices?operation_type=imp&invoice_type=TEM')
await expect(page).toHaveURL(/invoices/)
await expect(page.locator('main')).toBeVisible()
})
test('factura DEF carga sin error', async ({ page }) => {
await page.goto('/dashboard/invoices?operation_type=imp&invoice_type=DEF')
await expect(page).toHaveURL(/invoices/)
await expect(page.locator('main')).toBeVisible()
})
})
test.describe('Export Invoices', () => {
test('exportacion carga sin error', async ({ page }) => {
await page.goto('/dashboard/invoices?operation_type=exp')
await expect(page).toHaveURL(/invoices/)
await expect(page.locator('main')).toBeVisible()
})
})
test.describe('Fixed Catalogs', () => {
test('carga sin error', async ({ page }) => {
await page.goto('/dashboard/reference_data/code_pedimento_regimens')
await expect(page).toHaveURL(/reference_data/)
await expect(page.locator('main')).toBeVisible()
})
})
test.describe('General Catalogs', () => {
test('company information carga sin error', async ({ page }) => {
await page.goto('/dashboard/general_catalogs/company_information')
await expect(page).toHaveURL(/company_information/)
await expect(page.locator('main')).toBeVisible()
})
})
test.describe('Transportes', () => {
test('transporters carga sin error', async ({ page }) => {
await page.goto('/dashboard/general_catalogs/transporters')
await expect(page).toHaveURL(/transporters/)
await expect(page.locator('main')).toBeVisible()
})
})
test.describe('Goods', () => {
test('fixed asset classes carga sin error', async ({ page }) => {
await page.goto('/dashboard/goods/fixed-asset-classes')
await expect(page).toHaveURL(/goods/)
await expect(page.locator('main')).toBeVisible()
})
})
test.describe('Settings', () => {
test('general carga sin error', async ({ page }) => {
await page.goto('/dashboard/settings/general')
await expect(page).toHaveURL(/settings/)
await expect(page.locator('body')).toBeVisible()
})
})
test.describe('Reportes', () => {
test('invoices carga sin error', async ({ page }) => {
await page.goto('/dashboard/reports/invoices')
await expect(page).toHaveURL(/reports/)
await expect(page.locator('main')).toBeVisible()
})
})
test.describe('Logout', () => {
test('cerrar sesion redirige a login', async ({ page }) => {
await page.goto('/dashboard')
await page.waitForLoadState('networkidle')
// Abrir el dropdown del usuario; el trigger es un Sidebar.MenuButton que
// puede no responder al click central → forzar con teclado.
const userBtn = page.getByRole('button').filter({ hasText: '@' }).first()
await userBtn.focus()
await page.keyboard.press('Enter')
// El menú está parcialmente en inglés ("Log out").
await page.getByRole('menuitem', { name: /Log out|Cerrar sesión/i }).click({ timeout: 10000 })
await expect(page).toHaveURL(/(login|workspace\.aduanasoft\.com)/, { timeout: 15000 })
})
})
})

View File

@@ -0,0 +1,79 @@
import { test, expect } from '@playwright/test'
test.describe('Navegacion', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/dashboard')
})
test('dashboard carga con saludo', async ({ page }) => {
await expect(page.locator('h1')).toBeVisible()
})
test('header muestra nombre de la empresa', async ({ page }) => {
await page.waitForLoadState('networkidle')
// El nombre exacto depende de la empresa activa; aceptar cualquier nombre no vacío en el botón del sidebar
await expect(page.locator('[data-sidebar]').getByRole('button').first())
.toBeVisible({ timeout: 10000 })
})
test.describe('Menu lateral', () => {
// El sidebar tiene links directos y grupos plegables que cargan según permisos.
// Verificamos que aparezcan los textos en alguna parte del menú (botón o link).
test('tiene Bitácora en el menu', async ({ page }) => {
await page.waitForLoadState('networkidle')
await expect(page.getByRole('link', { name: 'Bitácora' }).or(
page.getByRole('button', { name: 'Bitácora' })
).first()).toBeVisible({ timeout: 10000 })
})
test('tiene Agentes Aduanales en el menu', async ({ page }) => {
await page.waitForLoadState('networkidle')
await expect(page.getByRole('link', { name: 'Agentes Aduanales' })).toBeVisible({ timeout: 10000 })
})
test('Fracciones aparece en el menu', async ({ page }) => {
await page.waitForLoadState('networkidle')
await expect(page.getByText('Fracciones').first()).toBeVisible({ timeout: 10000 })
})
test('Pedimentos aparece en el menu', async ({ page }) => {
await page.waitForLoadState('networkidle')
// "Pedimentos" aparece como botón top-level del sidebar (no como sub-items plegados).
await expect(page.getByRole('button', { name: /^Pedimentos$/ }).first())
.toBeVisible({ timeout: 10000 })
})
})
test.describe('Modulos accesibles', () => {
// Navegamos directo por URL — el menú lateral es dinámico y no garantiza link visible.
test('Bitácora carga sin error', async ({ page }) => {
await page.goto('/dashboard/audit_logs')
await expect(page).toHaveURL(/audit/)
await expect(page.locator('h1')).toBeVisible()
})
test('Agentes Aduanales carga sin error', async ({ page }) => {
await page.goto('/dashboard/customs_brokers')
await expect(page).toHaveURL(/customs/)
await expect(page.locator('h1')).toBeVisible()
})
test('Fraction Sitar carga sin error', async ({ page }) => {
await page.goto('/dashboard/general_catalogs/tariff-fractions/sitar')
await expect(page).toHaveURL(/tariff-fractions/)
await expect(page.locator('main')).toBeVisible()
})
test('Pedimentos carga sin error', async ({ page }) => {
await page.goto('/dashboard/reference_data/code_pedimento_regimens')
await expect(page).toHaveURL(/reference_data/)
await expect(page.locator('main')).toBeVisible()
})
})
})

43
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,43 @@
import prettier from 'eslint-config-prettier';
import { fileURLToPath } from 'node:url';
import { includeIgnoreFile } from '@eslint/compat';
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import { defineConfig } from 'eslint/config';
import globals from 'globals';
import ts from 'typescript-eslint';
import svelteConfig from './svelte.config.js';
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
export default defineConfig(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
prettier,
...svelte.configs.prettier,
{
languageOptions: {
globals: { ...globals.browser, ...globals.node }
},
rules: { // typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
"no-undef": 'off' }
},
{
files: [
'**/*.svelte',
'**/*.svelte.ts',
'**/*.svelte.js'
],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
parser: ts.parser,
svelteConfig
}
}
}
);

12723
frontend/index.html Normal file

File diff suppressed because it is too large Load Diff

2094
frontend/messages/en.json Normal file

File diff suppressed because it is too large Load Diff

2094
frontend/messages/es.json Normal file

File diff suppressed because it is too large Load Diff

71
frontend/package.json Normal file
View File

@@ -0,0 +1,71 @@
{
"name": "frontend",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"i18n:compile": "paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide",
"build": "vite build",
"preview": "vite preview",
"start": "node build/index.js",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check . && eslint .",
"test:unit": "vitest --project server",
"test:unit:full": "vitest",
"test": "npm run test:unit -- --run && npm run test:e2e",
"test:e2e": "playwright test"
},
"devDependencies": {
"@eslint/compat": "^1.4.0",
"@eslint/js": "^9.36.0",
"@inlang/paraglide-js": "^2.3.2",
"@internationalized/date": "^3.10.0",
"@lucide/svelte": "^0.561.0",
"@playwright/test": "^1.55.1",
"@sveltejs/adapter-node": "^5.3.2",
"@sveltejs/kit": "^2.43.2",
"@sveltejs/vite-plugin-svelte": "^6.2.0",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.14",
"@tanstack/table-core": "^8.21.3",
"@types/node": "^20",
"@vitest/browser": "^3.2.4",
"bits-ui": "^2.14.4",
"clsx": "^2.1.1",
"eslint": "^9.36.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.12.4",
"globals": "^16.4.0",
"playwright": "^1.55.1",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.7.1",
"svelte": "^5.39.5",
"svelte-check": "^4.3.2",
"tailwind-merge": "^3.3.1",
"tailwind-variants": "^3.1.1",
"tailwindcss": "^4.1.14",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.2",
"typescript-eslint": "^8.44.1",
"vite": "^7.1.7",
"vitest": "^3.2.4",
"vitest-browser-svelte": "^1.1.0"
},
"dependencies": {
"@types/dompurify": "^3.2.0",
"@types/marked": "^6.0.0",
"chart.js": "^4.5.1",
"dompurify": "^3.0.9",
"keycloak-js": "^26.2.1",
"lucide-svelte": "^0.553.0",
"marked": "^12.0.0",
"svelte-sonner": "^1.0.7"
},
"packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017"
}

View File

@@ -0,0 +1,36 @@
import { defineConfig } from '@playwright/test';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const authStatePath = path.join(__dirname, 'e2e/.auth/user.json');
/** GitHub/Gitea/GitLab suelen exportar CI=true; Jenkins no siempre, pero define JENKINS_URL. */
const inCI = process.env.CI === 'true' || Boolean(process.env.JENKINS_URL);
export default defineConfig({
// En CI, falla si queda un .only; en local no
forbidOnly: inCI,
timeout: 60_000,
use: {
baseURL: process.env.PLAYWRIGHT_TEST_BASE_URL || 'http://localhost:5173',
headless: inCI ? true : false
},
workers: 1,
testDir: 'e2e',
projects: [
{
name: 'setup',
testMatch: '**/auth.setup.ts',
use: { storageState: { cookies: [], origins: [] } }
},
// Proyecto principal de E2E temporalmente desactivado mientras se ajusta el flujo
// de Workspace / Fixed Assets. Rehabilitar cuando los E2E estén listos.
// {
// name: 'tests',
// dependencies: ['setup'],
// testIgnore: ['**/auth.setup.ts', '**/demo.test.ts'],
// use: { storageState: authStatePath }
// }
]
});

3790
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
packages:
- '.'
onlyBuiltDependencies:
- esbuild
- '@tailwindcss/oxide'

1
frontend/project.inlang/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
cache

View File

@@ -0,0 +1 @@
UYEx30XMEoBHyXSEuC

View File

@@ -0,0 +1,15 @@
{
"$schema": "https://inlang.com/schema/project-settings",
"modules": [
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js",
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js"
],
"plugin.inlang.messageFormat": {
"pathPattern": "./messages/{locale}.json"
},
"baseLocale": "es",
"locales": [
"en",
"es"
]
}

View File

@@ -0,0 +1,358 @@
#!/usr/bin/env python3
"""Fusiona bloques csv_upload en messages/en.json y messages/es.json y vuelca a src/lib/i18n/csv-upload-messages.*.json.
La fuente de verdad del copy CSV es `messages/{en,es}.json` (alineado con sidebar, dashboard, facturas).
Si editas solo esos JSON, sincroniza con:
node -e "const fs=require('fs'),p=require('path'),r='.../frontend';for(const l of['en','es']){const j=JSON.parse(fs.readFileSync(p.join(r,'messages',l+'.json'),'utf8'));fs.writeFileSync(p.join(r,'src/lib/i18n','csv-upload-messages.'+l+'.json'),JSON.stringify(j.csv_upload,null,'\\t')+'\\n')}"
Ejecutar desde frontend/: python scripts/merge_csv_upload_i18n.py
"""
from __future__ import annotations
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
MESSAGES = ROOT / "messages"
EN_CSV = {
"page_title": "CSV import",
"intro_help": "Left-click: upload CSV file. Right-click: download template.",
"tab_catalogos": "Catalogs",
"tab_transportes": "Transportation",
"tab_importacion": "Import",
"tab_exportacion": "Export",
"section_catalogs": "General Catalogs",
"section_transport": "Transportation",
"section_import": "Import operations",
"section_export": "Export operations",
"params_header": "Global parameters",
"config_prefix": "Settings",
"soon": "Coming soon",
"drop_here": "Drop the file!",
"groups": {
"permisos": "Permissions",
"impo_temp": "Temporary import",
"impo_def": "Definitive import",
"cmex": "Mexican purchases",
"expo_def": "Definitive export / regime change",
"expo_rep": "Export replenishment",
"manifest": "Manifest",
},
"items": {
"customs_brokers": "Customs Brokers",
"clients_providers": "Clients and Providers",
"exchange_rates": "Exchange Rates",
"material_classes": "Classes",
"part_numbers": "Parts",
"boms": "BOMs",
"items": "Lines (permissions)",
"headers": "Headers (permissions)",
"historical_fractions": "Historical tariff fractions",
"pedimentos": "Pedimentos",
"transporters": "Carriers",
"transports": "Vehicles",
"drivers": "Drivers",
"trailers": "Trailers",
"imp_temp_header": "Header",
"imp_temp_details": "Lines",
"imp_temp_series": "Serial numbers",
"imp_def_header": "Header",
"imp_def_details": "Lines",
"imp_def_series": "Serial numbers",
"comp_mex_header": "Header",
"comp_mex_details": "Lines",
"comp_mex_series": "Serial numbers",
"exp_def_header": "Header",
"exp_def_details": "Lines",
"exp_def_series": "Serial numbers",
"exp_def_nodes": "NODES",
"exp_rep_header": "Header",
"exp_rep_details": "Lines",
"exp_rep_series": "Serial numbers",
"manifest_header": "Header",
},
"params": {
"load_mode": "Load mode",
"date_format": "Date format",
"weight_unit": "Weight unit",
"autonumber_series": "Autonumber lines/series",
"load_subpartidas": "Load sub-lines",
"recalculate_pedimento_date": "Recalculate pedimento date",
"autonumber_remesas": "Autonumber consignments",
"recalculate_dates": "Recalculate dates",
"invoice_type": "Invoice type",
"is_regime_change": "Regime change",
},
"options": {
"update": "Update",
"replace": "Replace",
"yes": "Yes",
"no": "No",
"kgs": "Kilograms (kg)",
"lbs": "Pounds (lb)",
"date_dd_mm": "DD/MM/YYYY",
"date_mm_dd": "MM/DD/YYYY",
"date_iso": "YYYY-MM-DD",
"afi": "AFIJO",
"normal": "NORMAL",
},
"progress": {
"upload": "Uploading CSV file",
"scan": "Validating records on the server",
"commit": "Saving records to the database",
"upload_known": "Uploading file…",
"upload_unknown": "Uploading file (unknown size in browser)…",
"in_progress": "In progress…",
"resume_hint": "Resuming import saved in this tab…",
"rows_file": "File: ~{n} data row(s) — uploading (not yet validated on server)…",
"rows_scan": "Records processed: {current} / {total}",
"rows_commit": "Records saved: {current} / {total}",
"rows_commit_fallback": "Saving to database… ({current} / {total} using last known total)",
},
"toast": {
"invalid_csv": "Invalid format. Only .csv files are allowed.",
"download_loading": "Downloading template…",
"download_ok": "Template downloaded.",
"download_err": "Could not download the template.",
"upload_err": "Could not upload the file.",
"upload_err_generic": "Unexpected error uploading the file.",
"scan_done": "Scan complete. Review the results.",
"import_done": "Import completed. Review the record list.",
"import_maybe_done": "Import may have completed. Review the record list.",
"stale_job": "This import is no longer available (session expired or job removed). You can start a new upload.",
"poll_err": "Could not fetch status",
"commit_err": "Could not start import",
"scan_alt": "Scan finished. If you do not see the modal, check the record list.",
"finished_none": "No records inserted. Review the errors below.",
"commit_warning_ok": "{inserted} inserted, {updated} updated. {skipped} rejected.",
"commit_warning_none": "No records inserted or updated. {skipped} rejected.",
"success_counts": "Import completed: {msg}",
"warn_skipped": "{n} records rejected or skipped",
"error_processing": "Processing error: {msg}",
},
"pending": {
"badge": "Pending",
"title": "Imports pending confirmation",
"description": "Scans ready to save to the database. Expired jobs disappear when you refresh.",
"refresh": "Refresh",
"empty": "No pending imports for this company.",
"checking": "Checking with the server…",
"total_rows": "Total rows",
"valid_rows": "Valid",
"resume": "Resume",
"remove": "Remove",
"profiles": {
"customs_brokers": "Customs Brokers",
"clients_providers": "Clients and Providers",
"exchange_rates": "Exchange Rates",
"pedimentos": "Pedimentos",
"material_classes": "Classes",
"vehicles": "Vehicles",
"drivers": "Drivers",
"trailers": "Trailers",
"transporters": "Carriers",
"part_numbers": "Parts",
"boms": "BOMs",
"exportacion": "Export operations",
"imports": "Import operations",
},
},
"modal": {
"title_pending": "Import validation",
"title_success": "Import successful",
"title_warning": "Import with remarks",
"desc_pending": "Review the preliminary analysis before confirming.",
"desc_done": "The import process has finished.",
"total_rows": "Total rows",
"valid_rows": "Valid",
"invalid_rows": "Invalid",
},
}
ES_CSV = {
"page_title": "Importación CSV",
"intro_help": "Clic izquierdo: cargar archivo CSV. Clic derecho: descargar plantilla.",
"tab_catalogos": "Catálogos",
"tab_transportes": "Transportes",
"tab_importacion": "Importación",
"tab_exportacion": "Exportación",
"section_catalogs": "Catalogos Generales",
"section_transport": "Transportes",
"section_import": "Operaciones de importación",
"section_export": "Operaciones de exportación",
"params_header": "Parámetros globales",
"config_prefix": "Configuración",
"soon": "Próximamente",
"drop_here": "¡Suelta el archivo!",
"groups": {
"permisos": "Permisos",
"impo_temp": "Impo. temp.",
"impo_def": "Impo. def.",
"cmex": "Compras mex.",
"expo_def": "Expo. def./Cam. reg.",
"expo_rep": "Expo. rep.",
"manifest": "Manifiesto",
},
"items": {
"customs_brokers": "Agentes Aduanales",
"clients_providers": "Clientes y Proveedores",
"exchange_rates": "Tipos de cambio",
"material_classes": "Clases",
"part_numbers": "Partes",
"boms": "BOMs",
"items": "Partidas (permisos)",
"headers": "Encabezados (permisos)",
"historical_fractions": "Fracciones históricas",
"pedimentos": "Pedimentos",
"transporters": "Transportistas",
"transports": "Vehículos",
"drivers": "Conductores",
"trailers": "Trailers",
"imp_temp_header": "Encabezado",
"imp_temp_details": "Partidas",
"imp_temp_series": "Series",
"imp_def_header": "Encabezado",
"imp_def_details": "Partidas",
"imp_def_series": "Series",
"comp_mex_header": "Encabezado",
"comp_mex_details": "Partidas",
"comp_mex_series": "Series",
"exp_def_header": "Encabezado",
"exp_def_details": "Partidas",
"exp_def_series": "Series",
"exp_def_nodes": "NODES",
"exp_rep_header": "Encabezado",
"exp_rep_details": "Partidas",
"exp_rep_series": "Series",
"manifest_header": "Encabezado",
},
"params": {
"load_mode": "Modo de carga",
"date_format": "Formato de fecha",
"weight_unit": "Unidad de peso",
"autonumber_series": "Autonumerar partidas/series",
"load_subpartidas": "Levantar subpartidas",
"recalculate_pedimento_date": "Recalcular fecha pedimento",
"autonumber_remesas": "Autonumerar remesas",
"recalculate_dates": "Recalcular fechas",
"invoice_type": "Tipo de factura",
"is_regime_change": "Es cambio de régimen",
},
"options": {
"update": "Actualizar",
"replace": "Reemplazar",
"yes": "",
"no": "No",
"kgs": "Kilos (kg)",
"lbs": "Libras (lb)",
"date_dd_mm": "DD/MM/YYYY",
"date_mm_dd": "MM/DD/YYYY",
"date_iso": "YYYY-MM-DD",
"afi": "AFIJO",
"normal": "NORMAL",
},
"progress": {
"upload": "Subiendo archivo CSV",
"scan": "Validando registros en el servidor",
"commit": "Grabando registros en base de datos",
"upload_known": "Subiendo archivo…",
"upload_unknown": "Subiendo archivo (tamaño desconocido en el navegador)…",
"in_progress": "En proceso…",
"resume_hint": "Reanudando la importación guardada en esta pestaña…",
"rows_file": "Archivo: ~{n} fila(s) de datos — subiendo (aún no se validan registros en servidor)…",
"rows_scan": "Registros procesados: {current} / {total}",
"rows_commit": "Registros grabados: {current} / {total}",
"rows_commit_fallback": "Grabando en base de datos… ({current} / {total} según último total conocido)",
},
"toast": {
"invalid_csv": "Formato inválido. Solo se permiten archivos .csv",
"download_loading": "Descargando plantilla…",
"download_ok": "Plantilla descargada.",
"download_err": "Error al descargar la plantilla",
"upload_err": "Error al subir el archivo",
"upload_err_generic": "Error inesperado al subir el archivo",
"scan_done": "Escaneo completado. Revisa los resultados.",
"import_done": "Importación completada. Revisa el listado de registros.",
"import_maybe_done": "La importación pudo completarse. Revisa el listado de registros.",
"stale_job": "Esta importación ya no está disponible (sesión expirada o trabajo eliminado). Puedes iniciar una nueva carga.",
"poll_err": "Error al consultar el estado",
"commit_err": "Error al iniciar la importación",
"scan_alt": "El escaneo terminó. Si no ves el modal, revisa el listado de registros.",
"finished_none": "No se insertaron registros. Revisa los errores a continuación.",
"commit_warning_ok": "{inserted} insertados, {updated} actualizados. {skipped} rechazados.",
"commit_warning_none": "No se insertaron ni actualizaron registros. {skipped} rechazados.",
"success_counts": "Importación completada: {msg}",
"warn_skipped": "{n} registros fueron rechazados u omitidos",
"error_processing": "Error en el procesamiento: {msg}",
},
"pending": {
"badge": "Pendientes",
"title": "Importaciones pendientes de confirmar",
"description": "Escaneos listos para insertar en base de datos. Si el trabajo ya expiró en el servidor, desaparecerán al actualizar.",
"refresh": "Actualizar",
"empty": "No hay importaciones pendientes para esta empresa.",
"checking": "Comprobando con el servidor…",
"total_rows": "Total filas",
"valid_rows": "Válidas",
"resume": "Reanudar",
"remove": "Quitar",
"profiles": {
"customs_brokers": "Agentes Aduanales",
"clients_providers": "Clientes y Proveedores",
"exchange_rates": "Tipos de cambio",
"pedimentos": "Pedimentos",
"material_classes": "Clases",
"vehicles": "Vehículos",
"drivers": "Conductores",
"trailers": "Trailers",
"transporters": "Transportistas",
"part_numbers": "Partes",
"boms": "BOMs",
"exportacion": "Exportación (operaciones)",
"imports": "Importación (operaciones)",
},
},
"modal": {
"title_pending": "Validación de importación",
"title_success": "Importación exitosa",
"title_warning": "Importación con observaciones",
"desc_pending": "Revise el análisis preliminar antes de confirmar la carga de datos.",
"desc_done": "El proceso de importación ha finalizado.",
"total_rows": "Total filas",
"valid_rows": "Válidos",
"invalid_rows": "Inválidos",
},
}
def merge_locale(filename: str, csv_obj: dict) -> None:
path = MESSAGES / filename
data = json.loads(path.read_text(encoding="utf-8"))
data["csv_upload"] = csv_obj
path.write_text(json.dumps(data, ensure_ascii=False, indent="\t") + "\n", encoding="utf-8")
def extract_csv_upload_to_lib() -> None:
"""Copia `csv_upload` a src/lib/i18n/csv-upload-messages.*.json (lo que importa csv-msg.ts)."""
dest_dir = ROOT / "src/lib/i18n"
for filename, suffix in (("en.json", "en"), ("es.json", "es")):
path = MESSAGES / filename
data = json.loads(path.read_text(encoding="utf-8"))
cu = data.get("csv_upload")
if cu is None:
raise SystemExit(f"merge_csv_upload_i18n: falta csv_upload en {filename}")
out = dest_dir / f"csv-upload-messages.{suffix}.json"
out.write_text(json.dumps(cu, ensure_ascii=False, indent="\t") + "\n", encoding="utf-8")
print(f"Wrote {out.relative_to(ROOT)}")
def main() -> None:
merge_locale("en.json", EN_CSV)
merge_locale("es.json", ES_CSV)
print("Merged csv_upload into en.json and es.json")
extract_csv_upload_to_lib()
if __name__ == "__main__":
main()

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>

Some files were not shown because too many files have changed in this diff Show More