Merge pull request 'Design System: bootstrap tokens + paleta brand El Día' (#13) from chore/design-system-tokens into main
This commit was merged in pull request #13.
This commit is contained in:
@@ -59,3 +59,25 @@ Generated: 2026-04-13
|
|||||||
- Frontend: Vitest + React Testing Library — comando: `vitest`
|
- Frontend: Vitest + React Testing Library — comando: `vitest`
|
||||||
- Coverage backend: `dotnet test --collect:"XPlat Code Coverage"`
|
- Coverage backend: `dotnet test --collect:"XPlat Code Coverage"`
|
||||||
- Coverage frontend: `vitest --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/inter": "^5.2.8",
|
||||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
"@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-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tanstack/react-query": "^5.99.0",
|
"@tanstack/react-query": "^5.99.0",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"axios": "1.7",
|
"axios": "1.7",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
@@ -1530,12 +1536,102 @@
|
|||||||
"node": ">=14"
|
"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": {
|
"node_modules/@radix-ui/primitive": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||||
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@radix-ui/react-arrow": {
|
||||||
"version": "1.1.7",
|
"version": "1.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
|
"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": {
|
"node_modules/@radix-ui/react-popper": {
|
||||||
"version": "1.2.8",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
|
"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": {
|
"node_modules/@radix-ui/react-separator": {
|
||||||
"version": "1.1.8",
|
"version": "1.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz",
|
"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": {
|
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
"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"
|
"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": {
|
"node_modules/@testing-library/dom": {
|
||||||
"version": "10.4.1",
|
"version": "10.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||||
@@ -4873,6 +5284,22 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
|
|||||||
@@ -16,18 +16,24 @@
|
|||||||
"@fontsource/inter": "^5.2.8",
|
"@fontsource/inter": "^5.2.8",
|
||||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
"@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-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tanstack/react-query": "^5.99.0",
|
"@tanstack/react-query": "^5.99.0",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"axios": "1.7",
|
"axios": "1.7",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { Toaster } from 'sonner'
|
import { Toaster } from 'sonner'
|
||||||
|
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||||
import { AppRoutes } from './router'
|
import { AppRoutes } from './router'
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
@@ -13,10 +14,12 @@ const queryClient = new QueryClient({
|
|||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<BrowserRouter>
|
<TooltipProvider delayDuration={150}>
|
||||||
<AppRoutes />
|
<BrowserRouter>
|
||||||
</BrowserRouter>
|
<AppRoutes />
|
||||||
<Toaster richColors closeButton position="top-right" />
|
</BrowserRouter>
|
||||||
|
<Toaster richColors closeButton position="top-right" />
|
||||||
|
</TooltipProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function AppHeader() {
|
|||||||
<SheetHeader className="sr-only">
|
<SheetHeader className="sr-only">
|
||||||
<SheetTitle>Navegación</SheetTitle>
|
<SheetTitle>Navegación</SheetTitle>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<SidebarNav />
|
<SidebarNav forceExpanded />
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,14 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
|
PanelLeftClose,
|
||||||
|
PanelLeftOpen,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
import { useSidebar } from '@/hooks/useSidebar'
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
label: string
|
label: string
|
||||||
@@ -25,127 +29,209 @@ const navItems: NavItem[] = [
|
|||||||
{ label: 'Dashboard', href: '/', icon: LayoutDashboard },
|
{ label: 'Dashboard', href: '/', icon: LayoutDashboard },
|
||||||
{ label: 'Ventas', href: '/ventas', icon: ShoppingCart, disabled: true },
|
{ label: 'Ventas', href: '/ventas', icon: ShoppingCart, disabled: true },
|
||||||
{ label: 'Tasación', href: '/tasacion', icon: Calculator, disabled: true },
|
{ label: 'Tasación', href: '/tasacion', icon: Calculator, disabled: true },
|
||||||
{
|
{ label: 'Integraciones', href: '/integraciones', icon: Zap, disabled: true },
|
||||||
label: 'Integraciones',
|
{ label: 'Administración', href: '/administracion', icon: Settings, disabled: true },
|
||||||
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 { pathname } = useLocation()
|
||||||
const user = useAuthStore((s) => s.user)
|
const user = useAuthStore((s) => s.user)
|
||||||
const isAdmin = user?.rol === 'admin'
|
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 (
|
return (
|
||||||
<aside className="flex h-full flex-col bg-background border-r border-border">
|
<aside
|
||||||
{/* Brand */}
|
className={cn(
|
||||||
<div className="flex h-14 items-center px-4 border-b border-border">
|
'flex h-full flex-col bg-card text-card-foreground transition-[width] duration-200 ease-out',
|
||||||
<span className="text-base font-semibold tracking-tight text-foreground">
|
collapsed ? 'w-[68px]' : 'w-60',
|
||||||
SIG-CM 2.0
|
)}
|
||||||
</span>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Nav */}
|
{/* Nav */}
|
||||||
<nav className="flex-1 overflow-y-auto py-4 px-2 space-y-1">
|
<nav className="flex-1 overflow-y-auto overflow-x-hidden py-3 px-2 space-y-1">
|
||||||
{navItems.map((item) => {
|
{navItems.map((item) => (
|
||||||
const Icon = item.icon
|
<NavRow
|
||||||
const isActive = pathname === item.href && !item.disabled
|
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 && (
|
{isAdmin && (
|
||||||
<>
|
<>
|
||||||
<div className="pt-2 pb-1 px-3">
|
<SectionLabel collapsed={collapsed}>Administración</SectionLabel>
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground/60">
|
{adminItems.map((item) => (
|
||||||
Administración
|
<NavRow
|
||||||
</span>
|
key={item.href}
|
||||||
</div>
|
item={item}
|
||||||
<Link
|
collapsed={collapsed}
|
||||||
to="/usuarios"
|
active={isItemActive(item)}
|
||||||
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>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</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 * as React from "react"
|
||||||
import { Slot } from '@radix-ui/react-slot'
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
import { cva, type VariantProps } from 'class-variance-authority'
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
destructive:
|
destructive:
|
||||||
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
outline:
|
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:
|
secondary:
|
||||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
link: 'text-primary underline-offset-4 hover:underline',
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: 'h-10 px-4 py-2',
|
default: "h-10 px-4 py-2",
|
||||||
sm: 'h-9 rounded-md px-3',
|
sm: "h-9 rounded-md px-3",
|
||||||
lg: 'h-11 rounded-md px-8',
|
lg: "h-11 rounded-md px-8",
|
||||||
icon: 'h-10 w-10',
|
icon: "h-10 w-10",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: 'default',
|
variant: "default",
|
||||||
size: 'default',
|
size: "default",
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
@@ -41,7 +41,7 @@ export interface ButtonProps
|
|||||||
|
|
||||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
const Comp = asChild ? Slot : 'button'
|
const Comp = asChild ? Slot : "button"
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
@@ -49,8 +49,8 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
)
|
)
|
||||||
Button.displayName = 'Button'
|
Button.displayName = "Button"
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
export { Button, buttonVariants }
|
||||||
|
|||||||
@@ -1,20 +1,35 @@
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
const Card = React.forwardRef<
|
const cardVariants = cva('rounded-xl text-card-foreground transition-shadow', {
|
||||||
HTMLDivElement,
|
variants: {
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
variant: {
|
||||||
>(({ className, ...props }, ref) => (
|
default: 'border border-border bg-card shadow-md',
|
||||||
<div
|
elevated: 'border border-border bg-card shadow-lg',
|
||||||
ref={ref}
|
glass: 'glass shadow-xl',
|
||||||
className={cn(
|
flat: 'border border-border bg-card',
|
||||||
'rounded-lg border bg-card text-card-foreground shadow-sm',
|
},
|
||||||
className,
|
},
|
||||||
)}
|
defaultVariants: {
|
||||||
{...props}
|
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'
|
Card.displayName = 'Card'
|
||||||
|
|
||||||
const CardHeader = React.forwardRef<
|
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
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
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 { useNavigate } from 'react-router-dom'
|
||||||
import { isAxiosError } from 'axios'
|
import { isAxiosError } from 'axios'
|
||||||
import { AlertCircle } from 'lucide-react'
|
import { AlertCircle, Newspaper } from 'lucide-react'
|
||||||
import { useLogin } from '../hooks/useLogin'
|
import { useLogin } from '../hooks/useLogin'
|
||||||
import { LoginForm } from '../components/LoginForm'
|
import { LoginForm } from '../components/LoginForm'
|
||||||
import {
|
import {
|
||||||
@@ -39,14 +39,18 @@ export function LoginPage() {
|
|||||||
const errorMessage = resolveErrorMessage(error)
|
const errorMessage = resolveErrorMessage(error)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="w-full max-w-sm">
|
<Card variant="glass" className="w-full max-w-md">
|
||||||
<CardHeader className="space-y-1">
|
<CardHeader className="space-y-3 text-center pb-2">
|
||||||
<CardTitle className="text-2xl text-center">SIG-CM 2.0</CardTitle>
|
{/* Brand mark */}
|
||||||
<CardDescription className="text-center">
|
<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">
|
||||||
Iniciar sesión
|
<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>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4 pt-4">
|
||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<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 { Badge } from '@/components/ui/badge'
|
||||||
|
import { DataTable } from '@/components/ui/data-table'
|
||||||
|
import type { UserListItem } from '../types'
|
||||||
|
|
||||||
interface UsersTableProps {
|
interface UsersTableProps {
|
||||||
rows: UserListItem[]
|
rows: UserListItem[]
|
||||||
@@ -16,58 +19,84 @@ function formatDate(iso: string | null): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function UsersTable({ rows, onRowClick }: UsersTableProps) {
|
export function UsersTable({ rows, onRowClick }: UsersTableProps) {
|
||||||
if (rows.length === 0) {
|
const columns = useMemo<ColumnDef<UserListItem>[]>(
|
||||||
return (
|
() => [
|
||||||
<div className="py-12 text-center text-muted-foreground">
|
{
|
||||||
Sin resultados — no se encontraron usuarios con los filtros seleccionados.
|
accessorKey: 'username',
|
||||||
</div>
|
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 (
|
return (
|
||||||
<div className="rounded-md border border-border overflow-hidden">
|
<DataTable
|
||||||
<table className="w-full text-sm">
|
columns={columns}
|
||||||
<thead>
|
data={rows}
|
||||||
<tr className="border-b border-border bg-muted/50">
|
onRowClick={onRowClick}
|
||||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Usuario</th>
|
getRowId={(row) => String(row.id)}
|
||||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Nombre</th>
|
emptyMessage="Sin resultados — no se encontraron usuarios con los filtros seleccionados."
|
||||||
<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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
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/400.css";
|
||||||
@import "@fontsource/inter/500.css";
|
@import "@fontsource/inter/500.css";
|
||||||
@import "@fontsource/inter/600.css";
|
@import "@fontsource/inter/600.css";
|
||||||
|
@import "@fontsource/inter/700.css";
|
||||||
@import "@fontsource/jetbrains-mono/400.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 {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 0 0% 100%;
|
/* ── Brand cyan (logo #008fbe) ──────────────────────────── */
|
||||||
--foreground: 240 10% 3.9%;
|
--brand-50: oklch(0.972 0.013 220);
|
||||||
--card: 0 0% 100%;
|
--brand-100: oklch(0.935 0.035 220);
|
||||||
--card-foreground: 240 10% 3.9%;
|
--brand-200: oklch(0.870 0.072 220);
|
||||||
--popover: 0 0% 100%;
|
--brand-300: oklch(0.780 0.108 220);
|
||||||
--popover-foreground: 240 10% 3.9%;
|
--brand-400: oklch(0.700 0.135 220);
|
||||||
--primary: 240 5.9% 10%;
|
--brand-500: oklch(0.625 0.130 220); /* logo #008fbe */
|
||||||
--primary-foreground: 0 0% 98%;
|
--brand-600: oklch(0.530 0.135 222);
|
||||||
--secondary: 240 4.8% 95.9%;
|
--brand-700: oklch(0.440 0.115 224);
|
||||||
--secondary-foreground: 240 5.9% 10%;
|
--brand-800: oklch(0.350 0.085 226);
|
||||||
--muted: 240 4.8% 95.9%;
|
--brand-900: oklch(0.270 0.060 228);
|
||||||
--muted-foreground: 240 3.8% 46.1%;
|
--brand-950: oklch(0.190 0.040 230);
|
||||||
--accent: 240 4.8% 95.9%;
|
|
||||||
--accent-foreground: 240 5.9% 10%;
|
/* ── Violet accent (combo tech con brand cyan) ──────────── */
|
||||||
--destructive: 0 84.2% 60.2%;
|
--accent-violet-400: oklch(0.700 0.180 280);
|
||||||
--destructive-foreground: 0 0% 98%;
|
--accent-violet-500: oklch(0.620 0.200 280);
|
||||||
--border: 240 5.9% 90%;
|
--accent-violet-600: oklch(0.530 0.205 280);
|
||||||
--input: 240 5.9% 90%;
|
|
||||||
--ring: 240 5.9% 10%;
|
/* ── Neutral (slate con shift sutil hacia azul/violeta) ── */
|
||||||
--radius: 0.5rem;
|
--neutral-50: oklch(0.985 0.004 250);
|
||||||
--font-sans: "Inter", system-ui, sans-serif;
|
--neutral-100: oklch(0.962 0.007 250);
|
||||||
--font-mono: "JetBrains Mono", monospace;
|
--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 {
|
.dark {
|
||||||
--background: 240 10% 3.9%;
|
/* Background con shift sutil hacia violet → "tech" feel */
|
||||||
--foreground: 0 0% 98%;
|
--background: oklch(0.130 0.018 252); /* deep slate-violet — más oscuro para contraste */
|
||||||
--card: 240 10% 3.9%;
|
--foreground: var(--neutral-50);
|
||||||
--card-foreground: 0 0% 98%;
|
|
||||||
--popover: 240 10% 3.9%;
|
--card: oklch(0.220 0.024 252); /* elevación visual marcada — pop sobre bg */
|
||||||
--popover-foreground: 0 0% 98%;
|
--card-foreground: var(--neutral-50);
|
||||||
--primary: 0 0% 98%;
|
|
||||||
--primary-foreground: 240 5.9% 10%;
|
--popover: oklch(0.245 0.024 252); /* dropdowns/popovers más elevados aún */
|
||||||
--secondary: 240 3.7% 15.9%;
|
--popover-foreground: var(--neutral-50);
|
||||||
--secondary-foreground: 0 0% 98%;
|
|
||||||
--muted: 240 3.7% 15.9%;
|
--primary: var(--brand-400); /* brighter en dark, "neon" feel */
|
||||||
--muted-foreground: 240 5% 64.9%;
|
--primary-foreground: oklch(0.130 0.013 250);
|
||||||
--accent: 240 3.7% 15.9%;
|
|
||||||
--accent-foreground: 0 0% 98%;
|
--secondary: oklch(0.255 0.022 252);
|
||||||
--destructive: 0 62.8% 30.6%;
|
--secondary-foreground: var(--neutral-100);
|
||||||
--destructive-foreground: 0 0% 98%;
|
|
||||||
--border: 240 3.7% 15.9%;
|
--muted: oklch(0.245 0.022 252);
|
||||||
--input: 240 3.7% 15.9%;
|
--muted-foreground: var(--neutral-400);
|
||||||
--ring: 240 4.9% 83.9%;
|
|
||||||
|
--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 {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
border-color: hsl(var(--border));
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
color-scheme: light dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background-color: hsl(var(--background));
|
background-color: var(--background);
|
||||||
color: hsl(var(--foreground));
|
color: var(--foreground);
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
min-height: 100svh;
|
min-height: 100svh;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
::selection {
|
||||||
h2,
|
background-color: var(--brand-200);
|
||||||
h3,
|
color: var(--brand-900);
|
||||||
h4,
|
}
|
||||||
h5,
|
.dark ::selection {
|
||||||
h6 {
|
background-color: var(--brand-700);
|
||||||
font-family: var(--font-sans);
|
color: var(--brand-50);
|
||||||
}
|
}
|
||||||
|
|
||||||
code,
|
*:focus-visible {
|
||||||
pre,
|
outline: 2px solid var(--ring);
|
||||||
kbd,
|
outline-offset: 2px;
|
||||||
samp {
|
}
|
||||||
|
|
||||||
|
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);
|
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 {
|
@theme inline {
|
||||||
--color-background: hsl(var(--background));
|
--color-background: var(--background);
|
||||||
--color-foreground: hsl(var(--foreground));
|
--color-foreground: var(--foreground);
|
||||||
--color-card: hsl(var(--card));
|
--color-card: var(--card);
|
||||||
--color-card-foreground: hsl(var(--card-foreground));
|
--color-card-foreground: var(--card-foreground);
|
||||||
--color-popover: hsl(var(--popover));
|
--color-popover: var(--popover);
|
||||||
--color-popover-foreground: hsl(var(--popover-foreground));
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
--color-primary: hsl(var(--primary));
|
--color-primary: var(--primary);
|
||||||
--color-primary-foreground: hsl(var(--primary-foreground));
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
--color-secondary: hsl(var(--secondary));
|
--color-secondary: var(--secondary);
|
||||||
--color-secondary-foreground: hsl(var(--secondary-foreground));
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
--color-muted: hsl(var(--muted));
|
--color-muted: var(--muted);
|
||||||
--color-muted-foreground: hsl(var(--muted-foreground));
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
--color-accent: hsl(var(--accent));
|
--color-accent: var(--accent);
|
||||||
--color-accent-foreground: hsl(var(--accent-foreground));
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
--color-destructive: hsl(var(--destructive));
|
--color-destructive: var(--destructive);
|
||||||
--color-destructive-foreground: hsl(var(--destructive-foreground));
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
--color-border: hsl(var(--border));
|
--color-border: var(--border);
|
||||||
--color-input: hsl(var(--input));
|
--color-input: var(--input);
|
||||||
--color-ring: hsl(var(--ring));
|
--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-sm: calc(var(--radius) - 4px);
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
--radius-lg: var(--radius);
|
--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-sans: var(--font-sans);
|
||||||
--font-mono: var(--font-mono);
|
--font-mono: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,14 +8,21 @@ interface ProtectedLayoutProps {
|
|||||||
|
|
||||||
export function ProtectedLayout({ children }: ProtectedLayoutProps) {
|
export function ProtectedLayout({ children }: ProtectedLayoutProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-svh overflow-hidden bg-background text-foreground">
|
<div className="relative flex h-svh overflow-hidden bg-background text-foreground">
|
||||||
{/* Desktop sidebar */}
|
{/* Grid texture — fondo cuadriculado para dar profundidad y resaltar glass */}
|
||||||
<div className="hidden lg:flex lg:w-60 lg:flex-col lg:shrink-0">
|
<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 />
|
<SidebarNav />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main column */}
|
{/* 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 />
|
<AppHeader />
|
||||||
<main className="flex-1 overflow-y-auto p-6">{children}</main>
|
<main className="flex-1 overflow-y-auto p-6">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
import { ThemeToggle } from '@/components/layout/ThemeToggle'
|
||||||
|
|
||||||
interface PublicLayoutProps {
|
interface PublicLayoutProps {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
@@ -6,8 +7,24 @@ interface PublicLayoutProps {
|
|||||||
|
|
||||||
export function PublicLayout({ children }: PublicLayoutProps) {
|
export function PublicLayout({ children }: PublicLayoutProps) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-svh bg-muted/40 flex items-center justify-center p-4">
|
<div className="relative min-h-svh flex items-center justify-center p-4 overflow-hidden bg-background">
|
||||||
{children}
|
{/* 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>
|
</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": [
|
"references": [
|
||||||
{ "path": "./tsconfig.app.json" },
|
{ "path": "./tsconfig.app.json" },
|
||||||
{ "path": "./tsconfig.node.json" }
|
{ "path": "./tsconfig.node.json" }
|
||||||
]
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user