diff --git a/.husky/commit-msg b/.husky/commit-msg old mode 100755 new mode 100644 diff --git a/.husky/pre-commit b/.husky/pre-commit old mode 100755 new mode 100644 diff --git a/.husky/pre-push b/.husky/pre-push old mode 100755 new mode 100644 diff --git a/apps/dashboard/src-tauri/tauri.conf.json b/apps/dashboard/src-tauri/tauri.conf.json index d53bd18..ce37094 100644 --- a/apps/dashboard/src-tauri/tauri.conf.json +++ b/apps/dashboard/src-tauri/tauri.conf.json @@ -1,35 +1,35 @@ -{ - "$schema": "https://schema.tauri.app/config/2", - "productName": "dashboard", - "version": "0.1.0", - "identifier": "com.juanes.dashboard", - "build": { - "beforeDevCommand": "pnpm dev", - "devUrl": "http://localhost:3000", - "beforeBuildCommand": "pnpm build", - "frontendDist": "../dist" - }, - "app": { - "windows": [ - { - "title": "dashboard", - "width": 800, - "height": 600 - } - ], - "security": { - "csp": null - } - }, - "bundle": { - "active": true, - "targets": "all", - "icon": [ - "icons/32x32.png", - "icons/128x128.png", - "icons/128x128@2x.png", - "icons/icon.icns", - "icons/icon.ico" - ] - } +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "dashboard", + "version": "0.1.0", + "identifier": "com.juanes.dashboard", + "build": { + "beforeDevCommand": "pnpm dev", + "devUrl": "http://localhost:3000", + "beforeBuildCommand": "pnpm build", + "frontendDist": "../dist" + }, + "app": { + "windows": [ + { + "title": "dashboard", + "width": 800, + "height": 600 + } + ], + "security": { + "csp": null + } + }, + "bundle": { + "active": true, + "targets": "all", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ] + } } \ No newline at end of file diff --git a/apps/dashboard/src/globals.css b/apps/dashboard/src/globals.css index f6d6c24..27825a7 100644 --- a/apps/dashboard/src/globals.css +++ b/apps/dashboard/src/globals.css @@ -1,218 +1,218 @@ -@import 'tailwindcss'; -@import 'tw-animate-css'; -@import "shadcn/tailwind.css"; -@import "@fontsource-variable/dm-sans"; -@import "@fontsource-variable/inter"; - -@custom-variant dark (&:is(.dark *)); - -:root { - --background: oklch(1 0 0); - --foreground: oklch(0.141 0.005 285.823); - --card: oklch(1 0 0); - --card-foreground: oklch(0.141 0.005 285.823); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.141 0.005 285.823); - --primary: oklch(0.852 0.199 91.936); - --primary-foreground: oklch(0.421 0.095 57.708); - --secondary: oklch(0.967 0.001 286.375); - --secondary-foreground: oklch(0.21 0.006 285.885); - --muted: oklch(0.967 0.001 286.375); - --muted-foreground: oklch(0.552 0.016 285.938); - --accent: oklch(0.967 0.001 286.375); - --accent-foreground: oklch(0.21 0.006 285.885); - --destructive: oklch(0.577 0.245 27.325); - --destructive-foreground: oklch(0.97 0 0); - --success: oklch(0.72 0.19 145); - --success-foreground: oklch(0.13 0.005 260); - --warning: oklch(0.75 0.15 55); - --warning-foreground: oklch(0.13 0.005 260); - --border: oklch(0.92 0.004 286.32); - --input: oklch(0.92 0.004 286.32); - --ring: oklch(0.705 0.015 286.067); - --chart-1: oklch(0.905 0.182 98.111); - --chart-2: oklch(0.795 0.184 86.047); - --chart-3: oklch(0.681 0.162 75.834); - --chart-4: oklch(0.554 0.135 66.442); - --chart-5: oklch(0.476 0.114 61.907); - --radius: 0.625rem; - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.141 0.005 285.823); - --sidebar-primary: oklch(0.681 0.162 75.834); - --sidebar-primary-foreground: oklch(0.987 0.026 102.212); - --sidebar-accent: oklch(0.967 0.001 286.375); - --sidebar-accent-foreground: oklch(0.21 0.006 285.885); - --sidebar-border: oklch(0.92 0.004 286.32); - --sidebar-ring: oklch(0.705 0.015 286.067); -} - -.dark { - --background: oklch(0.141 0.005 285.823); - --foreground: oklch(0.985 0 0); - --card: oklch(0.21 0.006 285.885); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.21 0.006 285.885); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.795 0.184 86.047); - --primary-foreground: oklch(0.421 0.095 57.708); - --secondary: oklch(0.274 0.006 286.033); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.274 0.006 286.033); - --muted-foreground: oklch(0.705 0.015 286.067); - --accent: oklch(0.274 0.006 286.033); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --destructive-foreground: oklch(0.97 0 0); - --success: oklch(0.72 0.19 145); - --success-foreground: oklch(0.13 0.005 260); - --warning: oklch(0.75 0.15 55); - --warning-foreground: oklch(0.13 0.005 260); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.552 0.016 285.938); - --chart-1: oklch(0.905 0.182 98.111); - --chart-2: oklch(0.795 0.184 86.047); - --chart-3: oklch(0.681 0.162 75.834); - --chart-4: oklch(0.554 0.135 66.442); - --chart-5: oklch(0.476 0.114 61.907); - --sidebar: oklch(0.21 0.006 285.885); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.795 0.184 86.047); - --sidebar-primary-foreground: oklch(0.987 0.026 102.212); - --sidebar-accent: oklch(0.274 0.006 286.033); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.552 0.016 285.938); -} - -@theme inline { - --font-sans: 'DM Sans Variable', sans-serif; - --font-mono: 'Geist Mono', 'Geist Mono Fallback'; - --color-background: var(--background); - --color-foreground: var(--foreground); - --color-card: var(--card); - --color-card-foreground: var(--card-foreground); - --color-popover: var(--popover); - --color-popover-foreground: var(--popover-foreground); - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); - --color-secondary: var(--secondary); - --color-secondary-foreground: var(--secondary-foreground); - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); - --color-accent: var(--accent); - --color-accent-foreground: var(--accent-foreground); - --color-destructive: var(--destructive); - --color-destructive-foreground: var(--destructive-foreground); - --color-success: var(--success); - --color-success-foreground: var(--success-foreground); - --color-warning: var(--warning); - --color-warning-foreground: var(--warning-foreground); - --color-border: var(--border); - --color-input: var(--input); - --color-ring: var(--ring); - --color-chart-1: var(--chart-1); - --color-chart-2: var(--chart-2); - --color-chart-3: var(--chart-3); - --color-chart-4: var(--chart-4); - --color-chart-5: var(--chart-5); - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); - --color-sidebar: var(--sidebar); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-ring: var(--sidebar-ring); - --font-heading: 'Inter Variable', sans-serif; - --radius-2xl: calc(var(--radius) * 1.8); - --radius-3xl: calc(var(--radius) * 2.2); - --radius-4xl: calc(var(--radius) * 2.6); -} - -@layer base { - * { - @apply border-border outline-ring/50; - } - - body { - @apply bg-background text-foreground; - } - - html { - @apply font-sans; - } -} - -/* ── Edit mode styling ── */ -/* .edit-mode-card { - border-color: oklch(0.75 0.15 55 / 10%); - background-color: oklch(0.75 0.15 55 / 1.5%); - box-shadow: 0 4px 6px -1px oklch(0 0 0 / 10%); - transition: all 300ms; -} */ - -.edit-mode-badge { - border-color: oklch(0.75 0.15 55 / 30%); - background-color: oklch(0.75 0.15 55 / 10%); - color: oklch(0.75 0.15 55); -} - -/* ── Edit-mode "focus-in" reveal ── - When the General tab enters edit mode, its content materialises from a soft - blur into sharp focus, cascading field-by-field. Premium, content-only — the - card frame stays put. Honors reduced-motion (no animation, normal render). */ -@keyframes focus-in { - from { - opacity: 0; - filter: blur(6px); - transform: translateY(8px) scale(0.96); - } - to { - opacity: 1; - filter: blur(0); - transform: none; - } -} - -@media (prefers-reduced-motion: no-preference) { - /* `edit-focus-self` reveals the element itself (section headers, the cédula - panel, the save row). `edit-focus` reveals a container's children in a - downward cascade (the field grids). */ - .edit-focus-self, - .edit-focus > * { - animation: focus-in 0.6s cubic-bezier(0.16, 1, 0.3, 1) both; - } - .edit-focus > *:nth-child(2) { - animation-delay: 55ms; - } - .edit-focus > *:nth-child(3) { - animation-delay: 110ms; - } - .edit-focus > *:nth-child(4) { - animation-delay: 165ms; - } - .edit-focus > *:nth-child(5) { - animation-delay: 220ms; - } - .edit-focus > *:nth-child(6) { - animation-delay: 275ms; - } -} - -/* Ensure toasts render above dialog overlays */ -[data-sileo-viewport] { - z-index: 9999 !important; -} - -/* Toast surface follows the popover token. sileo paints the body/pill as SVG - elements, so this is set in CSS (where var() resolves and overrides the - presentation attribute) rather than via a hardcoded `fill` in the Toaster props. */ -[data-sileo-pill], -[data-sileo-body] { - fill: var(--popover); +@import 'tailwindcss'; +@import 'tw-animate-css'; +@import "shadcn/tailwind.css"; +@import "@fontsource-variable/dm-sans"; +@import "@fontsource-variable/inter"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.141 0.005 285.823); + --card: oklch(1 0 0); + --card-foreground: oklch(0.141 0.005 285.823); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.852 0.199 91.936); + --primary-foreground: oklch(0.421 0.095 57.708); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(0.97 0 0); + --success: oklch(0.72 0.19 145); + --success-foreground: oklch(0.13 0.005 260); + --warning: oklch(0.75 0.15 55); + --warning-foreground: oklch(0.13 0.005 260); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.705 0.015 286.067); + --chart-1: oklch(0.905 0.182 98.111); + --chart-2: oklch(0.795 0.184 86.047); + --chart-3: oklch(0.681 0.162 75.834); + --chart-4: oklch(0.554 0.135 66.442); + --chart-5: oklch(0.476 0.114 61.907); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.141 0.005 285.823); + --sidebar-primary: oklch(0.681 0.162 75.834); + --sidebar-primary-foreground: oklch(0.987 0.026 102.212); + --sidebar-accent: oklch(0.967 0.001 286.375); + --sidebar-accent-foreground: oklch(0.21 0.006 285.885); + --sidebar-border: oklch(0.92 0.004 286.32); + --sidebar-ring: oklch(0.705 0.015 286.067); +} + +.dark { + --background: oklch(0.141 0.005 285.823); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0.006 285.885); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.795 0.184 86.047); + --primary-foreground: oklch(0.421 0.095 57.708); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --destructive-foreground: oklch(0.97 0 0); + --success: oklch(0.72 0.19 145); + --success-foreground: oklch(0.13 0.005 260); + --warning: oklch(0.75 0.15 55); + --warning-foreground: oklch(0.13 0.005 260); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.552 0.016 285.938); + --chart-1: oklch(0.905 0.182 98.111); + --chart-2: oklch(0.795 0.184 86.047); + --chart-3: oklch(0.681 0.162 75.834); + --chart-4: oklch(0.554 0.135 66.442); + --chart-5: oklch(0.476 0.114 61.907); + --sidebar: oklch(0.21 0.006 285.885); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.795 0.184 86.047); + --sidebar-primary-foreground: oklch(0.987 0.026 102.212); + --sidebar-accent: oklch(0.274 0.006 286.033); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.552 0.016 285.938); +} + +@theme inline { + --font-sans: 'DM Sans Variable', sans-serif; + --font-mono: 'Geist Mono', 'Geist Mono Fallback'; + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-success: var(--success); + --color-success-foreground: var(--success-foreground); + --color-warning: var(--warning); + --color-warning-foreground: var(--warning-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + --font-heading: 'Inter Variable', sans-serif; + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + + body { + @apply bg-background text-foreground; + } + + html { + @apply font-sans; + } +} + +/* ── Edit mode styling ── */ +/* .edit-mode-card { + border-color: oklch(0.75 0.15 55 / 10%); + background-color: oklch(0.75 0.15 55 / 1.5%); + box-shadow: 0 4px 6px -1px oklch(0 0 0 / 10%); + transition: all 300ms; +} */ + +.edit-mode-badge { + border-color: oklch(0.75 0.15 55 / 30%); + background-color: oklch(0.75 0.15 55 / 10%); + color: oklch(0.75 0.15 55); +} + +/* ── Edit-mode "focus-in" reveal ── + When the General tab enters edit mode, its content materialises from a soft + blur into sharp focus, cascading field-by-field. Premium, content-only — the + card frame stays put. Honors reduced-motion (no animation, normal render). */ +@keyframes focus-in { + from { + opacity: 0; + filter: blur(6px); + transform: translateY(8px) scale(0.96); + } + to { + opacity: 1; + filter: blur(0); + transform: none; + } +} + +@media (prefers-reduced-motion: no-preference) { + /* `edit-focus-self` reveals the element itself (section headers, the cédula + panel, the save row). `edit-focus` reveals a container's children in a + downward cascade (the field grids). */ + .edit-focus-self, + .edit-focus > * { + animation: focus-in 0.6s cubic-bezier(0.16, 1, 0.3, 1) both; + } + .edit-focus > *:nth-child(2) { + animation-delay: 55ms; + } + .edit-focus > *:nth-child(3) { + animation-delay: 110ms; + } + .edit-focus > *:nth-child(4) { + animation-delay: 165ms; + } + .edit-focus > *:nth-child(5) { + animation-delay: 220ms; + } + .edit-focus > *:nth-child(6) { + animation-delay: 275ms; + } +} + +/* Ensure toasts render above dialog overlays */ +[data-sileo-viewport] { + z-index: 9999 !important; +} + +/* Toast surface follows the popover token. sileo paints the body/pill as SVG + elements, so this is set in CSS (where var() resolves and overrides the + presentation attribute) rather than via a hardcoded `fill` in the Toaster props. */ +[data-sileo-pill], +[data-sileo-body] { + fill: var(--popover); } \ No newline at end of file diff --git a/apps/dashboard/src/routeTree.gen.ts b/apps/dashboard/src/routeTree.gen.ts index ee6e107..9f9db42 100644 --- a/apps/dashboard/src/routeTree.gen.ts +++ b/apps/dashboard/src/routeTree.gen.ts @@ -15,16 +15,20 @@ import { Route as IndexRouteImport } from './routes/index'; import { Route as DashboardIndexRouteImport } from './routes/dashboard/index'; import { Route as DashboardUsersRouteImport } from './routes/dashboard/users'; import { Route as DashboardSettingsRouteImport } from './routes/dashboard/settings'; +import { Route as DashboardRoutinesRouteImport } from './routes/dashboard/routines'; import { Route as DashboardReportsRouteImport } from './routes/dashboard/reports'; import { Route as DashboardProfileRouteImport } from './routes/dashboard/profile'; +import { Route as DashboardClassesRouteImport } from './routes/dashboard/classes'; import { Route as DashboardCatalogRouteImport } from './routes/dashboard/catalog'; import { Route as DashboardBackupsRouteImport } from './routes/dashboard/backups'; import { Route as DashboardAuditRouteImport } from './routes/dashboard/audit'; import { Route as AuthForbiddenRouteImport } from './routes/auth/forbidden'; import { Route as DashboardUsersIndexRouteImport } from './routes/dashboard/users/index'; +import { Route as DashboardRoutinesIndexRouteImport } from './routes/dashboard/routines/index'; import { Route as DashboardMembershipsIndexRouteImport } from './routes/dashboard/memberships/index'; import { Route as DashboardMembersIndexRouteImport } from './routes/dashboard/members/index'; import { Route as DashboardTrainerMembersRouteImport } from './routes/dashboard/trainer/members'; +import { Route as DashboardRoutinesIdRouteImport } from './routes/dashboard/routines/$id'; import { Route as DashboardMembershipsApprovalRouteImport } from './routes/dashboard/memberships/approval'; import { Route as DashboardMembersNewRouteImport } from './routes/dashboard/members/new'; import { Route as DashboardUsersTrainersIdRouteImport } from './routes/dashboard/users/trainers.$id'; @@ -61,6 +65,11 @@ const DashboardSettingsRoute = DashboardSettingsRouteImport.update({ path: '/settings', getParentRoute: () => DashboardRouteRoute, } as any); +const DashboardRoutinesRoute = DashboardRoutinesRouteImport.update({ + id: '/routines', + path: '/routines', + getParentRoute: () => DashboardRouteRoute, +} as any); const DashboardReportsRoute = DashboardReportsRouteImport.update({ id: '/reports', path: '/reports', @@ -71,6 +80,11 @@ const DashboardProfileRoute = DashboardProfileRouteImport.update({ path: '/profile', getParentRoute: () => DashboardRouteRoute, } as any); +const DashboardClassesRoute = DashboardClassesRouteImport.update({ + id: '/classes', + path: '/classes', + getParentRoute: () => DashboardRouteRoute, +} as any); const DashboardCatalogRoute = DashboardCatalogRouteImport.update({ id: '/catalog', path: '/catalog', @@ -96,6 +110,11 @@ const DashboardUsersIndexRoute = DashboardUsersIndexRouteImport.update({ path: '/', getParentRoute: () => DashboardUsersRoute, } as any); +const DashboardRoutinesIndexRoute = DashboardRoutinesIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => DashboardRoutinesRoute, +} as any); const DashboardMembershipsIndexRoute = DashboardMembershipsIndexRouteImport.update({ id: '/memberships/', @@ -112,6 +131,11 @@ const DashboardTrainerMembersRoute = DashboardTrainerMembersRouteImport.update({ path: '/trainer/members', getParentRoute: () => DashboardRouteRoute, } as any); +const DashboardRoutinesIdRoute = DashboardRoutinesIdRouteImport.update({ + id: '/$id', + path: '/$id', + getParentRoute: () => DashboardRoutinesRoute, +} as any); const DashboardMembershipsApprovalRoute = DashboardMembershipsApprovalRouteImport.update({ id: '/memberships/approval', @@ -148,16 +172,20 @@ export interface FileRoutesByFullPath { '/dashboard/audit': typeof DashboardAuditRoute; '/dashboard/backups': typeof DashboardBackupsRoute; '/dashboard/catalog': typeof DashboardCatalogRoute; + '/dashboard/classes': typeof DashboardClassesRoute; '/dashboard/profile': typeof DashboardProfileRoute; '/dashboard/reports': typeof DashboardReportsRoute; + '/dashboard/routines': typeof DashboardRoutinesRouteWithChildren; '/dashboard/settings': typeof DashboardSettingsRoute; '/dashboard/users': typeof DashboardUsersRouteWithChildren; '/dashboard/': typeof DashboardIndexRoute; '/dashboard/members/new': typeof DashboardMembersNewRoute; '/dashboard/memberships/approval': typeof DashboardMembershipsApprovalRoute; + '/dashboard/routines/$id': typeof DashboardRoutinesIdRoute; '/dashboard/trainer/members': typeof DashboardTrainerMembersRoute; '/dashboard/members/': typeof DashboardMembersIndexRoute; '/dashboard/memberships/': typeof DashboardMembershipsIndexRoute; + '/dashboard/routines/': typeof DashboardRoutinesIndexRoute; '/dashboard/users/': typeof DashboardUsersIndexRoute; '/dashboard/users/members/$id': typeof DashboardUsersMembersIdRoute; '/dashboard/users/staff/$id': typeof DashboardUsersStaffIdRoute; @@ -170,15 +198,18 @@ export interface FileRoutesByTo { '/dashboard/audit': typeof DashboardAuditRoute; '/dashboard/backups': typeof DashboardBackupsRoute; '/dashboard/catalog': typeof DashboardCatalogRoute; + '/dashboard/classes': typeof DashboardClassesRoute; '/dashboard/profile': typeof DashboardProfileRoute; '/dashboard/reports': typeof DashboardReportsRoute; '/dashboard/settings': typeof DashboardSettingsRoute; '/dashboard': typeof DashboardIndexRoute; '/dashboard/members/new': typeof DashboardMembersNewRoute; '/dashboard/memberships/approval': typeof DashboardMembershipsApprovalRoute; + '/dashboard/routines/$id': typeof DashboardRoutinesIdRoute; '/dashboard/trainer/members': typeof DashboardTrainerMembersRoute; '/dashboard/members': typeof DashboardMembersIndexRoute; '/dashboard/memberships': typeof DashboardMembershipsIndexRoute; + '/dashboard/routines': typeof DashboardRoutinesIndexRoute; '/dashboard/users': typeof DashboardUsersIndexRoute; '/dashboard/users/members/$id': typeof DashboardUsersMembersIdRoute; '/dashboard/users/staff/$id': typeof DashboardUsersStaffIdRoute; @@ -193,16 +224,20 @@ export interface FileRoutesById { '/dashboard/audit': typeof DashboardAuditRoute; '/dashboard/backups': typeof DashboardBackupsRoute; '/dashboard/catalog': typeof DashboardCatalogRoute; + '/dashboard/classes': typeof DashboardClassesRoute; '/dashboard/profile': typeof DashboardProfileRoute; '/dashboard/reports': typeof DashboardReportsRoute; + '/dashboard/routines': typeof DashboardRoutinesRouteWithChildren; '/dashboard/settings': typeof DashboardSettingsRoute; '/dashboard/users': typeof DashboardUsersRouteWithChildren; '/dashboard/': typeof DashboardIndexRoute; '/dashboard/members/new': typeof DashboardMembersNewRoute; '/dashboard/memberships/approval': typeof DashboardMembershipsApprovalRoute; + '/dashboard/routines/$id': typeof DashboardRoutinesIdRoute; '/dashboard/trainer/members': typeof DashboardTrainerMembersRoute; '/dashboard/members/': typeof DashboardMembersIndexRoute; '/dashboard/memberships/': typeof DashboardMembershipsIndexRoute; + '/dashboard/routines/': typeof DashboardRoutinesIndexRoute; '/dashboard/users/': typeof DashboardUsersIndexRoute; '/dashboard/users/members/$id': typeof DashboardUsersMembersIdRoute; '/dashboard/users/staff/$id': typeof DashboardUsersStaffIdRoute; @@ -218,16 +253,20 @@ export interface FileRouteTypes { | '/dashboard/audit' | '/dashboard/backups' | '/dashboard/catalog' + | '/dashboard/classes' | '/dashboard/profile' | '/dashboard/reports' + | '/dashboard/routines' | '/dashboard/settings' | '/dashboard/users' | '/dashboard/' | '/dashboard/members/new' | '/dashboard/memberships/approval' + | '/dashboard/routines/$id' | '/dashboard/trainer/members' | '/dashboard/members/' | '/dashboard/memberships/' + | '/dashboard/routines/' | '/dashboard/users/' | '/dashboard/users/members/$id' | '/dashboard/users/staff/$id' @@ -240,15 +279,18 @@ export interface FileRouteTypes { | '/dashboard/audit' | '/dashboard/backups' | '/dashboard/catalog' + | '/dashboard/classes' | '/dashboard/profile' | '/dashboard/reports' | '/dashboard/settings' | '/dashboard' | '/dashboard/members/new' | '/dashboard/memberships/approval' + | '/dashboard/routines/$id' | '/dashboard/trainer/members' | '/dashboard/members' | '/dashboard/memberships' + | '/dashboard/routines' | '/dashboard/users' | '/dashboard/users/members/$id' | '/dashboard/users/staff/$id' @@ -262,16 +304,20 @@ export interface FileRouteTypes { | '/dashboard/audit' | '/dashboard/backups' | '/dashboard/catalog' + | '/dashboard/classes' | '/dashboard/profile' | '/dashboard/reports' + | '/dashboard/routines' | '/dashboard/settings' | '/dashboard/users' | '/dashboard/' | '/dashboard/members/new' | '/dashboard/memberships/approval' + | '/dashboard/routines/$id' | '/dashboard/trainer/members' | '/dashboard/members/' | '/dashboard/memberships/' + | '/dashboard/routines/' | '/dashboard/users/' | '/dashboard/users/members/$id' | '/dashboard/users/staff/$id' @@ -329,6 +375,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DashboardSettingsRouteImport; parentRoute: typeof DashboardRouteRoute; }; + '/dashboard/routines': { + id: '/dashboard/routines'; + path: '/routines'; + fullPath: '/dashboard/routines'; + preLoaderRoute: typeof DashboardRoutinesRouteImport; + parentRoute: typeof DashboardRouteRoute; + }; '/dashboard/reports': { id: '/dashboard/reports'; path: '/reports'; @@ -343,6 +396,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DashboardProfileRouteImport; parentRoute: typeof DashboardRouteRoute; }; + '/dashboard/classes': { + id: '/dashboard/classes'; + path: '/classes'; + fullPath: '/dashboard/classes'; + preLoaderRoute: typeof DashboardClassesRouteImport; + parentRoute: typeof DashboardRouteRoute; + }; '/dashboard/catalog': { id: '/dashboard/catalog'; path: '/catalog'; @@ -378,6 +438,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DashboardUsersIndexRouteImport; parentRoute: typeof DashboardUsersRoute; }; + '/dashboard/routines/': { + id: '/dashboard/routines/'; + path: '/'; + fullPath: '/dashboard/routines/'; + preLoaderRoute: typeof DashboardRoutinesIndexRouteImport; + parentRoute: typeof DashboardRoutinesRoute; + }; '/dashboard/memberships/': { id: '/dashboard/memberships/'; path: '/memberships'; @@ -399,6 +466,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DashboardTrainerMembersRouteImport; parentRoute: typeof DashboardRouteRoute; }; + '/dashboard/routines/$id': { + id: '/dashboard/routines/$id'; + path: '/$id'; + fullPath: '/dashboard/routines/$id'; + preLoaderRoute: typeof DashboardRoutinesIdRouteImport; + parentRoute: typeof DashboardRoutinesRoute; + }; '/dashboard/memberships/approval': { id: '/dashboard/memberships/approval'; path: '/memberships/approval'; @@ -437,6 +511,19 @@ declare module '@tanstack/react-router' { } } +interface DashboardRoutinesRouteChildren { + DashboardRoutinesIdRoute: typeof DashboardRoutinesIdRoute; + DashboardRoutinesIndexRoute: typeof DashboardRoutinesIndexRoute; +} + +const DashboardRoutinesRouteChildren: DashboardRoutinesRouteChildren = { + DashboardRoutinesIdRoute: DashboardRoutinesIdRoute, + DashboardRoutinesIndexRoute: DashboardRoutinesIndexRoute, +}; + +const DashboardRoutinesRouteWithChildren = + DashboardRoutinesRoute._addFileChildren(DashboardRoutinesRouteChildren); + interface DashboardUsersRouteChildren { DashboardUsersIndexRoute: typeof DashboardUsersIndexRoute; DashboardUsersMembersIdRoute: typeof DashboardUsersMembersIdRoute; @@ -459,8 +546,10 @@ interface DashboardRouteRouteChildren { DashboardAuditRoute: typeof DashboardAuditRoute; DashboardBackupsRoute: typeof DashboardBackupsRoute; DashboardCatalogRoute: typeof DashboardCatalogRoute; + DashboardClassesRoute: typeof DashboardClassesRoute; DashboardProfileRoute: typeof DashboardProfileRoute; DashboardReportsRoute: typeof DashboardReportsRoute; + DashboardRoutinesRoute: typeof DashboardRoutinesRouteWithChildren; DashboardSettingsRoute: typeof DashboardSettingsRoute; DashboardUsersRoute: typeof DashboardUsersRouteWithChildren; DashboardIndexRoute: typeof DashboardIndexRoute; @@ -475,8 +564,10 @@ const DashboardRouteRouteChildren: DashboardRouteRouteChildren = { DashboardAuditRoute: DashboardAuditRoute, DashboardBackupsRoute: DashboardBackupsRoute, DashboardCatalogRoute: DashboardCatalogRoute, + DashboardClassesRoute: DashboardClassesRoute, DashboardProfileRoute: DashboardProfileRoute, DashboardReportsRoute: DashboardReportsRoute, + DashboardRoutinesRoute: DashboardRoutinesRouteWithChildren, DashboardSettingsRoute: DashboardSettingsRoute, DashboardUsersRoute: DashboardUsersRouteWithChildren, DashboardIndexRoute: DashboardIndexRoute, diff --git a/apps/dashboard/src/routes/dashboard/classes.tsx b/apps/dashboard/src/routes/dashboard/classes.tsx new file mode 100644 index 0000000..a528a4a --- /dev/null +++ b/apps/dashboard/src/routes/dashboard/classes.tsx @@ -0,0 +1,49 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { useState } from 'react'; +import { DashboardHeader } from '@/src/features/dashboard/components/dashboard-header'; +import { PageHeader } from '@/src/shared/components/page-header'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { CalendarDays, ClipboardList } from 'lucide-react'; +import { TrainerAgendaTab } from '@/src/features/dashboard/classes/components/TrainerAgendaTab'; +import { ClassManagementTab } from '@/src/features/dashboard/classes/components/ClassManagementTab'; + +export const Route = createFileRoute('/dashboard/classes')({ + component: ClassesPage, +}); + +function ClassesPage() { + const [activeTab, setActiveTab] = useState('agenda'); + + return ( + <> + +
+ + + + + + + Agenda del entrenador + + + + Gestión de clases y cupos + + + + + + + + + + +
+ + ); +} diff --git a/apps/dashboard/src/routes/dashboard/routines.tsx b/apps/dashboard/src/routes/dashboard/routines.tsx new file mode 100644 index 0000000..bdb2918 --- /dev/null +++ b/apps/dashboard/src/routes/dashboard/routines.tsx @@ -0,0 +1,9 @@ +import { createFileRoute, Outlet } from '@tanstack/react-router'; + +export const Route = createFileRoute('/dashboard/routines')({ + component: RoutinesLayout, +}); + +function RoutinesLayout() { + return ; +} diff --git a/apps/dashboard/src/routes/dashboard/routines/$id.tsx b/apps/dashboard/src/routes/dashboard/routines/$id.tsx new file mode 100644 index 0000000..34ec6e6 --- /dev/null +++ b/apps/dashboard/src/routes/dashboard/routines/$id.tsx @@ -0,0 +1,350 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { useState } from 'react'; +import { DashboardHeader } from '@/src/features/dashboard/components/dashboard-header'; +import { PageHeader } from '@/src/shared/components/page-header'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { DataTable, type Column } from '@/src/shared/components/data-table'; +import { RoutineForm } from '@/src/features/dashboard/routines/components/routine-form'; +import { AssignMemberModal } from '@/src/features/dashboard/routines/components/assign-member-modal'; +import { Dumbbell, Users, CalendarDays, History, Pencil, UserPlus, CheckCircle } from 'lucide-react'; +import { sileo } from 'sileo'; +import { formatDate } from '@/lib/utils'; +import type { + Routine, + RoutineFormData, + RoutineMember, + TrainingSession, + SessionStatus, +} from '@/src/features/dashboard/routines/types'; +import { + useRoutine, + useUpdateRoutine, + useRoutineMembers, + useAssignRoutine, + useRoutineSessions, + useValidateSession, + useRoutineVersions, +} from '@/src/features/dashboard/routines/hooks'; + +export const Route = createFileRoute('/dashboard/routines/$id')({ + component: RoutineDetailPage, +}); + +const sessionStatusLabels: Record = { + PENDING: 'Pendiente', + CONFIRMED: 'Confirmada', + VALIDATED: 'Validada', + EXPIRED: 'Expirada', +}; + +const sessionStatusColors: Record = { + PENDING: 'bg-warning/20 text-warning border-warning/30', + CONFIRMED: 'bg-primary/20 text-primary border-primary/30', + VALIDATED: 'bg-success/20 text-success border-success/30', + EXPIRED: 'bg-muted text-muted-foreground border-border', +}; + +function RoutineDetailPage() { + const { id }: { id: string } = Route.useParams(); + const { data: routine, isLoading } = useRoutine(id); + const [activeTab, setActiveTab] = useState('details'); + const [editOpen, setEditOpen] = useState(false); + const [assignOpen, setAssignOpen] = useState(false); + + const updateRoutine = useUpdateRoutine(id); + const assignRoutine = useAssignRoutine(id); + const validateSession = useValidateSession(id); + + const { data: members } = useRoutineMembers(id); + const { data: sessions } = useRoutineSessions(id); + const { data: versions } = useRoutineVersions(id); + + if (isLoading || !routine) { + return ( + <> + +
+
+ Cargando rutina... +
+
+ + ); + } + + const handleEditSubmit = (data: RoutineFormData) => { + updateRoutine.mutate(data, { + onSuccess: () => { + sileo.success({ title: 'Rutina actualizada' }); + setEditOpen(false); + }, + onError: (err) => sileo.error({ title: 'Error al actualizar', description: err.message }), + }); + }; + + const handleAssign = (memberId: string) => { + assignRoutine.mutate(memberId, { + onSuccess: () => { + sileo.success({ title: 'Rutina asignada' }); + setAssignOpen(false); + }, + onError: (err) => sileo.error({ title: 'Error al asignar', description: err.message }), + }); + }; + + const handleValidate = (session: TrainingSession) => { + validateSession.mutate(session.id, { + onSuccess: () => sileo.success({ title: 'Sesión validada' }), + onError: (err) => sileo.error({ title: 'Error al validar', description: err.message }), + }); + }; + + const exerciseColumns: Column[] = [ + { key: 'displayOrder', header: '#', render: (e) => e.displayOrder, className: 'w-12' }, + { key: 'name', header: 'Ejercicio', render: (e) => e.exerciseName ?? e.exercise?.name ?? '—' }, + { key: 'sets', header: 'Series', render: (e) => e.sets }, + { key: 'reps', header: 'Reps', render: (e) => e.reps || '—' }, + { key: 'notes', header: 'Notas', render: (e) => e.notes || '—' }, + ]; + + const memberColumns: Column[] = [ + { key: 'name', header: 'Miembro', render: (m) => {m.fullName} }, + { key: 'email', header: 'Email', render: (m) => m.email || '—' }, + { + key: 'status', + header: 'Estado', + render: (m) => ( + + {m.status} + + ), + }, + { key: 'assignedAt', header: 'Asignado', render: (m) => formatDate(m.assignedAt) }, + ]; + + const sessionColumns: Column[] = [ + { key: 'date', header: 'Fecha', render: (s) => formatDate(s.date) }, + { + key: 'member', + header: 'Miembro', + render: (s) => s.member?.fullName ?? '—', + }, + { + key: 'status', + header: 'Estado', + render: (s) => ( + + {sessionStatusLabels[s.status]} + + ), + }, + { + key: 'validatedAt', + header: 'Validado', + render: (s) => (s.validatedAt ? formatDate(s.validatedAt) : '—'), + }, + { + key: 'actions', + header: '', + className: 'w-40', + render: (s) => + s.status === 'CONFIRMED' && !s.validatedAt ? ( + + ) : null, + }, + ]; + + type Snapshot = { + name?: string; + exercises?: Array<{ + exercise_id: string; + display_order?: number | null; + sets?: number | null; + reps?: string | null; + notes?: string | null; + }>; + }; + + const versionRows = (versions ?? []).map((v, i, arr) => { + const prev = arr[i + 1]?.snapshot as Snapshot | undefined; + const curr = (v.snapshot as Snapshot) ?? { name: '', exercises: [] }; + const prevMap = new Map((prev?.exercises ?? []).map((e) => [e.exercise_id, e])); + const currMap = new Map((curr.exercises ?? []).map((e) => [e.exercise_id, e])); + const added = (curr.exercises ?? []).filter((e) => !prevMap.has(e.exercise_id)).length; + const removed = (prev?.exercises ?? []).filter((e) => !currMap.has(e.exercise_id)).length; + let changed = 0; + for (const [id, e] of currMap) { + const p = prevMap.get(id); + if (!p) continue; + if (e.display_order !== p.display_order || e.sets !== p.sets || e.reps !== p.reps || e.notes !== p.notes) + changed++; + } + const nameChanged = prev ? prev.name !== curr.name : false; + return { ...v, added, removed, changed, nameChanged }; + }); + + const versionColumns: Column<(typeof versionRows)[number]>[] = [ + { key: 'version', header: 'Versión', render: (v) => `v${v.versionNumber}`, className: 'w-20' }, + { key: 'changeReason', header: 'Motivo', render: (v) => v.changeReason || '—' }, + { + key: 'diff', + header: 'Cambios', + render: (v) => { + const parts: string[] = []; + if (v.nameChanged) parts.push('nombre'); + if (v.added) parts.push(`+${v.added} ejercicios`); + if (v.removed) parts.push(`-${v.removed} ejercicios`); + if (v.changed) parts.push(`~${v.changed} ejercicios`); + return parts.length ? parts.join(', ') : 'Sin cambios'; + }, + }, + { key: 'changedByName', header: 'Modificado por', render: (v) => v.changedByName ?? 'Entrenador' }, + { key: 'createdAt', header: 'Fecha', render: (v) => formatDate(v.createdAt) }, + ]; + + return ( + <> + +
+ setEditOpen(true)}> + + Editar + + } + className="mb-6" + /> + + + + + + Detalles + + + + Miembros + + + + Sesiones + + + + Historial + + + + + + + Ejercicios de la rutina + + + a.displayOrder - b.displayOrder)} + keyExtractor={(e) => e.id} + emptyMessage="No hay ejercicios en esta rutina" + /> + + + + + + + + Miembros asignados + + + + m.id} + emptyMessage="No hay miembros asignados" + /> + + + + + + + + Sesiones de entrenamiento + + + s.id} + emptyMessage="No hay sesiones registradas" + /> + + + + + + + + Historial de versiones + + + String(v.versionNumber)} + emptyMessage="No hay versiones registradas" + /> + + + + + + + + +
+ + ); +} diff --git a/apps/dashboard/src/routes/dashboard/routines/index.tsx b/apps/dashboard/src/routes/dashboard/routines/index.tsx new file mode 100644 index 0000000..604f11b --- /dev/null +++ b/apps/dashboard/src/routes/dashboard/routines/index.tsx @@ -0,0 +1,149 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { useMemo, useState } from 'react'; +import { DashboardHeader } from '@/src/features/dashboard/components/dashboard-header'; +import { PageHeader } from '@/src/shared/components/page-header'; +import { RoutinesTable } from '@/src/features/dashboard/routines/components/routines-table'; +import { RoutineForm } from '@/src/features/dashboard/routines/components/routine-form'; +import { ConfirmDialog } from '@/src/shared/components/confirm-dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Plus, Search } from 'lucide-react'; +import { sileo } from 'sileo'; +import type { Routine, RoutineFormData } from '@/src/features/dashboard/routines/types'; +import { + useRoutines, + useCreateRoutine, + useUpdateRoutine, + useDeleteRoutine, +} from '@/src/features/dashboard/routines/hooks'; + +export const Route = createFileRoute('/dashboard/routines/')({ + component: RoutinesPage, +}); + +function RoutinesPage() { + const { data: routines, isLoading } = useRoutines(); + const [search, setSearch] = useState(''); + const [formOpen, setFormOpen] = useState(false); + const [editingRoutine, setEditingRoutine] = useState(null); + const [deleteRoutine, setDeleteRoutine] = useState(null); + + const createRoutine = useCreateRoutine(); + const updateRoutine = useUpdateRoutine(editingRoutine?.id ?? ''); + const deleteMutation = useDeleteRoutine(deleteRoutine?.id ?? ''); + + const filteredRoutines = useMemo(() => { + const term = search.trim().toLowerCase(); + if (!term) return routines ?? []; + return (routines ?? []).filter( + (r) => r.name.toLowerCase().includes(term) || (r.objective ?? '').toLowerCase().includes(term), + ); + }, [routines, search]); + + const handleOpenCreate = () => { + setEditingRoutine(null); + setFormOpen(true); + }; + + const handleOpenEdit = (routine: Routine) => { + setEditingRoutine(routine); + setFormOpen(true); + }; + + const handleFormClose = (open: boolean) => { + setFormOpen(open); + if (!open) setEditingRoutine(null); + }; + + const handleSubmit = (data: RoutineFormData) => { + if (editingRoutine) { + updateRoutine.mutate(data, { + onSuccess: () => { + sileo.success({ title: 'Rutina actualizada' }); + handleFormClose(false); + }, + onError: (err) => { + sileo.error({ title: 'Error al actualizar', description: err.message }); + }, + }); + } else { + createRoutine.mutate(data, { + onSuccess: () => { + sileo.success({ title: 'Rutina creada' }); + handleFormClose(false); + }, + onError: (err) => { + sileo.error({ title: 'Error al crear', description: err.message }); + }, + }); + } + }; + + const handleConfirmDelete = () => { + if (!deleteRoutine) return; + deleteMutation.mutate(undefined, { + onSuccess: () => { + sileo.success({ title: 'Rutina eliminada' }); + setDeleteRoutine(null); + }, + onError: (err) => { + sileo.error({ title: 'Error al eliminar', description: err.message }); + }, + }); + }; + + return ( + <> + +
+ + + Crear + + } + className="mb-6" + /> + +
+ + setSearch(e.target.value)} + className="pl-9 bg-secondary/50 border-border" + /> +
+ + {isLoading ? ( +
+ Cargando rutinas... +
+ ) : ( + + )} + + + + !open && setDeleteRoutine(null)} + title="Eliminar rutina" + description={`¿Estás seguro de eliminar "${deleteRoutine?.name}"? Esta acción no se puede deshacer.`} + confirmLabel="Eliminar" + onConfirm={handleConfirmDelete} + variant="destructive" + /> +
+ + ); +} diff --git a/apps/dashboard/src/src/features/dashboard/classes/components/AgendaEmptyState.tsx b/apps/dashboard/src/src/features/dashboard/classes/components/AgendaEmptyState.tsx new file mode 100644 index 0000000..69f24e9 --- /dev/null +++ b/apps/dashboard/src/src/features/dashboard/classes/components/AgendaEmptyState.tsx @@ -0,0 +1,27 @@ +import { Card, CardContent } from '@/components/ui/card'; +import { CalendarX2 } from 'lucide-react'; + +interface AgendaEmptyStateProps { + title?: string; + description?: string; +} + +/** Empty state shown when the selected day has no scheduled sessions. */ +export function AgendaEmptyState({ + title = 'No hay sesiones programadas', + description = 'No hay reservas ni sesiones para la fecha seleccionada.', +}: AgendaEmptyStateProps) { + return ( + + +
+ +
+
+

{title}

+

{description}

+
+
+
+ ); +} diff --git a/apps/dashboard/src/src/features/dashboard/classes/components/AgendaMetricCard.tsx b/apps/dashboard/src/src/features/dashboard/classes/components/AgendaMetricCard.tsx new file mode 100644 index 0000000..a7639a5 --- /dev/null +++ b/apps/dashboard/src/src/features/dashboard/classes/components/AgendaMetricCard.tsx @@ -0,0 +1,40 @@ +import { Card, CardContent } from '@/components/ui/card'; +import { cn } from '@/lib/utils'; +import type { LucideIcon } from 'lucide-react'; + +type Tone = 'primary' | 'success' | 'warning' | 'destructive'; + +const toneClasses: Record = { + primary: { wrap: 'bg-primary/10', icon: 'text-primary' }, + success: { wrap: 'bg-success/10', icon: 'text-success' }, + warning: { wrap: 'bg-warning/10', icon: 'text-warning' }, + destructive: { wrap: 'bg-destructive/10', icon: 'text-destructive' }, +}; + +interface AgendaMetricCardProps { + label: string; + value: string | number; + icon: LucideIcon; + tone?: Tone; + /** Small caption under the value (e.g. "Confirmadas"). */ + caption?: string; +} + +/** Compact KPI card for the trainer agenda header. */ +export function AgendaMetricCard({ label, value, icon: Icon, tone = 'primary', caption }: AgendaMetricCardProps) { + const t = toneClasses[tone]; + return ( + + +
+ +
+
+

{label}

+

{value}

+ {caption &&

{caption}

} +
+
+
+ ); +} diff --git a/apps/dashboard/src/src/features/dashboard/classes/components/ClassFormDialog.tsx b/apps/dashboard/src/src/features/dashboard/classes/components/ClassFormDialog.tsx new file mode 100644 index 0000000..23514c9 --- /dev/null +++ b/apps/dashboard/src/src/features/dashboard/classes/components/ClassFormDialog.tsx @@ -0,0 +1,261 @@ +import { useState, useEffect } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Switch } from '@/components/ui/switch'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Loader2 } from 'lucide-react'; +import { MOCK_TRAINERS, WEEK_DAYS, type ManagedClass, type WeekDay } from '../mock-classes'; + +export interface ClassFormData { + name: string; + description: string; + trainer: string; + day: WeekDay; + time: string; + durationMin: number; + capacity: number; + allowWaitlist: boolean; + status: 'active' | 'inactive'; +} + +interface ClassFormDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + item?: ManagedClass | null; + onSubmit: (data: ClassFormData) => Promise<{ success: boolean; error?: string }>; +} + +const emptyForm: ClassFormData = { + name: '', + description: '', + trainer: MOCK_TRAINERS[0], + day: 'Lunes', + time: '08:00', + durationMin: 60, + capacity: 10, + allowWaitlist: true, + status: 'active', +}; + +export function ClassFormDialog({ open, onOpenChange, item, onSubmit }: ClassFormDialogProps) { + const [loading, setLoading] = useState(false); + const [formData, setFormData] = useState(emptyForm); + + useEffect(() => { + if (item) { + setFormData({ + name: item.name, + description: item.description, + trainer: item.trainer, + day: item.day, + time: item.time, + durationMin: item.durationMin, + capacity: item.capacity, + allowWaitlist: item.allowWaitlist, + // full / waitlist are derived states — the form only toggles active/inactive. + status: item.status === 'inactive' ? 'inactive' : 'active', + }); + } else { + setFormData(emptyForm); + } + }, [item, open]); + + const isEditing = !!item; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + try { + const result = await onSubmit(formData); + if (result.success) { + onOpenChange(false); + } + } finally { + setLoading(false); + } + }; + + return ( + + + + {isEditing ? 'Editar clase' : 'Nueva clase'} + + {isEditing + ? 'Modifica la información, horario y cupos de la clase.' + : 'Completa los datos para crear una nueva clase disponible.'} + + + +
{ + void handleSubmit(e); + }} + className="flex flex-col gap-4" + > +
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="Ej. HIIT Power" + required + className="bg-secondary/50 border-border" + /> +
+ +
+ +