Design System: bootstrap tokens + paleta brand El Día #13
@@ -59,3 +59,25 @@ Generated: 2026-04-13
|
||||
- Frontend: Vitest + React Testing Library — comando: `vitest`
|
||||
- Coverage backend: `dotnet test --collect:"XPlat Code Coverage"`
|
||||
- Coverage frontend: `vitest --coverage`
|
||||
|
||||
### Design System (frontend) — v2.3
|
||||
- Source of truth: `Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.14 🎨 Design System.md`. Engram topic_key: `sig-cm2/design-system`
|
||||
- Personality: tech sophisticated (Vercel/Linear/Railway). Glass + gradient mesh + multi-layer shadows + glow blobs corners
|
||||
- Brand `#008fbe` (logo) → escalado OKLCH `--brand-50..950`. **Violet accent** `oklch(0.62 0.20 280)` (`--accent-violet-*`) para combos tech. Neutral cool slate con shift hue 250-252 (`--neutral-50..950`)
|
||||
- NO usar `gray-*`/`slate-*`/`blue-*` genéricos de Tailwind. Solo brand/neutral/violet/semantic
|
||||
- Tokens semánticos: `bg-background`, `text-foreground`, `bg-primary`, `bg-card`, `text-muted-foreground`, `border-border`, `ring-ring`, `bg-input` (con `border-input-border`). NUNCA hardcodear `bg-white`/`text-black`/hex inline
|
||||
- Density compact: button 32-40px, input 40px (`h-10`), table row 40px. `--radius` base 10px (sm/md/lg/xl = 6/8/10/14)
|
||||
- Light + Dark con default = system preference (`useTheme()` hook). Dark NO es pure black (slate-violet). Smoke test ambos antes de mergear
|
||||
- Forms: ≤4 campos single col, ≥5 campos `grid grid-cols-1 md:grid-cols-2 gap-4`
|
||||
- Tablas mobile: priority columns + tap-to-expand (NO cards-on-mobile, NO pure horizontal scroll)
|
||||
- **Utilities CSS** (`@layer components` en index.css): `.glass`, `.gradient-mesh`, `.grid-bg` (usar en root layouts), `.surface` (tablas), `.focus-glow`
|
||||
- **Card variants**: `default` (shadow-md) / `elevated` / `glass` / `flat`
|
||||
- **Tooltips**: usar SIEMPRE `<Tooltip>` de `@/components/ui/tooltip` (Radix Portal). NO CSS absolute en sidebars/modals — clipping issue
|
||||
- **Sidebar**: colapsable con `useSidebar()` hook (persiste en localStorage). Toggle en top header al lado del brand
|
||||
- **DataTable**: usar SIEMPRE `<DataTable>` de `@/components/ui/data-table` para tablas. NUNCA HTML `<table>` crudo. Soporta `meta: { priority: 'high'|'medium'|'low' }` para responsive + tap-to-expand row mobile automático
|
||||
- **shadcn MCP**: registrado globalmente (user scope). Pedirle a Claude que instale componentes shadcn — lo hace via MCP sin que el dev toque CLI. 22 componentes ya instalados
|
||||
- Toasts via `sonner` (`<Toaster richColors closeButton position="top-right" />` ya montado en `App.tsx`). `toast.success()` / `toast.error()`
|
||||
- TooltipProvider ya envuelve App con `delayDuration={150}`
|
||||
- Componentes shadcn: instalar via shadcn MCP server o `npx shadcn@latest add`. NUNCA copy-paste manual del website
|
||||
- WCAG AA obligatorio: focus rings visibles (ya forzado en CSS base), contrast ≥ 4.5:1 texto normal, aria-label en botones icon-only
|
||||
- Browser autofill fix ya aplicado en `@layer base` — respeta tokens del DS
|
||||
|
||||
427
src/web/package-lock.json
generated
427
src/web/package-lock.json
generated
@@ -11,18 +11,24 @@
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.99.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"axios": "1.7",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
@@ -1530,12 +1536,102 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/number": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
||||
"integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-alert-dialog": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz",
|
||||
"integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-dialog": "1.1.15",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-context": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-arrow": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
|
||||
@@ -2309,6 +2405,99 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popover": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
|
||||
"integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||
"@radix-ui/react-focus-guards": "1.1.3",
|
||||
"@radix-ui/react-focus-scope": "1.1.7",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-popper": "1.2.8",
|
||||
"@radix-ui/react-portal": "1.1.9",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"react-remove-scroll": "^2.6.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-context": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popper": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
|
||||
@@ -2596,6 +2785,105 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-select": {
|
||||
"version": "2.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
|
||||
"integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/number": "1.1.1",
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-collection": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||
"@radix-ui/react-focus-guards": "1.1.3",
|
||||
"@radix-ui/react-focus-scope": "1.1.7",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-popper": "1.2.8",
|
||||
"@radix-ui/react-portal": "1.1.9",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1",
|
||||
"@radix-ui/react-use-previous": "1.1.1",
|
||||
"@radix-ui/react-visually-hidden": "1.2.3",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"react-remove-scroll": "^2.6.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-context": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-separator": {
|
||||
"version": "1.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz",
|
||||
@@ -2723,6 +3011,96 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
|
||||
"integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-popper": "1.2.8",
|
||||
"@radix-ui/react-portal": "1.1.9",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-visually-hidden": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-context": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||
@@ -3879,6 +4257,39 @@
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-table": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
|
||||
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/table-core": "8.21.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/table-core": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
|
||||
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
@@ -4873,6 +5284,22 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/cmdk": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
|
||||
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "^1.1.1",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-id": "^1.1.0",
|
||||
"@radix-ui/react-primitive": "^2.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19 || ^19.0.0-rc",
|
||||
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
|
||||
@@ -16,18 +16,24 @@
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.99.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"axios": "1.7",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { Toaster } from 'sonner'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { AppRoutes } from './router'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -13,10 +14,12 @@ const queryClient = new QueryClient({
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<AppRoutes />
|
||||
</BrowserRouter>
|
||||
<Toaster richColors closeButton position="top-right" />
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<BrowserRouter>
|
||||
<AppRoutes />
|
||||
</BrowserRouter>
|
||||
<Toaster richColors closeButton position="top-right" />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ export function AppHeader() {
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Navegación</SheetTitle>
|
||||
</SheetHeader>
|
||||
<SidebarNav />
|
||||
<SidebarNav forceExpanded />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
|
||||
@@ -9,10 +9,14 @@ import {
|
||||
Users,
|
||||
ShieldCheck,
|
||||
KeyRound,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { useSidebar } from '@/hooks/useSidebar'
|
||||
|
||||
interface NavItem {
|
||||
label: string
|
||||
@@ -25,127 +29,209 @@ const navItems: NavItem[] = [
|
||||
{ label: 'Dashboard', href: '/', icon: LayoutDashboard },
|
||||
{ label: 'Ventas', href: '/ventas', icon: ShoppingCart, disabled: true },
|
||||
{ label: 'Tasación', href: '/tasacion', icon: Calculator, disabled: true },
|
||||
{
|
||||
label: 'Integraciones',
|
||||
href: '/integraciones',
|
||||
icon: Zap,
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
label: 'Administración',
|
||||
href: '/administracion',
|
||||
icon: Settings,
|
||||
disabled: true,
|
||||
},
|
||||
{ label: 'Integraciones', href: '/integraciones', icon: Zap, disabled: true },
|
||||
{ label: 'Administración', href: '/administracion', icon: Settings, disabled: true },
|
||||
]
|
||||
|
||||
export function SidebarNav() {
|
||||
const adminItems: NavItem[] = [
|
||||
{ label: 'Usuarios', href: '/usuarios', icon: Users },
|
||||
{ label: 'Crear Usuario', href: '/usuarios/nuevo', icon: UserPlus },
|
||||
{ label: 'Roles', href: '/admin/roles', icon: ShieldCheck },
|
||||
{ label: 'Permisos', href: '/admin/permisos', icon: KeyRound },
|
||||
]
|
||||
|
||||
interface SidebarNavProps {
|
||||
/** When true forces expanded layout regardless of persisted state — used by mobile Sheet. */
|
||||
forceExpanded?: boolean
|
||||
}
|
||||
|
||||
export function SidebarNav({ forceExpanded = false }: SidebarNavProps) {
|
||||
const { pathname } = useLocation()
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const isAdmin = user?.rol === 'admin'
|
||||
const { collapsed: persisted, toggle } = useSidebar()
|
||||
const collapsed = forceExpanded ? false : persisted
|
||||
|
||||
function isItemActive(item: NavItem): boolean {
|
||||
if (item.disabled) return false
|
||||
if (item.href === '/') return pathname === '/'
|
||||
if (item.href === '/usuarios') {
|
||||
return pathname.startsWith('/usuarios') && pathname !== '/usuarios/nuevo'
|
||||
}
|
||||
if (item.href === '/usuarios/nuevo') return pathname === '/usuarios/nuevo'
|
||||
return pathname.startsWith(item.href)
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="flex h-full flex-col bg-background border-r border-border">
|
||||
{/* Brand */}
|
||||
<div className="flex h-14 items-center px-4 border-b border-border">
|
||||
<span className="text-base font-semibold tracking-tight text-foreground">
|
||||
SIG-CM 2.0
|
||||
</span>
|
||||
<aside
|
||||
className={cn(
|
||||
'flex h-full flex-col bg-card text-card-foreground transition-[width] duration-200 ease-out',
|
||||
collapsed ? 'w-[68px]' : 'w-60',
|
||||
)}
|
||||
data-collapsed={collapsed}
|
||||
>
|
||||
{/* Brand + Toggle (top header) */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-14 items-center border-b border-border shrink-0',
|
||||
collapsed ? 'justify-center px-2' : 'px-3 gap-2',
|
||||
)}
|
||||
>
|
||||
{!forceExpanded && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-label={collapsed ? 'Expandir sidebar' : 'Colapsar sidebar'}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground shrink-0"
|
||||
>
|
||||
{collapsed ? (
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
) : (
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{collapsed ? 'Expandir' : 'Colapsar'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{!collapsed && (
|
||||
<span className="text-sm font-semibold tracking-tight text-foreground truncate">
|
||||
SIG-CM 2.0
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 overflow-y-auto py-4 px-2 space-y-1">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive = pathname === item.href && !item.disabled
|
||||
<nav className="flex-1 overflow-y-auto overflow-x-hidden py-3 px-2 space-y-1">
|
||||
{navItems.map((item) => (
|
||||
<NavRow
|
||||
key={item.href}
|
||||
item={item}
|
||||
collapsed={collapsed}
|
||||
active={isItemActive(item)}
|
||||
/>
|
||||
))}
|
||||
|
||||
return item.disabled ? (
|
||||
<div
|
||||
key={item.href}
|
||||
className="flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground cursor-not-allowed opacity-60"
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
<span className="flex-1">{item.label}</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Próximamente
|
||||
</Badge>
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
key={item.href}
|
||||
to={item.href}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground',
|
||||
isActive
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Admin-only section */}
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="pt-2 pb-1 px-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground/60">
|
||||
Administración
|
||||
</span>
|
||||
</div>
|
||||
<Link
|
||||
to="/usuarios"
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground',
|
||||
pathname.startsWith('/usuarios') && pathname !== '/usuarios/nuevo'
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<Users className="h-4 w-4 shrink-0" />
|
||||
<span>Usuarios</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/usuarios/nuevo"
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground',
|
||||
pathname === '/usuarios/nuevo'
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<UserPlus className="h-4 w-4 shrink-0" />
|
||||
<span>Crear Usuario</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/admin/roles"
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground',
|
||||
pathname.startsWith('/admin/roles')
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<ShieldCheck className="h-4 w-4 shrink-0" />
|
||||
<span>Roles</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/admin/permisos"
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground',
|
||||
pathname.startsWith('/admin/permisos')
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<KeyRound className="h-4 w-4 shrink-0" />
|
||||
<span>Permisos</span>
|
||||
</Link>
|
||||
<SectionLabel collapsed={collapsed}>Administración</SectionLabel>
|
||||
{adminItems.map((item) => (
|
||||
<NavRow
|
||||
key={item.href}
|
||||
item={item}
|
||||
collapsed={collapsed}
|
||||
active={isItemActive(item)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────
|
||||
Sub-componentes
|
||||
──────────────────────────────────────────────────────────────── */
|
||||
|
||||
interface NavRowProps {
|
||||
item: NavItem
|
||||
collapsed: boolean
|
||||
active: boolean
|
||||
}
|
||||
|
||||
function NavRow({ item, collapsed, active }: NavRowProps) {
|
||||
const Icon = item.icon
|
||||
|
||||
const baseClasses = cn(
|
||||
'relative flex items-center rounded-md text-sm transition-colors',
|
||||
collapsed ? 'justify-center h-10 w-10 mx-auto' : 'gap-3 px-3 py-2',
|
||||
)
|
||||
|
||||
// Disabled item
|
||||
if (item.disabled) {
|
||||
const content = (
|
||||
<div
|
||||
className={cn(
|
||||
baseClasses,
|
||||
'text-muted-foreground/70 cursor-not-allowed opacity-60',
|
||||
)}
|
||||
aria-disabled="true"
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span className="flex-1 truncate">{item.label}</span>
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 shrink-0">
|
||||
Próx.
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!collapsed) return content
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{content}</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{item.label}{' '}
|
||||
<span className="text-muted-foreground">· Próximamente</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
// Active link
|
||||
const link = (
|
||||
<Link
|
||||
to={item.href}
|
||||
className={cn(
|
||||
baseClasses,
|
||||
active
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
)}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
>
|
||||
{/* Active indicator bar (left edge) when expanded */}
|
||||
{active && !collapsed && (
|
||||
<span className="absolute left-0 top-1/2 -translate-y-1/2 h-5 w-0.5 rounded-r-full bg-primary" />
|
||||
)}
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
{!collapsed && <span className="truncate">{item.label}</span>}
|
||||
</Link>
|
||||
)
|
||||
|
||||
if (!collapsed) return link
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{link}</TooltipTrigger>
|
||||
<TooltipContent side="right">{item.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionLabel({
|
||||
collapsed,
|
||||
children,
|
||||
}: {
|
||||
collapsed: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
if (collapsed) {
|
||||
return <div className="my-2 mx-2 border-t border-border" aria-hidden="true" />
|
||||
}
|
||||
return (
|
||||
<div className="pt-3 pb-1 px-3">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60">
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
139
src/web/src/components/ui/alert-dialog.tsx
Normal file
139
src/web/src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
))
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"mt-2 sm:mt-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
115
src/web/src/components/ui/breadcrumb.tsx
Normal file
115
src/web/src/components/ui/breadcrumb.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Breadcrumb = React.forwardRef<
|
||||
HTMLElement,
|
||||
React.ComponentPropsWithoutRef<"nav"> & {
|
||||
separator?: React.ReactNode
|
||||
}
|
||||
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
|
||||
Breadcrumb.displayName = "Breadcrumb"
|
||||
|
||||
const BreadcrumbList = React.forwardRef<
|
||||
HTMLOListElement,
|
||||
React.ComponentPropsWithoutRef<"ol">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ol
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
BreadcrumbList.displayName = "BreadcrumbList"
|
||||
|
||||
const BreadcrumbItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentPropsWithoutRef<"li">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<li
|
||||
ref={ref}
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
BreadcrumbItem.displayName = "BreadcrumbItem"
|
||||
|
||||
const BreadcrumbLink = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentPropsWithoutRef<"a"> & {
|
||||
asChild?: boolean
|
||||
}
|
||||
>(({ asChild, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={cn("transition-colors hover:text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
BreadcrumbLink.displayName = "BreadcrumbLink"
|
||||
|
||||
const BreadcrumbPage = React.forwardRef<
|
||||
HTMLSpanElement,
|
||||
React.ComponentPropsWithoutRef<"span">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<span
|
||||
ref={ref}
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("font-normal text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
BreadcrumbPage.displayName = "BreadcrumbPage"
|
||||
|
||||
const BreadcrumbSeparator = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) => (
|
||||
<li
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
)
|
||||
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
|
||||
|
||||
const BreadcrumbEllipsis = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) => (
|
||||
<span
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
)
|
||||
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
}
|
||||
@@ -1,36 +1,36 @@
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
@@ -41,7 +41,7 @@ export interface ButtonProps
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
@@ -49,8 +49,8 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
|
||||
@@ -1,20 +1,35 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-lg border bg-card text-card-foreground shadow-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
const cardVariants = cva('rounded-xl text-card-foreground transition-shadow', {
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border border-border bg-card shadow-md',
|
||||
elevated: 'border border-border bg-card shadow-lg',
|
||||
glass: 'glass shadow-xl',
|
||||
flat: 'border border-border bg-card',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
})
|
||||
|
||||
export interface CardProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof cardVariants> {}
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, CardProps>(
|
||||
({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(cardVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
)
|
||||
Card.displayName = 'Card'
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
|
||||
153
src/web/src/components/ui/command.tsx
Normal file
153
src/web/src/components/ui/command.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { type DialogProps } from "@radix-ui/react-dialog"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { Search } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Command.displayName = CommandPrimitive.displayName
|
||||
|
||||
const CommandDialog = ({ children, ...props }: DialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0 shadow-lg">
|
||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandList.displayName = CommandPrimitive.List.displayName
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName
|
||||
|
||||
const CommandShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
CommandShortcut.displayName = "CommandShortcut"
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
284
src/web/src/components/ui/data-table.tsx
Normal file
284
src/web/src/components/ui/data-table.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
import { Fragment, useState } from 'react'
|
||||
import {
|
||||
type ColumnDef,
|
||||
type Row,
|
||||
type RowData,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────
|
||||
Column meta para priority responsive
|
||||
──────────────────────────────────────────────────────────────── */
|
||||
|
||||
declare module '@tanstack/react-table' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface ColumnMeta<TData extends RowData, TValue> {
|
||||
/**
|
||||
* Visibility priority for responsive tables:
|
||||
* - 'high' (default): always visible
|
||||
* - 'medium': hidden < md (768px)
|
||||
* - 'low': hidden < lg (1024px)
|
||||
*/
|
||||
priority?: 'high' | 'medium' | 'low'
|
||||
/** Right-align cell content (numbers, badges) */
|
||||
align?: 'left' | 'right' | 'center'
|
||||
}
|
||||
}
|
||||
|
||||
function priorityClass(priority?: 'high' | 'medium' | 'low'): string {
|
||||
if (priority === 'low') return 'hidden lg:table-cell'
|
||||
if (priority === 'medium') return 'hidden md:table-cell'
|
||||
return ''
|
||||
}
|
||||
|
||||
function alignClass(align?: 'left' | 'right' | 'center'): string {
|
||||
if (align === 'right') return 'text-right'
|
||||
if (align === 'center') return 'text-center'
|
||||
return ''
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────
|
||||
DataTable
|
||||
──────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface DataTableProps<TData> {
|
||||
columns: ColumnDef<TData>[]
|
||||
data: TData[]
|
||||
/** Row click handler — fires on row body click (NOT on expand chevron) */
|
||||
onRowClick?: (row: TData) => void
|
||||
isLoading?: boolean
|
||||
/** Empty state message */
|
||||
emptyMessage?: string
|
||||
/** Stable row ID extractor for React keys + expand state */
|
||||
getRowId?: (row: TData, index: number) => string
|
||||
}
|
||||
|
||||
export function DataTable<TData>({
|
||||
columns,
|
||||
data,
|
||||
onRowClick,
|
||||
isLoading = false,
|
||||
emptyMessage = 'Sin resultados.',
|
||||
getRowId,
|
||||
}: DataTableProps<TData>) {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId: getRowId ?? ((_row, index) => String(index)),
|
||||
})
|
||||
|
||||
// Hidden columns trigger the mobile expand-row affordance
|
||||
const hasHiddenCols = columns.some(
|
||||
(c) => c.meta?.priority === 'medium' || c.meta?.priority === 'low',
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return <DataTableSkeleton columns={Math.max(columns.length, 4)} rows={5} />
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="surface py-12 text-center text-sm text-muted-foreground">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="surface overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow
|
||||
key={headerGroup.id}
|
||||
className="bg-muted/40 hover:bg-muted/40"
|
||||
>
|
||||
{hasHiddenCols && (
|
||||
<TableHead className="w-10 md:hidden" aria-hidden="true" />
|
||||
)}
|
||||
{headerGroup.headers.map((header) => {
|
||||
const meta = header.column.columnDef.meta
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={cn(
|
||||
'h-10 text-xs font-medium uppercase tracking-wider text-muted-foreground/80',
|
||||
priorityClass(meta?.priority),
|
||||
alignClass(meta?.align),
|
||||
)}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<DataTableRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
expanded={expandedId === row.id}
|
||||
onToggleExpand={() =>
|
||||
setExpandedId((prev) => (prev === row.id ? null : row.id))
|
||||
}
|
||||
onRowClick={onRowClick}
|
||||
hasHiddenCols={hasHiddenCols}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────
|
||||
Row con expand para mobile
|
||||
──────────────────────────────────────────────────────────────── */
|
||||
|
||||
interface DataTableRowProps<TData> {
|
||||
row: Row<TData>
|
||||
expanded: boolean
|
||||
onToggleExpand: () => void
|
||||
onRowClick?: (row: TData) => void
|
||||
hasHiddenCols: boolean
|
||||
}
|
||||
|
||||
function DataTableRow<TData>({
|
||||
row,
|
||||
expanded,
|
||||
onToggleExpand,
|
||||
onRowClick,
|
||||
hasHiddenCols,
|
||||
}: DataTableRowProps<TData>) {
|
||||
const cells = row.getVisibleCells()
|
||||
const visibleCellsCount = cells.length + (hasHiddenCols ? 1 : 0)
|
||||
|
||||
// Cells that DO get hidden (medium/low priority) — needed for expanded panel
|
||||
const hiddenCells = cells.filter((cell) => {
|
||||
const p = cell.column.columnDef.meta?.priority
|
||||
return p === 'medium' || p === 'low'
|
||||
})
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<TableRow
|
||||
data-state={expanded ? 'selected' : undefined}
|
||||
className={cn(
|
||||
'border-b border-border last:border-0 transition-colors',
|
||||
onRowClick && 'cursor-pointer hover:bg-accent/40',
|
||||
)}
|
||||
onClick={onRowClick ? () => onRowClick(row.original) : undefined}
|
||||
>
|
||||
{hasHiddenCols && (
|
||||
<TableCell
|
||||
className="w-10 px-2 md:hidden"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggleExpand()
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={expanded ? 'Colapsar fila' : 'Expandir fila'}
|
||||
aria-expanded={expanded}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-4 w-4 transition-transform duration-200',
|
||||
expanded && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</TableCell>
|
||||
)}
|
||||
{cells.map((cell) => {
|
||||
const meta = cell.column.columnDef.meta
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={cn(
|
||||
'px-4 py-3 align-middle',
|
||||
priorityClass(meta?.priority),
|
||||
alignClass(meta?.align),
|
||||
)}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
|
||||
{/* Expanded panel — visible solo en mobile cuando hay cols ocultas */}
|
||||
{expanded && hiddenCells.length > 0 && (
|
||||
<TableRow className="md:hidden border-b border-border bg-muted/20 hover:bg-muted/20">
|
||||
<TableCell colSpan={visibleCellsCount} className="px-4 py-3">
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">
|
||||
{hiddenCells.map((cell) => {
|
||||
const header = cell.column.columnDef.header
|
||||
return (
|
||||
<Fragment key={cell.id}>
|
||||
<dt className="text-xs font-medium uppercase tracking-wider text-muted-foreground/80">
|
||||
{typeof header === 'string' ? header : cell.column.id}
|
||||
</dt>
|
||||
<dd className="text-foreground">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</dd>
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</dl>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────
|
||||
Skeleton
|
||||
──────────────────────────────────────────────────────────────── */
|
||||
|
||||
function DataTableSkeleton({ columns, rows }: { columns: number; rows: number }) {
|
||||
return (
|
||||
<div className="surface overflow-hidden">
|
||||
<div className="space-y-px">
|
||||
<div className="flex h-10 items-center gap-4 bg-muted/40 px-4">
|
||||
{Array.from({ length: columns }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-3 flex-1 max-w-[120px]" />
|
||||
))}
|
||||
</div>
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<div key={i} className="flex h-12 items-center gap-4 px-4 border-b border-border last:border-0">
|
||||
{Array.from({ length: columns }).map((_, j) => (
|
||||
<Skeleton key={j} className="h-3 flex-1 max-w-[200px]" />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
120
src/web/src/components/ui/dialog.tsx
Normal file
120
src/web/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
@@ -10,7 +10,11 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'flex h-10 w-full rounded-md border border-input-border bg-input px-3 py-2 text-sm text-foreground shadow-sm ring-offset-background transition-colors',
|
||||
'placeholder:text-muted-foreground/70',
|
||||
'file:border-0 file:bg-transparent file:text-sm file:font-medium',
|
||||
'hover:border-border focus-visible:border-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30 focus-visible:ring-offset-0',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
117
src/web/src/components/ui/pagination.tsx
Normal file
117
src/web/src/components/ui/pagination.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import * as React from "react"
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ButtonProps, buttonVariants } from "@/components/ui/button"
|
||||
|
||||
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
className={cn("mx-auto flex w-full justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
Pagination.displayName = "Pagination"
|
||||
|
||||
const PaginationContent = React.forwardRef<
|
||||
HTMLUListElement,
|
||||
React.ComponentProps<"ul">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ul
|
||||
ref={ref}
|
||||
className={cn("flex flex-row items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
PaginationContent.displayName = "PaginationContent"
|
||||
|
||||
const PaginationItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentProps<"li">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<li ref={ref} className={cn("", className)} {...props} />
|
||||
))
|
||||
PaginationItem.displayName = "PaginationItem"
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean
|
||||
} & Pick<ButtonProps, "size"> &
|
||||
React.ComponentProps<"a">
|
||||
|
||||
const PaginationLink = ({
|
||||
className,
|
||||
isActive,
|
||||
size = "icon",
|
||||
...props
|
||||
}: PaginationLinkProps) => (
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
PaginationLink.displayName = "PaginationLink"
|
||||
|
||||
const PaginationPrevious = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn("gap-1 pl-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span>Previous</span>
|
||||
</PaginationLink>
|
||||
)
|
||||
PaginationPrevious.displayName = "PaginationPrevious"
|
||||
|
||||
const PaginationNext = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn("gap-1 pr-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<span>Next</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</PaginationLink>
|
||||
)
|
||||
PaginationNext.displayName = "PaginationNext"
|
||||
|
||||
const PaginationEllipsis = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) => (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
)
|
||||
PaginationEllipsis.displayName = "PaginationEllipsis"
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
}
|
||||
31
src/web/src/components/ui/popover.tsx
Normal file
31
src/web/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent }
|
||||
158
src/web/src/components/ui/select.tsx
Normal file
158
src/web/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
117
src/web/src/components/ui/table.tsx
Normal file
117
src/web/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
33
src/web/src/components/ui/tooltip.tsx
Normal file
33
src/web/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import * as React from 'react'
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 8, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 overflow-hidden rounded-md border border-border bg-popover px-2.5 py-1.5 text-xs font-medium text-popover-foreground shadow-lg',
|
||||
'data-[state=delayed-open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=delayed-open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=delayed-open]:zoom-in-95',
|
||||
'data-[side=right]:slide-in-from-left-1 data-[side=left]:slide-in-from-right-1',
|
||||
'data-[side=top]:slide-in-from-bottom-1 data-[side=bottom]:slide-in-from-top-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { isAxiosError } from 'axios'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import { AlertCircle, Newspaper } from 'lucide-react'
|
||||
import { useLogin } from '../hooks/useLogin'
|
||||
import { LoginForm } from '../components/LoginForm'
|
||||
import {
|
||||
@@ -39,14 +39,18 @@ export function LoginPage() {
|
||||
const errorMessage = resolveErrorMessage(error)
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl text-center">SIG-CM 2.0</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
Iniciar sesión
|
||||
<Card variant="glass" className="w-full max-w-md">
|
||||
<CardHeader className="space-y-3 text-center pb-2">
|
||||
{/* Brand mark */}
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-brand-500 to-violet-500 shadow-lg shadow-brand-500/30">
|
||||
<Newspaper className="h-6 w-6 text-white" strokeWidth={2.25} />
|
||||
</div>
|
||||
<CardTitle className="text-2xl tracking-tight">SIG-CM 2.0</CardTitle>
|
||||
<CardDescription className="text-sm">
|
||||
Sistema de gestión comercial · El Día
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<CardContent className="space-y-4 pt-4">
|
||||
{errorMessage && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { UserListItem } from '../types'
|
||||
import { useMemo } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { DataTable } from '@/components/ui/data-table'
|
||||
import type { UserListItem } from '../types'
|
||||
|
||||
interface UsersTableProps {
|
||||
rows: UserListItem[]
|
||||
@@ -16,58 +19,84 @@ function formatDate(iso: string | null): string {
|
||||
}
|
||||
|
||||
export function UsersTable({ rows, onRowClick }: UsersTableProps) {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="py-12 text-center text-muted-foreground">
|
||||
Sin resultados — no se encontraron usuarios con los filtros seleccionados.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const columns = useMemo<ColumnDef<UserListItem>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'username',
|
||||
header: 'Usuario',
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs">{row.original.username}</span>
|
||||
),
|
||||
meta: { priority: 'high' },
|
||||
},
|
||||
{
|
||||
id: 'fullName',
|
||||
header: 'Nombre',
|
||||
cell: ({ row }) => `${row.original.nombre} ${row.original.apellido}`,
|
||||
meta: { priority: 'high' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'rol',
|
||||
header: 'Rol',
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="secondary" className="capitalize">
|
||||
{row.original.rol}
|
||||
</Badge>
|
||||
),
|
||||
meta: { priority: 'high' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'activo',
|
||||
header: 'Estado',
|
||||
cell: ({ row }) =>
|
||||
row.original.activo ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
|
||||
>
|
||||
Activo
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
|
||||
>
|
||||
Inactivo
|
||||
</Badge>
|
||||
),
|
||||
meta: { priority: 'medium' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'email',
|
||||
header: 'Email',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground">
|
||||
{row.original.email ?? '—'}
|
||||
</span>
|
||||
),
|
||||
meta: { priority: 'medium' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'ultimoLogin',
|
||||
header: 'Último login',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground">
|
||||
{formatDate(row.original.ultimoLogin)}
|
||||
</span>
|
||||
),
|
||||
meta: { priority: 'low' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Usuario</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Nombre</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Email</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Rol</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Estado</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Último login</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((u) => (
|
||||
<tr
|
||||
key={u.id}
|
||||
onClick={() => onRowClick(u)}
|
||||
className="border-b border-border last:border-0 hover:bg-accent/50 cursor-pointer transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3 font-mono text-xs">{u.username}</td>
|
||||
<td className="px-4 py-3">{`${u.nombre} ${u.apellido}`}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{u.email ?? '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="secondary" className="capitalize">
|
||||
{u.rol}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{u.activo ? (
|
||||
<Badge variant="secondary" className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||
Activo
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
|
||||
Inactivo
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{formatDate(u.ultimoLogin)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
onRowClick={onRowClick}
|
||||
getRowId={(row) => String(row.id)}
|
||||
emptyMessage="Sin resultados — no se encontraron usuarios con los filtros seleccionados."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
24
src/web/src/hooks/useSidebar.ts
Normal file
24
src/web/src/hooks/useSidebar.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
|
||||
const STORAGE_KEY = 'sidebar-collapsed'
|
||||
|
||||
function readInitial(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
return window.localStorage.getItem(STORAGE_KEY) === '1'
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages desktop sidebar collapsed state with localStorage persistence.
|
||||
* Mobile sidebar (Sheet) is independent — this hook only affects desktop ≥ lg.
|
||||
*/
|
||||
export function useSidebar() {
|
||||
const [collapsed, setCollapsed] = useState<boolean>(readInitial)
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(STORAGE_KEY, collapsed ? '1' : '0')
|
||||
}, [collapsed])
|
||||
|
||||
const toggle = useCallback(() => setCollapsed((prev) => !prev), [])
|
||||
|
||||
return { collapsed, setCollapsed, toggle }
|
||||
}
|
||||
@@ -3,113 +3,333 @@
|
||||
@import "@fontsource/inter/400.css";
|
||||
@import "@fontsource/inter/500.css";
|
||||
@import "@fontsource/inter/600.css";
|
||||
@import "@fontsource/inter/700.css";
|
||||
@import "@fontsource/jetbrains-mono/400.css";
|
||||
|
||||
/* ================================================================
|
||||
SIG-CM 2.0 Design System v2.0 — Tokens
|
||||
Source of truth: Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.14 🎨 Design System.md
|
||||
Brand: #008fbe (logo) + violet accent → tech sophistication
|
||||
Style: dark-first elegante, glassmorphism, multi-layer shadows
|
||||
================================================================ */
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 5.9% 10%;
|
||||
--radius: 0.5rem;
|
||||
--font-sans: "Inter", system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", monospace;
|
||||
/* ── Brand cyan (logo #008fbe) ──────────────────────────── */
|
||||
--brand-50: oklch(0.972 0.013 220);
|
||||
--brand-100: oklch(0.935 0.035 220);
|
||||
--brand-200: oklch(0.870 0.072 220);
|
||||
--brand-300: oklch(0.780 0.108 220);
|
||||
--brand-400: oklch(0.700 0.135 220);
|
||||
--brand-500: oklch(0.625 0.130 220); /* logo #008fbe */
|
||||
--brand-600: oklch(0.530 0.135 222);
|
||||
--brand-700: oklch(0.440 0.115 224);
|
||||
--brand-800: oklch(0.350 0.085 226);
|
||||
--brand-900: oklch(0.270 0.060 228);
|
||||
--brand-950: oklch(0.190 0.040 230);
|
||||
|
||||
/* ── Violet accent (combo tech con brand cyan) ──────────── */
|
||||
--accent-violet-400: oklch(0.700 0.180 280);
|
||||
--accent-violet-500: oklch(0.620 0.200 280);
|
||||
--accent-violet-600: oklch(0.530 0.205 280);
|
||||
|
||||
/* ── Neutral (slate con shift sutil hacia azul/violeta) ── */
|
||||
--neutral-50: oklch(0.985 0.004 250);
|
||||
--neutral-100: oklch(0.962 0.007 250);
|
||||
--neutral-200: oklch(0.910 0.012 250);
|
||||
--neutral-300: oklch(0.835 0.018 250);
|
||||
--neutral-400: oklch(0.680 0.022 250);
|
||||
--neutral-500: oklch(0.540 0.025 250);
|
||||
--neutral-600: oklch(0.435 0.025 250);
|
||||
--neutral-700: oklch(0.345 0.022 250);
|
||||
--neutral-800: oklch(0.250 0.020 250);
|
||||
--neutral-900: oklch(0.185 0.015 250);
|
||||
--neutral-950: oklch(0.130 0.013 250);
|
||||
|
||||
/* ── Semantic ──────────────────────────────────────────── */
|
||||
--success: oklch(0.640 0.155 145);
|
||||
--success-foreground: oklch(0.990 0.000 0);
|
||||
--warning: oklch(0.760 0.150 75);
|
||||
--warning-foreground: oklch(0.220 0.050 75);
|
||||
|
||||
/* ── shadcn semantic mapping (LIGHT) ─────────────────── */
|
||||
--background: oklch(0.962 0.006 250); /* slate cool — pop con cards white */
|
||||
--foreground: var(--neutral-900);
|
||||
|
||||
--card: oklch(1 0 0); /* pure white — máximo contraste */
|
||||
--card-foreground: var(--neutral-900);
|
||||
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: var(--neutral-900);
|
||||
|
||||
--primary: var(--brand-600); /* WCAG AA on white */
|
||||
--primary-foreground: oklch(0.990 0.000 0);
|
||||
|
||||
--secondary: var(--neutral-100);
|
||||
--secondary-foreground: var(--neutral-800);
|
||||
|
||||
--muted: var(--neutral-100);
|
||||
--muted-foreground: var(--neutral-500);
|
||||
|
||||
--accent: var(--brand-50);
|
||||
--accent-foreground: var(--brand-700);
|
||||
|
||||
--destructive: oklch(0.600 0.220 25);
|
||||
--destructive-foreground: oklch(0.990 0.000 0);
|
||||
|
||||
--border: var(--neutral-200);
|
||||
--input: oklch(1 0 0); /* WHITE inputs en light, no gris */
|
||||
--input-border: var(--neutral-300); /* border más prominente */
|
||||
--ring: var(--brand-500);
|
||||
|
||||
/* ── Glass surfaces (backdrop-blur) ───────────────────── */
|
||||
--glass-bg: oklch(1 0 0 / 0.7);
|
||||
--glass-border: oklch(1 0 0 / 0.4);
|
||||
|
||||
/* ── Gradient backgrounds (hero, login) ───────────────── */
|
||||
--gradient-mesh:
|
||||
radial-gradient(at 100% 0%, oklch(0.625 0.130 220 / 0.12) 0px, transparent 50%),
|
||||
radial-gradient(at 0% 100%, oklch(0.620 0.200 280 / 0.10) 0px, transparent 50%),
|
||||
radial-gradient(at 50% 50%, oklch(0.700 0.135 220 / 0.05) 0px, transparent 60%);
|
||||
|
||||
/* ── Shadows (multi-layer para depth real) ────────────── */
|
||||
--shadow-sm: 0 1px 2px 0 oklch(0.185 0.015 250 / 0.05);
|
||||
--shadow: 0 1px 3px 0 oklch(0.185 0.015 250 / 0.08), 0 1px 2px -1px oklch(0.185 0.015 250 / 0.06);
|
||||
--shadow-md: 0 4px 8px -2px oklch(0.185 0.015 250 / 0.08), 0 2px 4px -2px oklch(0.185 0.015 250 / 0.04);
|
||||
--shadow-lg: 0 12px 24px -6px oklch(0.185 0.015 250 / 0.12), 0 4px 8px -3px oklch(0.185 0.015 250 / 0.06);
|
||||
--shadow-xl: 0 24px 48px -12px oklch(0.185 0.015 250 / 0.18), 0 8px 16px -8px oklch(0.185 0.015 250 / 0.08);
|
||||
--shadow-glow: 0 0 0 1px oklch(0.625 0.130 220 / 0.10), 0 0 24px -4px oklch(0.625 0.130 220 / 0.20);
|
||||
|
||||
/* ── Spacing & shape ───────────────────────────────────── */
|
||||
--radius: 0.625rem; /* 10px */
|
||||
|
||||
/* ── Typography ────────────────────────────────────────── */
|
||||
--font-sans: "Inter", system-ui, -apple-system, sans-serif;
|
||||
--font-mono: "JetBrains Mono", "Cascadia Code", Consolas, monospace;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
/* Background con shift sutil hacia violet → "tech" feel */
|
||||
--background: oklch(0.130 0.018 252); /* deep slate-violet — más oscuro para contraste */
|
||||
--foreground: var(--neutral-50);
|
||||
|
||||
--card: oklch(0.220 0.024 252); /* elevación visual marcada — pop sobre bg */
|
||||
--card-foreground: var(--neutral-50);
|
||||
|
||||
--popover: oklch(0.245 0.024 252); /* dropdowns/popovers más elevados aún */
|
||||
--popover-foreground: var(--neutral-50);
|
||||
|
||||
--primary: var(--brand-400); /* brighter en dark, "neon" feel */
|
||||
--primary-foreground: oklch(0.130 0.013 250);
|
||||
|
||||
--secondary: oklch(0.255 0.022 252);
|
||||
--secondary-foreground: var(--neutral-100);
|
||||
|
||||
--muted: oklch(0.245 0.022 252);
|
||||
--muted-foreground: var(--neutral-400);
|
||||
|
||||
--accent: oklch(0.275 0.032 252);
|
||||
--accent-foreground: var(--brand-300);
|
||||
|
||||
--destructive: oklch(0.580 0.190 25);
|
||||
--destructive-foreground: oklch(0.990 0.000 0);
|
||||
|
||||
--border: oklch(1 0 0 / 0.10); /* sutil glass-style border */
|
||||
--input: oklch(0.245 0.022 252); /* elevado, mismo nivel que muted */
|
||||
--input-border: oklch(1 0 0 / 0.14);
|
||||
--ring: var(--brand-400);
|
||||
|
||||
/* Glass para dark */
|
||||
--glass-bg: oklch(0.180 0.020 252 / 0.6);
|
||||
--glass-border: oklch(1 0 0 / 0.10);
|
||||
|
||||
/* Gradient mesh dark mode — más vibrante */
|
||||
--gradient-mesh:
|
||||
radial-gradient(at 100% 0%, oklch(0.625 0.130 220 / 0.20) 0px, transparent 50%),
|
||||
radial-gradient(at 0% 100%, oklch(0.620 0.200 280 / 0.18) 0px, transparent 50%),
|
||||
radial-gradient(at 50% 50%, oklch(0.700 0.135 220 / 0.08) 0px, transparent 60%);
|
||||
|
||||
/* Shadows más intensas en dark */
|
||||
--shadow-sm: 0 1px 2px 0 oklch(0 0 0 / 0.30);
|
||||
--shadow: 0 2px 4px 0 oklch(0 0 0 / 0.40), 0 1px 2px -1px oklch(0 0 0 / 0.30);
|
||||
--shadow-md: 0 4px 8px -2px oklch(0 0 0 / 0.40), 0 2px 4px -2px oklch(0 0 0 / 0.30);
|
||||
--shadow-lg: 0 12px 24px -6px oklch(0 0 0 / 0.50), 0 4px 8px -3px oklch(0 0 0 / 0.30);
|
||||
--shadow-xl: 0 24px 48px -12px oklch(0 0 0 / 0.60), 0 8px 16px -8px oklch(0 0 0 / 0.30);
|
||||
--shadow-glow: 0 0 0 1px oklch(0.700 0.135 220 / 0.30), 0 0 32px -4px oklch(0.700 0.135 220 / 0.40);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
border-color: hsl(var(--border));
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
html {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100svh;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: var(--font-sans);
|
||||
::selection {
|
||||
background-color: var(--brand-200);
|
||||
color: var(--brand-900);
|
||||
}
|
||||
.dark ::selection {
|
||||
background-color: var(--brand-700);
|
||||
color: var(--brand-50);
|
||||
}
|
||||
|
||||
code,
|
||||
pre,
|
||||
kbd,
|
||||
samp {
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
code, pre, kbd, samp {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* ── Browser autofill (Chrome/Safari): forzar tokens del DS ──
|
||||
Sin esto, autofill aplica bg amarillo + texto blanco que se ve horrible
|
||||
y rompe contraste con cualquier paleta. */
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus,
|
||||
input:-webkit-autofill:active,
|
||||
textarea:-webkit-autofill,
|
||||
select:-webkit-autofill {
|
||||
-webkit-text-fill-color: var(--foreground) !important;
|
||||
-webkit-box-shadow: 0 0 0 1000px var(--input) inset !important;
|
||||
caret-color: var(--foreground) !important;
|
||||
transition: background-color 5000s ease-in-out 0s;
|
||||
}
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
Glass surface utility
|
||||
Uso: <div className="glass">…</div>
|
||||
================================================================ */
|
||||
@layer components {
|
||||
.glass {
|
||||
background-color: var(--glass-bg);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
border: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.gradient-mesh {
|
||||
background-image: var(--gradient-mesh);
|
||||
}
|
||||
|
||||
/* Grid texture overlay — usar como fondo decorativo en surfaces grandes
|
||||
Light: 2.5% opacity (sutil pero visible). Dark: 5% (más presente) */
|
||||
.grid-bg {
|
||||
background-image:
|
||||
linear-gradient(to right, currentColor 1px, transparent 1px),
|
||||
linear-gradient(to bottom, currentColor 1px, transparent 1px);
|
||||
background-size: 56px 56px;
|
||||
color: var(--neutral-500);
|
||||
opacity: 0.07;
|
||||
}
|
||||
.dark .grid-bg {
|
||||
color: var(--neutral-400);
|
||||
opacity: 0.10;
|
||||
}
|
||||
|
||||
/* Surface utility — para contenedores que no usan <Card> (ej: tablas, listas)
|
||||
Mismo treatment visual: bg elevado + border + shadow */
|
||||
.surface {
|
||||
background-color: var(--card);
|
||||
color: var(--card-foreground);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* Brand glow para focus en inputs críticos */
|
||||
.focus-glow:focus-visible {
|
||||
box-shadow: var(--shadow-glow);
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
Tailwind 4 inline theme — utilidades disponibles globalmente
|
||||
================================================================ */
|
||||
@theme inline {
|
||||
--color-background: hsl(var(--background));
|
||||
--color-foreground: hsl(var(--foreground));
|
||||
--color-card: hsl(var(--card));
|
||||
--color-card-foreground: hsl(var(--card-foreground));
|
||||
--color-popover: hsl(var(--popover));
|
||||
--color-popover-foreground: hsl(var(--popover-foreground));
|
||||
--color-primary: hsl(var(--primary));
|
||||
--color-primary-foreground: hsl(var(--primary-foreground));
|
||||
--color-secondary: hsl(var(--secondary));
|
||||
--color-secondary-foreground: hsl(var(--secondary-foreground));
|
||||
--color-muted: hsl(var(--muted));
|
||||
--color-muted-foreground: hsl(var(--muted-foreground));
|
||||
--color-accent: hsl(var(--accent));
|
||||
--color-accent-foreground: hsl(var(--accent-foreground));
|
||||
--color-destructive: hsl(var(--destructive));
|
||||
--color-destructive-foreground: hsl(var(--destructive-foreground));
|
||||
--color-border: hsl(var(--border));
|
||||
--color-input: hsl(var(--input));
|
||||
--color-ring: hsl(var(--ring));
|
||||
--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-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-input-border: var(--input-border);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
/* Brand */
|
||||
--color-brand-50: var(--brand-50);
|
||||
--color-brand-100: var(--brand-100);
|
||||
--color-brand-200: var(--brand-200);
|
||||
--color-brand-300: var(--brand-300);
|
||||
--color-brand-400: var(--brand-400);
|
||||
--color-brand-500: var(--brand-500);
|
||||
--color-brand-600: var(--brand-600);
|
||||
--color-brand-700: var(--brand-700);
|
||||
--color-brand-800: var(--brand-800);
|
||||
--color-brand-900: var(--brand-900);
|
||||
--color-brand-950: var(--brand-950);
|
||||
|
||||
/* Violet accent */
|
||||
--color-violet-400: var(--accent-violet-400);
|
||||
--color-violet-500: var(--accent-violet-500);
|
||||
--color-violet-600: var(--accent-violet-600);
|
||||
|
||||
/* Semantic */
|
||||
--color-success: var(--success);
|
||||
--color-success-foreground: var(--success-foreground);
|
||||
--color-warning: var(--warning);
|
||||
--color-warning-foreground: var(--warning-foreground);
|
||||
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
|
||||
--shadow-sm: var(--shadow-sm);
|
||||
--shadow: var(--shadow);
|
||||
--shadow-md: var(--shadow-md);
|
||||
--shadow-lg: var(--shadow-lg);
|
||||
--shadow-xl: var(--shadow-xl);
|
||||
--shadow-glow: var(--shadow-glow);
|
||||
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-mono);
|
||||
}
|
||||
|
||||
@@ -8,14 +8,21 @@ interface ProtectedLayoutProps {
|
||||
|
||||
export function ProtectedLayout({ children }: ProtectedLayoutProps) {
|
||||
return (
|
||||
<div className="flex h-svh overflow-hidden bg-background text-foreground">
|
||||
{/* Desktop sidebar */}
|
||||
<div className="hidden lg:flex lg:w-60 lg:flex-col lg:shrink-0">
|
||||
<div className="relative flex h-svh overflow-hidden bg-background text-foreground">
|
||||
{/* Grid texture — fondo cuadriculado para dar profundidad y resaltar glass */}
|
||||
<div className="absolute inset-0 grid-bg pointer-events-none" />
|
||||
|
||||
{/* Subtle brand/violet glow accents — corners */}
|
||||
<div className="absolute top-[-15%] right-[-8%] w-[500px] h-[500px] rounded-full bg-brand-500/10 dark:bg-brand-500/12 blur-[140px] pointer-events-none" />
|
||||
<div className="absolute bottom-[-15%] left-[20%] w-[500px] h-[500px] rounded-full bg-violet-500/8 dark:bg-violet-500/10 blur-[140px] pointer-events-none" />
|
||||
|
||||
{/* Desktop sidebar — width controlled by SidebarNav itself (collapsed/expanded) */}
|
||||
<div className="relative z-10 hidden lg:flex lg:flex-col lg:shrink-0 border-r border-border">
|
||||
<SidebarNav />
|
||||
</div>
|
||||
|
||||
{/* Main column */}
|
||||
<div className="flex flex-1 flex-col min-w-0 overflow-hidden">
|
||||
<div className="relative z-10 flex flex-1 flex-col min-w-0 overflow-hidden">
|
||||
<AppHeader />
|
||||
<main className="flex-1 overflow-y-auto p-6">{children}</main>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { ThemeToggle } from '@/components/layout/ThemeToggle'
|
||||
|
||||
interface PublicLayoutProps {
|
||||
children: ReactNode
|
||||
@@ -6,8 +7,24 @@ interface PublicLayoutProps {
|
||||
|
||||
export function PublicLayout({ children }: PublicLayoutProps) {
|
||||
return (
|
||||
<div className="min-h-svh bg-muted/40 flex items-center justify-center p-4">
|
||||
{children}
|
||||
<div className="relative min-h-svh flex items-center justify-center p-4 overflow-hidden bg-background">
|
||||
{/* Grid texture — sutil en light, más presente en dark */}
|
||||
<div className="absolute inset-0 grid-bg pointer-events-none" />
|
||||
|
||||
{/* Gradient mesh con radial blobs (brand cyan + violet) */}
|
||||
<div className="absolute inset-0 gradient-mesh pointer-events-none" />
|
||||
|
||||
{/* Brand glow accents — más intensos para que la profundidad funcione en light también */}
|
||||
<div className="absolute top-[-20%] right-[-10%] w-[600px] h-[600px] rounded-full bg-brand-500/25 dark:bg-brand-500/20 blur-[120px] pointer-events-none" />
|
||||
<div className="absolute bottom-[-20%] left-[-10%] w-[600px] h-[600px] rounded-full bg-violet-500/20 dark:bg-violet-500/18 blur-[120px] pointer-events-none" />
|
||||
|
||||
{/* Theme toggle — top-right, glass over gradient */}
|
||||
<div className="absolute top-4 right-4 z-20">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10 w-full flex justify-center">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
219
src/web/src/tests/components/ui/data-table.test.tsx
Normal file
219
src/web/src/tests/components/ui/data-table.test.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { DataTable } from '@/components/ui/data-table'
|
||||
|
||||
interface Row {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
role: string
|
||||
lastLogin: string
|
||||
}
|
||||
|
||||
const mockRows: Row[] = [
|
||||
{ id: '1', name: 'Juan Pérez', email: 'juan@x.com', role: 'admin', lastLogin: '2026-01-15' },
|
||||
{ id: '2', name: 'Ana García', email: 'ana@x.com', role: 'cajero', lastLogin: '2026-01-10' },
|
||||
{ id: '3', name: 'Luis Torres', email: 'luis@x.com', role: 'cajero', lastLogin: '2026-01-08' },
|
||||
]
|
||||
|
||||
const columns: ColumnDef<Row>[] = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Nombre',
|
||||
meta: { priority: 'high' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'role',
|
||||
header: 'Rol',
|
||||
meta: { priority: 'high' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'email',
|
||||
header: 'Email',
|
||||
meta: { priority: 'medium' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'lastLogin',
|
||||
header: 'Último login',
|
||||
meta: { priority: 'low' },
|
||||
},
|
||||
]
|
||||
|
||||
describe('DataTable', () => {
|
||||
describe('rendering', () => {
|
||||
it('renders headers and rows from data', () => {
|
||||
render(<DataTable columns={columns} data={mockRows} getRowId={(r) => r.id} />)
|
||||
|
||||
expect(screen.getByText('Nombre')).toBeInTheDocument()
|
||||
expect(screen.getByText('Rol')).toBeInTheDocument()
|
||||
expect(screen.getByText('Juan Pérez')).toBeInTheDocument()
|
||||
expect(screen.getByText('Ana García')).toBeInTheDocument()
|
||||
expect(screen.getByText('Luis Torres')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders empty state when data is empty', () => {
|
||||
render(<DataTable columns={columns} data={[]} emptyMessage="Nada acá" />)
|
||||
|
||||
expect(screen.getByText('Nada acá')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders default empty message when none provided', () => {
|
||||
render(<DataTable columns={columns} data={[]} />)
|
||||
|
||||
expect(screen.getByText('Sin resultados.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders skeleton when isLoading=true', () => {
|
||||
const { container } = render(
|
||||
<DataTable columns={columns} data={[]} isLoading />,
|
||||
)
|
||||
|
||||
// Skeleton uses pulse animation — check presence via class
|
||||
const skeletons = container.querySelectorAll('[class*="animate-pulse"]')
|
||||
expect(skeletons.length).toBeGreaterThan(0)
|
||||
// Empty message should NOT be visible during loading
|
||||
expect(screen.queryByText('Sin resultados.')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('row click', () => {
|
||||
it('calls onRowClick with row data when row clicked', async () => {
|
||||
const onRowClick = vi.fn()
|
||||
render(
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={mockRows}
|
||||
onRowClick={onRowClick}
|
||||
getRowId={(r) => r.id}
|
||||
/>,
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText('Juan Pérez'))
|
||||
expect(onRowClick).toHaveBeenCalledTimes(1)
|
||||
expect(onRowClick).toHaveBeenCalledWith(mockRows[0])
|
||||
})
|
||||
|
||||
it('does not attach click handler when onRowClick missing', () => {
|
||||
const { container } = render(
|
||||
<DataTable columns={columns} data={mockRows} getRowId={(r) => r.id} />,
|
||||
)
|
||||
|
||||
const rows = container.querySelectorAll('tbody tr')
|
||||
// First row should NOT have cursor-pointer
|
||||
expect(rows[0]?.className).not.toContain('cursor-pointer')
|
||||
})
|
||||
})
|
||||
|
||||
describe('priority columns + expand row (mobile pattern)', () => {
|
||||
it('renders expand chevron when there are medium/low priority columns', () => {
|
||||
render(<DataTable columns={columns} data={mockRows} getRowId={(r) => r.id} />)
|
||||
|
||||
const expandButtons = screen.getAllByLabelText(/expandir fila/i)
|
||||
expect(expandButtons).toHaveLength(mockRows.length)
|
||||
})
|
||||
|
||||
it('does NOT render expand chevron when all columns are high priority', () => {
|
||||
const highOnlyCols: ColumnDef<Row>[] = [
|
||||
{ accessorKey: 'name', header: 'Nombre', meta: { priority: 'high' } },
|
||||
{ accessorKey: 'role', header: 'Rol', meta: { priority: 'high' } },
|
||||
]
|
||||
render(<DataTable columns={highOnlyCols} data={mockRows} getRowId={(r) => r.id} />)
|
||||
|
||||
expect(screen.queryAllByLabelText(/expandir fila/i)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('expand button reveals hidden columns in panel below', async () => {
|
||||
render(<DataTable columns={columns} data={mockRows} getRowId={(r) => r.id} />)
|
||||
|
||||
const firstExpand = screen.getAllByLabelText(/expandir fila/i)[0]
|
||||
await userEvent.click(firstExpand)
|
||||
|
||||
// Email and lastLogin (hidden cols) should appear in expanded panel.
|
||||
// Email is also rendered in the regular cell (hidden visually but in DOM)
|
||||
// — instead check the dl/dt structure:
|
||||
const dts = screen.getAllByText('Email')
|
||||
expect(dts.length).toBeGreaterThan(0)
|
||||
const dtLastLogin = screen.getAllByText('Último login')
|
||||
expect(dtLastLogin.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('expand button toggle changes aria-expanded', async () => {
|
||||
render(<DataTable columns={columns} data={mockRows} getRowId={(r) => r.id} />)
|
||||
|
||||
const firstExpand = screen.getAllByLabelText(/expandir fila/i)[0]
|
||||
expect(firstExpand).toHaveAttribute('aria-expanded', 'false')
|
||||
|
||||
await userEvent.click(firstExpand)
|
||||
// After click, label changes to "Colapsar fila"
|
||||
const collapse = screen.getAllByLabelText(/colapsar fila/i)[0]
|
||||
expect(collapse).toHaveAttribute('aria-expanded', 'true')
|
||||
})
|
||||
|
||||
it('expand button click does NOT trigger onRowClick', async () => {
|
||||
const onRowClick = vi.fn()
|
||||
render(
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={mockRows}
|
||||
onRowClick={onRowClick}
|
||||
getRowId={(r) => r.id}
|
||||
/>,
|
||||
)
|
||||
|
||||
const firstExpand = screen.getAllByLabelText(/expandir fila/i)[0]
|
||||
await userEvent.click(firstExpand)
|
||||
|
||||
expect(onRowClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('only one row can be expanded at a time', async () => {
|
||||
render(<DataTable columns={columns} data={mockRows} getRowId={(r) => r.id} />)
|
||||
|
||||
const expands = screen.getAllByLabelText(/expandir fila/i)
|
||||
await userEvent.click(expands[0])
|
||||
// Now expands[1] still shows expand label (since only first is expanded)
|
||||
const stillExpand = screen.getAllByLabelText(/expandir fila/i)
|
||||
expect(stillExpand.length).toBe(2) // 2 rows still collapsed (rows 1 and 2)
|
||||
|
||||
await userEvent.click(stillExpand[0]) // Click row 1's expand
|
||||
// Row 0 should be collapsed now, row 1 expanded
|
||||
const labels = {
|
||||
expand: screen.queryAllByLabelText(/expandir fila/i),
|
||||
collapse: screen.queryAllByLabelText(/colapsar fila/i),
|
||||
}
|
||||
expect(labels.collapse.length).toBe(1)
|
||||
expect(labels.expand.length).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('responsive priority CSS classes', () => {
|
||||
it('applies hidden md:table-cell to medium priority columns', () => {
|
||||
const { container } = render(
|
||||
<DataTable columns={columns} data={mockRows} getRowId={(r) => r.id} />,
|
||||
)
|
||||
|
||||
// Email column (medium) — find header cell
|
||||
const headers = within(container.querySelector('thead')!).getAllByRole(
|
||||
'columnheader',
|
||||
)
|
||||
const emailHeader = headers.find((h) => h.textContent === 'Email')
|
||||
expect(emailHeader?.className).toContain('hidden')
|
||||
expect(emailHeader?.className).toContain('md:table-cell')
|
||||
})
|
||||
|
||||
it('applies hidden lg:table-cell to low priority columns', () => {
|
||||
const { container } = render(
|
||||
<DataTable columns={columns} data={mockRows} getRowId={(r) => r.id} />,
|
||||
)
|
||||
|
||||
const headers = within(container.querySelector('thead')!).getAllByRole(
|
||||
'columnheader',
|
||||
)
|
||||
const lastLoginHeader = headers.find((h) => h.textContent === 'Último login')
|
||||
expect(lastLoginHeader?.className).toContain('hidden')
|
||||
expect(lastLoginHeader?.className).toContain('lg:table-cell')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,5 +3,11 @@
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user