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:
2026-04-16 15:00:26 +00:00
27 changed files with 2641 additions and 285 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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

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

View File

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

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

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

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

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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