Added routes
This commit is contained in:
@@ -48,7 +48,8 @@ bun --filter @gruperly/backend db:* # db:generate / db:migrate / db:push
|
||||
- **Base de datos**: todas las tablas usan **snake_case** vía `@@map` en `prisma/models/*.prisma` (p. ej. `User` → `users`, `WaitlistEntry` → `waitlist_entries`). Al agregar un modelo nuevo, incluir siempre `@@map("nombre_tabla")`.
|
||||
- **API** responde errores en **Problem Details RFC 7807** (`application/problem+json`) y resultados como `{ data, pagination }`; los "use cases" devuelven `Result` y nunca lanzan excepciones de dominio.
|
||||
- **Tailwind v4** con tokens en `@theme` dentro de `apps/web/src/index.css` (p. ej. `--color-accent: #1e90ff`). Se usan como clases auto-generadas: `text-accent`, `bg-success-soft`, etc. Radius por defecto `0.75rem` (`rounded-xl`).
|
||||
- **Router NO es file-based** (aunque `stack.md` lo diga): las rutas se declaran manualmente en `apps/web/src/router.tsx` con `createRoute` + `addChildren` y se registran vía module augmentation. **Cada vista nueva debe añadirse ahí.**
|
||||
- **Router ES file-based** (TanStack Router + `@tanstack/router-plugin`): las rutas viven en `apps/web/src/routes/` y solo contienen definiciones (`createFileRoute` + `component`), sin lógica de UI. `src/router.tsx` solo crea el router con el `routeTree` importado de `src/routeTree.gen.ts` (**archivo generado por el plugin en build/dev, versionado en git**: si agregás/renombrás una ruta, corré `bun --filter @gruperly/web build` para regenerarlo). Las rutas autenticadas cuelgan del layout pathless `_authenticated.tsx` (`AppLayoutGuard` + `RootLayout` con `<Outlet />`).
|
||||
- **Vistas en `apps/web/src/features/<feature>/`**: un componente por archivo, nombre de archivo en PascalCase igual al componente, imports relativos (sin alias). El estado compartido padre-hijo vive en un `*Provider.tsx` del feature (contexto + queries/mutations de React Query) que se monta en el route file; los componentes hijos consumen `use<Feature>()` y las forms locales mantienen su propio estado.
|
||||
- Nav (Inicio/Grupos/Cobros/Ajustes) vive en `apps/web/src/components/layout/nav-items.ts`; es la fuente única para `BottomNav` (móvil) y `Sidebar` (desktop) — no dupliques la lista.
|
||||
- **UI**: primitivos propios en `apps/web/src/components/ui/` (avatar, button, badge) más helper `cn()` en `src/lib/utils.ts` (clsx + tailwind-merge). Aunque `stack.md` mencione Shadcn, **todavía no está instalado** (sin Radix); úsalos directos.
|
||||
- Layout mobile-first: `RootLayout` usa columna `max-w-md` en móvil y dos columnas (Sidebar + contenido `max-w-6xl`) en `lg+`.
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"@gruperly/shared": "workspace:*",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"@tanstack/react-router": "^1.90.0",
|
||||
"@tanstack/react-router": "^1.170.38",
|
||||
"better-auth": "1.7.2",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.34.0",
|
||||
@@ -27,6 +27,7 @@
|
||||
"devDependencies": {
|
||||
"@gruperly/config": "workspace:*",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tanstack/router-plugin": "^1.168.40",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
@@ -6,7 +7,7 @@ import { useAuth } from '../../context/AuthProvider'
|
||||
import { getOnboardingStatus } from '../../lib/api'
|
||||
import { RootLayout } from './RootLayout'
|
||||
|
||||
export function AppLayoutGuard() {
|
||||
export function AppLayoutGuard({ children }: { children: ReactNode }) {
|
||||
const { user, isPending } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
@@ -30,5 +31,5 @@ export function AppLayoutGuard() {
|
||||
)
|
||||
}
|
||||
|
||||
return <RootLayout />
|
||||
return <RootLayout>{children}</RootLayout>
|
||||
}
|
||||
@@ -1,24 +1,8 @@
|
||||
import { Moon, Sun } from 'lucide-react'
|
||||
import { useTheme } from '../../context/ThemeProvider'
|
||||
import { Logo } from '../brand'
|
||||
import { Breadcrumb } from './Breadcrumb'
|
||||
import { ThemeToggle } from './ThemeToggle'
|
||||
import { UserMenu } from './UserMenu'
|
||||
|
||||
function ThemeToggle() {
|
||||
const { isDark, setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme(isDark ? 'light' : 'dark')}
|
||||
aria-label={isDark ? 'Cambiar a tema claro' : 'Cambiar a tema oscuro'}
|
||||
className="flex size-9 items-center justify-center rounded-full border border-border text-foreground/70 transition-colors hover:bg-primary-soft hover:text-primary"
|
||||
>
|
||||
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
return (
|
||||
<header className="sticky top-0 z-40 h-16 w-full border-b border-border bg-background/90 backdrop-blur">
|
||||
|
||||
7
apps/web/src/components/layout/PublicFooter.tsx
Normal file
7
apps/web/src/components/layout/PublicFooter.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export function PublicFooter() {
|
||||
return (
|
||||
<footer className="border-t border-border px-4 py-4 text-center text-xs text-foreground/40">
|
||||
Gruperly — Gestión sencilla de cobros y grupos
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
14
apps/web/src/components/layout/PublicHeader.tsx
Normal file
14
apps/web/src/components/layout/PublicHeader.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Logo } from '../brand'
|
||||
import { ThemeToggle } from './ThemeToggle'
|
||||
|
||||
export function PublicHeader() {
|
||||
return (
|
||||
<header className="sticky top-0 z-10 flex items-center justify-between border-b border-border bg-surface/80 px-4 py-3 backdrop-blur-md sm:px-8">
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<Logo className="h-7 w-auto" />
|
||||
</Link>
|
||||
<ThemeToggle />
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Outlet } from '@tanstack/react-router'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Header } from './Header'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { BottomNav } from './BottomNav'
|
||||
|
||||
export function RootLayout() {
|
||||
export function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-dvh w-full">
|
||||
<Header />
|
||||
@@ -11,12 +11,10 @@ export function RootLayout() {
|
||||
<div className="mx-auto flex w-full max-w-7xl gap-6 lg:px-6">
|
||||
<Sidebar />
|
||||
|
||||
<main className="min-w-0 flex-1 px-4 pb-24 pt-4 lg:px-0 lg:pb-8 lg:pt-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
<main className="min-w-0 flex-1 px-4 pb-24 pt-4 lg:px-0 lg:pb-8 lg:pt-8">{children}</main>
|
||||
</div>
|
||||
|
||||
<BottomNav />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
17
apps/web/src/components/layout/ThemeToggle.tsx
Normal file
17
apps/web/src/components/layout/ThemeToggle.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Moon, Sun } from 'lucide-react'
|
||||
import { useTheme } from '../../context/ThemeProvider'
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { isDark, setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme(isDark ? 'light' : 'dark')}
|
||||
aria-label={isDark ? 'Cambiar a tema claro' : 'Cambiar a tema oscuro'}
|
||||
className="flex size-9 items-center justify-center rounded-full border border-border text-foreground/70 transition-colors hover:bg-primary-soft hover:text-primary"
|
||||
>
|
||||
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
export { RootLayout } from './RootLayout'
|
||||
export { Header } from './Header'
|
||||
export { ThemeToggle } from './ThemeToggle'
|
||||
export { PublicHeader } from './PublicHeader'
|
||||
export { PublicFooter } from './PublicFooter'
|
||||
export { Sidebar } from './Sidebar'
|
||||
export { BottomNav } from './BottomNav'
|
||||
export { NAV_ITEMS, type NavItem } from './nav-items'
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { createContext, useContext, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import type { AbsenceDetails } from '@gruperly/shared'
|
||||
import { useToast } from '../../components/ui'
|
||||
import { ApiError, getAbsenceDetails, publicNotifyAbsence } from '../../lib/api'
|
||||
|
||||
type AbsenceNotifyContextValue = {
|
||||
token: string
|
||||
details: AbsenceDetails | undefined
|
||||
detailsPending: boolean
|
||||
detailsFailed: boolean
|
||||
isNotified: (session: { notified: boolean; sessionId: string }) => boolean
|
||||
isNotifying: (sessionId: string) => boolean
|
||||
notify: (sessionId: string) => void
|
||||
}
|
||||
|
||||
const AbsenceNotifyContext = createContext<AbsenceNotifyContextValue | null>(null)
|
||||
|
||||
export function AbsenceNotifyProvider({ token, children }: { token: string; children: ReactNode }) {
|
||||
const toast = useToast()
|
||||
const [locallyNotified, setLocallyNotified] = useState<string[]>([])
|
||||
|
||||
const detailsQuery = useQuery({
|
||||
queryKey: ['absence-details', token],
|
||||
queryFn: () => getAbsenceDetails(token),
|
||||
enabled: Boolean(token),
|
||||
retry: 1,
|
||||
})
|
||||
|
||||
const notifyMutation = useMutation({
|
||||
mutationFn: (sessionId: string) => publicNotifyAbsence({ token, classSessionId: sessionId }),
|
||||
onSuccess: (_data, sessionId) => {
|
||||
setLocallyNotified((prev) => [...prev, sessionId])
|
||||
toast.success('Tu profesor ya tiene registrado el aviso. Gracias por avisar.', 'Ausencia avisada')
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
if (error instanceof ApiError && error.problem?.status === 404) {
|
||||
toast.error('Este enlace ya no es válido. Consultá a tu profesor.')
|
||||
return
|
||||
}
|
||||
toast.error(error instanceof Error ? error.message : 'No pudimos registrar el aviso.')
|
||||
},
|
||||
})
|
||||
|
||||
const value: AbsenceNotifyContextValue = {
|
||||
token,
|
||||
details: detailsQuery.data,
|
||||
detailsPending: detailsQuery.isPending,
|
||||
detailsFailed: detailsQuery.isError,
|
||||
isNotified: (session) => session.notified || locallyNotified.includes(session.sessionId),
|
||||
isNotifying: (sessionId) => notifyMutation.isPending && notifyMutation.variables === sessionId,
|
||||
notify: (sessionId) => notifyMutation.mutate(sessionId),
|
||||
}
|
||||
|
||||
return <AbsenceNotifyContext.Provider value={value}>{children}</AbsenceNotifyContext.Provider>
|
||||
}
|
||||
|
||||
export function useAbsenceNotify() {
|
||||
const context = useContext(AbsenceNotifyContext)
|
||||
if (!context) {
|
||||
throw new Error('useAbsenceNotify debe usarse dentro de <AbsenceNotifyProvider>')
|
||||
}
|
||||
return context
|
||||
}
|
||||
71
apps/web/src/features/absence-notify/AbsenceNotifyView.tsx
Normal file
71
apps/web/src/features/absence-notify/AbsenceNotifyView.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { CalendarX2, Loader2 } from 'lucide-react'
|
||||
import { PublicFooter, PublicHeader } from '../../components/layout'
|
||||
import { Badge } from '../../components/ui'
|
||||
import { AbsenceSessionRow } from './AbsenceSessionRow'
|
||||
import { useAbsenceNotify } from './AbsenceNotifyProvider'
|
||||
|
||||
export function AbsenceNotifyView() {
|
||||
const { details, detailsPending, detailsFailed } = useAbsenceNotify()
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col justify-between bg-background text-primary selection:bg-accent selection:text-white">
|
||||
<PublicHeader />
|
||||
|
||||
<main className="flex flex-1 items-center justify-center p-4 sm:p-6 md:p-10">
|
||||
<div className="w-full max-w-lg space-y-6">
|
||||
{detailsPending ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-20">
|
||||
<Loader2 className="size-8 animate-spin text-accent" />
|
||||
<p className="text-sm text-foreground/60">Cargando tus clases...</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{detailsFailed || (!details && !detailsPending) ? (
|
||||
<div className="space-y-3 rounded-2xl border border-danger/20 bg-danger-soft p-6 text-center sm:p-8">
|
||||
<h2 className="text-xl font-bold text-danger">Enlace no válido</h2>
|
||||
<p className="mx-auto max-w-sm text-sm leading-relaxed text-foreground/70">
|
||||
No pudimos identificarte con este enlace. Consultá con tu profesor para solicitar uno nuevo.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{details ? (
|
||||
<div className="animate-step-enter space-y-5 rounded-2xl border border-border bg-surface p-6 shadow-xl sm:p-8">
|
||||
<div className="border-b border-border pb-5 text-center">
|
||||
<div className="mx-auto mb-3 flex size-14 items-center justify-center rounded-full bg-accent-soft text-accent">
|
||||
<CalendarX2 className="size-7" />
|
||||
</div>
|
||||
<Badge variant="neutral" className="mb-2">
|
||||
Aviso de ausencia
|
||||
</Badge>
|
||||
<h1 className="text-2xl font-bold text-primary">Hola, {details.fullName}</h1>
|
||||
<p className="mt-1 text-sm font-medium text-foreground/80">
|
||||
Grupo: <span className="text-primary">{details.groupName}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{details.sessions.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-border bg-primary-soft/40 px-4 py-8 text-center text-sm text-foreground/60">
|
||||
No tenés clases programadas para hoy. Si necesitás avisar de todas formas, contactá a tu
|
||||
profesor.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{details.sessions.map((session) => (
|
||||
<AbsenceSessionRow key={session.sessionId} session={session} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<p className="text-center text-xs leading-relaxed text-foreground/50">
|
||||
Si avisás con anticipación, tu cupo se libera para una clase de recuperación.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<PublicFooter />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
44
apps/web/src/features/absence-notify/AbsenceSessionRow.tsx
Normal file
44
apps/web/src/features/absence-notify/AbsenceSessionRow.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { CheckCircle2, Loader2 } from 'lucide-react'
|
||||
import { Button } from '../../components/ui'
|
||||
import { useAbsenceNotify } from './AbsenceNotifyProvider'
|
||||
|
||||
type AbsenceSession = {
|
||||
sessionId: string
|
||||
startsAt: string
|
||||
groupName: string
|
||||
notified: boolean
|
||||
}
|
||||
|
||||
export function AbsenceSessionRow({ session }: { session: AbsenceSession }) {
|
||||
const { isNotified, isNotifying, notify } = useAbsenceNotify()
|
||||
const time = new Date(session.startsAt).toLocaleTimeString('es-MX', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
|
||||
return (
|
||||
<li className="flex items-center justify-between gap-3 rounded-xl border border-border bg-primary-soft/30 px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-primary">Hoy · {time} hs</p>
|
||||
<p className="truncate text-xs text-foreground/60">{session.groupName}</p>
|
||||
</div>
|
||||
{isNotified(session) ? (
|
||||
<span className="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-success-soft px-2.5 py-1 text-xs font-medium text-success">
|
||||
<CheckCircle2 className="size-3.5" />
|
||||
Ya avisaste
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={isNotifying(session.sessionId)}
|
||||
onClick={() => notify(session.sessionId)}
|
||||
>
|
||||
{isNotifying(session.sessionId) ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
Avisar ausencia
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
25
apps/web/src/features/auth/AuthShell.tsx
Normal file
25
apps/web/src/features/auth/AuthShell.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Logo } from '../../components/brand'
|
||||
|
||||
export type AuthShellProps = {
|
||||
title: string
|
||||
subtitle?: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function AuthShell({ title, subtitle, children }: AuthShellProps) {
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center bg-background px-4 py-8">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="mb-6 flex items-center justify-center">
|
||||
<Logo className="h-10" />
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-surface p-6 shadow-sm">
|
||||
<h1 className="text-xl font-bold text-primary">{title}</h1>
|
||||
{subtitle ? <p className="mt-1 text-sm text-foreground/60">{subtitle}</p> : null}
|
||||
<div className="mt-5">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
113
apps/web/src/features/auth/LoginPage.tsx
Normal file
113
apps/web/src/features/auth/LoginPage.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import { Fingerprint, Loader2 } from 'lucide-react'
|
||||
import { authClient } from '../../lib/auth-client'
|
||||
import { Button, Input, Label } from '../../components/ui'
|
||||
import { AuthShell } from './AuthShell'
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().email('Ingresá un email válido'),
|
||||
password: z.string().min(8, 'La contraseña debe tener al menos 8 caracteres'),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof schema>
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<FormValues>({ resolver: zodResolver(schema) })
|
||||
|
||||
const onSubmit = handleSubmit(async ({ email, password }) => {
|
||||
const { error } = await authClient.signIn.email({ email, password })
|
||||
if (error) {
|
||||
setError('root', { message: error.message ?? 'No se pudo iniciar sesión' })
|
||||
return
|
||||
}
|
||||
navigate({ to: '/' })
|
||||
})
|
||||
|
||||
const handleGoogle = async () => {
|
||||
await authClient.signIn.social({ provider: 'google', callbackURL: '/' })
|
||||
}
|
||||
|
||||
const handlePasskey = async () => {
|
||||
const { error, data } = await authClient.signIn.passkey()
|
||||
if (error) {
|
||||
setError('root', { message: error.message ?? 'No se pudo autenticar con passkey' })
|
||||
return
|
||||
}
|
||||
if (data) navigate({ to: '/' })
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell title="Iniciar sesión" subtitle="Accedé a tus cobros grupales">
|
||||
<div className="space-y-4">
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="vos@ejemplo.com"
|
||||
invalid={!!errors.email}
|
||||
{...register('email')}
|
||||
/>
|
||||
{errors.email ? <p className="mt-1 text-sm text-danger">{errors.email.message}</p> : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="password">Contraseña</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="••••••••"
|
||||
invalid={!!errors.password}
|
||||
{...register('password')}
|
||||
/>
|
||||
{errors.password ? (
|
||||
<p className="mt-1 text-sm text-danger">{errors.password.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{errors.root ? <p className="text-sm text-danger">{errors.root.message}</p> : null}
|
||||
|
||||
<Button type="submit" variant="primary" className="w-full" disabled={isSubmitting}>
|
||||
{isSubmitting ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
{isSubmitting ? 'Ingresando…' : 'Ingresar'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="text-xs uppercase tracking-wide text-foreground/40">o</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Button variant="outline" className="w-full" onClick={handleGoogle}>
|
||||
Continuar con Google
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full" onClick={handlePasskey}>
|
||||
<Fingerprint className="size-4" />
|
||||
Entrar con passkey
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-foreground/60">
|
||||
¿No tenés cuenta?{' '}
|
||||
<Link to="/signup" className="font-medium text-accent hover:underline">
|
||||
Registrate
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
142
apps/web/src/features/auth/SignupPage.tsx
Normal file
142
apps/web/src/features/auth/SignupPage.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { authClient } from '../../lib/auth-client'
|
||||
import { Button, Input, Label } from '../../components/ui'
|
||||
import { AuthShell } from './AuthShell'
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
name: z.string().min(2, 'Ingresá tu nombre'),
|
||||
email: z.string().email('Ingresá un email válido'),
|
||||
password: z.string().min(8, 'La contraseña debe tener al menos 8 caracteres'),
|
||||
confirmPassword: z.string(),
|
||||
})
|
||||
.refine((v) => v.password === v.confirmPassword, {
|
||||
message: 'Las contraseñas no coinciden',
|
||||
path: ['confirmPassword'],
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof schema>
|
||||
|
||||
export function SignupPage() {
|
||||
const navigate = useNavigate()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<FormValues>({ resolver: zodResolver(schema) })
|
||||
|
||||
const onSubmit = handleSubmit(async ({ name, email, password }) => {
|
||||
const { error, data } = await authClient.signUp.email(
|
||||
{ name, email, password },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await authClient.getSession()
|
||||
},
|
||||
},
|
||||
)
|
||||
if (error) {
|
||||
setError('root', { message: error.message ?? 'No se pudo registrar' })
|
||||
return
|
||||
}
|
||||
if (data?.token) {
|
||||
// Con email verification activo, la sesión no se crea hasta verificar.
|
||||
navigate({ to: '/verify-email', search: { email } })
|
||||
}
|
||||
})
|
||||
|
||||
const handleGoogle = async () => {
|
||||
await authClient.signIn.social({ provider: 'google', callbackURL: '/' })
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell title="Crear cuenta" subtitle="Empezá a cobrar en grupo en minutos">
|
||||
<div className="space-y-4">
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Nombre</Label>
|
||||
<Input
|
||||
id="name"
|
||||
autoComplete="name"
|
||||
placeholder="Nombre completo"
|
||||
invalid={!!errors.name}
|
||||
{...register('name')}
|
||||
/>
|
||||
{errors.name ? <p className="mt-1 text-sm text-danger">{errors.name.message}</p> : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="vos@ejemplo.com"
|
||||
invalid={!!errors.email}
|
||||
{...register('email')}
|
||||
/>
|
||||
{errors.email ? <p className="mt-1 text-sm text-danger">{errors.email.message}</p> : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="password">Contraseña</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="Mínimo 8 caracteres"
|
||||
invalid={!!errors.password}
|
||||
{...register('password')}
|
||||
/>
|
||||
{errors.password ? (
|
||||
<p className="mt-1 text-sm text-danger">{errors.password.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="confirmPassword">Repetí la contraseña</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="••••••••"
|
||||
invalid={!!errors.confirmPassword}
|
||||
{...register('confirmPassword')}
|
||||
/>
|
||||
{errors.confirmPassword ? (
|
||||
<p className="mt-1 text-sm text-danger">{errors.confirmPassword.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{errors.root ? <p className="text-sm text-danger">{errors.root.message}</p> : null}
|
||||
|
||||
<Button type="submit" variant="primary" className="w-full" disabled={isSubmitting}>
|
||||
{isSubmitting ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
{isSubmitting ? 'Creando…' : 'Crear cuenta'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="text-xs uppercase tracking-wide text-foreground/40">o</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
|
||||
<Button variant="outline" className="w-full" onClick={handleGoogle}>
|
||||
Continuar con Google
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-sm text-foreground/60">
|
||||
¿Ya tenés cuenta?{' '}
|
||||
<Link to="/login" className="font-medium text-accent hover:underline">
|
||||
Iniciá sesión
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
91
apps/web/src/features/auth/VerifyEmailPage.tsx
Normal file
91
apps/web/src/features/auth/VerifyEmailPage.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useSearch } from '@tanstack/react-router'
|
||||
import { MailCheck, MailWarning, Loader2 } from 'lucide-react'
|
||||
import { authClient } from '../../lib/auth-client'
|
||||
import { AuthShell } from './AuthShell'
|
||||
|
||||
type SearchParams = {
|
||||
token?: string
|
||||
email?: string
|
||||
}
|
||||
|
||||
type Status = 'idle' | 'verifying' | 'success' | 'error'
|
||||
|
||||
export function VerifyEmailPage() {
|
||||
const { token, email } = useSearch({ strict: false }) as SearchParams
|
||||
const navigate = useNavigate()
|
||||
const [status, setStatus] = useState<Status>(token ? 'verifying' : 'idle')
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || status !== 'verifying') return
|
||||
let cancelled = false
|
||||
authClient
|
||||
.verifyEmail({ query: { token } })
|
||||
.then(async ({ error }) => {
|
||||
if (cancelled) return
|
||||
if (error) {
|
||||
setStatus('error')
|
||||
return
|
||||
}
|
||||
await authClient.getSession()
|
||||
navigate({ to: '/' })
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setStatus('error')
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [token, status, navigate])
|
||||
|
||||
const title =
|
||||
status === 'success'
|
||||
? 'Email verificado'
|
||||
: status === 'error'
|
||||
? 'Vínculo inválido'
|
||||
: 'Verificá tu email'
|
||||
|
||||
return (
|
||||
<AuthShell title={title}>
|
||||
<div className="flex flex-col items-center gap-4 py-2 text-center">
|
||||
{status === 'verifying' ? (
|
||||
<Loader2 className="size-10 animate-spin text-accent" />
|
||||
) : status === 'success' ? (
|
||||
<MailCheck className="size-10 text-success" />
|
||||
) : (
|
||||
<MailWarning className="size-10 text-warning" />
|
||||
)}
|
||||
|
||||
{status === 'success' ? (
|
||||
<p className="text-sm text-foreground/60">
|
||||
Tu cuenta quedó verificada. Te estamos llevando a Gruperly…
|
||||
</p>
|
||||
) : status === 'error' ? (
|
||||
<p className="text-sm text-foreground/60">
|
||||
El vínculo de verificación no es válido o expiró.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-foreground/60">
|
||||
Te enviamos un correo a <span className="font-medium text-primary">{email}</span> para
|
||||
confirmar tu cuenta.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status === 'success' ? (
|
||||
<Link
|
||||
to="/"
|
||||
className="w-full rounded-xl bg-accent py-2 text-center text-sm font-medium text-on-accent hover:bg-accent-strong"
|
||||
>
|
||||
Ir a Gruperly
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
{status === 'error' ? (
|
||||
<Link to="/login" className="text-sm font-medium text-accent hover:underline">
|
||||
Intentar iniciar sesión
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
117
apps/web/src/features/class-attendance/AttendanceProvider.tsx
Normal file
117
apps/web/src/features/class-attendance/AttendanceProvider.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import type { SessionStudents } from '@gruperly/shared'
|
||||
import { useToast } from '../../components/ui'
|
||||
import { getSessionStudents, markAttendance } from '../../lib/api'
|
||||
import { isAttendanceLocked } from './utils'
|
||||
import type { AttendanceChoice } from './utils'
|
||||
|
||||
type AttendanceContextValue = {
|
||||
sessionId: string
|
||||
sessionInfo: SessionStudents | undefined
|
||||
studentsPending: boolean
|
||||
studentsFailed: boolean
|
||||
retryStudents: () => void
|
||||
startTime: string | null
|
||||
presentCount: number
|
||||
choices: Record<string, AttendanceChoice>
|
||||
choiceFor: (attendeeId: string) => AttendanceChoice
|
||||
toggleStudent: (attendeeId: string) => void
|
||||
saveAttendance: () => void
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
const AttendanceContext = createContext<AttendanceContextValue | null>(null)
|
||||
|
||||
export function AttendanceProvider({ sessionId, children }: { sessionId: string; children: ReactNode }) {
|
||||
const queryClient = useQueryClient()
|
||||
const toast = useToast()
|
||||
|
||||
const studentsQuery = useQuery({
|
||||
queryKey: ['class-students', sessionId],
|
||||
queryFn: () => getSessionStudents(sessionId),
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
const sessionInfo = studentsQuery.data
|
||||
const [choices, setChoices] = useState<Record<string, AttendanceChoice>>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionInfo) return
|
||||
const initial: Record<string, AttendanceChoice> = {}
|
||||
for (const student of sessionInfo.students) {
|
||||
if (isAttendanceLocked(student)) continue
|
||||
initial[student.attendeeId] = student.attendanceStatus === 'ABSENT' ? 'ABSENT' : 'PRESENT'
|
||||
}
|
||||
setChoices(initial)
|
||||
}, [sessionInfo])
|
||||
|
||||
const presentCount = useMemo(
|
||||
() =>
|
||||
(sessionInfo?.students ?? []).filter(
|
||||
(student) => !isAttendanceLocked(student) && choices[student.attendeeId] === 'PRESENT',
|
||||
).length,
|
||||
[sessionInfo, choices],
|
||||
)
|
||||
|
||||
const toggleStudent = (attendeeId: string) => {
|
||||
setChoices((prev) => ({
|
||||
...prev,
|
||||
[attendeeId]: prev[attendeeId] === 'ABSENT' ? 'PRESENT' : 'ABSENT',
|
||||
}))
|
||||
}
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const records = (sessionInfo?.students ?? [])
|
||||
.filter((student) => !isAttendanceLocked(student))
|
||||
.map((student) => ({
|
||||
attendeeId: student.attendeeId,
|
||||
status: choices[student.attendeeId] ?? 'PRESENT',
|
||||
}))
|
||||
return markAttendance(sessionId, { classSessionId: sessionId, records })
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Asistencia guardada correctamente.', 'Listo')
|
||||
void queryClient.invalidateQueries({ queryKey: ['classes-today'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['class-students', sessionId] })
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || 'No pudimos guardar la asistencia.')
|
||||
},
|
||||
})
|
||||
|
||||
const startTime = sessionInfo
|
||||
? new Date(sessionInfo.startsAt).toLocaleTimeString('es-MX', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
: null
|
||||
|
||||
const value: AttendanceContextValue = {
|
||||
sessionId,
|
||||
sessionInfo,
|
||||
studentsPending: studentsQuery.isPending,
|
||||
studentsFailed: studentsQuery.isError,
|
||||
retryStudents: () => void studentsQuery.refetch(),
|
||||
startTime,
|
||||
presentCount,
|
||||
choices,
|
||||
choiceFor: (attendeeId) => choices[attendeeId] ?? 'PRESENT',
|
||||
toggleStudent,
|
||||
saveAttendance: () => saveMutation.mutate(),
|
||||
isSaving: saveMutation.isPending,
|
||||
}
|
||||
|
||||
return <AttendanceContext.Provider value={value}>{children}</AttendanceContext.Provider>
|
||||
}
|
||||
|
||||
export function useAttendance() {
|
||||
const context = useContext(AttendanceContext)
|
||||
if (!context) {
|
||||
throw new Error('useAttendance debe usarse dentro de <AttendanceProvider>')
|
||||
}
|
||||
return context
|
||||
}
|
||||
45
apps/web/src/features/class-attendance/AttendanceToggle.tsx
Normal file
45
apps/web/src/features/class-attendance/AttendanceToggle.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Check, X } from 'lucide-react'
|
||||
import { cn } from '../../lib/utils'
|
||||
import type { AttendanceChoice } from './utils'
|
||||
|
||||
export function AttendanceToggle({
|
||||
choice,
|
||||
disabled,
|
||||
onToggle,
|
||||
fullName,
|
||||
}: {
|
||||
choice: AttendanceChoice
|
||||
disabled?: boolean
|
||||
onToggle: () => void
|
||||
fullName: string
|
||||
}) {
|
||||
const isPresent = choice === 'PRESENT'
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={isPresent}
|
||||
aria-label={`${isPresent ? 'Presente' : 'Ausente'}: ${fullName}`}
|
||||
disabled={disabled}
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
'relative inline-flex h-11 w-14 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface disabled:cursor-not-allowed disabled:opacity-70',
|
||||
isPresent ? 'bg-success' : 'bg-danger',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex size-9 items-center justify-center rounded-full bg-white shadow-sm transition-transform duration-150',
|
||||
isPresent ? 'translate-x-3' : 'translate-x-1',
|
||||
)}
|
||||
>
|
||||
{isPresent ? (
|
||||
<Check className="size-5 text-success" aria-hidden />
|
||||
) : (
|
||||
<X className="size-5 text-danger" aria-hidden />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { ArrowLeft, Loader2 } from 'lucide-react'
|
||||
import { Badge, Button } from '../../components/ui'
|
||||
import { useAttendance } from './AttendanceProvider'
|
||||
import { StudentRow } from './StudentRow'
|
||||
|
||||
export function ClassAttendanceView() {
|
||||
const {
|
||||
sessionInfo,
|
||||
studentsPending,
|
||||
studentsFailed,
|
||||
retryStudents,
|
||||
startTime,
|
||||
presentCount,
|
||||
saveAttendance,
|
||||
isSaving,
|
||||
} = useAttendance()
|
||||
|
||||
return (
|
||||
<section>
|
||||
<Link
|
||||
to="/"
|
||||
className="mb-3 hidden items-center gap-1 text-sm text-foreground/60 transition-colors hover:text-primary lg:inline-flex"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
<span>Volver al inicio</span>
|
||||
</Link>
|
||||
|
||||
{studentsPending ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="size-6 animate-spin text-foreground/40" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{studentsFailed ? (
|
||||
<div className="mt-4 space-y-3 rounded-xl bg-danger-soft px-4 py-3 text-sm text-danger">
|
||||
<p>No pudimos cargar la lista de alumnos.</p>
|
||||
<Button variant="outline" size="sm" onClick={retryStudents}>
|
||||
Reintentar
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{sessionInfo ? (
|
||||
<>
|
||||
<header className="sticky top-16 z-30 -mx-4 border-b border-border bg-background/95 px-4 py-3 backdrop-blur lg:mx-0 lg:rounded-xl lg:border lg:px-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-lg font-bold text-primary">{sessionInfo.groupName}</h1>
|
||||
<p className="text-xs text-foreground/60">{startTime ? `Hoy · ${startTime} hs` : 'Clase de hoy'}</p>
|
||||
</div>
|
||||
<Badge variant="success" className="shrink-0 text-sm">
|
||||
{presentCount}/{sessionInfo.students.length} presentes
|
||||
</Badge>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{sessionInfo.students.length === 0 ? (
|
||||
<div className="mt-6 rounded-xl border border-dashed border-border bg-surface px-4 py-10 text-center">
|
||||
<p className="text-sm font-medium text-primary">Este grupo no tiene alumnos</p>
|
||||
<p className="mt-1 text-sm text-foreground/60">
|
||||
Agregá miembros desde la ficha del grupo para tomar asistencia.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="mt-4 space-y-3 pb-2">
|
||||
{sessionInfo.students.map((student) => (
|
||||
<StudentRow key={student.attendeeId} student={student} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{sessionInfo.students.length > 0 ? (
|
||||
<div className="sticky bottom-20 z-30 mt-6 lg:bottom-6">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="h-12 w-full text-base font-semibold shadow-lg"
|
||||
disabled={isSaving}
|
||||
onClick={saveAttendance}
|
||||
>
|
||||
{isSaving ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
Guardar Asistencia
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
37
apps/web/src/features/class-attendance/StudentRow.tsx
Normal file
37
apps/web/src/features/class-attendance/StudentRow.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { SessionStudentDto } from '@gruperly/shared'
|
||||
import { Avatar, Badge } from '../../components/ui'
|
||||
import { AttendanceToggle } from './AttendanceToggle'
|
||||
import { useAttendance } from './AttendanceProvider'
|
||||
import { isAttendanceLocked } from './utils'
|
||||
|
||||
function PaymentBadge({ status }: { status: SessionStudentDto['paymentStatus'] }) {
|
||||
return status === 'UP_TO_DATE' ? <Badge variant="success">Al día</Badge> : <Badge variant="danger">Pendiente</Badge>
|
||||
}
|
||||
|
||||
export function StudentRow({ student }: { student: SessionStudentDto }) {
|
||||
const { choiceFor, toggleStudent } = useAttendance()
|
||||
const locked = isAttendanceLocked(student)
|
||||
|
||||
return (
|
||||
<li
|
||||
className={`flex items-center gap-3 rounded-xl border bg-surface px-4 py-3 ${
|
||||
locked ? 'border-warning/30 bg-warning-soft/30' : 'border-border'
|
||||
}`}
|
||||
>
|
||||
<Avatar name={student.fullName} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-primary">{student.fullName}</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
||||
<PaymentBadge status={student.paymentStatus} />
|
||||
{locked ? <Badge variant="warning">Avisó ausencia</Badge> : null}
|
||||
</div>
|
||||
</div>
|
||||
<AttendanceToggle
|
||||
choice={choiceFor(student.attendeeId)}
|
||||
disabled={locked}
|
||||
onToggle={() => toggleStudent(student.attendeeId)}
|
||||
fullName={student.fullName}
|
||||
/>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
7
apps/web/src/features/class-attendance/utils.ts
Normal file
7
apps/web/src/features/class-attendance/utils.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { SessionStudentDto } from '@gruperly/shared'
|
||||
|
||||
export type AttendanceChoice = 'PRESENT' | 'ABSENT'
|
||||
|
||||
export function isAttendanceLocked(student: SessionStudentDto): boolean {
|
||||
return student.notifiedAbsence || student.attendanceStatus === 'EXCUSED'
|
||||
}
|
||||
42
apps/web/src/features/groups/CreateGroupView.tsx
Normal file
42
apps/web/src/features/groups/CreateGroupView.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import type { CreateFirstGroup } from '@gruperly/shared'
|
||||
import { ChevronLeft } from 'lucide-react'
|
||||
import { createGroup } from '../../lib/api'
|
||||
import { GroupForm } from './GroupForm'
|
||||
|
||||
export function CreateGroupView() {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (values: CreateFirstGroup) => createGroup(values),
|
||||
onSuccess: (result) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['groups'] })
|
||||
void navigate({ to: '/groups/$groupId', params: { groupId: result.group.id } })
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<section className="mx-auto w-full max-w-md">
|
||||
<Link
|
||||
to="/groups"
|
||||
className="mb-6 hidden items-center gap-1 text-sm font-medium text-foreground/70 transition-colors hover:text-primary lg:inline-flex"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Grupos
|
||||
</Link>
|
||||
|
||||
<GroupForm
|
||||
heading="Nuevo grupo"
|
||||
description="Definí los datos de la clase que vas a cobrar. Después siempre podés editarlos."
|
||||
submitLabel="Crear grupo"
|
||||
isSubmitting={create.isPending}
|
||||
errorMessage={
|
||||
create.isError ? 'No pudimos crear el grupo. Revisá los datos e intentá de nuevo.' : null
|
||||
}
|
||||
onSubmit={(values) => create.mutate(values)}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
78
apps/web/src/features/groups/GroupCard.tsx
Normal file
78
apps/web/src/features/groups/GroupCard.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import type { GroupDto, GroupRiskLevel } from '@gruperly/shared'
|
||||
import { CalendarClock, Users } from 'lucide-react'
|
||||
import { Badge, Button } from '../../components/ui'
|
||||
import { BILLING_LABELS, formatPrice, formatSchedule } from '../../lib/format'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { RISK_LABELS } from './constants'
|
||||
|
||||
export function GroupCard({
|
||||
group,
|
||||
riskLevel,
|
||||
}: {
|
||||
group: GroupDto
|
||||
riskLevel: GroupRiskLevel
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const hasSchedule = (group.days?.length ?? 0) > 0
|
||||
const hasRisk = riskLevel === 'HIGH' || riskLevel === 'MEDIUM'
|
||||
|
||||
return (
|
||||
<article className="rounded-xl border border-border bg-surface p-5 transition-all hover:border-accent/40">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-base font-semibold text-primary">{group.name}</h3>
|
||||
{group.description ? (
|
||||
<p className="mt-0.5 truncate text-sm text-foreground/60">{group.description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1.5">
|
||||
{hasRisk ? (
|
||||
<Badge variant={riskLevel === 'HIGH' ? 'danger' : 'warning'} className="gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'size-2 rounded-full',
|
||||
riskLevel === 'HIGH' ? 'bg-danger' : 'bg-warning',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{RISK_LABELS[riskLevel]}
|
||||
</Badge>
|
||||
) : null}
|
||||
<Badge variant="success">Activo</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasSchedule ? (
|
||||
<dl className="mt-4 space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2 text-foreground/70">
|
||||
<CalendarClock className="size-4 shrink-0 text-accent" />
|
||||
<span>{formatSchedule(group.days ?? [], group.time)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-foreground/70">
|
||||
<Users className="size-4 shrink-0 text-accent" />
|
||||
<span>
|
||||
Cupo {group.capacity ?? '—'} miembros
|
||||
{group.price != null
|
||||
? ` · ${formatPrice(group.price)}${group.billingType ? ` · ${BILLING_LABELS[group.billingType]}` : ''}`
|
||||
: ''}
|
||||
{group.dueDay != null ? ` · vence el día ${group.dueDay}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</dl>
|
||||
) : (
|
||||
<p className="mt-4 text-sm text-foreground/50">Aún sin plan de cobro configurado.</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex items-center justify-end gap-2 border-t border-border pt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void navigate({ to: `/groups/${group.id}` })}
|
||||
>
|
||||
Gestionar Miembros
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
231
apps/web/src/features/groups/GroupForm.tsx
Normal file
231
apps/web/src/features/groups/GroupForm.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import type { BillingType, CreateFirstGroup, WeekDay } from '@gruperly/shared'
|
||||
import { CreateFirstGroupSchema } from '@gruperly/shared'
|
||||
import { ArrowLeft, Check, Loader2 } from 'lucide-react'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { Button, Input, Label } from '../../components/ui'
|
||||
import { BILLING_TYPES, WEEK_DAY_CHIPS } from '../onboarding/constants'
|
||||
|
||||
const defaultValues: CreateFirstGroup = {
|
||||
name: '',
|
||||
days: [],
|
||||
time: '09:00',
|
||||
capacity: 1,
|
||||
price: 0,
|
||||
billingType: 'MONTHLY',
|
||||
dueDay: 1,
|
||||
}
|
||||
|
||||
type GroupFormProps = {
|
||||
heading: string
|
||||
description: string
|
||||
submitLabel: string
|
||||
isSubmitting: boolean
|
||||
errorMessage?: string | null
|
||||
onSubmit: (values: CreateFirstGroup) => void
|
||||
onBack?: () => void
|
||||
}
|
||||
|
||||
export function GroupForm({
|
||||
heading,
|
||||
description,
|
||||
submitLabel,
|
||||
isSubmitting,
|
||||
errorMessage,
|
||||
onSubmit,
|
||||
onBack,
|
||||
}: GroupFormProps) {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<CreateFirstGroup>({
|
||||
resolver: zodResolver(CreateFirstGroupSchema),
|
||||
defaultValues,
|
||||
mode: 'onTouched',
|
||||
})
|
||||
|
||||
const days = watch('days')
|
||||
const billingType = watch('billingType')
|
||||
const dueDay = watch('dueDay')
|
||||
|
||||
const toggleDay = (day: WeekDay) => {
|
||||
const next = days.includes(day) ? days.filter((d) => d !== day) : [...days, day]
|
||||
setValue('days', next, { shouldValidate: true })
|
||||
}
|
||||
|
||||
const selectBillingType = (type: BillingType) => {
|
||||
setValue('billingType', type, { shouldValidate: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-5">
|
||||
<header className="space-y-1">
|
||||
<h2 className="text-2xl font-bold text-primary">{heading}</h2>
|
||||
<p className="text-sm text-foreground/60">{description}</p>
|
||||
</header>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="name">Nombre del grupo</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="Ej: Yoga Vinyasa · Nivel 1"
|
||||
invalid={!!errors.name}
|
||||
{...register('name')}
|
||||
/>
|
||||
{errors.name ? <p className="mt-1 text-sm text-danger">{errors.name.message}</p> : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Días de clase</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{WEEK_DAY_CHIPS.map(({ value, label }) => {
|
||||
const isActive = days.includes(value)
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
aria-pressed={isActive}
|
||||
onClick={() => toggleDay(value)}
|
||||
className={cn(
|
||||
'h-9 rounded-full border px-3.5 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'border-accent bg-accent text-on-accent'
|
||||
: 'border-border bg-surface text-primary hover:bg-primary-soft',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{errors.days ? <p className="mt-1 text-sm text-danger">{errors.days.message}</p> : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="time">Horario</Label>
|
||||
<Input
|
||||
id="time"
|
||||
type="time"
|
||||
invalid={!!errors.time}
|
||||
{...register('time')}
|
||||
/>
|
||||
{errors.time ? <p className="mt-1 text-sm text-danger">{errors.time.message}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="capacity">Cupo máximo</Label>
|
||||
<Input
|
||||
id="capacity"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
step={1}
|
||||
invalid={!!errors.capacity}
|
||||
{...register('capacity', { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.capacity ? (
|
||||
<p className="mt-1 text-sm text-danger">{errors.capacity.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="price">Precio</Label>
|
||||
<div className="relative">
|
||||
<span className="pointer-events-none absolute inset-y-0 left-3 flex items-center text-sm text-foreground/50">
|
||||
$
|
||||
</span>
|
||||
<Input
|
||||
id="price"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
className="pl-7"
|
||||
invalid={!!errors.price}
|
||||
{...register('price', { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
{errors.price ? (
|
||||
<p className="mt-1 text-sm text-danger">{errors.price.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Tipo de cobro</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{BILLING_TYPES.map(({ value, label, hint }) => {
|
||||
const isActive = billingType === value
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
aria-pressed={isActive}
|
||||
onClick={() => selectBillingType(value)}
|
||||
className={cn(
|
||||
'rounded-xl border px-3 py-2.5 text-left transition-colors',
|
||||
isActive
|
||||
? 'border-accent bg-accent-soft'
|
||||
: 'border-border bg-surface hover:bg-primary-soft',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'block text-sm font-semibold',
|
||||
isActive ? 'text-accent' : 'text-primary',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span className="block text-xs text-foreground/50">{hint}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="dueDay">Día de vencimiento</Label>
|
||||
<Input
|
||||
id="dueDay"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={28}
|
||||
step={1}
|
||||
invalid={!!errors.dueDay}
|
||||
{...register('dueDay', { valueAsNumber: true })}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-foreground/50">
|
||||
Los cobros vencerán el día {isFinite(dueDay) && dueDay ? dueDay : '1'} de cada mes.
|
||||
</p>
|
||||
{errors.dueDay ? (
|
||||
<p className="mt-1 text-sm text-danger">{errors.dueDay.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{errorMessage ? (
|
||||
<p className="rounded-xl bg-danger-soft px-4 py-3 text-sm text-danger">{errorMessage}</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{onBack ? (
|
||||
<Button variant="outline" onClick={onBack} disabled={isSubmitting}>
|
||||
<ArrowLeft className="size-4" />
|
||||
Volver
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="submit" variant="primary" className="flex-1" disabled={isSubmitting}>
|
||||
{isSubmitting ? <Loader2 className="size-4 animate-spin" /> : <Check className="size-4" />}
|
||||
{isSubmitting ? 'Creando…' : submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
81
apps/web/src/features/groups/GroupsView.tsx
Normal file
81
apps/web/src/features/groups/GroupsView.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Loader2, Plus } from 'lucide-react'
|
||||
import { Button } from '../../components/ui'
|
||||
import { getGroups, getGroupsRiskOverview } from '../../lib/api'
|
||||
import { GroupCard } from './GroupCard'
|
||||
|
||||
export function GroupsView() {
|
||||
const navigate = useNavigate()
|
||||
const groupsQuery = useQuery({
|
||||
queryKey: ['groups'],
|
||||
queryFn: getGroups,
|
||||
})
|
||||
const riskQuery = useQuery({
|
||||
queryKey: ['groups-risk-overview'],
|
||||
queryFn: getGroupsRiskOverview,
|
||||
})
|
||||
|
||||
const riskByGroup = new Map(
|
||||
(riskQuery.data?.items ?? []).map((item) => [item.groupId, item.riskLevel]),
|
||||
)
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-primary">Grupos</h1>
|
||||
<p className="mt-2 text-sm text-foreground/60">Tus grupos de cobranza.</p>
|
||||
</div>
|
||||
<Button variant="primary" onClick={() => void navigate({ to: '/groups/new' })}>
|
||||
<Plus className="size-4" />
|
||||
Crear
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{groupsQuery.isPending ? (
|
||||
<div className="mt-8 flex items-center justify-center py-16">
|
||||
<Loader2 className="size-6 animate-spin text-foreground/40" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{groupsQuery.isError ? (
|
||||
<div className="mt-8 space-y-3 rounded-xl bg-danger-soft px-4 py-3 text-sm text-danger">
|
||||
<p>No pudimos cargar tus grupos.</p>
|
||||
<Button variant="outline" size="sm" onClick={() => void groupsQuery.refetch()}>
|
||||
Reintentar
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{groupsQuery.isSuccess && groupsQuery.data.data.length === 0 ? (
|
||||
<div className="mt-8 rounded-xl border border-dashed border-border bg-surface px-4 py-12 text-center">
|
||||
<p className="font-medium text-primary">Todavía no tenés grupos</p>
|
||||
<p className="mt-1 text-sm text-foreground/60">
|
||||
Crea tu primer grupo para empezar a cobrar.
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="mt-5"
|
||||
onClick={() => void navigate({ to: '/groups/new' })}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Crear tu primer grupo
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{groupsQuery.isSuccess && groupsQuery.data.data.length > 0 ? (
|
||||
<div className="mt-6 grid gap-4">
|
||||
{groupsQuery.data.data.map((group) => (
|
||||
<GroupCard
|
||||
key={group.id}
|
||||
group={group}
|
||||
riskLevel={riskByGroup.get(group.id) ?? 'NONE'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
219
apps/web/src/features/groups/analytics/AnalyticsView.tsx
Normal file
219
apps/web/src/features/groups/analytics/AnalyticsView.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link, useParams } from '@tanstack/react-router'
|
||||
import type { StudentAtRiskDto } from '@gruperly/shared'
|
||||
import { ArrowLeft, CalendarCheck, DoorOpen, Loader2, Percent, Users } from 'lucide-react'
|
||||
import { Badge, Button, useToast } from '../../../components/ui'
|
||||
import { getAttendeeHistory, getGroupAnalytics, getStudentsAtRisk, updateAttendeeStatus } from '../../../lib/api'
|
||||
import { KpiCard } from './KpiCard'
|
||||
import { AtRiskStudentCard, type StatusAction } from './AtRiskStudentCard'
|
||||
import { AttendeeHistoryModal } from './AttendeeHistoryModal'
|
||||
import { StatusActionModal } from './StatusActionModal'
|
||||
|
||||
export function AnalyticsView() {
|
||||
const { groupId } = useParams({ strict: false }) as { groupId: string }
|
||||
const queryClient = useQueryClient()
|
||||
const toast = useToast()
|
||||
|
||||
const [historyStudent, setHistoryStudent] = useState<StudentAtRiskDto | null>(null)
|
||||
const [statusAction, setStatusAction] = useState<StatusAction>(null)
|
||||
const [menuOpenFor, setMenuOpenFor] = useState<string | null>(null)
|
||||
|
||||
const analyticsQuery = useQuery({
|
||||
queryKey: ['group-analytics', groupId],
|
||||
queryFn: () => getGroupAnalytics(groupId),
|
||||
enabled: Boolean(groupId),
|
||||
})
|
||||
|
||||
const riskQuery = useQuery({
|
||||
queryKey: ['students-at-risk', groupId],
|
||||
queryFn: () => getStudentsAtRisk(groupId),
|
||||
enabled: Boolean(groupId),
|
||||
})
|
||||
|
||||
const historyQuery = useQuery({
|
||||
queryKey: ['attendee-history', groupId, historyStudent?.attendeeId],
|
||||
queryFn: () => getAttendeeHistory(groupId, historyStudent!.attendeeId!),
|
||||
enabled: Boolean(groupId && historyStudent),
|
||||
})
|
||||
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: (action: Exclude<StatusAction, null>) =>
|
||||
updateAttendeeStatus(action.student.attendeeId, action.status),
|
||||
onSuccess: (_result, action) => {
|
||||
const name = action.student.fullName.split(' ')[0]
|
||||
toast.success(
|
||||
action.status === 'DROPPED'
|
||||
? `${name} fue dado de baja del grupo. Su vacante quedó disponible.`
|
||||
: `La vacante de ${name} quedó pausada.`,
|
||||
)
|
||||
setStatusAction(null)
|
||||
void queryClient.invalidateQueries({ queryKey: ['students-at-risk', groupId] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['group-attendees', groupId] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['group', groupId] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['classes-today'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['home-summary'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['groups-risk-overview'] })
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(err.message || 'No pudimos actualizar el estado del alumno.')
|
||||
},
|
||||
})
|
||||
|
||||
const handleReengage = async (student: StudentAtRiskDto, groupName: string) => {
|
||||
if (!student.phone) return
|
||||
const firstName = student.fullName.split(' ')[0] ?? student.fullName
|
||||
const message =
|
||||
`¡Hola ${firstName}! 👋 Te extrañamos en ${groupName}. ` +
|
||||
'Notamos que no asististe a las últimas clases y queremos que vuelvas. ' +
|
||||
'Si tenés algún problema con tus horarios o tu membresía, escribinos y lo resolvemos juntos.'
|
||||
const waUrl = `https://wa.me/${student.phone.replace(/\D/g, '')}?text=${encodeURIComponent(message)}`
|
||||
try {
|
||||
await navigator.clipboard.writeText(message)
|
||||
toast.success('¡Abriendo WhatsApp! Mensaje copiado al portapapeles.')
|
||||
} catch {
|
||||
// Si clipboard falla, igual abrimos WhatsApp con el mensaje.
|
||||
}
|
||||
window.open(waUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
const groupName = riskQuery.data?.groupName
|
||||
|
||||
if (analyticsQuery.isPending || riskQuery.isPending) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="size-8 animate-spin text-accent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (analyticsQuery.isError || riskQuery.isError || !analyticsQuery.data) {
|
||||
return (
|
||||
<div className="rounded-xl border border-danger/20 bg-danger-soft p-6 text-danger">
|
||||
<h2 className="text-lg font-semibold">No pudimos cargar las estadísticas</h2>
|
||||
<p className="mt-2 text-sm">Verificá tu conexión e intentá de nuevo.</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={() => {
|
||||
void analyticsQuery.refetch()
|
||||
void riskQuery.refetch()
|
||||
}}
|
||||
>
|
||||
Reintentar
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const analytics = analyticsQuery.data
|
||||
const atRisk = riskQuery.data?.data ?? []
|
||||
|
||||
return (
|
||||
<section className="mx-auto w-full max-w-md lg:max-w-6xl">
|
||||
<Link
|
||||
to="/groups/$groupId"
|
||||
params={{ groupId }}
|
||||
className="mb-3 hidden items-center gap-1 text-sm text-foreground/60 transition-colors hover:text-primary lg:inline-flex"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Volver a {groupName ?? 'el grupo'}
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-primary">Estadísticas</h1>
|
||||
<p className="mt-2 text-sm text-foreground/60">
|
||||
Asistencia y gestión de riesgo de {groupName ?? 'tu grupo'}.
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-xl bg-primary-soft px-3 py-1.5 text-xs font-semibold text-foreground/70">
|
||||
Últimos 30 días
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<KpiCard
|
||||
icon={<Percent className="size-5" />}
|
||||
label="Asistencia promedio"
|
||||
value={`${analytics.attendanceRate}%`}
|
||||
hint="últimos 30 días"
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<CalendarCheck className="size-5" />}
|
||||
label="Asistencias del mes"
|
||||
value={analytics.totalPresent.toLocaleString('es-MX')}
|
||||
hint={`${analytics.totalClasses} clases dictadas`}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<DoorOpen className="size-5" />}
|
||||
label="Cupos recuperados"
|
||||
value={analytics.recoveredSlots.toLocaleString('es-MX')}
|
||||
hint="por ausencias avisadas"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="size-5 text-accent" />
|
||||
<h2 className="text-lg font-semibold text-primary">Alumnos en riesgo</h2>
|
||||
{atRisk.length > 0 ? (
|
||||
<Badge variant={atRisk.some((s) => s.riskLevel === 'HIGH') ? 'danger' : 'warning'}>
|
||||
{atRisk.length} {atRisk.length === 1 ? 'alumno' : 'alumnos'}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-foreground/60">
|
||||
Alumnos con ausencias consecutivas o baja asistencia mensual.
|
||||
</p>
|
||||
|
||||
{atRisk.length === 0 ? (
|
||||
<div className="mt-4 rounded-xl border border-dashed border-border bg-surface px-4 py-10 text-center">
|
||||
<p className="font-medium text-primary">¡Sin alumnos en riesgo!</p>
|
||||
<p className="mt-1 text-sm text-foreground/60">
|
||||
No hay ausencias consecutivas ni asistencia por debajo del 50%.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="mt-4 space-y-3">
|
||||
{atRisk.map((student) => (
|
||||
<AtRiskStudentCard
|
||||
key={student.attendeeId}
|
||||
student={student}
|
||||
menuOpen={menuOpenFor === student.attendeeId}
|
||||
onToggleMenu={() =>
|
||||
setMenuOpenFor(menuOpenFor === student.attendeeId ? null : student.attendeeId)
|
||||
}
|
||||
onViewHistory={() => setHistoryStudent(student)}
|
||||
onReengage={() => void handleReengage(student, groupName ?? 'tu clase')}
|
||||
onRequestStatusAction={(action) => {
|
||||
setStatusAction(action)
|
||||
setMenuOpenFor(null)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AttendeeHistoryModal
|
||||
student={historyStudent}
|
||||
sessions={historyQuery.data?.sessions}
|
||||
attendanceRate={historyQuery.data?.attendanceRate}
|
||||
isPending={historyQuery.isPending}
|
||||
isError={historyQuery.isError}
|
||||
onClose={() => setHistoryStudent(null)}
|
||||
/>
|
||||
|
||||
<StatusActionModal
|
||||
action={statusAction}
|
||||
isPending={statusMutation.isPending}
|
||||
groupName={groupName}
|
||||
onCancel={() => setStatusAction(null)}
|
||||
onConfirm={() => {
|
||||
if (statusAction) statusMutation.mutate(statusAction)
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
100
apps/web/src/features/groups/analytics/AtRiskStudentCard.tsx
Normal file
100
apps/web/src/features/groups/analytics/AtRiskStudentCard.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { StudentAtRiskDto } from '@gruperly/shared'
|
||||
import { MessageCircle, MoreVertical, Pause, UserMinus } from 'lucide-react'
|
||||
import { Avatar, Badge, Button } from '../../../components/ui'
|
||||
import { cn } from '../../../lib/utils'
|
||||
|
||||
export type StatusAction = { student: StudentAtRiskDto; status: 'PAUSED' | 'DROPPED' } | null
|
||||
|
||||
export function AtRiskStudentCard({
|
||||
student,
|
||||
menuOpen,
|
||||
onToggleMenu,
|
||||
onViewHistory,
|
||||
onReengage,
|
||||
onRequestStatusAction,
|
||||
}: {
|
||||
student: StudentAtRiskDto
|
||||
menuOpen: boolean
|
||||
onToggleMenu: () => void
|
||||
onViewHistory: () => void
|
||||
onReengage: () => void
|
||||
onRequestStatusAction: (action: Exclude<StatusAction, null>) => void
|
||||
}) {
|
||||
return (
|
||||
<li className="rounded-xl border border-border bg-surface p-4 transition-colors hover:border-accent/40">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onViewHistory}
|
||||
className="flex min-w-0 flex-1 items-center gap-3 text-left"
|
||||
>
|
||||
<Avatar name={student.fullName} />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-primary">{student.fullName}</p>
|
||||
<p className="mt-0.5 text-xs text-foreground/50">
|
||||
{student.phone ?? 'Sin teléfono'} · {student.monthlyAttendanceRate}% de asistencia
|
||||
mensual
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{student.riskLevel === 'HIGH' ? (
|
||||
<Badge variant="danger">{student.consecutiveAbsences} faltas seguidas</Badge>
|
||||
) : (
|
||||
<Badge variant="warning">Baja asistencia</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2 border-t border-border pt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!student.phone}
|
||||
onClick={onReengage}
|
||||
className="flex-1 gap-2 border-0 bg-[#25D366] text-white hover:bg-[#1EBE5D] disabled:bg-foreground/10 disabled:text-foreground/40"
|
||||
>
|
||||
<MessageCircle className="size-4" />
|
||||
Reenganchar por WhatsApp
|
||||
</Button>
|
||||
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Gestión de la vacante"
|
||||
onClick={onToggleMenu}
|
||||
>
|
||||
<MoreVertical className="size-4" />
|
||||
</Button>
|
||||
|
||||
{menuOpen ? (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-20"
|
||||
onClick={onToggleMenu}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="absolute right-0 z-30 mt-1 w-44 animate-fade-in rounded-xl border border-border bg-surface p-1.5 shadow-xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRequestStatusAction({ student, status: 'PAUSED' })}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-primary transition-colors hover:bg-primary-soft"
|
||||
>
|
||||
<Pause className="size-4" />
|
||||
Pausar vacante
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRequestStatusAction({ student, status: 'DROPPED' })}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-danger transition-colors hover:bg-danger-soft"
|
||||
>
|
||||
<UserMinus className="size-4" />
|
||||
Dar de baja
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { StudentAtRiskDto } from '@gruperly/shared'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { Modal } from '../../../components/ui'
|
||||
import { cn } from '../../../lib/utils'
|
||||
import { dotClass, formatSessionDate, sessionStatusTextClass, statusLabel } from './format'
|
||||
|
||||
export function AttendeeHistoryModal({
|
||||
student,
|
||||
sessions,
|
||||
attendanceRate,
|
||||
isPending,
|
||||
isError,
|
||||
onClose,
|
||||
}: {
|
||||
student: StudentAtRiskDto | null
|
||||
sessions:
|
||||
| {
|
||||
classSessionId: string
|
||||
startsAt: string
|
||||
status: string | null
|
||||
}[]
|
||||
| undefined
|
||||
attendanceRate: number | undefined
|
||||
isPending: boolean
|
||||
isError: boolean
|
||||
onClose: () => void
|
||||
}) {
|
||||
return (
|
||||
<Modal
|
||||
isOpen={student !== null}
|
||||
onClose={onClose}
|
||||
title={student?.fullName ?? 'Historial'}
|
||||
description={student ? 'Historial de presentismo de las últimas clases.' : undefined}
|
||||
maxWidth="md"
|
||||
>
|
||||
{student ? (
|
||||
<div>
|
||||
{isPending ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 className="size-6 animate-spin text-accent" />
|
||||
</div>
|
||||
) : isError || !sessions ? (
|
||||
<div className="rounded-xl bg-danger-soft px-4 py-3 text-sm text-danger">
|
||||
No pudimos cargar el historial de este alumno.
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 rounded-xl border border-border bg-primary-soft/50 p-3">
|
||||
<span className="text-2xl font-bold text-primary">{attendanceRate}%</span>
|
||||
<span className="text-xs font-medium uppercase text-foreground/60">
|
||||
Presentismo general ({sessions.length} clases)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul className="mt-4 space-y-2">
|
||||
{sessions.map((session) => (
|
||||
<li
|
||||
key={session.classSessionId}
|
||||
className="flex items-center gap-3 rounded-xl border border-border bg-surface px-4 py-3"
|
||||
>
|
||||
<span
|
||||
className={cn('size-2.5 shrink-0 rounded-full', dotClass(session.status))}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 text-sm font-medium text-primary">
|
||||
{formatSessionDate(session.startsAt)}
|
||||
</span>
|
||||
<span className={cn('text-xs font-semibold', sessionStatusTextClass(session.status))}>
|
||||
{statusLabel(session.status)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
26
apps/web/src/features/groups/analytics/KpiCard.tsx
Normal file
26
apps/web/src/features/groups/analytics/KpiCard.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export function KpiCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
}: {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
value: string
|
||||
hint?: string
|
||||
}) {
|
||||
return (
|
||||
<article className="flex items-center gap-3 rounded-xl border border-border bg-surface p-4">
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-accent-soft text-accent">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium uppercase text-foreground/50">{label}</p>
|
||||
<p className="truncate text-2xl font-bold text-primary">{value}</p>
|
||||
{hint ? <p className="text-xs text-foreground/50">{hint}</p> : null}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
72
apps/web/src/features/groups/analytics/StatusActionModal.tsx
Normal file
72
apps/web/src/features/groups/analytics/StatusActionModal.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Loader2, Pause, UserMinus } from 'lucide-react'
|
||||
import { Button, Modal } from '../../../components/ui'
|
||||
import { cn } from '../../../lib/utils'
|
||||
import type { StatusAction } from './AtRiskStudentCard'
|
||||
|
||||
export function StatusActionModal({
|
||||
action,
|
||||
isPending,
|
||||
groupName,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
action: StatusAction
|
||||
isPending: boolean
|
||||
groupName: string | undefined
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
}) {
|
||||
return (
|
||||
<Modal
|
||||
isOpen={action !== null}
|
||||
onClose={onCancel}
|
||||
title={action?.status === 'DROPPED' ? 'Dar de baja' : 'Pausar vacante'}
|
||||
maxWidth="md"
|
||||
>
|
||||
{action ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-foreground/70">
|
||||
{action.student.fullName}{' '}
|
||||
{action.status === 'DROPPED' ? (
|
||||
<>
|
||||
dejará de asistir al grupo y <strong className="text-primary">{groupName}</strong>. Su
|
||||
vacante quedará <strong className="text-primary">disponible</strong> para otro alumno, y
|
||||
su historial de asistencia se conservará.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
será <strong className="text-primary">pausado</strong> en{' '}
|
||||
<strong className="text-primary">{groupName}</strong>. Su vacante quedará{' '}
|
||||
<strong className="text-primary">libre</strong> y podés reactivarla en cualquier
|
||||
momento.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2.5 sm:flex-row sm:justify-end">
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className={cn(
|
||||
action.status === 'DROPPED' &&
|
||||
'border border-danger/20 bg-danger text-white hover:bg-danger/90',
|
||||
)}
|
||||
disabled={isPending}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : action.status === 'DROPPED' ? (
|
||||
<UserMinus className="size-4" />
|
||||
) : (
|
||||
<Pause className="size-4" />
|
||||
)}
|
||||
<span>{action.status === 'DROPPED' ? 'Dar de baja' : 'Pausar vacante'}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
46
apps/web/src/features/groups/analytics/format.ts
Normal file
46
apps/web/src/features/groups/analytics/format.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export function statusLabel(status: string | null): string {
|
||||
switch (status) {
|
||||
case 'PRESENT':
|
||||
return 'Presente'
|
||||
case 'ABSENT':
|
||||
return 'Ausente'
|
||||
case 'EXCUSED':
|
||||
return 'Avisó ausencia'
|
||||
default:
|
||||
return 'Sin registrar'
|
||||
}
|
||||
}
|
||||
|
||||
export function dotClass(status: string | null): string {
|
||||
switch (status) {
|
||||
case 'PRESENT':
|
||||
return 'bg-success'
|
||||
case 'ABSENT':
|
||||
return 'bg-danger'
|
||||
case 'EXCUSED':
|
||||
return 'bg-warning'
|
||||
default:
|
||||
return 'bg-border'
|
||||
}
|
||||
}
|
||||
|
||||
export function sessionStatusTextClass(status: string | null): string {
|
||||
switch (status) {
|
||||
case 'PRESENT':
|
||||
return 'text-success'
|
||||
case 'ABSENT':
|
||||
return 'text-danger'
|
||||
case 'EXCUSED':
|
||||
return 'text-warning'
|
||||
default:
|
||||
return 'text-foreground/50'
|
||||
}
|
||||
}
|
||||
|
||||
export function formatSessionDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('es-MX', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})
|
||||
}
|
||||
6
apps/web/src/features/groups/constants.ts
Normal file
6
apps/web/src/features/groups/constants.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { GroupRiskLevel } from '@gruperly/shared'
|
||||
|
||||
export const RISK_LABELS: Record<Exclude<GroupRiskLevel, 'NONE'>, string> = {
|
||||
HIGH: 'Riesgo alto',
|
||||
MEDIUM: 'Riesgo moderado',
|
||||
}
|
||||
46
apps/web/src/features/groups/detail/AddAttendeeModal.tsx
Normal file
46
apps/web/src/features/groups/detail/AddAttendeeModal.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Modal } from '../../../components/ui'
|
||||
import { cn } from '../../../lib/utils'
|
||||
import { BulkImportPanel } from './BulkImportPanel'
|
||||
import { QuickAddForm } from './QuickAddForm'
|
||||
import { useGroupDetail } from './GroupDetailProvider'
|
||||
|
||||
export function AddAttendeeModal() {
|
||||
const { isAddAttendeeModalOpen, closeAddAttendeeModal, activeTab, setActiveTab } = useGroupDetail()
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isAddAttendeeModalOpen}
|
||||
onClose={closeAddAttendeeModal}
|
||||
title="Agregar Miembros al Grupo"
|
||||
description="Agrega miembros rápidamente completando sus datos o importa una lista de contactos."
|
||||
maxWidth="lg"
|
||||
>
|
||||
<div>
|
||||
<div className="mb-5 flex items-center rounded-xl bg-primary-soft p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('quick')}
|
||||
className={cn(
|
||||
'flex-1 rounded-lg py-2 text-xs font-semibold transition-all',
|
||||
activeTab === 'quick' ? 'bg-surface text-primary shadow-xs' : 'text-foreground/60 hover:text-primary',
|
||||
)}
|
||||
>
|
||||
Carga Rápida (Individual)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('bulk')}
|
||||
className={cn(
|
||||
'flex-1 rounded-lg py-2 text-xs font-semibold transition-all',
|
||||
activeTab === 'bulk' ? 'bg-surface text-primary shadow-xs' : 'text-foreground/60 hover:text-primary',
|
||||
)}
|
||||
>
|
||||
Carga Masiva (CSV / Excel)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'quick' ? <QuickAddForm /> : <BulkImportPanel />}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
150
apps/web/src/features/groups/detail/AttendeeDetailModal.tsx
Normal file
150
apps/web/src/features/groups/detail/AttendeeDetailModal.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { Copy, Mail, MessageCircle, Phone, ShieldCheck, StickyNote, UserMinus } from 'lucide-react'
|
||||
import { Button, Modal } from '../../../components/ui'
|
||||
import { DetailRow } from './DetailRow'
|
||||
import { useGroupDetail } from './GroupDetailProvider'
|
||||
|
||||
export function AttendeeDetailModal() {
|
||||
const { selectedAttendee, setSelectedAttendee, requestRemoveAttendee, copyAbsenceNotifyUrl } =
|
||||
useGroupDetail()
|
||||
const absenceNotifyUrl = selectedAttendee?.notifyToken
|
||||
? `${window.location.origin}/avisar-ausencia/${selectedAttendee.notifyToken}`
|
||||
: null
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={selectedAttendee !== null}
|
||||
onClose={() => setSelectedAttendee(null)}
|
||||
title="Detalle del Participante"
|
||||
maxWidth="sm"
|
||||
>
|
||||
{selectedAttendee ? (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-accent/10 text-lg font-bold text-accent">
|
||||
{selectedAttendee.fullName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-lg font-semibold text-primary">{selectedAttendee.fullName}</p>
|
||||
<p className="text-xs text-foreground/50">
|
||||
Incorporado el{' '}
|
||||
{new Date(selectedAttendee.createdAt).toLocaleDateString('es-ES', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 divide-y divide-border rounded-xl border border-border bg-primary-soft/30">
|
||||
<DetailRow icon={<Phone className="size-4 text-accent" />} label="Teléfono">
|
||||
{selectedAttendee.phone ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-primary">{selectedAttendee.phone}</span>
|
||||
<a
|
||||
href={`https://wa.me/${selectedAttendee.phone.replace(/\D/g, '')}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-success transition-colors hover:text-success/80"
|
||||
>
|
||||
<MessageCircle className="size-3.5" />
|
||||
<span>WhatsApp</span>
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-foreground/40">Sin teléfono</span>
|
||||
)}
|
||||
</DetailRow>
|
||||
|
||||
<DetailRow icon={<Mail className="size-4 text-accent" />} label="Email">
|
||||
{selectedAttendee.email ? (
|
||||
<a
|
||||
href={`mailto:${selectedAttendee.email}`}
|
||||
className="text-primary transition-colors hover:text-accent"
|
||||
>
|
||||
{selectedAttendee.email}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-foreground/40">Sin email</span>
|
||||
)}
|
||||
</DetailRow>
|
||||
|
||||
{selectedAttendee.guardianName || selectedAttendee.guardianPhone ? (
|
||||
<>
|
||||
<DetailRow icon={<ShieldCheck className="size-4 text-accent" />} label="Responsable">
|
||||
<span className="text-primary">
|
||||
{selectedAttendee.guardianName || <span className="text-foreground/40">—</span>}
|
||||
</span>
|
||||
</DetailRow>
|
||||
{selectedAttendee.guardianPhone ? (
|
||||
<DetailRow icon={<Phone className="size-4 text-accent" />} label="Tel. Responsable">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-primary">{selectedAttendee.guardianPhone}</span>
|
||||
<a
|
||||
href={`https://wa.me/${selectedAttendee.guardianPhone.replace(/\D/g, '')}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-success transition-colors hover:text-success/80"
|
||||
>
|
||||
<MessageCircle className="size-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
</DetailRow>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<DetailRow icon={<StickyNote className="size-4 text-accent" />} label="Notas">
|
||||
{selectedAttendee.notes ? (
|
||||
<span className="text-xs leading-relaxed text-primary">{selectedAttendee.notes}</span>
|
||||
) : (
|
||||
<span className="text-foreground/40">Sin notas</span>
|
||||
)}
|
||||
</DetailRow>
|
||||
</div>
|
||||
|
||||
{absenceNotifyUrl ? (
|
||||
<div className="space-y-2 border-t border-border pt-4">
|
||||
<p className="text-xs font-medium text-foreground/60">Link para avisar ausencias</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="min-w-0 flex-1 truncate rounded-lg bg-primary-soft px-2 py-1.5 text-xs text-primary">
|
||||
{absenceNotifyUrl}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => copyAbsenceNotifyUrl(absenceNotifyUrl)}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
Copiar
|
||||
</Button>
|
||||
</div>
|
||||
{selectedAttendee.phone ? (
|
||||
<a
|
||||
href={`https://wa.me/${selectedAttendee.phone.replace(/\D/g, '')}?text=${encodeURIComponent(`Hola ${selectedAttendee.fullName.split(' ')[0]}, desde este link podés avisar tus ausencias en ${absenceNotifyUrl}`)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-success transition-colors hover:text-success/80"
|
||||
>
|
||||
<MessageCircle className="size-3.5" />
|
||||
<span>Enviar por WhatsApp</span>
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="border-t border-border pt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => requestRemoveAttendee(selectedAttendee)}
|
||||
className="w-full gap-2 border border-danger/20 text-danger hover:bg-danger/10 hover:text-danger"
|
||||
>
|
||||
<UserMinus className="size-4" />
|
||||
Quitar del grupo
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
41
apps/web/src/features/groups/detail/BulkCapacityModal.tsx
Normal file
41
apps/web/src/features/groups/detail/BulkCapacityModal.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Check } from 'lucide-react'
|
||||
import { Button, Modal } from '../../../components/ui'
|
||||
import { useGroupDetail } from './GroupDetailProvider'
|
||||
|
||||
export function BulkCapacityModal() {
|
||||
const {
|
||||
isBulkCapacityModalOpen,
|
||||
closeBulkCapacityModal,
|
||||
confirmBulkImportOverCapacity,
|
||||
validRowsCount,
|
||||
attendeesTotal,
|
||||
group,
|
||||
} = useGroupDetail()
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isBulkCapacityModalOpen}
|
||||
onClose={closeBulkCapacityModal}
|
||||
title="Aviso de cupo"
|
||||
description="La carga supera el cupo del grupo."
|
||||
maxWidth="md"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-foreground/70">
|
||||
Estás por importar <strong className="text-primary">{validRowsCount}</strong> miembro(s), pero el
|
||||
grupo ya tiene <strong className="text-primary">{attendeesTotal}</strong> inscrito(s) y su cupo es
|
||||
de <strong className="text-primary">{group?.capacity}</strong>. ¿Deseas importarlos de todos modos?
|
||||
</p>
|
||||
<div className="flex items-center justify-end gap-2.5 pt-2">
|
||||
<Button variant="ghost" onClick={closeBulkCapacityModal}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button variant="primary" onClick={confirmBulkImportOverCapacity} className="gap-2">
|
||||
<Check className="size-4" />
|
||||
Importar de todos modos
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
228
apps/web/src/features/groups/detail/BulkImportPanel.tsx
Normal file
228
apps/web/src/features/groups/detail/BulkImportPanel.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import { useRef } from 'react'
|
||||
import { Check, Download, FileSpreadsheet, Loader2, Trash2, UploadCloud } from 'lucide-react'
|
||||
import { Badge, Button, useToast } from '../../../components/ui'
|
||||
import {
|
||||
downloadAttendeeTemplateCsv,
|
||||
normalizeRowsToContacts,
|
||||
parseCsvContent,
|
||||
parseXlsxContent,
|
||||
} from '../../../lib/file-parser'
|
||||
import { cn } from '../../../lib/utils'
|
||||
import { useGroupDetail } from './GroupDetailProvider'
|
||||
|
||||
export function BulkImportPanel() {
|
||||
const {
|
||||
parsedContacts,
|
||||
setParsedContacts,
|
||||
removeParsedContact,
|
||||
fileName,
|
||||
setFileName,
|
||||
isParsingFile,
|
||||
setIsParsingFile,
|
||||
isDragging,
|
||||
setIsDragging,
|
||||
validRowsCount,
|
||||
invalidRowsCount,
|
||||
isSubmittingBulkImport,
|
||||
closeAddAttendeeModal,
|
||||
requestBulkImport,
|
||||
} = useGroupDetail()
|
||||
const toast = useToast()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const processFile = async (file: File) => {
|
||||
const fileExtension = file.name.split('.').pop()?.toLowerCase()
|
||||
if (!['csv', 'xlsx', 'xls', 'txt'].includes(fileExtension ?? '')) {
|
||||
toast.error('Formato no compatible. Sube un archivo .csv o .xlsx')
|
||||
return
|
||||
}
|
||||
|
||||
setIsParsingFile(true)
|
||||
setFileName(file.name)
|
||||
try {
|
||||
const rawRows =
|
||||
fileExtension === 'xlsx' || fileExtension === 'xls'
|
||||
? await parseXlsxContent(await file.arrayBuffer())
|
||||
: parseCsvContent(await file.text())
|
||||
|
||||
const contacts = normalizeRowsToContacts(rawRows)
|
||||
if (contacts.length === 0) {
|
||||
toast.error('No se detectaron contactos en el archivo.')
|
||||
} else {
|
||||
setParsedContacts(contacts)
|
||||
const validCount = contacts.filter((c) => c.isValid).length
|
||||
toast.info(`Se detectaron ${contacts.length} filas (${validCount} válidas).`)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Error al procesar el archivo.'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setIsParsingFile(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) {
|
||||
void processFile(file)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
void processFile(file)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
}}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={handleFileDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={cn(
|
||||
'relative flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed p-6 transition-all',
|
||||
isDragging
|
||||
? 'border-accent bg-accent/5'
|
||||
: 'border-border bg-primary-soft/30 hover:border-accent/60',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.xls,.txt"
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<UploadCloud className="mb-2 size-10 text-accent" />
|
||||
<p className="text-sm font-semibold text-primary">
|
||||
Arrastra tu archivo aquí o haz clic para seleccionarlo
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-foreground/60">Formatos soportados: CSV (.csv) o Excel (.xlsx)</p>
|
||||
|
||||
{fileName ? (
|
||||
<div className="mt-3 flex items-center gap-2 rounded-lg border border-border bg-surface px-3 py-1.5 text-xs font-medium text-primary">
|
||||
<FileSpreadsheet className="size-4 text-accent" />
|
||||
<span>{fileName}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between px-1 text-xs">
|
||||
<span className="text-foreground/60">¿No tienes el archivo listo? Usa nuestra plantilla.</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={downloadAttendeeTemplateCsv}
|
||||
className="inline-flex items-center gap-1 font-medium text-accent transition-colors hover:text-accent-strong"
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
<span>Descargar plantilla CSV</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isParsingFile ? (
|
||||
<div className="flex items-center justify-center gap-2 py-6 text-sm text-foreground/60">
|
||||
<Loader2 className="size-4 animate-spin text-accent" />
|
||||
<span>Leyendo y analizando archivo...</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{parsedContacts.length > 0 ? (
|
||||
<div className="space-y-3 pt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="font-semibold text-primary">Vista Previa:</span>
|
||||
<Badge variant="success">{validRowsCount} listos para importar</Badge>
|
||||
{invalidRowsCount > 0 ? <Badge variant="danger">{invalidRowsCount} con errores</Badge> : null}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setParsedContacts([])
|
||||
setFileName(null)
|
||||
}}
|
||||
className="h-7 px-2 text-xs text-foreground/50 hover:text-danger"
|
||||
>
|
||||
Limpiar lista
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-60 overflow-y-auto rounded-xl border border-border">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="sticky top-0 border-b border-border bg-surface font-semibold text-foreground/70">
|
||||
<tr>
|
||||
<th className="px-3 py-2">Nombre</th>
|
||||
<th className="px-3 py-2">Teléfono</th>
|
||||
<th className="px-3 py-2">Email</th>
|
||||
<th className="px-3 py-2">Estado</th>
|
||||
<th className="px-2 py-2 text-right" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{parsedContacts.map((contact) => (
|
||||
<tr
|
||||
key={contact.id}
|
||||
className={!contact.isValid ? 'bg-danger-soft/30' : 'hover:bg-primary-soft/30'}
|
||||
>
|
||||
<td className="px-3 py-2 font-medium text-primary">
|
||||
{contact.fullName || <span className="italic text-danger">Sin nombre</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-foreground/80">
|
||||
{contact.phone || <span className="italic text-danger">Sin teléfono</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-foreground/60">{contact.email || '—'}</td>
|
||||
<td className="px-3 py-2">
|
||||
{contact.isValid ? (
|
||||
<Badge variant="success" className="py-0 text-[10px]">
|
||||
Válido
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="danger" className="py-0 text-[10px]" title={contact.error}>
|
||||
{contact.error}
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeParsedContact(contact.id)}
|
||||
className="rounded p-1 text-foreground/40 transition-colors hover:text-danger"
|
||||
title="Eliminar fila"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2.5 border-t border-border pt-3">
|
||||
<Button variant="ghost" onClick={closeAddAttendeeModal}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={requestBulkImport}
|
||||
disabled={isSubmittingBulkImport || validRowsCount === 0}
|
||||
className="gap-2"
|
||||
>
|
||||
{isSubmittingBulkImport ? <Loader2 className="size-4 animate-spin" /> : <Check className="size-4" />}
|
||||
<span>Confirmar e Importar ({validRowsCount})</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
61
apps/web/src/features/groups/detail/CapacityModal.tsx
Normal file
61
apps/web/src/features/groups/detail/CapacityModal.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Clock, Loader2, UserPlus } from 'lucide-react'
|
||||
import { Button, Modal } from '../../../components/ui'
|
||||
import { useGroupDetail } from './GroupDetailProvider'
|
||||
|
||||
export function CapacityModal() {
|
||||
const {
|
||||
isCapacityModalOpen,
|
||||
closeCapacityModal,
|
||||
capacityPayload,
|
||||
group,
|
||||
submitForcedAttendee,
|
||||
submitWaitlistEntry,
|
||||
isSubmittingForcedAttendee,
|
||||
isSubmittingWaitlistEntry,
|
||||
} = useGroupDetail()
|
||||
const isBusy = isSubmittingForcedAttendee || isSubmittingWaitlistEntry
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isCapacityModalOpen}
|
||||
onClose={closeCapacityModal}
|
||||
title="Cupo alcanzado"
|
||||
description="El grupo llegó a su cupo máximo de miembros."
|
||||
maxWidth="md"
|
||||
>
|
||||
{capacityPayload ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-foreground/70">
|
||||
El grupo <strong className="text-primary">{group?.name}</strong> ya alcanzó su cupo de{' '}
|
||||
<strong className="text-primary">{group?.capacity}</strong> miembros. ¿Qué deseas hacer con{' '}
|
||||
<strong className="text-primary">
|
||||
{`${capacityPayload.firstName.trim()} ${(capacityPayload.lastName ?? '').trim()}`.trim()}
|
||||
</strong>
|
||||
?
|
||||
</p>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<Button variant="primary" onClick={submitForcedAttendee} disabled={isBusy} className="gap-2">
|
||||
{isSubmittingForcedAttendee ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<UserPlus className="size-4" />
|
||||
)}
|
||||
<span>Agregar de todos modos</span>
|
||||
</Button>
|
||||
<Button variant="outline" onClick={submitWaitlistEntry} disabled={isBusy} className="gap-2">
|
||||
{isSubmittingWaitlistEntry ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Clock className="size-4" />
|
||||
)}
|
||||
<span>Sumar a la lista de espera</span>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-center text-[11px] text-foreground/50">
|
||||
Si eliges agregarlo de todos modos, el grupo quedará por encima de su cupo.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
15
apps/web/src/features/groups/detail/DetailRow.tsx
Normal file
15
apps/web/src/features/groups/detail/DetailRow.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export function DetailRow({ icon, label, children }: { icon: ReactNode; label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 px-4 py-3">
|
||||
<div className="mt-0.5 shrink-0">{icon}</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="mb-0.5 text-[11px] font-medium uppercase tracking-wide text-foreground/50">
|
||||
{label}
|
||||
</p>
|
||||
<div className="text-sm">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
537
apps/web/src/features/groups/detail/GroupDetailProvider.tsx
Normal file
537
apps/web/src/features/groups/detail/GroupDetailProvider.tsx
Normal file
@@ -0,0 +1,537 @@
|
||||
import { createContext, useContext, useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import type {
|
||||
AttendeeDto,
|
||||
CreateAttendee,
|
||||
CreateAttendeeResult,
|
||||
CreateGroupWaitlistEntry,
|
||||
GroupDto,
|
||||
GroupWaitlistEntryDto,
|
||||
GroupWaitlistList,
|
||||
GroupRiskLevel,
|
||||
} from '@gruperly/shared'
|
||||
import { useToast } from '../../../components/ui'
|
||||
import type { ParsedContactRow } from '../../../lib/file-parser'
|
||||
import {
|
||||
addToGroupWaitlist,
|
||||
ApiError,
|
||||
bulkCreateAttendees,
|
||||
createAttendee,
|
||||
getGroup,
|
||||
getGroupAttendees,
|
||||
getGroupsRiskOverview,
|
||||
getGroupWaitlist,
|
||||
getInviteToken,
|
||||
promoteGroupWaitlistEntry,
|
||||
removeGroupAttendee,
|
||||
removeGroupWaitlistEntry,
|
||||
} from '../../../lib/api'
|
||||
|
||||
export type BulkAttendeeRow = {
|
||||
firstName: string
|
||||
lastName: string
|
||||
fullName: string
|
||||
phone: string
|
||||
email?: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export type ListSection = 'members' | 'waitlist'
|
||||
export type AddAttendeeTab = 'quick' | 'bulk'
|
||||
|
||||
type GroupDetailContextValue = {
|
||||
groupId: string
|
||||
group: GroupDto | undefined
|
||||
groupQueryPending: boolean
|
||||
riskLevel: GroupRiskLevel
|
||||
attendees: AttendeeDto[]
|
||||
attendeesTotal: number
|
||||
attendeesPending: boolean
|
||||
waitlistEntries: GroupWaitlistEntryDto[]
|
||||
waitlistTotal: number
|
||||
waitlistPending: boolean
|
||||
firstWaitlistEntry: GroupWaitlistEntryDto | null
|
||||
hasFreeCapacity: boolean
|
||||
inviteUrl: string | undefined
|
||||
invitePending: boolean
|
||||
whatsappMessage: string
|
||||
searchFilter: string
|
||||
setSearchFilter: (value: string) => void
|
||||
listSection: ListSection
|
||||
setListSection: (value: ListSection) => void
|
||||
activeTab: AddAttendeeTab
|
||||
setActiveTab: (value: AddAttendeeTab) => void
|
||||
isInviteModalOpen: boolean
|
||||
openInviteModal: () => void
|
||||
closeInviteModal: () => void
|
||||
isAddAttendeeModalOpen: boolean
|
||||
openAddAttendeeModal: () => void
|
||||
closeAddAttendeeModal: () => void
|
||||
selectedAttendee: AttendeeDto | null
|
||||
setSelectedAttendee: (attendee: AttendeeDto | null) => void
|
||||
attendeeToRemove: AttendeeDto | null
|
||||
requestRemoveAttendee: (attendee: AttendeeDto) => void
|
||||
cancelRemoveAttendee: () => void
|
||||
confirmRemoveAttendee: (promoteFromWaitlist: boolean) => void
|
||||
selectedWaitlistEntry: GroupWaitlistEntryDto | null
|
||||
setSelectedWaitlistEntry: (entry: GroupWaitlistEntryDto | null) => void
|
||||
isCapacityModalOpen: boolean
|
||||
capacityPayload: CreateAttendee | null
|
||||
closeCapacityModal: () => void
|
||||
isBulkCapacityModalOpen: boolean
|
||||
closeBulkCapacityModal: () => void
|
||||
parsedContacts: ParsedContactRow[]
|
||||
setParsedContacts: (contacts: ParsedContactRow[]) => void
|
||||
removeParsedContact: (id: string) => void
|
||||
fileName: string | null
|
||||
setFileName: (name: string | null) => void
|
||||
isParsingFile: boolean
|
||||
setIsParsingFile: (value: boolean) => void
|
||||
isDragging: boolean
|
||||
setIsDragging: (value: boolean) => void
|
||||
validRowsCount: number
|
||||
invalidRowsCount: number
|
||||
isSubmittingAttendee: boolean
|
||||
isSubmittingForcedAttendee: boolean
|
||||
isSubmittingWaitlistEntry: boolean
|
||||
isSubmittingBulkImport: boolean
|
||||
isSubmittingRemoval: boolean
|
||||
isSubmittingPromotion: boolean
|
||||
isSubmittingWaitlistRemoval: boolean
|
||||
isRegeneratingInvite: boolean
|
||||
submitQuickAttendee: (payload: CreateAttendee) => void
|
||||
submitForcedAttendee: () => void
|
||||
submitWaitlistEntry: () => void
|
||||
requestBulkImport: () => void
|
||||
confirmBulkImportOverCapacity: () => void
|
||||
promoteEntry: (entry: GroupWaitlistEntryDto) => void
|
||||
removeEntry: (entry: GroupWaitlistEntryDto) => void
|
||||
regenerateInvite: () => void
|
||||
copyInviteLink: () => Promise<void>
|
||||
copyWhatsappMessage: () => Promise<void>
|
||||
openWhatsapp: () => Promise<void>
|
||||
copyAbsenceNotifyUrl: (url: string) => void
|
||||
}
|
||||
|
||||
const GroupDetailContext = createContext<GroupDetailContextValue | null>(null)
|
||||
|
||||
export function GroupDetailProvider({ groupId, children }: { groupId: string; children: ReactNode }) {
|
||||
const queryClient = useQueryClient()
|
||||
const toast = useToast()
|
||||
|
||||
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false)
|
||||
const [isAddAttendeeModalOpen, setIsAddAttendeeModalOpen] = useState(false)
|
||||
const [selectedAttendee, setSelectedAttendee] = useState<AttendeeDto | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<AddAttendeeTab>('quick')
|
||||
const [listSection, setListSection] = useState<ListSection>('members')
|
||||
const [attendeeToRemove, setAttendeeToRemove] = useState<AttendeeDto | null>(null)
|
||||
const [selectedWaitlistEntry, setSelectedWaitlistEntry] = useState<GroupWaitlistEntryDto | null>(null)
|
||||
const [isCapacityModalOpen, setIsCapacityModalOpen] = useState(false)
|
||||
const [pendingCapacityPayload, setPendingCapacityPayload] = useState<CreateAttendee | null>(null)
|
||||
const [isBulkCapacityModalOpen, setIsBulkCapacityModalOpen] = useState(false)
|
||||
const [searchFilter, setSearchFilter] = useState('')
|
||||
const [parsedContacts, setParsedContacts] = useState<ParsedContactRow[]>([])
|
||||
const [fileName, setFileName] = useState<string | null>(null)
|
||||
const [isParsingFile, setIsParsingFile] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
const groupQuery = useQuery({
|
||||
queryKey: ['group', groupId],
|
||||
queryFn: () => getGroup(groupId),
|
||||
enabled: Boolean(groupId),
|
||||
})
|
||||
|
||||
const riskOverviewQuery = useQuery({
|
||||
queryKey: ['groups-risk-overview'],
|
||||
queryFn: getGroupsRiskOverview,
|
||||
enabled: Boolean(groupId),
|
||||
})
|
||||
|
||||
const inviteTokenQuery = useQuery({
|
||||
queryKey: ['invite-token', groupId],
|
||||
queryFn: () => getInviteToken(groupId),
|
||||
enabled: Boolean(groupId) && isInviteModalOpen,
|
||||
})
|
||||
|
||||
const attendeesQuery = useQuery({
|
||||
queryKey: ['group-attendees', groupId],
|
||||
queryFn: () => getGroupAttendees(groupId, 1, 100),
|
||||
enabled: Boolean(groupId),
|
||||
})
|
||||
|
||||
const waitlistQuery = useQuery({
|
||||
queryKey: ['group-waitlist', groupId],
|
||||
queryFn: () => getGroupWaitlist(groupId, 1, 100),
|
||||
enabled: Boolean(groupId),
|
||||
})
|
||||
|
||||
const group = groupQuery.data
|
||||
const attendees = useMemo(() => attendeesQuery.data?.data ?? [], [attendeesQuery.data])
|
||||
const attendeesTotal = attendeesQuery.data?.pagination?.total ?? attendees.length
|
||||
const waitlistEntries = useMemo(() => waitlistQuery.data?.data ?? [], [waitlistQuery.data])
|
||||
const waitlistTotal = waitlistQuery.data?.pagination?.total ?? waitlistEntries.length
|
||||
const firstWaitlistEntry = waitlistEntries[0] ?? null
|
||||
const hasFreeCapacity = group?.capacity == null || attendeesTotal < group.capacity
|
||||
|
||||
const closeAddAttendeeFlow = () => {
|
||||
setPendingCapacityPayload(null)
|
||||
setIsCapacityModalOpen(false)
|
||||
setIsAddAttendeeModalOpen(false)
|
||||
}
|
||||
|
||||
const invalidateGroupQueries = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['group-attendees', groupId] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['group-waitlist', groupId] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['group', groupId] })
|
||||
}
|
||||
|
||||
const handleCreateSuccess = (result: CreateAttendeeResult) => {
|
||||
if (result.outcome === 'created') {
|
||||
toast.success(`Miembro ${result.attendee.fullName} agregado con éxito.`)
|
||||
} else {
|
||||
toast.info(result.message)
|
||||
}
|
||||
void queryClient.invalidateQueries({ queryKey: ['group-attendees', groupId] })
|
||||
closeAddAttendeeFlow()
|
||||
}
|
||||
|
||||
const regenerateTokenMutation = useMutation({
|
||||
mutationFn: () => getInviteToken(groupId, true),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(['invite-token', groupId], data)
|
||||
toast.success('Se generó un nuevo enlace de invitación.')
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('No se pudo regenerar el enlace de invitación.')
|
||||
},
|
||||
})
|
||||
|
||||
const createAttendeeMutation = useMutation({
|
||||
mutationFn: (payload: CreateAttendee) => createAttendee(groupId, payload),
|
||||
onMutate: (payload) => setPendingCapacityPayload(payload),
|
||||
onSuccess: handleCreateSuccess,
|
||||
onError: (err: Error) => {
|
||||
if (err instanceof ApiError && err.problem?.code === 'group_capacity_reached') {
|
||||
setIsCapacityModalOpen(true)
|
||||
return
|
||||
}
|
||||
toast.error(err.message || 'Error al agregar miembro.')
|
||||
},
|
||||
})
|
||||
|
||||
const createAttendeeForceMutation = useMutation({
|
||||
mutationFn: (payload: CreateAttendee) => createAttendee(groupId, payload, { allowOverflow: true }),
|
||||
onSuccess: handleCreateSuccess,
|
||||
onError: (err: Error) => {
|
||||
toast.error(err.message || 'Error al agregar miembro.')
|
||||
},
|
||||
})
|
||||
|
||||
const addToWaitlistMutation = useMutation({
|
||||
mutationFn: (payload: CreateGroupWaitlistEntry) => addToGroupWaitlist(groupId, payload),
|
||||
onMutate: async (payload: CreateGroupWaitlistEntry) => {
|
||||
await queryClient.cancelQueries({ queryKey: ['group-waitlist', groupId] })
|
||||
const previousWaitlist = queryClient.getQueryData<GroupWaitlistList>(['group-waitlist', groupId])
|
||||
|
||||
if (previousWaitlist) {
|
||||
const optimisticEntry: GroupWaitlistEntryDto = {
|
||||
id: `temp-${Date.now()}`,
|
||||
groupId,
|
||||
fullName: `${payload.firstName} ${payload.lastName ?? ''}`.trim(),
|
||||
phone: payload.phone,
|
||||
email: payload.email || null,
|
||||
notes: payload.notes ?? null,
|
||||
status: 'PENDING',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
queryClient.setQueryData<GroupWaitlistList>(['group-waitlist', groupId], {
|
||||
data: [optimisticEntry, ...previousWaitlist.data],
|
||||
pagination: {
|
||||
...previousWaitlist.pagination,
|
||||
total: previousWaitlist.pagination.total + 1,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return { previousWaitlist }
|
||||
},
|
||||
onSuccess: (entry) => {
|
||||
toast.info(`${entry.fullName} fue agregado a la lista de espera del grupo.`)
|
||||
invalidateGroupQueries()
|
||||
closeAddAttendeeFlow()
|
||||
},
|
||||
onError: (err: Error, _payload, context) => {
|
||||
if (context?.previousWaitlist) {
|
||||
queryClient.setQueryData<GroupWaitlistList>(['group-waitlist', groupId], context.previousWaitlist)
|
||||
}
|
||||
if (err instanceof ApiError && err.problem?.code === 'already_waitlisted') {
|
||||
toast.info('Este número ya está en la lista de espera del grupo.')
|
||||
} else {
|
||||
toast.error(err.message || 'Error al agregar a la lista de espera.')
|
||||
}
|
||||
setPendingCapacityPayload(null)
|
||||
setIsCapacityModalOpen(false)
|
||||
},
|
||||
})
|
||||
|
||||
const bulkImportMutation = useMutation({
|
||||
mutationFn: (rows: BulkAttendeeRow[]) => bulkCreateAttendees(groupId, { attendees: rows }),
|
||||
onSuccess: (res) => {
|
||||
toast.success(res.message)
|
||||
setParsedContacts([])
|
||||
setFileName(null)
|
||||
void queryClient.invalidateQueries({ queryKey: ['group-attendees', groupId] })
|
||||
setIsAddAttendeeModalOpen(false)
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(err.message || 'Error al importar miembros.')
|
||||
},
|
||||
})
|
||||
|
||||
const removeAttendeeMutation = useMutation({
|
||||
mutationFn: (payload: { attendeeId: string; promoteFromWaitlist: boolean }) =>
|
||||
removeGroupAttendee(groupId, payload.attendeeId, { promoteFromWaitlist: payload.promoteFromWaitlist }),
|
||||
onSuccess: (result, payload) => {
|
||||
const removedName = attendees.find((a) => a.id === payload.attendeeId)?.fullName ?? 'El miembro'
|
||||
if (result.promoted) {
|
||||
toast.success(
|
||||
`${removedName} fue quitado del grupo y ${result.promoted.fullName} pasó de la lista de espera al grupo.`,
|
||||
)
|
||||
} else {
|
||||
toast.success(`${removedName} fue quitado del grupo.`)
|
||||
}
|
||||
invalidateGroupQueries()
|
||||
setSelectedAttendee(null)
|
||||
setAttendeeToRemove(null)
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
if (err instanceof ApiError && err.problem?.code === 'attendee_has_payments') {
|
||||
toast.error(err.problem.detail ?? 'Este miembro tiene cobros asociados.')
|
||||
} else {
|
||||
toast.error(err.message || 'Error al quitar al miembro del grupo.')
|
||||
}
|
||||
setAttendeeToRemove(null)
|
||||
},
|
||||
})
|
||||
|
||||
const promoteWaitlistMutation = useMutation({
|
||||
mutationFn: (entryId: string) => promoteGroupWaitlistEntry(groupId, entryId),
|
||||
onSuccess: (result) => {
|
||||
toast.success(`${result.attendee.fullName} pasó de la lista de espera al grupo.`)
|
||||
invalidateGroupQueries()
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
if (err instanceof ApiError && err.problem?.code === 'group_capacity_reached') {
|
||||
toast.error('El grupo alcanzó su cupo. Quita un miembro o aumenta el cupo primero.')
|
||||
} else {
|
||||
toast.error(err.message || 'No se pudo pasar al miembro al grupo.')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const removeWaitlistEntryMutation = useMutation({
|
||||
mutationFn: (entry: GroupWaitlistEntryDto) => removeGroupWaitlistEntry(groupId, entry.id),
|
||||
onSuccess: (_result, entry) => {
|
||||
toast.success(`${entry.fullName} fue quitado de la lista de espera.`)
|
||||
invalidateGroupQueries()
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(err.message || 'No se pudo quitar de la lista de espera.')
|
||||
},
|
||||
})
|
||||
|
||||
const inviteUrl = inviteTokenQuery.data?.inviteUrl
|
||||
const whatsappMessage =
|
||||
group && inviteUrl
|
||||
? `¡Hola! 👋 Te invito a unirte al grupo *${group.name}*.\n\nCompleta tus datos de inscripción en el siguiente enlace:\n${inviteUrl}`
|
||||
: ''
|
||||
const whatsappShareUrl = whatsappMessage
|
||||
? `https://wa.me/?text=${encodeURIComponent(whatsappMessage)}`
|
||||
: ''
|
||||
|
||||
const copyInviteLink = async () => {
|
||||
if (!inviteUrl) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(inviteUrl)
|
||||
toast.success('¡Enlace de invitación copiado al portapapeles!')
|
||||
} catch {
|
||||
toast.error('No se pudo copiar automáticamente. Copia el texto manualmente.')
|
||||
}
|
||||
}
|
||||
|
||||
const copyWhatsappMessage = async () => {
|
||||
if (!whatsappMessage) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(whatsappMessage)
|
||||
toast.success('¡Mensaje copiado al portapapeles!')
|
||||
} catch {
|
||||
toast.error('No se pudo copiar automáticamente. Copia el texto manualmente.')
|
||||
}
|
||||
}
|
||||
|
||||
const openWhatsapp = async () => {
|
||||
if (!whatsappShareUrl) return
|
||||
try {
|
||||
if (whatsappMessage) {
|
||||
await navigator.clipboard.writeText(whatsappMessage)
|
||||
toast.success('¡Abriendo WhatsApp! Mensaje copiado al portapapeles.')
|
||||
}
|
||||
} catch {
|
||||
// Si el portapapeles falla, igual abrimos WhatsApp con el mensaje.
|
||||
}
|
||||
window.open(whatsappShareUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
const copyAbsenceNotifyUrl = (url: string) => {
|
||||
void navigator.clipboard.writeText(url).then(() => {
|
||||
toast.success('Link copiado al portapapeles.')
|
||||
})
|
||||
}
|
||||
|
||||
const validRows: BulkAttendeeRow[] = useMemo(
|
||||
() =>
|
||||
parsedContacts
|
||||
.filter((contact) => contact.isValid)
|
||||
.map((contact) => ({
|
||||
firstName: contact.firstName,
|
||||
lastName: contact.lastName,
|
||||
fullName: contact.fullName,
|
||||
phone: contact.phone,
|
||||
email: contact.email || undefined,
|
||||
notes: contact.notes || undefined,
|
||||
})),
|
||||
[parsedContacts],
|
||||
)
|
||||
|
||||
const runBulkImport = () => {
|
||||
if (validRows.length === 0) {
|
||||
toast.error('No hay miembros válidos para importar.')
|
||||
return
|
||||
}
|
||||
bulkImportMutation.mutate(validRows)
|
||||
}
|
||||
|
||||
const requestBulkImport = () => {
|
||||
if (validRows.length === 0) {
|
||||
toast.error('No hay miembros válidos para importar.')
|
||||
return
|
||||
}
|
||||
if (group?.capacity != null && attendeesTotal + validRows.length > group.capacity) {
|
||||
setIsBulkCapacityModalOpen(true)
|
||||
return
|
||||
}
|
||||
runBulkImport()
|
||||
}
|
||||
|
||||
const value: GroupDetailContextValue = {
|
||||
groupId,
|
||||
group,
|
||||
groupQueryPending: groupQuery.isPending,
|
||||
riskLevel: riskOverviewQuery.data?.items.find((item) => item.groupId === groupId)?.riskLevel ?? 'NONE',
|
||||
attendees,
|
||||
attendeesTotal,
|
||||
attendeesPending: attendeesQuery.isPending,
|
||||
waitlistEntries,
|
||||
waitlistTotal,
|
||||
waitlistPending: waitlistQuery.isPending,
|
||||
firstWaitlistEntry,
|
||||
hasFreeCapacity,
|
||||
inviteUrl,
|
||||
invitePending: inviteTokenQuery.isPending,
|
||||
whatsappMessage,
|
||||
searchFilter,
|
||||
setSearchFilter,
|
||||
listSection,
|
||||
setListSection,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
isInviteModalOpen,
|
||||
openInviteModal: () => setIsInviteModalOpen(true),
|
||||
closeInviteModal: () => setIsInviteModalOpen(false),
|
||||
isAddAttendeeModalOpen,
|
||||
openAddAttendeeModal: () => setIsAddAttendeeModalOpen(true),
|
||||
closeAddAttendeeModal: () => setIsAddAttendeeModalOpen(false),
|
||||
selectedAttendee,
|
||||
setSelectedAttendee,
|
||||
attendeeToRemove,
|
||||
requestRemoveAttendee: (attendee) => setAttendeeToRemove(attendee),
|
||||
cancelRemoveAttendee: () => setAttendeeToRemove(null),
|
||||
confirmRemoveAttendee: (promoteFromWaitlist) => {
|
||||
if (!attendeeToRemove) return
|
||||
removeAttendeeMutation.mutate({
|
||||
attendeeId: attendeeToRemove.id,
|
||||
promoteFromWaitlist,
|
||||
})
|
||||
},
|
||||
selectedWaitlistEntry,
|
||||
setSelectedWaitlistEntry,
|
||||
isCapacityModalOpen,
|
||||
capacityPayload: pendingCapacityPayload,
|
||||
closeCapacityModal: () => {
|
||||
setPendingCapacityPayload(null)
|
||||
setIsCapacityModalOpen(false)
|
||||
},
|
||||
isBulkCapacityModalOpen,
|
||||
closeBulkCapacityModal: () => setIsBulkCapacityModalOpen(false),
|
||||
parsedContacts,
|
||||
setParsedContacts,
|
||||
removeParsedContact: (id) => setParsedContacts((prev) => prev.filter((c) => c.id !== id)),
|
||||
fileName,
|
||||
setFileName,
|
||||
isParsingFile,
|
||||
setIsParsingFile,
|
||||
isDragging,
|
||||
setIsDragging,
|
||||
validRowsCount: validRows.length,
|
||||
invalidRowsCount: parsedContacts.length - validRows.length,
|
||||
isSubmittingAttendee: createAttendeeMutation.isPending,
|
||||
isSubmittingForcedAttendee: createAttendeeForceMutation.isPending,
|
||||
isSubmittingWaitlistEntry: addToWaitlistMutation.isPending,
|
||||
isSubmittingBulkImport: bulkImportMutation.isPending,
|
||||
isSubmittingRemoval: removeAttendeeMutation.isPending,
|
||||
isSubmittingPromotion: promoteWaitlistMutation.isPending,
|
||||
isSubmittingWaitlistRemoval: removeWaitlistEntryMutation.isPending,
|
||||
isRegeneratingInvite: regenerateTokenMutation.isPending,
|
||||
submitQuickAttendee: (payload) => createAttendeeMutation.mutate(payload),
|
||||
submitForcedAttendee: () => {
|
||||
if (pendingCapacityPayload) {
|
||||
createAttendeeForceMutation.mutate({ ...pendingCapacityPayload })
|
||||
}
|
||||
},
|
||||
submitWaitlistEntry: () => {
|
||||
if (pendingCapacityPayload) {
|
||||
addToWaitlistMutation.mutate({ ...pendingCapacityPayload })
|
||||
}
|
||||
},
|
||||
requestBulkImport,
|
||||
confirmBulkImportOverCapacity: () => {
|
||||
setIsBulkCapacityModalOpen(false)
|
||||
runBulkImport()
|
||||
},
|
||||
promoteEntry: (entry) => {
|
||||
setSelectedWaitlistEntry(null)
|
||||
promoteWaitlistMutation.mutate(entry.id)
|
||||
},
|
||||
removeEntry: (entry) => {
|
||||
setSelectedWaitlistEntry(null)
|
||||
removeWaitlistEntryMutation.mutate(entry)
|
||||
},
|
||||
regenerateInvite: () => regenerateTokenMutation.mutate(),
|
||||
copyInviteLink,
|
||||
copyWhatsappMessage,
|
||||
openWhatsapp,
|
||||
copyAbsenceNotifyUrl,
|
||||
}
|
||||
|
||||
return <GroupDetailContext.Provider value={value}>{children}</GroupDetailContext.Provider>
|
||||
}
|
||||
|
||||
export function useGroupDetail() {
|
||||
const context = useContext(GroupDetailContext)
|
||||
if (!context) {
|
||||
throw new Error('useGroupDetail debe usarse dentro de <GroupDetailProvider>')
|
||||
}
|
||||
return context
|
||||
}
|
||||
55
apps/web/src/features/groups/detail/GroupDetailView.tsx
Normal file
55
apps/web/src/features/groups/detail/GroupDetailView.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { Button } from '../../../components/ui'
|
||||
import { AddAttendeeModal } from './AddAttendeeModal'
|
||||
import { AttendeeDetailModal } from './AttendeeDetailModal'
|
||||
import { BulkCapacityModal } from './BulkCapacityModal'
|
||||
import { CapacityModal } from './CapacityModal'
|
||||
import { GroupHeader } from './GroupHeader'
|
||||
import { GroupInfoCard } from './GroupInfoCard'
|
||||
import { InviteLinkModal } from './InviteLinkModal'
|
||||
import { MembersWaitlistSection } from './MembersWaitlistSection'
|
||||
import { RemoveAttendeeModal } from './RemoveAttendeeModal'
|
||||
import { useGroupDetail } from './GroupDetailProvider'
|
||||
import { WaitlistEntryDetailModal } from './WaitlistEntryDetailModal'
|
||||
|
||||
export function GroupDetailView() {
|
||||
const { group, groupQueryPending } = useGroupDetail()
|
||||
const navigate = useNavigate()
|
||||
|
||||
if (groupQueryPending) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="size-8 animate-spin text-accent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!group) {
|
||||
return (
|
||||
<div className="rounded-xl border border-danger/20 bg-danger-soft p-6 text-danger">
|
||||
<h2 className="text-lg font-semibold">Grupo no encontrado</h2>
|
||||
<p className="mt-2 text-sm">No se pudo cargar la información de este grupo.</p>
|
||||
<Button variant="outline" className="mt-4" onClick={() => void navigate({ to: '/groups' })}>
|
||||
Volver a Grupos
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="animate-fade-in space-y-6">
|
||||
<GroupHeader />
|
||||
<GroupInfoCard />
|
||||
<MembersWaitlistSection />
|
||||
|
||||
<InviteLinkModal />
|
||||
<AddAttendeeModal />
|
||||
<CapacityModal />
|
||||
<BulkCapacityModal />
|
||||
<AttendeeDetailModal />
|
||||
<RemoveAttendeeModal />
|
||||
<WaitlistEntryDetailModal />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
77
apps/web/src/features/groups/detail/GroupHeader.tsx
Normal file
77
apps/web/src/features/groups/detail/GroupHeader.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import { ArrowLeft, BarChart3, Share2, UserPlus } from 'lucide-react'
|
||||
import { Badge, Button } from '../../../components/ui'
|
||||
import { RISK_LABELS } from '../constants'
|
||||
import { useGroupDetail } from './GroupDetailProvider'
|
||||
|
||||
export function GroupHeader() {
|
||||
const { group, groupId, riskLevel, openInviteModal, openAddAttendeeModal } = useGroupDetail()
|
||||
const navigate = useNavigate()
|
||||
const hasRisk = riskLevel === 'HIGH' || riskLevel === 'MEDIUM'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Link
|
||||
to="/groups"
|
||||
className="mb-3 hidden items-center gap-1 text-sm text-foreground/60 transition-colors hover:text-primary lg:inline-flex"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
<span>Volver a grupos</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h1 className="text-2xl font-bold text-primary">{group?.name}</h1>
|
||||
<Badge variant="success">Activo</Badge>
|
||||
{hasRisk ? (
|
||||
<Badge variant={riskLevel === 'HIGH' ? 'danger' : 'warning'} className="gap-1.5">
|
||||
<span
|
||||
className={`size-2 rounded-full ${riskLevel === 'HIGH' ? 'bg-danger' : 'bg-warning'}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{RISK_LABELS[riskLevel]}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{group?.description ? (
|
||||
<p className="mt-1 text-sm text-foreground/70">{group.description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid w-full grid-cols-3 gap-2 sm:flex sm:w-auto sm:flex-wrap sm:items-center sm:gap-2.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
void navigate({ to: '/groups/$groupId/analytics', params: { groupId } })
|
||||
}
|
||||
className="w-full justify-center gap-1.5 border-border sm:w-auto"
|
||||
>
|
||||
<BarChart3 className="size-4 text-accent" />
|
||||
<span>Estadísticas</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={openInviteModal}
|
||||
className="w-full justify-center gap-1.5 border-border sm:w-auto"
|
||||
>
|
||||
<Share2 className="size-4 text-accent" />
|
||||
<span>
|
||||
Compartir <span className="hidden sm:inline">Link</span>
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={openAddAttendeeModal}
|
||||
className="w-full justify-center gap-1.5 sm:w-auto"
|
||||
>
|
||||
<UserPlus className="size-4" />
|
||||
<span>
|
||||
Agregar <span className="hidden sm:inline">Miembros</span>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
47
apps/web/src/features/groups/detail/GroupInfoCard.tsx
Normal file
47
apps/web/src/features/groups/detail/GroupInfoCard.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { CalendarClock, Users } from 'lucide-react'
|
||||
import { BILLING_LABELS, formatPrice, formatSchedule } from '../../../lib/format'
|
||||
import { useGroupDetail } from './GroupDetailProvider'
|
||||
|
||||
export function GroupInfoCard() {
|
||||
const { group, attendees, waitlistTotal } = useGroupDetail()
|
||||
if (!group) return null
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 rounded-xl border border-border bg-surface p-4 text-sm md:grid-cols-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<CalendarClock className="size-5 shrink-0 text-accent" />
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase text-foreground/50">Horario</p>
|
||||
<p className="font-medium text-primary">
|
||||
{(group.days?.length ?? 0) > 0
|
||||
? formatSchedule(group.days ?? [], group.time)
|
||||
: 'Sin horario definido'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Users className="size-5 shrink-0 text-accent" />
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase text-foreground/50">Miembros & Cupo</p>
|
||||
<p className="font-medium text-primary">
|
||||
{attendees.length} inscritos {waitlistTotal > 0 ? ` · ${waitlistTotal} en espera` : ''}
|
||||
{group.capacity ? ` · Cupo de ${group.capacity}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-5 shrink-0 items-center justify-center font-bold text-accent">$</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase text-foreground/50">Cobro</p>
|
||||
<p className="font-medium text-primary">
|
||||
{group.price != null ? formatPrice(group.price) : 'Sin precio'}
|
||||
{group.billingType ? ` · ${BILLING_LABELS[group.billingType]}` : ''}
|
||||
{group.dueDay ? ` · Vence día ${group.dueDay}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
117
apps/web/src/features/groups/detail/InviteLinkModal.tsx
Normal file
117
apps/web/src/features/groups/detail/InviteLinkModal.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { Copy, Loader2, MessageCircle, RefreshCw } from 'lucide-react'
|
||||
import { Button, Input, Label, Modal } from '../../../components/ui'
|
||||
import { cn } from '../../../lib/utils'
|
||||
import { useGroupDetail } from './GroupDetailProvider'
|
||||
|
||||
export function InviteLinkModal() {
|
||||
const {
|
||||
isInviteModalOpen,
|
||||
closeInviteModal,
|
||||
group,
|
||||
inviteUrl,
|
||||
invitePending,
|
||||
isRegeneratingInvite,
|
||||
regenerateInvite,
|
||||
copyInviteLink,
|
||||
copyWhatsappMessage,
|
||||
openWhatsapp,
|
||||
} = useGroupDetail()
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isInviteModalOpen}
|
||||
onClose={closeInviteModal}
|
||||
title="Compartir Link de Invitación"
|
||||
description="Envía este enlace a tus miembros para que completen sus datos e ingresen directamente al grupo."
|
||||
maxWidth="md"
|
||||
>
|
||||
<div className="space-y-5">
|
||||
{invitePending ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader2 className="size-6 animate-spin text-accent" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="invite-link-input" className="mb-1.5 block">
|
||||
Enlace único del grupo
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="invite-link-input"
|
||||
readOnly
|
||||
value={inviteUrl ?? ''}
|
||||
className="select-text bg-primary-soft/50 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void copyInviteLink()}
|
||||
className="shrink-0 gap-1.5 px-3"
|
||||
title="Copiar al portapapeles"
|
||||
>
|
||||
<Copy className="size-4" />
|
||||
<span>Copiar</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5 rounded-xl border border-accent/20 bg-accent-soft p-4 text-xs text-foreground/80">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="font-semibold text-primary">Vista previa del mensaje para WhatsApp:</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void copyWhatsappMessage()}
|
||||
className="h-7 shrink-0 gap-1.5 px-2 text-xs text-primary hover:text-primary/80"
|
||||
title="Copiar mensaje completo al portapapeles"
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
<span>Copiar mensaje</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="whitespace-pre-line rounded-lg border border-accent/10 bg-surface/70 p-3 font-sans text-xs leading-relaxed text-foreground">
|
||||
{`¡Hola! 👋 Te invito a unirte al grupo `}
|
||||
<strong>{group?.name}</strong>.
|
||||
{'\n\n'}
|
||||
Completa tus datos de inscripción en el siguiente enlace:
|
||||
{'\n'}
|
||||
<span className="break-all text-accent underline">{inviteUrl}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-2.5 pt-2 sm:flex-row">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void openWhatsapp()}
|
||||
className="w-full gap-2 border-0 bg-[#25D366] text-white hover:bg-[#1EBE5D] sm:flex-1"
|
||||
>
|
||||
<MessageCircle className="size-4" />
|
||||
<span>Abrir en WhatsApp</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={regenerateInvite}
|
||||
disabled={isRegeneratingInvite}
|
||||
className="w-full gap-1.5 text-xs text-foreground/60 hover:text-danger sm:w-auto"
|
||||
title="Invalida el enlace anterior y genera uno nuevo"
|
||||
>
|
||||
<RefreshCw className={cn('size-3.5', isRegeneratingInvite && 'animate-spin')} />
|
||||
<span>Regenerar enlace</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-[11px] font-normal leading-normal text-foreground/50 sm:text-left">
|
||||
Al hacer clic en <strong>Abrir en WhatsApp</strong>, el mensaje completo se copia
|
||||
automáticamente al portapapeles. Si WhatsApp Web solo carga el enlace, podés pegarlo
|
||||
directamente con <kbd className="rounded bg-primary-soft px-1 py-0.5 font-mono text-[10px] text-primary">Ctrl+V</kbd>{' '}
|
||||
o <kbd className="rounded bg-primary-soft px-1 py-0.5 font-mono text-[10px] text-primary">Cmd+V</kbd>.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
45
apps/web/src/features/groups/detail/MembersTable.tsx
Normal file
45
apps/web/src/features/groups/detail/MembersTable.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { AttendeeDto } from '@gruperly/shared'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { useGroupDetail } from './GroupDetailProvider'
|
||||
|
||||
export function MembersTable({ attendees }: { attendees: AttendeeDto[] }) {
|
||||
const { setSelectedAttendee } = useGroupDetail()
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b border-border text-xs font-semibold uppercase text-foreground/60">
|
||||
<tr>
|
||||
<th className="px-3 pb-3">Nombre</th>
|
||||
<th className="px-3 pb-3">Teléfono</th>
|
||||
<th className="w-8 px-3 pb-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{attendees.map((attendee) => (
|
||||
<tr
|
||||
key={attendee.id}
|
||||
className="cursor-pointer transition-colors hover:bg-primary-soft/50"
|
||||
onClick={() => setSelectedAttendee(attendee)}
|
||||
>
|
||||
<td className="px-3 py-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-8 items-center justify-center rounded-full bg-accent/10 text-xs font-semibold text-accent">
|
||||
{attendee.fullName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="font-medium text-primary">{attendee.fullName}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-foreground/70">
|
||||
{attendee.phone || <span className="text-foreground/40">—</span>}
|
||||
</td>
|
||||
<td className="px-1 py-3 text-foreground/30">
|
||||
<ChevronRight className="size-4" />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
146
apps/web/src/features/groups/detail/MembersWaitlistSection.tsx
Normal file
146
apps/web/src/features/groups/detail/MembersWaitlistSection.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { Clock, Loader2, Plus, Search, Share2, Users } from 'lucide-react'
|
||||
import { Button, Input } from '../../../components/ui'
|
||||
import { cn } from '../../../lib/utils'
|
||||
import { useGroupDetail } from './GroupDetailProvider'
|
||||
import { MembersTable } from './MembersTable'
|
||||
import { WaitlistTable } from './WaitlistTable'
|
||||
|
||||
export function MembersWaitlistSection() {
|
||||
const {
|
||||
attendees,
|
||||
attendeesTotal,
|
||||
attendeesPending,
|
||||
waitlistEntries,
|
||||
waitlistTotal,
|
||||
waitlistPending,
|
||||
listSection,
|
||||
setListSection,
|
||||
searchFilter,
|
||||
setSearchFilter,
|
||||
openInviteModal,
|
||||
openAddAttendeeModal,
|
||||
} = useGroupDetail()
|
||||
|
||||
const query = searchFilter.toLowerCase()
|
||||
const filteredAttendees = attendees.filter(
|
||||
(a) =>
|
||||
a.fullName.toLowerCase().includes(query) ||
|
||||
(a.phone && a.phone.includes(query)) ||
|
||||
(a.email && a.email.toLowerCase().includes(query)),
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-xl border border-border bg-surface p-5">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col justify-between gap-3 sm:flex-row sm:items-center">
|
||||
<div className="flex w-full items-center rounded-xl bg-primary-soft p-1 sm:w-[26rem]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setListSection('members')}
|
||||
className={cn(
|
||||
'flex flex-1 items-center justify-center gap-1.5 rounded-lg py-2 text-xs font-semibold transition-all',
|
||||
listSection === 'members'
|
||||
? 'bg-surface text-primary shadow-xs'
|
||||
: 'text-foreground/60 hover:text-primary',
|
||||
)}
|
||||
>
|
||||
<Users className="size-4 shrink-0" />
|
||||
<span className="whitespace-nowrap">Miembros ({attendeesTotal})</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setListSection('waitlist')}
|
||||
className={cn(
|
||||
'flex flex-1 items-center justify-center gap-1.5 rounded-lg py-2 text-xs font-semibold transition-all',
|
||||
listSection === 'waitlist'
|
||||
? 'bg-surface text-primary shadow-xs'
|
||||
: 'text-foreground/60 hover:text-primary',
|
||||
)}
|
||||
>
|
||||
<Clock className="size-4 shrink-0" />
|
||||
<span className="whitespace-nowrap">Lista de espera ({waitlistTotal})</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{listSection === 'members' ? (
|
||||
<div className="relative w-full sm:w-64">
|
||||
<Search className="absolute left-3 top-2.5 size-4 text-foreground/40" />
|
||||
<Input
|
||||
placeholder="Buscar por nombre o teléfono..."
|
||||
value={searchFilter}
|
||||
onChange={(e) => setSearchFilter(e.target.value)}
|
||||
className="h-9 pl-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-foreground/60">
|
||||
{listSection === 'members'
|
||||
? 'Listado de todos los miembros incorporados al grupo.'
|
||||
: 'Personas que esperan un cupo libre en el grupo. Al pasar a alguien al grupo, se crea automáticamente su registro como miembro.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{listSection === 'members' ? (
|
||||
<>
|
||||
{attendeesPending ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="size-6 animate-spin text-accent" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!attendeesPending && attendees.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-border px-4 py-12 text-center">
|
||||
<Users className="mx-auto mb-2 size-10 text-foreground/30" />
|
||||
<p className="font-medium text-primary">Todavía no hay miembros en este grupo</p>
|
||||
<p className="mx-auto mt-1 max-w-sm text-sm text-foreground/60">
|
||||
Puedes compartir el enlace de invitación único o agregar miembros de forma manual o
|
||||
masiva.
|
||||
</p>
|
||||
<div className="mt-4 flex items-center justify-center gap-3">
|
||||
<Button variant="outline" size="sm" onClick={openInviteModal} className="gap-2">
|
||||
<Share2 className="size-4" />
|
||||
Compartir Enlace
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" onClick={openAddAttendeeModal} className="gap-2">
|
||||
<Plus className="size-4" />
|
||||
Agregar Miembros
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{attendees.length > 0 && filteredAttendees.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-foreground/60">
|
||||
No se encontraron miembros que coincidan con la búsqueda.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{filteredAttendees.length > 0 ? <MembersTable attendees={filteredAttendees} /> : null}
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{waitlistPending ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="size-6 animate-spin text-accent" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!waitlistPending && waitlistEntries.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-border px-4 py-12 text-center">
|
||||
<Clock className="mx-auto mb-2 size-10 text-foreground/30" />
|
||||
<p className="font-medium text-primary">No hay nadie en la lista de espera</p>
|
||||
<p className="mx-auto mt-1 max-w-sm text-sm text-foreground/60">
|
||||
Cuando el grupo alcance su cupo, las personas podrán sumarse a la espera y podrás
|
||||
pasarlas al grupo desde aquí.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{waitlistEntries.length > 0 ? <WaitlistTable entries={waitlistEntries} /> : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
124
apps/web/src/features/groups/detail/QuickAddForm.tsx
Normal file
124
apps/web/src/features/groups/detail/QuickAddForm.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useState } from 'react'
|
||||
import { Check, Loader2 } from 'lucide-react'
|
||||
import { Button, Input, Label, useToast } from '../../../components/ui'
|
||||
import { useGroupDetail } from './GroupDetailProvider'
|
||||
|
||||
export function QuickAddForm() {
|
||||
const { submitQuickAttendee, isSubmittingAttendee, closeAddAttendeeModal } = useGroupDetail()
|
||||
const toast = useToast()
|
||||
const [firstName, setFirstName] = useState('')
|
||||
const [lastName, setLastName] = useState('')
|
||||
const [phone, setPhone] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!firstName.trim() || !phone.trim()) {
|
||||
toast.error('Nombre y teléfono son obligatorios.')
|
||||
return
|
||||
}
|
||||
submitQuickAttendee({
|
||||
firstName: firstName.trim(),
|
||||
lastName: lastName.trim(),
|
||||
phone: phone.trim(),
|
||||
email: email.trim() || undefined,
|
||||
notes: notes.trim() || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
setFirstName('')
|
||||
setLastName('')
|
||||
setPhone('')
|
||||
setEmail('')
|
||||
setNotes('')
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="quick-first-name" className="mb-1 block text-xs">
|
||||
Nombre <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="quick-first-name"
|
||||
placeholder="Ej. Lucas"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="quick-last-name" className="mb-1 block text-xs">
|
||||
Apellido
|
||||
</Label>
|
||||
<Input
|
||||
id="quick-last-name"
|
||||
placeholder="Ej. González"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="quick-phone" className="mb-1 block text-xs">
|
||||
Teléfono / WhatsApp <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="quick-phone"
|
||||
placeholder="Ej. +54 9 11 2345-6789"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<p className="mt-1 text-[11px] text-foreground/50">
|
||||
Usado para contacto y validación de duplicados.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="quick-email" className="mb-1 block text-xs">
|
||||
Email (opcional)
|
||||
</Label>
|
||||
<Input
|
||||
id="quick-email"
|
||||
type="email"
|
||||
placeholder="alumno@ejemplo.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="quick-notes" className="mb-1 block text-xs">
|
||||
Notas u Observaciones (opcional)
|
||||
</Label>
|
||||
<Input
|
||||
id="quick-notes"
|
||||
placeholder="Ej. Nivel intermedio, trae materiales propios"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2.5 border-t border-border pt-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
resetForm()
|
||||
closeAddAttendeeModal()
|
||||
}}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button variant="primary" type="submit" disabled={isSubmittingAttendee} className="gap-2">
|
||||
{isSubmittingAttendee ? <Loader2 className="size-4 animate-spin" /> : <Check className="size-4" />}
|
||||
<span>Guardar Miembro</span>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
99
apps/web/src/features/groups/detail/RemoveAttendeeModal.tsx
Normal file
99
apps/web/src/features/groups/detail/RemoveAttendeeModal.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Clock, Loader2, Trash2, UserCheck, UserMinus } from 'lucide-react'
|
||||
import { Button, Modal } from '../../../components/ui'
|
||||
import { useGroupDetail } from './GroupDetailProvider'
|
||||
|
||||
export function RemoveAttendeeModal() {
|
||||
const {
|
||||
attendeeToRemove,
|
||||
cancelRemoveAttendee,
|
||||
confirmRemoveAttendee,
|
||||
group,
|
||||
firstWaitlistEntry,
|
||||
waitlistTotal,
|
||||
isSubmittingRemoval,
|
||||
} = useGroupDetail()
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={attendeeToRemove !== null}
|
||||
onClose={cancelRemoveAttendee}
|
||||
title="Quitar del grupo"
|
||||
maxWidth="md"
|
||||
>
|
||||
{attendeeToRemove ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-foreground/70">
|
||||
{attendeeToRemove.fullName} dejará de ser miembro del grupo{' '}
|
||||
<strong className="text-primary">{group?.name}</strong>
|
||||
{firstWaitlistEntry ? ' y el cupo quedará libre.' : '.'}
|
||||
{attendeeToRemove.phone ? (
|
||||
<span className="mt-1 block text-xs text-foreground/50">Teléfono: {attendeeToRemove.phone}</span>
|
||||
) : null}
|
||||
</p>
|
||||
|
||||
{firstWaitlistEntry ? (
|
||||
<div className="space-y-3 rounded-xl border border-accent/20 bg-accent-soft p-4">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-primary">
|
||||
<Clock className="size-4 text-accent" />
|
||||
<span>
|
||||
Hay {waitlistTotal} persona{waitlistTotal === 1 ? '' : 's'} esperando un cupo
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-foreground/80">
|
||||
¿Quieres pasar a <strong className="text-primary">{firstWaitlistEntry.fullName}</strong>, el
|
||||
primero de la lista de espera, al grupo?
|
||||
</p>
|
||||
<div className="flex flex-col gap-2.5 pt-1">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => confirmRemoveAttendee(true)}
|
||||
disabled={isSubmittingRemoval}
|
||||
className="gap-2"
|
||||
>
|
||||
{isSubmittingRemoval ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<UserCheck className="size-4" />
|
||||
)}
|
||||
<span>Quitar y pasar a {firstWaitlistEntry.fullName} al grupo</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => confirmRemoveAttendee(false)}
|
||||
disabled={isSubmittingRemoval}
|
||||
className="gap-2"
|
||||
>
|
||||
{isSubmittingRemoval ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<UserMinus className="size-4" />
|
||||
)}
|
||||
<span>Solo quitar del grupo</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-end gap-2.5 border-t border-border pt-2">
|
||||
<Button variant="ghost" onClick={cancelRemoveAttendee}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => confirmRemoveAttendee(false)}
|
||||
disabled={isSubmittingRemoval}
|
||||
className="gap-2"
|
||||
>
|
||||
{isSubmittingRemoval ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-4" />
|
||||
)}
|
||||
<span>Quitar del grupo</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
118
apps/web/src/features/groups/detail/WaitlistEntryDetailModal.tsx
Normal file
118
apps/web/src/features/groups/detail/WaitlistEntryDetailModal.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Loader2, Mail, MessageCircle, Phone, StickyNote, Trash2, UserCheck } from 'lucide-react'
|
||||
import { Button, Modal } from '../../../components/ui'
|
||||
import { DetailRow } from './DetailRow'
|
||||
import { useGroupDetail } from './GroupDetailProvider'
|
||||
|
||||
export function WaitlistEntryDetailModal() {
|
||||
const {
|
||||
selectedWaitlistEntry,
|
||||
setSelectedWaitlistEntry,
|
||||
hasFreeCapacity,
|
||||
promoteEntry,
|
||||
removeEntry,
|
||||
isSubmittingPromotion,
|
||||
isSubmittingWaitlistRemoval,
|
||||
} = useGroupDetail()
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={selectedWaitlistEntry !== null}
|
||||
onClose={() => setSelectedWaitlistEntry(null)}
|
||||
title="Detalle de la lista de espera"
|
||||
maxWidth="sm"
|
||||
>
|
||||
{selectedWaitlistEntry ? (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-accent/10 text-lg font-bold text-accent">
|
||||
{selectedWaitlistEntry.fullName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-lg font-semibold text-primary">{selectedWaitlistEntry.fullName}</p>
|
||||
<p className="text-xs text-foreground/50">
|
||||
En espera desde el{' '}
|
||||
{new Date(selectedWaitlistEntry.createdAt).toLocaleDateString('es-ES', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 divide-y divide-border rounded-xl border border-border bg-primary-soft/30">
|
||||
<DetailRow icon={<Phone className="size-4 text-accent" />} label="Teléfono">
|
||||
{selectedWaitlistEntry.phone ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-primary">{selectedWaitlistEntry.phone}</span>
|
||||
<a
|
||||
href={`https://wa.me/${selectedWaitlistEntry.phone.replace(/\D/g, '')}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-success transition-colors hover:text-success/80"
|
||||
>
|
||||
<MessageCircle className="size-3.5" />
|
||||
<span>WhatsApp</span>
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-foreground/40">Sin teléfono</span>
|
||||
)}
|
||||
</DetailRow>
|
||||
|
||||
{selectedWaitlistEntry.email ? (
|
||||
<DetailRow icon={<Mail className="size-4 text-accent" />} label="Email">
|
||||
<a
|
||||
href={`mailto:${selectedWaitlistEntry.email}`}
|
||||
className="text-primary transition-colors hover:text-accent"
|
||||
>
|
||||
{selectedWaitlistEntry.email}
|
||||
</a>
|
||||
</DetailRow>
|
||||
) : null}
|
||||
|
||||
<DetailRow icon={<StickyNote className="size-4 text-accent" />} label="Notas">
|
||||
{selectedWaitlistEntry.notes ? (
|
||||
<span className="text-xs leading-relaxed text-primary">{selectedWaitlistEntry.notes}</span>
|
||||
) : (
|
||||
<span className="text-foreground/40">Sin notas</span>
|
||||
)}
|
||||
</DetailRow>
|
||||
</div>
|
||||
|
||||
{!hasFreeCapacity ? (
|
||||
<p className="rounded-xl border border-border bg-primary-soft/30 px-4 py-3 text-xs text-foreground/70">
|
||||
El grupo alcanzó su cupo de miembros. Quita un miembro o aumenta el cupo para poder pasar a esta
|
||||
persona al grupo.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-1 gap-2.5 border-t border-border pt-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => promoteEntry(selectedWaitlistEntry)}
|
||||
disabled={isSubmittingPromotion || !hasFreeCapacity}
|
||||
className="gap-2"
|
||||
>
|
||||
{isSubmittingPromotion ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<UserCheck className="size-4" />
|
||||
)}
|
||||
<span>Pasar al grupo</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => removeEntry(selectedWaitlistEntry)}
|
||||
disabled={isSubmittingWaitlistRemoval}
|
||||
className="gap-2 border border-danger/20 text-danger hover:bg-danger/10 hover:text-danger"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Quitar de la lista de espera
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
48
apps/web/src/features/groups/detail/WaitlistTable.tsx
Normal file
48
apps/web/src/features/groups/detail/WaitlistTable.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { GroupWaitlistEntryDto } from '@gruperly/shared'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { useGroupDetail } from './GroupDetailProvider'
|
||||
|
||||
export function WaitlistTable({ entries }: { entries: GroupWaitlistEntryDto[] }) {
|
||||
const { setSelectedWaitlistEntry } = useGroupDetail()
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b border-border text-xs font-semibold uppercase text-foreground/60">
|
||||
<tr>
|
||||
<th className="px-3 pb-3">Nombre</th>
|
||||
<th className="px-3 pb-3">Teléfono</th>
|
||||
<th className="w-8 px-3 pb-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{entries.map((entry) => (
|
||||
<tr
|
||||
key={entry.id}
|
||||
className="cursor-pointer transition-colors hover:bg-primary-soft/50"
|
||||
onClick={() => setSelectedWaitlistEntry(entry)}
|
||||
>
|
||||
<td className="px-3 py-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-8 items-center justify-center rounded-full bg-accent/10 text-xs font-semibold text-accent">
|
||||
{entry.fullName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-primary">{entry.fullName}</p>
|
||||
{entry.notes ? (
|
||||
<p className="truncate text-xs text-foreground/50">{entry.notes}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-foreground/70">{entry.phone}</td>
|
||||
<td className="px-1 py-3 text-foreground/30">
|
||||
<ChevronRight className="size-4" />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
108
apps/web/src/features/home/HomeView.tsx
Normal file
108
apps/web/src/features/home/HomeView.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Loader2, Plus } from 'lucide-react'
|
||||
import { Button } from '../../components/ui'
|
||||
import { getClassesToday, getHomeSummary } from '../../lib/api'
|
||||
import { SummaryStats } from './SummaryStats'
|
||||
import { TodayClassCard } from './TodayClassCard'
|
||||
import { NextClassHero } from './NextClassHero'
|
||||
import { UpcomingPaymentRow } from './UpcomingPaymentRow'
|
||||
|
||||
export function HomeView() {
|
||||
const navigate = useNavigate()
|
||||
const summaryQuery = useQuery({
|
||||
queryKey: ['home-summary'],
|
||||
queryFn: getHomeSummary,
|
||||
})
|
||||
const classesTodayQuery = useQuery({
|
||||
queryKey: ['classes-today'],
|
||||
queryFn: getClassesToday,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
const todayClasses = classesTodayQuery.data?.data ?? []
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-primary">Inicio</h1>
|
||||
<p className="mt-2 text-sm text-foreground/60">Tu actividad de cobros de un vistazo.</p>
|
||||
</div>
|
||||
|
||||
{summaryQuery.isPending ? (
|
||||
<div className="mt-8 flex items-center justify-center py-16">
|
||||
<Loader2 className="size-6 animate-spin text-foreground/40" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{summaryQuery.isError ? (
|
||||
<div className="mt-8 space-y-3 rounded-xl bg-danger-soft px-4 py-3 text-sm text-danger">
|
||||
<p>No pudimos cargar tu resumen.</p>
|
||||
<Button variant="outline" size="sm" onClick={() => void summaryQuery.refetch()}>
|
||||
Reintentar
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{summaryQuery.isSuccess && summaryQuery.data.stats.groups === 0 ? (
|
||||
<div className="mt-8 rounded-xl border border-dashed border-border bg-surface px-4 py-12 text-center">
|
||||
<p className="font-medium text-primary">Todavía no tenés grupos</p>
|
||||
<p className="mt-1 text-sm text-foreground/60">
|
||||
Crea tu primer grupo para empezar a cobrar.
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="mt-5"
|
||||
onClick={() => void navigate({ to: '/groups/new' })}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Crear tu primer grupo
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{summaryQuery.isSuccess && summaryQuery.data.stats.groups > 0 ? (
|
||||
<div className="mt-6 space-y-6">
|
||||
{todayClasses.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{todayClasses.map((classItem) => (
|
||||
<TodayClassCard key={classItem.sessionId} item={classItem} />
|
||||
))}
|
||||
</div>
|
||||
) : summaryQuery.data.nextClass ? (
|
||||
<NextClassHero nextClass={summaryQuery.data.nextClass} />
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-border bg-surface px-4 py-8 text-center">
|
||||
<p className="text-sm font-medium text-primary">Sin clases programadas</p>
|
||||
<p className="mt-1 text-sm text-foreground/60">
|
||||
Agregá días y horario a tus grupos para ver tu próxima clase acá.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SummaryStats summary={summaryQuery.data} />
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-lg font-bold text-primary">Próximos cobros</h2>
|
||||
<Button variant="ghost" size="sm" onClick={() => void navigate({ to: '/payments' })}>
|
||||
Ver todos
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{summaryQuery.data.upcomingPayments.length > 0 ? (
|
||||
<ul className="mt-3 space-y-3">
|
||||
{summaryQuery.data.upcomingPayments.map((payment) => (
|
||||
<UpcomingPaymentRow key={payment.id} payment={payment} />
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mt-3 rounded-xl border border-dashed border-border bg-surface px-4 py-6 text-center text-sm text-foreground/60">
|
||||
Sin cobros pendientes por ahora.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
43
apps/web/src/features/home/NextClassHero.tsx
Normal file
43
apps/web/src/features/home/NextClassHero.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import type { NextClassDto } from '@gruperly/shared'
|
||||
import { CalendarClock } from 'lucide-react'
|
||||
import { Badge, Button } from '../../components/ui'
|
||||
import { formatRelativeDateTime, formatSchedule } from '../../lib/format'
|
||||
|
||||
export function NextClassHero({ nextClass }: { nextClass: NextClassDto }) {
|
||||
const navigate = useNavigate()
|
||||
const hasSchedule = (nextClass.days?.length ?? 0) > 0
|
||||
|
||||
return (
|
||||
<article className="relative overflow-hidden rounded-xl border border-accent/30 bg-surface p-5">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-foreground/50">
|
||||
{nextClass.isNow ? 'Clase en curso' : 'Próxima clase'}
|
||||
</p>
|
||||
<h2 className="mt-1 truncate text-xl font-bold text-primary">{nextClass.name}</h2>
|
||||
{hasSchedule ? (
|
||||
<div className="mt-2 flex items-center gap-2 text-sm text-foreground/70">
|
||||
<CalendarClock className="size-4 shrink-0 text-accent" />
|
||||
<span>{formatSchedule(nextClass.days ?? [], nextClass.time)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Badge variant={nextClass.isNow ? 'success' : 'neutral'}>
|
||||
{nextClass.isNow ? 'En curso ahora' : formatRelativeDateTime(nextClass.occurrenceAt)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end border-t border-border pt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void navigate({ to: `/groups/${nextClass.groupId}` })}
|
||||
>
|
||||
Ver grupo
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
67
apps/web/src/features/home/SummaryStats.tsx
Normal file
67
apps/web/src/features/home/SummaryStats.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import type { HomeSummaryDto } from '@gruperly/shared'
|
||||
import { UserCheck, Users, Wallet, type LucideIcon } from 'lucide-react'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { formatPrice } from '../../lib/format'
|
||||
|
||||
type StatTarget = '/groups' | '/payments'
|
||||
|
||||
export function SummaryStats({ summary }: { summary: HomeSummaryDto }) {
|
||||
const navigate = useNavigate()
|
||||
const stats: {
|
||||
label: string
|
||||
value: string
|
||||
sub?: string
|
||||
icon: LucideIcon
|
||||
to: StatTarget
|
||||
}[] = [
|
||||
{
|
||||
label: 'Grupos activos',
|
||||
value: String(summary.stats.groups),
|
||||
icon: Users,
|
||||
to: '/groups',
|
||||
},
|
||||
{
|
||||
label: 'Cobros pendientes',
|
||||
value: summary.stats.pendingAmount > 0 ? formatPrice(summary.stats.pendingAmount) : '0',
|
||||
sub: `${summary.stats.pendingPayments} por cobrar`,
|
||||
icon: Wallet,
|
||||
to: '/payments',
|
||||
},
|
||||
{
|
||||
label: 'Asistentes',
|
||||
value: String(summary.stats.attendees),
|
||||
icon: UserCheck,
|
||||
to: '/groups',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
{stats.map((stat) => {
|
||||
const Icon = stat.icon
|
||||
return (
|
||||
<button
|
||||
key={stat.label}
|
||||
type="button"
|
||||
onClick={() => void navigate({ to: stat.to })}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-xl border border-border bg-surface p-4 text-left transition-all',
|
||||
'hover:border-accent/40',
|
||||
)}
|
||||
>
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-accent-soft text-accent">
|
||||
<Icon className="size-5" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-xl font-bold leading-tight text-primary">{stat.value}</span>
|
||||
<span className="block truncate text-xs text-foreground/60">
|
||||
{stat.sub ?? stat.label}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
58
apps/web/src/features/home/TodayClassCard.tsx
Normal file
58
apps/web/src/features/home/TodayClassCard.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import type { ClassTodayDto } from '@gruperly/shared'
|
||||
import { CalendarClock, ClipboardCheck } from 'lucide-react'
|
||||
import { Badge, Button } from '../../components/ui'
|
||||
|
||||
export function TodayClassCard({ item }: { item: ClassTodayDto }) {
|
||||
const navigate = useNavigate()
|
||||
const time = new Date(item.startsAt).toLocaleTimeString('es-MX', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
|
||||
return (
|
||||
<article className="relative overflow-hidden rounded-xl border border-accent/30 bg-surface p-5">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-foreground/50">
|
||||
Clase de hoy
|
||||
</p>
|
||||
<h2 className="mt-1 truncate text-xl font-bold text-primary">{item.groupName}</h2>
|
||||
<div className="mt-2 flex items-center gap-2 text-sm text-foreground/70">
|
||||
<CalendarClock className="size-4 shrink-0 text-accent" />
|
||||
<span>Hoy · {time} hs</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-foreground/60">
|
||||
{item.enrolledCount} inscrito{item.enrolledCount === 1 ? '' : 's'}
|
||||
{item.availableSlots != null
|
||||
? ` · ${item.availableSlots} cupo${item.availableSlots === 1 ? '' : 's'} disponible${item.availableSlots === 1 ? '' : 's'}`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Badge variant={item.hasAttendance ? 'success' : 'neutral'}>
|
||||
{item.hasAttendance ? 'Asistencia tomada' : 'Por tomar'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end gap-2 border-t border-border pt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void navigate({ to: `/groups/${item.groupId}` })}
|
||||
>
|
||||
Ver grupo
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => void navigate({ to: `/class/${item.sessionId}/attendance` })}
|
||||
>
|
||||
<ClipboardCheck className="size-4" />
|
||||
Tomar Asistencia
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
23
apps/web/src/features/home/UpcomingPaymentRow.tsx
Normal file
23
apps/web/src/features/home/UpcomingPaymentRow.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { UpcomingPaymentDto } from '@gruperly/shared'
|
||||
import { Badge } from '../../components/ui'
|
||||
import { formatPrice, formatRelativeDateTime, PAYMENT_STATUS_LABELS } from '../../lib/format'
|
||||
import { PAYMENT_BADGE_VARIANT } from './constants'
|
||||
|
||||
export function UpcomingPaymentRow({ payment }: { payment: UpcomingPaymentDto }) {
|
||||
return (
|
||||
<li className="flex items-center justify-between gap-3 rounded-xl border border-border bg-surface px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-primary">{payment.attendeeName}</p>
|
||||
<p className="mt-0.5 truncate text-xs text-foreground/60">
|
||||
{payment.groupName} · {formatRelativeDateTime(payment.dueDate)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="text-sm font-semibold text-primary">{formatPrice(payment.amount)}</span>
|
||||
<Badge variant={PAYMENT_BADGE_VARIANT[payment.status]}>
|
||||
{PAYMENT_STATUS_LABELS[payment.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
10
apps/web/src/features/home/constants.ts
Normal file
10
apps/web/src/features/home/constants.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { PaymentDto } from '@gruperly/shared'
|
||||
|
||||
export type PaymentBadgeVariant = 'success' | 'warning' | 'danger' | 'neutral'
|
||||
|
||||
export const PAYMENT_BADGE_VARIANT: Record<PaymentDto['status'], PaymentBadgeVariant> = {
|
||||
PENDING: 'warning',
|
||||
OVERDUE: 'danger',
|
||||
PAID: 'success',
|
||||
CANCELLED: 'neutral',
|
||||
}
|
||||
48
apps/web/src/features/join/GroupInviteSummary.tsx
Normal file
48
apps/web/src/features/join/GroupInviteSummary.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { CalendarClock, GraduationCap, Users } from 'lucide-react'
|
||||
import { Badge } from '../../components/ui'
|
||||
import { BILLING_LABELS, formatPrice, formatSchedule } from '../../lib/format'
|
||||
import { useJoinGroup } from './JoinGroupProvider'
|
||||
|
||||
export function GroupInviteSummary() {
|
||||
const { group } = useJoinGroup()
|
||||
if (!group) return null
|
||||
|
||||
return (
|
||||
<div className="border-b border-border pb-5">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Badge variant="neutral" className="gap-1 text-[11px]">
|
||||
<GraduationCap className="size-3.5 text-accent" />
|
||||
<span>Invitación Oficial</span>
|
||||
</Badge>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-primary">{group.name}</h1>
|
||||
<p className="mt-1 text-sm font-medium text-foreground/80">
|
||||
Profesor: <span className="font-semibold text-primary">{group.teacherName}</span>
|
||||
</p>
|
||||
{group.description ? <p className="mt-2 text-sm text-foreground/60">{group.description}</p> : null}
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2 text-xs">
|
||||
{(group.days?.length ?? 0) > 0 ? (
|
||||
<div className="inline-flex items-center gap-1.5 rounded-lg bg-primary-soft px-2.5 py-1 text-foreground/80">
|
||||
<CalendarClock className="size-3.5 text-accent" />
|
||||
<span>{formatSchedule(group.days ?? [], group.time)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{group.price != null ? (
|
||||
<div className="inline-flex items-center gap-1.5 rounded-lg bg-primary-soft px-2.5 py-1 text-sm font-medium text-foreground/80">
|
||||
<span>{formatPrice(group.price)}</span>
|
||||
{group.billingType ? <span>· {BILLING_LABELS[group.billingType]}</span> : ''}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{group.capacity ? (
|
||||
<div className="inline-flex items-center gap-1.5 rounded-lg bg-primary-soft px-2.5 py-1 text-foreground/80">
|
||||
<Users className="size-3.5 text-accent" />
|
||||
<span>Cupo {group.capacity}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
11
apps/web/src/features/join/InvalidInviteCard.tsx
Normal file
11
apps/web/src/features/join/InvalidInviteCard.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
export function InvalidInviteCard() {
|
||||
return (
|
||||
<div className="space-y-3 rounded-2xl border border-danger/20 bg-danger-soft p-6 text-center sm:p-8">
|
||||
<h2 className="text-xl font-bold text-danger">Enlace no válido o expirado</h2>
|
||||
<p className="mx-auto max-w-sm text-sm text-foreground/70">
|
||||
No pudimos encontrar este grupo. Por favor consulta con tu profesor para solicitar un nuevo enlace de
|
||||
invitación.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
113
apps/web/src/features/join/JoinForm.tsx
Normal file
113
apps/web/src/features/join/JoinForm.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { Button, Input, Label } from '../../components/ui'
|
||||
import { useJoinGroup } from './JoinGroupProvider'
|
||||
|
||||
export function JoinForm() {
|
||||
const {
|
||||
firstName,
|
||||
setFirstName,
|
||||
lastName,
|
||||
setLastName,
|
||||
phone,
|
||||
setPhone,
|
||||
email,
|
||||
setEmail,
|
||||
errorMessage,
|
||||
submit,
|
||||
isSubmitting,
|
||||
} = useJoinGroup()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-primary">Completa tus datos</h2>
|
||||
<p className="mt-0.5 text-xs text-foreground/60">
|
||||
Ingresa tus datos para registrarte en la lista de alumnos de este grupo.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="animate-fade-in rounded-xl border border-danger/30 bg-danger-soft p-3 text-xs font-medium text-danger">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
submit()
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="student-first-name" className="mb-1 block text-xs">
|
||||
Nombre <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="student-first-name"
|
||||
placeholder="Ej. Sofía"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="student-last-name" className="mb-1 block text-xs">
|
||||
Apellido <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="student-last-name"
|
||||
placeholder="Ej. Fernández"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="student-phone" className="mb-1 block text-xs">
|
||||
Teléfono / WhatsApp <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="student-phone"
|
||||
placeholder="Ej. +54 9 11 2345-6789"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<p className="mt-1 text-[11px] text-foreground/50">
|
||||
El profesor se comunicará contigo por WhatsApp a este número.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="student-email" className="mb-1 block text-xs">
|
||||
Email (opcional)
|
||||
</Label>
|
||||
<Input
|
||||
id="student-email"
|
||||
type="email"
|
||||
placeholder="alumno@ejemplo.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={isSubmitting}
|
||||
className="w-full gap-2 py-2.5 text-sm font-semibold"
|
||||
>
|
||||
{isSubmitting ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
<span>Completar Inscripción</span>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
128
apps/web/src/features/join/JoinGroupProvider.tsx
Normal file
128
apps/web/src/features/join/JoinGroupProvider.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { createContext, useContext, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import type { GroupInviteInfoDto } from '@gruperly/shared'
|
||||
import { ApiError, getInviteInfo, joinViaInvite } from '../../lib/api'
|
||||
|
||||
type RegisteredAttendee = {
|
||||
fullName: string
|
||||
phone: string | null
|
||||
}
|
||||
|
||||
type JoinGroupContextValue = {
|
||||
token: string
|
||||
group: GroupInviteInfoDto | undefined
|
||||
invitePending: boolean
|
||||
inviteFailed: boolean
|
||||
firstName: string
|
||||
setFirstName: (value: string) => void
|
||||
lastName: string
|
||||
setLastName: (value: string) => void
|
||||
phone: string
|
||||
setPhone: (value: string) => void
|
||||
email: string
|
||||
setEmail: (value: string) => void
|
||||
errorMessage: string | null
|
||||
registeredAttendee: RegisteredAttendee | null
|
||||
waitlistMessage: string | null
|
||||
submit: () => void
|
||||
isSubmitting: boolean
|
||||
}
|
||||
|
||||
const JoinGroupContext = createContext<JoinGroupContextValue | null>(null)
|
||||
|
||||
export function JoinGroupProvider({ token, children }: { token: string; children: ReactNode }) {
|
||||
const [firstName, setFirstName] = useState('')
|
||||
const [lastName, setLastName] = useState('')
|
||||
const [phone, setPhone] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [registeredAttendee, setRegisteredAttendee] = useState<RegisteredAttendee | null>(null)
|
||||
const [waitlistMessage, setWaitlistMessage] = useState<string | null>(null)
|
||||
|
||||
const inviteQuery = useQuery({
|
||||
queryKey: ['public-invite', token],
|
||||
queryFn: () => getInviteInfo(token),
|
||||
enabled: Boolean(token),
|
||||
retry: 1,
|
||||
})
|
||||
|
||||
const joinMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
joinViaInvite(token, {
|
||||
firstName: firstName.trim(),
|
||||
lastName: lastName.trim(),
|
||||
phone: phone.trim(),
|
||||
email: email.trim() || undefined,
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
setErrorMessage(null)
|
||||
if (data.status === 'waitlisted') {
|
||||
setRegisteredAttendee(null)
|
||||
setWaitlistMessage(data.message)
|
||||
return
|
||||
}
|
||||
setWaitlistMessage(null)
|
||||
setRegisteredAttendee({
|
||||
fullName: data.attendee?.fullName ?? `${firstName.trim()} ${lastName.trim()}`.trim(),
|
||||
phone: data.attendee?.phone ?? null,
|
||||
})
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
if (error instanceof ApiError) {
|
||||
if (error.problem?.code === 'attendee_already_registered') {
|
||||
setErrorMessage(
|
||||
'Ya te encuentras registrado en este grupo con ese número de teléfono. El profesor ya tiene tus datos.',
|
||||
)
|
||||
return
|
||||
}
|
||||
setErrorMessage(error.message || 'Error al procesar la inscripción.')
|
||||
return
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
setErrorMessage(error.message)
|
||||
return
|
||||
}
|
||||
setErrorMessage('Ocurrió un error inesperado. Por favor, intenta de nuevo.')
|
||||
},
|
||||
})
|
||||
|
||||
const submit = () => {
|
||||
setErrorMessage(null)
|
||||
if (!firstName.trim() || !lastName.trim() || !phone.trim()) {
|
||||
setErrorMessage('Por favor completa los campos requeridos (Nombre, Apellido y Teléfono).')
|
||||
return
|
||||
}
|
||||
joinMutation.mutate()
|
||||
}
|
||||
|
||||
const value: JoinGroupContextValue = {
|
||||
token,
|
||||
group: inviteQuery.data,
|
||||
invitePending: inviteQuery.isPending,
|
||||
inviteFailed: inviteQuery.isError,
|
||||
firstName,
|
||||
setFirstName,
|
||||
lastName,
|
||||
setLastName,
|
||||
phone,
|
||||
setPhone,
|
||||
email,
|
||||
setEmail,
|
||||
errorMessage,
|
||||
registeredAttendee,
|
||||
waitlistMessage,
|
||||
submit,
|
||||
isSubmitting: joinMutation.isPending,
|
||||
}
|
||||
|
||||
return <JoinGroupContext.Provider value={value}>{children}</JoinGroupContext.Provider>
|
||||
}
|
||||
|
||||
export function useJoinGroup() {
|
||||
const context = useContext(JoinGroupContext)
|
||||
if (!context) {
|
||||
throw new Error('useJoinGroup debe usarse dentro de <JoinGroupProvider>')
|
||||
}
|
||||
return context
|
||||
}
|
||||
43
apps/web/src/features/join/JoinGroupView.tsx
Normal file
43
apps/web/src/features/join/JoinGroupView.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { PublicFooter, PublicHeader } from '../../components/layout'
|
||||
import { GroupInviteSummary } from './GroupInviteSummary'
|
||||
import { InvalidInviteCard } from './InvalidInviteCard'
|
||||
import { JoinForm } from './JoinForm'
|
||||
import { JoinSuccessCard } from './JoinSuccessCard'
|
||||
import { useJoinGroup } from './JoinGroupProvider'
|
||||
import { WaitlistNoticeCard } from './WaitlistNoticeCard'
|
||||
|
||||
export function JoinGroupView() {
|
||||
const { group, invitePending, inviteFailed, registeredAttendee, waitlistMessage } = useJoinGroup()
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col justify-between bg-background text-primary selection:bg-accent selection:text-white">
|
||||
<PublicHeader />
|
||||
|
||||
<main className="flex flex-1 items-center justify-center p-4 sm:p-6 md:p-10">
|
||||
<div className="w-full max-w-lg space-y-6">
|
||||
{invitePending ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-20">
|
||||
<Loader2 className="size-8 animate-spin text-accent" />
|
||||
<p className="text-sm text-foreground/60">Cargando información del grupo...</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{inviteFailed || (!group && !invitePending) ? <InvalidInviteCard /> : null}
|
||||
|
||||
{registeredAttendee ? <JoinSuccessCard /> : null}
|
||||
{waitlistMessage ? <WaitlistNoticeCard /> : null}
|
||||
|
||||
{!registeredAttendee && !waitlistMessage && group ? (
|
||||
<div className="space-y-6 rounded-2xl border border-border bg-surface p-6 shadow-xl sm:p-8">
|
||||
<GroupInviteSummary />
|
||||
<JoinForm />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<PublicFooter />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
49
apps/web/src/features/join/JoinSuccessCard.tsx
Normal file
49
apps/web/src/features/join/JoinSuccessCard.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { CheckCircle2 } from 'lucide-react'
|
||||
import { Badge } from '../../components/ui'
|
||||
import { useJoinGroup } from './JoinGroupProvider'
|
||||
|
||||
export function JoinSuccessCard() {
|
||||
const { group, registeredAttendee } = useJoinGroup()
|
||||
if (!group || !registeredAttendee) return null
|
||||
|
||||
return (
|
||||
<div className="animate-step-enter space-y-5 rounded-2xl border border-success/30 bg-surface p-6 text-center shadow-xl sm:p-8">
|
||||
<div className="mx-auto flex size-16 items-center justify-center rounded-full bg-success-soft text-success">
|
||||
<CheckCircle2 className="size-10" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Badge variant="success" className="mb-2">
|
||||
Registro Confirmado
|
||||
</Badge>
|
||||
<h1 className="text-2xl font-bold text-primary">¡Inscripción Exitosa!</h1>
|
||||
<p className="mt-2 text-sm leading-relaxed text-foreground/70">
|
||||
Tus datos han sido registrados con éxito en el grupo{' '}
|
||||
<strong className="font-semibold text-primary">{group.name}</strong>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 rounded-xl border border-border bg-primary-soft/50 p-4 text-left text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-foreground/60">Profesor:</span>
|
||||
<span className="font-semibold text-primary">{group.teacherName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-foreground/60">Alumno:</span>
|
||||
<span className="font-semibold text-primary">{registeredAttendee.fullName}</span>
|
||||
</div>
|
||||
{registeredAttendee.phone ? (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-foreground/60">Teléfono registrado:</span>
|
||||
<span className="font-semibold text-primary">{registeredAttendee.phone}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-foreground/60">
|
||||
El profesor se pondrá en contacto contigo a la brevedad por WhatsApp para darte la bienvenida y
|
||||
coordinar los detalles de la clase.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
29
apps/web/src/features/join/WaitlistNoticeCard.tsx
Normal file
29
apps/web/src/features/join/WaitlistNoticeCard.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Clock } from 'lucide-react'
|
||||
import { Badge } from '../../components/ui'
|
||||
import { useJoinGroup } from './JoinGroupProvider'
|
||||
|
||||
export function WaitlistNoticeCard() {
|
||||
const { group, waitlistMessage } = useJoinGroup()
|
||||
if (!group || !waitlistMessage) return null
|
||||
|
||||
return (
|
||||
<div className="animate-step-enter space-y-5 rounded-2xl border border-accent/30 bg-surface p-6 text-center shadow-xl sm:p-8">
|
||||
<div className="mx-auto flex size-16 items-center justify-center rounded-full bg-accent-soft text-accent">
|
||||
<Clock className="size-10" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Badge variant="neutral" className="mb-2">
|
||||
Lista de Espera
|
||||
</Badge>
|
||||
<h1 className="text-2xl font-bold text-primary">Cupo completo</h1>
|
||||
<p className="mt-2 text-sm leading-relaxed text-foreground/70">{waitlistMessage}</p>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-foreground/60">
|
||||
El profesor se pondrá en contacto contigo cuando haya un lugar disponible en{' '}
|
||||
<strong className="font-semibold text-primary">{group.name}</strong>.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
84
apps/web/src/features/onboarding/ConfirmationStep.tsx
Normal file
84
apps/web/src/features/onboarding/ConfirmationStep.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import type { OnboardingGroupDto } from '@gruperly/shared'
|
||||
import { ArrowRight, CheckCircle2, CreditCard } from 'lucide-react'
|
||||
import { useAuth } from '../../context/AuthProvider'
|
||||
import { Badge, Button } from '../../components/ui'
|
||||
import { BILLING_LABELS, formatPrice, formatSchedule } from './constants'
|
||||
|
||||
type ConfirmationStepProps = {
|
||||
group: OnboardingGroupDto
|
||||
providerName?: string
|
||||
}
|
||||
|
||||
export function ConfirmationStep({ group, providerName }: ConfirmationStepProps) {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const { refresh } = useAuth()
|
||||
|
||||
const goToDashboard = () => {
|
||||
// Revalida la caché (estado del onboarding, sesión, grupos) antes de redirigir.
|
||||
void queryClient.invalidateQueries()
|
||||
void refresh()
|
||||
void navigate({ to: '/' })
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<header className="space-y-2 text-center">
|
||||
<span className="mx-auto flex size-14 items-center justify-center rounded-2xl bg-success-soft">
|
||||
<CheckCircle2 className="size-7 text-success" />
|
||||
</span>
|
||||
<h2 className="text-2xl font-bold text-primary">¡Todo listo!</h2>
|
||||
<p className="text-sm text-foreground/60">
|
||||
Tu grupo se creó y ya podés empezar a cobrar a tus miembros.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="rounded-xl border border-border bg-surface p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-base font-semibold text-primary">{group.name}</h3>
|
||||
<p className="mt-0.5 text-sm text-foreground/60">
|
||||
{formatSchedule(group.days ?? [], group.time)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="success">Activo</Badge>
|
||||
</div>
|
||||
|
||||
<dl className="mt-4 divide-y divide-border text-sm">
|
||||
<div className="flex items-center justify-between py-2.5">
|
||||
<dt className="text-foreground/60">Precio</dt>
|
||||
<dd className="font-semibold text-primary">
|
||||
{formatPrice(group.price)}
|
||||
{group.billingType ? ` · ${BILLING_LABELS[group.billingType]}` : ''}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2.5">
|
||||
<dt className="text-foreground/60">Cupo</dt>
|
||||
<dd className="font-semibold text-primary">{group.capacity ?? '—'} miembros</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2.5">
|
||||
<dt className="text-foreground/60">Vencimiento</dt>
|
||||
<dd className="font-semibold text-primary">Día {group.dueDay ?? '—'} de cada mes</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{providerName ? (
|
||||
<div className="flex items-center gap-2 rounded-xl bg-primary-soft px-4 py-3">
|
||||
<CreditCard className="size-4 shrink-0 text-accent" />
|
||||
<p className="text-xs text-primary">
|
||||
Vas a cobrar con <span className="font-semibold">{providerName}</span> en modo
|
||||
prueba.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button variant="primary" className="w-full" onClick={goToDashboard}>
|
||||
Ir a mi Panel
|
||||
<ArrowRight className="size-4" />
|
||||
</Button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
30
apps/web/src/features/onboarding/FirstGroupStep.tsx
Normal file
30
apps/web/src/features/onboarding/FirstGroupStep.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import type { CreateFirstGroup, OnboardingGroupDto } from '@gruperly/shared'
|
||||
import { createFirstGroup } from '../../lib/api'
|
||||
import { GroupForm } from '../groups/GroupForm'
|
||||
|
||||
type FirstGroupStepProps = {
|
||||
onBack: () => void
|
||||
onCompleted: (group: OnboardingGroupDto) => void
|
||||
}
|
||||
|
||||
export function FirstGroupStep({ onBack, onCompleted }: FirstGroupStepProps) {
|
||||
const create = useMutation({
|
||||
mutationFn: (values: CreateFirstGroup) => createFirstGroup(values),
|
||||
onSuccess: (result) => onCompleted(result.group),
|
||||
})
|
||||
|
||||
return (
|
||||
<GroupForm
|
||||
heading="Tu primer grupo"
|
||||
description="Definí los datos de la clase que vas a cobrar. Después siempre podés editarlos."
|
||||
submitLabel="Crear grupo"
|
||||
isSubmitting={create.isPending}
|
||||
errorMessage={
|
||||
create.isError ? 'No pudimos crear el grupo. Revisá los datos e intentá de nuevo.' : null
|
||||
}
|
||||
onSubmit={(values) => create.mutate(values)}
|
||||
onBack={onBack}
|
||||
/>
|
||||
)
|
||||
}
|
||||
72
apps/web/src/features/onboarding/OnboardingProvider.tsx
Normal file
72
apps/web/src/features/onboarding/OnboardingProvider.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { ConnectPaymentResult, OnboardingGroupDto, OnboardingStatusDto } from '@gruperly/shared'
|
||||
import type { UseQueryResult } from '@tanstack/react-query'
|
||||
import { useAuth } from '../../context/AuthProvider'
|
||||
import { getOnboardingStatus } from '../../lib/api'
|
||||
import { PAYMENT_PROVIDERS } from './constants'
|
||||
|
||||
type OnboardingContextValue = {
|
||||
step: number
|
||||
setStep: (step: number) => void
|
||||
paymentResult: ConnectPaymentResult | null
|
||||
setPaymentResult: (result: ConnectPaymentResult) => void
|
||||
createdGroup: OnboardingGroupDto | null
|
||||
setCreatedGroup: (group: OnboardingGroupDto) => void
|
||||
providerName: string | undefined
|
||||
statusQuery: UseQueryResult<OnboardingStatusDto>
|
||||
}
|
||||
|
||||
const OnboardingContext = createContext<OnboardingContextValue | null>(null)
|
||||
|
||||
export function OnboardingProvider({ children }: { children: ReactNode }) {
|
||||
const { user } = useAuth()
|
||||
const [step, setStep] = useState(0)
|
||||
const [paymentResult, setPaymentResult] = useState<ConnectPaymentResult | null>(null)
|
||||
const [createdGroup, setCreatedGroup] = useState<OnboardingGroupDto | null>(null)
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ['onboarding', 'status'],
|
||||
queryFn: getOnboardingStatus,
|
||||
enabled: !!user,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const status = statusQuery.data
|
||||
if (!status) return
|
||||
// Si interrumpió después de conectar el cobro, retomamos en el paso del grupo.
|
||||
if (status.step === 'PAYMENT_CONNECTED') {
|
||||
setStep((current) => (current === 0 ? 2 : current))
|
||||
}
|
||||
}, [statusQuery.data])
|
||||
|
||||
const providerName = paymentResult
|
||||
? PAYMENT_PROVIDERS.find((p) => p.value === paymentResult.provider)?.name
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<OnboardingContext.Provider
|
||||
value={{
|
||||
step,
|
||||
setStep,
|
||||
paymentResult,
|
||||
setPaymentResult,
|
||||
createdGroup,
|
||||
setCreatedGroup,
|
||||
providerName,
|
||||
statusQuery,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</OnboardingContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useOnboarding() {
|
||||
const context = useContext(OnboardingContext)
|
||||
if (!context) {
|
||||
throw new Error('useOnboarding debe usarse dentro de <OnboardingProvider>')
|
||||
}
|
||||
return context
|
||||
}
|
||||
117
apps/web/src/features/onboarding/OnboardingView.tsx
Normal file
117
apps/web/src/features/onboarding/OnboardingView.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useEffect } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Logo, LogoIcon } from '../../components/brand'
|
||||
import { Button } from '../../components/ui'
|
||||
import { useAuth } from '../../context/AuthProvider'
|
||||
import { useOnboarding } from './OnboardingProvider'
|
||||
import { Stepper } from './Stepper'
|
||||
import { WelcomeStep } from './WelcomeStep'
|
||||
import { PaymentStep } from './PaymentStep'
|
||||
import { FirstGroupStep } from './FirstGroupStep'
|
||||
import { ConfirmationStep } from './ConfirmationStep'
|
||||
|
||||
const STEPS = [
|
||||
{ label: 'Bienvenida' },
|
||||
{ label: 'Cobros' },
|
||||
{ label: 'Tu grupo' },
|
||||
{ label: 'Listo' },
|
||||
]
|
||||
|
||||
export function OnboardingView() {
|
||||
const { user, isPending: authPending } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const {
|
||||
step,
|
||||
setStep,
|
||||
paymentResult,
|
||||
setPaymentResult,
|
||||
createdGroup,
|
||||
setCreatedGroup,
|
||||
providerName,
|
||||
statusQuery,
|
||||
} = useOnboarding()
|
||||
|
||||
useEffect(() => {
|
||||
if (statusQuery.data?.completed) {
|
||||
void navigate({ to: '/' })
|
||||
}
|
||||
}, [statusQuery.data, navigate])
|
||||
|
||||
if (authPending) {
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center">
|
||||
<Loader2 className="size-6 animate-spin text-foreground/40" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm space-y-4 text-center">
|
||||
<span className="mx-auto flex size-14 items-center justify-center rounded-2xl bg-accent-soft">
|
||||
<LogoIcon className="size-8" />
|
||||
</span>
|
||||
<h1 className="text-2xl font-bold text-primary">Tu cuenta, lista</h1>
|
||||
<p className="text-sm text-foreground/60">
|
||||
Iniciá sesión para configurar tus cobros y crear tu primer grupo.
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={() => void navigate({ to: '/login' })}
|
||||
>
|
||||
Iniciar sesión
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (statusQuery.isPending) {
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center">
|
||||
<Loader2 className="size-6 animate-spin text-foreground/40" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const firstName = user.name?.trim().split(/\s+/)[0] ?? 'profesor/a'
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-dvh w-full max-w-md flex-col px-4 pb-12 pt-8 sm:pt-12">
|
||||
<header className="mb-6 flex items-center justify-center">
|
||||
<Logo className="h-6" />
|
||||
</header>
|
||||
|
||||
<Stepper steps={STEPS} current={step} />
|
||||
|
||||
<div key={step} className="mt-8 animate-step-enter">
|
||||
{step === 0 ? <WelcomeStep name={firstName} onNext={() => setStep(1)} /> : null}
|
||||
{step === 1 ? (
|
||||
<PaymentStep
|
||||
initialResult={paymentResult}
|
||||
onConnected={(result) => {
|
||||
setPaymentResult(result)
|
||||
setStep(2)
|
||||
}}
|
||||
onBack={() => setStep(0)}
|
||||
/>
|
||||
) : null}
|
||||
{step === 2 ? (
|
||||
<FirstGroupStep
|
||||
onBack={() => setStep(1)}
|
||||
onCompleted={(group) => {
|
||||
setCreatedGroup(group)
|
||||
setStep(3)
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{step === 3 && createdGroup ? (
|
||||
<ConfirmationStep group={createdGroup} providerName={providerName} />
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
171
apps/web/src/features/onboarding/PaymentStep.tsx
Normal file
171
apps/web/src/features/onboarding/PaymentStep.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import type { ConnectPaymentResult, PaymentProvider } from '@gruperly/shared'
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
BadgeCheck,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
CreditCard,
|
||||
Loader2,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react'
|
||||
import { connectPayment } from '../../lib/api'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { Badge, Button } from '../../components/ui'
|
||||
import { PAYMENT_PROVIDERS } from './constants'
|
||||
|
||||
type PaymentStepProps = {
|
||||
initialResult: ConnectPaymentResult | null
|
||||
onConnected: (result: ConnectPaymentResult) => void
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
export function PaymentStep({ initialResult, onConnected, onBack }: PaymentStepProps) {
|
||||
const [selected, setSelected] = useState<PaymentProvider>('MERCADO_PAGO')
|
||||
const [connected, setConnected] = useState<ConnectPaymentResult | null>(initialResult)
|
||||
|
||||
const connect = useMutation({
|
||||
mutationFn: () => connectPayment({ provider: selected, sandbox: true }),
|
||||
onSuccess: (result) => {
|
||||
setConnected(result)
|
||||
onConnected(result)
|
||||
},
|
||||
})
|
||||
|
||||
if (connected) {
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<header className="space-y-1">
|
||||
<h2 className="text-2xl font-bold text-primary">Tu cobro está conectado</h2>
|
||||
<p className="text-sm text-foreground/60">
|
||||
Ya podés dejar listo tu primer grupo para cobrar con{' '}
|
||||
{PAYMENT_PROVIDERS.find((p) => p.value === connected.provider)?.name ?? ''}.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="rounded-xl border border-success/30 bg-success-soft p-5 text-center">
|
||||
<span className="mx-auto flex size-12 items-center justify-center rounded-full bg-surface">
|
||||
<CheckCircle2 className="size-6 text-success" />
|
||||
</span>
|
||||
<p className="mt-3 text-sm font-semibold text-success">Cuenta conectada</p>
|
||||
<div className="mt-1 flex items-center justify-center gap-2">
|
||||
<span className="text-sm font-medium text-primary">
|
||||
{PAYMENT_PROVIDERS.find((p) => p.value === connected.provider)?.name}
|
||||
</span>
|
||||
<Badge variant="success">Modo prueba</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline" onClick={() => setConnected(null)}>
|
||||
<ArrowLeft className="size-4" />
|
||||
Cambiar
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="flex-1"
|
||||
onClick={() => onConnected(connected)}
|
||||
>
|
||||
Continuar
|
||||
<ArrowRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-5">
|
||||
<header className="space-y-1">
|
||||
<h2 className="text-2xl font-bold text-primary">Elegí tu procesador de cobro</h2>
|
||||
<p className="text-sm text-foreground/60">
|
||||
Los pagos de tus miembros van a llegar por esta plataforma.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div role="radiogroup" aria-label="Procesador de pago" className="space-y-3">
|
||||
{PAYMENT_PROVIDERS.map((provider) => {
|
||||
const isSelected = selected === provider.value
|
||||
return (
|
||||
<button
|
||||
key={provider.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isSelected}
|
||||
onClick={() => setSelected(provider.value)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-xl border bg-surface p-4 text-left transition-colors',
|
||||
isSelected
|
||||
? 'border-accent ring-2 ring-accent/20'
|
||||
: 'border-border hover:bg-primary-soft',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-lg p-2',
|
||||
isSelected ? 'bg-accent-soft' : 'bg-primary-soft',
|
||||
)}
|
||||
>
|
||||
<CreditCard
|
||||
className={cn('size-5', isSelected ? 'text-accent' : 'text-primary/40')}
|
||||
/>
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-semibold text-primary">
|
||||
{provider.name}
|
||||
</span>
|
||||
<span className="block text-xs text-foreground/60">
|
||||
{provider.description}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-5 shrink-0 items-center justify-center rounded-full border transition-colors',
|
||||
isSelected ? 'border-accent bg-accent text-on-accent' : 'border-border',
|
||||
)}
|
||||
>
|
||||
{isSelected ? <Check className="size-3" strokeWidth={3} /> : null}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 rounded-xl bg-warning-soft px-4 py-3">
|
||||
<ShieldCheck className="mt-0.5 size-4 shrink-0 text-warning" />
|
||||
<p className="text-xs text-warning">
|
||||
Por ahora la conexión se hace en modo prueba (sandbox). Más adelante vas a poder
|
||||
vincular tu cuenta real de {selected === 'STRIPE' ? 'Stripe' : 'Mercado Pago'}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{connect.isError ? (
|
||||
<p className="rounded-xl bg-danger-soft px-4 py-3 text-sm text-danger">
|
||||
No pudimos conectar la cuenta. Intentalo de nuevo.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline" onClick={onBack}>
|
||||
<ArrowLeft className="size-4" />
|
||||
Volver
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="flex-1"
|
||||
disabled={connect.isPending}
|
||||
onClick={() => connect.mutate()}
|
||||
>
|
||||
{connect.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<BadgeCheck className="size-4" />
|
||||
)}
|
||||
{connect.isPending ? 'Conectando…' : 'Conectar cuenta'}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
59
apps/web/src/features/onboarding/Stepper.tsx
Normal file
59
apps/web/src/features/onboarding/Stepper.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Check } from 'lucide-react'
|
||||
import { cn } from '../../lib/utils'
|
||||
|
||||
export type StepperProps = {
|
||||
steps: readonly { label: string }[]
|
||||
current: number
|
||||
}
|
||||
|
||||
export function Stepper({ steps, current }: StepperProps) {
|
||||
return (
|
||||
<ol aria-label="Progreso del alta" className="flex items-center">
|
||||
{steps.map((step, index) => {
|
||||
const isDone = index < current
|
||||
const isActive = index === current
|
||||
|
||||
return (
|
||||
<li
|
||||
key={step.label}
|
||||
className={cn('flex items-center', index < steps.length - 1 ? 'flex-1' : '')}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-8 items-center justify-center rounded-full border text-sm font-semibold transition-colors',
|
||||
isDone && 'border-accent bg-accent text-on-accent',
|
||||
isActive && 'border-accent text-accent ring-4 ring-accent/15',
|
||||
!isDone && !isActive && 'border-border text-foreground/40',
|
||||
)}
|
||||
>
|
||||
{isDone ? (
|
||||
<Check className="size-4" strokeWidth={3} />
|
||||
) : (
|
||||
<span>{index + 1}</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'hidden text-xs font-medium sm:block',
|
||||
isActive ? 'text-accent' : 'text-foreground/50',
|
||||
)}
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{index < steps.length - 1 ? (
|
||||
<span
|
||||
className={cn(
|
||||
'mx-2 mb-0 h-px flex-1 rounded-full transition-colors sm:mb-4',
|
||||
isDone ? 'bg-accent' : 'bg-border',
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
63
apps/web/src/features/onboarding/WelcomeStep.tsx
Normal file
63
apps/web/src/features/onboarding/WelcomeStep.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { ArrowRight, Rocket, Users, Wallet } from 'lucide-react'
|
||||
import { Button } from '../../components/ui'
|
||||
|
||||
type WelcomeStepProps = {
|
||||
name: string
|
||||
onNext: () => void
|
||||
}
|
||||
|
||||
const STEPS_TO_SETUP = [
|
||||
{
|
||||
icon: Wallet,
|
||||
title: 'Conectá tu cuenta de cobro',
|
||||
description: 'Mercado Pago o Stripe, en modo prueba por ahora.',
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: 'Creá tu primer grupo',
|
||||
description: 'Días, horario, precio y cupo de tu clase.',
|
||||
},
|
||||
{
|
||||
icon: Rocket,
|
||||
title: 'Empezá a cobrar',
|
||||
description: 'Todo listo para sumar miembros y cobrar al instante.',
|
||||
},
|
||||
]
|
||||
|
||||
export function WelcomeStep({ name, onNext }: WelcomeStepProps) {
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<header className="space-y-2 text-center">
|
||||
<span className="mx-auto flex size-14 items-center justify-center rounded-2xl bg-accent-soft">
|
||||
<Rocket className="size-7 text-accent" />
|
||||
</span>
|
||||
<h1 className="text-2xl font-bold text-primary">¡Hola, {name}!</h1>
|
||||
<p className="text-sm text-foreground/60">
|
||||
Vamos a configurar tu cuenta en 3 pasos. Vas a tardar menos de 5 minutos.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ol className="space-y-3">
|
||||
{STEPS_TO_SETUP.map(({ icon: Icon, title, description }) => (
|
||||
<li
|
||||
key={title}
|
||||
className="flex items-center gap-3 rounded-xl border border-border bg-surface p-4"
|
||||
>
|
||||
<span className="rounded-lg bg-accent-soft p-2">
|
||||
<Icon className="size-5 text-accent" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-primary">{title}</p>
|
||||
<p className="text-xs text-foreground/60">{description}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<Button variant="primary" size="md" className="w-full" onClick={onNext}>
|
||||
Comenzar
|
||||
<ArrowRight className="size-4" />
|
||||
</Button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
44
apps/web/src/features/onboarding/constants.ts
Normal file
44
apps/web/src/features/onboarding/constants.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { BillingType, PaymentProvider, WeekDay } from '@gruperly/shared'
|
||||
export { BILLING_LABELS, WEEK_DAY_FULL_LABELS, formatPrice, formatSchedule } from '../../lib/format'
|
||||
|
||||
export const WEEK_DAYS: readonly WeekDay[] = [
|
||||
'MONDAY',
|
||||
'TUESDAY',
|
||||
'WEDNESDAY',
|
||||
'THURSDAY',
|
||||
'FRIDAY',
|
||||
'SATURDAY',
|
||||
'SUNDAY',
|
||||
]
|
||||
|
||||
export const WEEK_DAY_CHIPS: readonly { value: WeekDay; label: string }[] = [
|
||||
{ value: 'MONDAY', label: 'Lun' },
|
||||
{ value: 'TUESDAY', label: 'Mar' },
|
||||
{ value: 'WEDNESDAY', label: 'Mié' },
|
||||
{ value: 'THURSDAY', label: 'Jue' },
|
||||
{ value: 'FRIDAY', label: 'Vie' },
|
||||
{ value: 'SATURDAY', label: 'Sáb' },
|
||||
{ value: 'SUNDAY', label: 'Dom' },
|
||||
]
|
||||
|
||||
export const PAYMENT_PROVIDERS: readonly {
|
||||
value: PaymentProvider
|
||||
name: string
|
||||
description: string
|
||||
}[] = [
|
||||
{
|
||||
value: 'MERCADO_PAGO',
|
||||
name: 'Mercado Pago',
|
||||
description: 'El procesador más usado en Latinoamérica',
|
||||
},
|
||||
{
|
||||
value: 'STRIPE',
|
||||
name: 'Stripe',
|
||||
description: 'Cobrá con tarjetas e internacionalmente',
|
||||
},
|
||||
]
|
||||
|
||||
export const BILLING_TYPES: readonly { value: BillingType; label: string; hint: string }[] = [
|
||||
{ value: 'MONTHLY', label: 'Mensual', hint: 'Un cobro por mes' },
|
||||
{ value: 'PER_CLASS', label: 'Por clase', hint: 'Cada clase que asista' },
|
||||
]
|
||||
8
apps/web/src/features/payments/PaymentsView.tsx
Normal file
8
apps/web/src/features/payments/PaymentsView.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
export function PaymentsView() {
|
||||
return (
|
||||
<section>
|
||||
<h1 className="text-2xl font-bold text-primary">Cobros</h1>
|
||||
<p className="mt-2 text-sm text-foreground/60">Sigue los pagos de tus grupos.</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
27
apps/web/src/features/profile/ProfileView.tsx
Normal file
27
apps/web/src/features/profile/ProfileView.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useAuth } from '../../context/AuthProvider'
|
||||
import { Avatar } from '../../components/ui'
|
||||
|
||||
export function ProfileView() {
|
||||
const { user } = useAuth()
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-primary">Mi perfil</h1>
|
||||
<p className="mt-1 text-sm text-foreground/60">Tus datos personales.</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border bg-surface p-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar name={user?.name} src={user?.image ?? undefined} className="size-14 text-lg" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-base font-semibold text-primary">
|
||||
{user?.name ?? 'Usuario'}
|
||||
</p>
|
||||
<p className="truncate text-sm text-foreground/50">{user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
56
apps/web/src/features/security/PasskeyList.tsx
Normal file
56
apps/web/src/features/security/PasskeyList.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useState } from 'react'
|
||||
import { Fingerprint, Loader2, Trash2 } from 'lucide-react'
|
||||
import { authClient } from '../../lib/auth-client'
|
||||
import { Button } from '../../components/ui'
|
||||
|
||||
export function PasskeyList({ onRefresh }: { onRefresh: () => void }) {
|
||||
const { data, isPending } = authClient.useListPasskeys()
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
setDeletingId(id)
|
||||
const { error } = await authClient.$fetch('/passkey/delete-passkey', {
|
||||
method: 'POST',
|
||||
body: { id },
|
||||
})
|
||||
setDeletingId(null)
|
||||
if (!error) onRefresh()
|
||||
}
|
||||
|
||||
if (isPending) {
|
||||
return <Loader2 className="size-5 animate-spin text-foreground/40" />
|
||||
}
|
||||
|
||||
const passkeys = data ?? []
|
||||
if (passkeys.length === 0) {
|
||||
return <p className="text-sm text-foreground/60">Todavía no registraste ninguna passkey.</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="divide-y divide-border rounded-xl border border-border bg-surface">
|
||||
{passkeys.map((passkey) => (
|
||||
<li key={passkey.id} className="flex items-center gap-3 px-4 py-3">
|
||||
<span className="rounded-lg bg-accent-soft p-2">
|
||||
<Fingerprint className="size-4 text-accent" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-primary">{passkey.name ?? 'Passkey'}</p>
|
||||
<p className="text-xs text-foreground/50">
|
||||
{passkey.deviceType === 'singleDevice' ? 'Dispositivo' : 'Llave de seguridad'} ·{' '}
|
||||
{new Date(passkey.createdAt).toLocaleDateString('es-AR')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label="Eliminar passkey"
|
||||
disabled={deletingId === passkey.id}
|
||||
onClick={() => void handleDelete(passkey.id)}
|
||||
>
|
||||
<Trash2 className="size-4 text-danger" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
123
apps/web/src/features/security/SecurityPage.tsx
Normal file
123
apps/web/src/features/security/SecurityPage.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Fingerprint, KeyRound, Loader2, Plus } from 'lucide-react'
|
||||
import { authClient } from '../../lib/auth-client'
|
||||
import { Button, Input, Label } from '../../components/ui'
|
||||
import { PasskeyList } from './PasskeyList'
|
||||
import { changePasswordSchema } from './schema'
|
||||
import type { ChangePasswordValues } from './schema'
|
||||
|
||||
export function SecurityPage() {
|
||||
const [passkeyKeys, setPasskeyKeys] = useState(0)
|
||||
const [feedback, setFeedback] = useState<string | null>(null)
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setError,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<ChangePasswordValues>({ resolver: zodResolver(changePasswordSchema) })
|
||||
|
||||
const refreshPasskeys = () => {
|
||||
setPasskeyKeys((k) => k + 1)
|
||||
}
|
||||
|
||||
const handleAddPasskey = async () => {
|
||||
setFeedback(null)
|
||||
const { error } = await authClient.passkey.addPasskey()
|
||||
if (error) {
|
||||
setFeedback(`No se pudo agregar la passkey: ${error.message ?? 'error desconocido'}`)
|
||||
} else {
|
||||
setFeedback('Passkey registrada correctamente.')
|
||||
refreshPasskeys()
|
||||
}
|
||||
}
|
||||
|
||||
const onChangePassword = handleSubmit(async ({ currentPassword, newPassword }) => {
|
||||
setFeedback(null)
|
||||
const { error } = await authClient.changePassword({
|
||||
currentPassword,
|
||||
newPassword,
|
||||
revokeOtherSessions: true,
|
||||
})
|
||||
if (error) {
|
||||
setError('currentPassword', {
|
||||
message: error.message ?? 'No se pudo cambiar la contraseña',
|
||||
})
|
||||
return
|
||||
}
|
||||
reset({ currentPassword: '', newPassword: '' })
|
||||
setFeedback('Contraseña actualizada.')
|
||||
})
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-primary">Seguridad</h1>
|
||||
<p className="mt-1 text-sm text-foreground/60">
|
||||
Gestioná tu contraseña y tus llaves de acceso (passkeys).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{feedback ? (
|
||||
<p className="rounded-xl bg-accent-soft px-4 py-3 text-sm text-accent-strong">{feedback}</p>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-xl border border-border bg-surface p-5">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<KeyRound className="size-4 text-accent" />
|
||||
<h2 className="text-base font-semibold text-primary">Cambiar contraseña</h2>
|
||||
</div>
|
||||
<form onSubmit={onChangePassword} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="currentPassword">Contraseña actual</Label>
|
||||
<Input
|
||||
id="currentPassword"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
invalid={!!errors.currentPassword}
|
||||
{...register('currentPassword')}
|
||||
/>
|
||||
{errors.currentPassword ? (
|
||||
<p className="mt-1 text-sm text-danger">{errors.currentPassword.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="newPassword">Nueva contraseña</Label>
|
||||
<Input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
invalid={!!errors.newPassword}
|
||||
{...register('newPassword')}
|
||||
/>
|
||||
{errors.newPassword ? (
|
||||
<p className="mt-1 text-sm text-danger">{errors.newPassword.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Button type="submit" variant="primary" disabled={isSubmitting}>
|
||||
{isSubmitting ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
Actualizar contraseña
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border bg-surface p-5">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Fingerprint className="size-4 text-accent" />
|
||||
<h2 className="text-base font-semibold text-primary">Passkeys</h2>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => void handleAddPasskey()}>
|
||||
<Plus className="size-4" />
|
||||
Registrar
|
||||
</Button>
|
||||
</div>
|
||||
<PasskeyList key={passkeyKeys} onRefresh={refreshPasskeys} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
13
apps/web/src/features/security/schema.ts
Normal file
13
apps/web/src/features/security/schema.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const changePasswordSchema = z
|
||||
.object({
|
||||
currentPassword: z.string().min(1, 'Ingresá tu contraseña actual'),
|
||||
newPassword: z.string().min(8, 'La nueva contraseña debe tener al menos 8 caracteres'),
|
||||
})
|
||||
.refine((v) => v.currentPassword !== v.newPassword, {
|
||||
message: 'La nueva contraseña debe ser distinta',
|
||||
path: ['newPassword'],
|
||||
})
|
||||
|
||||
export type ChangePasswordValues = z.infer<typeof changePasswordSchema>
|
||||
42
apps/web/src/features/settings/AppearanceCard.tsx
Normal file
42
apps/web/src/features/settings/AppearanceCard.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Check } from 'lucide-react'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { useTheme } from '../../context/ThemeProvider'
|
||||
import { THEME_ICONS, THEME_OPTIONS } from './constants'
|
||||
|
||||
export function AppearanceCard() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-border rounded-xl border border-border bg-surface">
|
||||
<div className="px-4 py-3">
|
||||
<p className="text-sm font-medium text-primary">Apariencia</p>
|
||||
<p className="text-xs text-foreground/50">Tema claro u oscuro</p>
|
||||
</div>
|
||||
{THEME_OPTIONS.map((option) => {
|
||||
const Icon = THEME_ICONS[option.value]
|
||||
const isSelected = theme === option.value
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setTheme(option.value)}
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-primary-soft"
|
||||
>
|
||||
<span className={cn('rounded-lg p-2', isSelected ? 'bg-accent-soft' : 'bg-primary-soft')}>
|
||||
<Icon
|
||||
className={cn('size-4', isSelected ? 'text-accent-fg' : 'text-foreground/70')}
|
||||
/>
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={cn('text-sm font-medium', isSelected ? 'text-accent-fg' : 'text-primary')}>
|
||||
{option.label}
|
||||
</p>
|
||||
<p className="text-xs text-foreground/50">{option.description}</p>
|
||||
</div>
|
||||
{isSelected ? <Check className="size-4 text-accent-fg" /> : null}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
41
apps/web/src/features/settings/SettingsView.tsx
Normal file
41
apps/web/src/features/settings/SettingsView.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Fingerprint } from 'lucide-react'
|
||||
import { signOut } from '../../lib/auth-client'
|
||||
import { Button } from '../../components/ui'
|
||||
import { AppearanceCard } from './AppearanceCard'
|
||||
|
||||
export function SettingsView() {
|
||||
const handleSignOut = async () => {
|
||||
await signOut({
|
||||
fetchOptions: { headers: { 'Cache-Control': 'no-cache' } },
|
||||
})
|
||||
window.location.assign('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-primary">Ajustes</h1>
|
||||
<p className="mt-1 text-sm text-foreground/60">Configurá tu cuenta y tus grupos.</p>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border rounded-xl border border-border bg-surface">
|
||||
<Link to="/seguridad" className="flex items-center gap-3 px-4 py-3 hover:bg-primary-soft">
|
||||
<span className="rounded-lg bg-accent-soft p-2">
|
||||
<Fingerprint className="size-4 text-accent-fg" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-primary">Seguridad</p>
|
||||
<p className="text-xs text-foreground/50">Contraseña y passkeys</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<AppearanceCard />
|
||||
|
||||
<Button variant="outline" onClick={handleSignOut}>
|
||||
Cerrar sesión
|
||||
</Button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
14
apps/web/src/features/settings/constants.ts
Normal file
14
apps/web/src/features/settings/constants.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Monitor, Moon, Sun } from 'lucide-react'
|
||||
import type { Theme } from '../../context/ThemeProvider'
|
||||
|
||||
export const THEME_OPTIONS: { value: Theme; label: string; description: string }[] = [
|
||||
{ value: 'light', label: 'Claro', description: 'Interfaz en tonos claros' },
|
||||
{ value: 'dark', label: 'Oscuro', description: 'Interfaz en tonos oscuros' },
|
||||
{ value: 'system', label: 'Sistema', description: 'Sigue la preferencia de tu dispositivo' },
|
||||
]
|
||||
|
||||
export const THEME_ICONS: Record<Theme, typeof Sun> = {
|
||||
light: Sun,
|
||||
dark: Moon,
|
||||
system: Monitor,
|
||||
}
|
||||
410
apps/web/src/routeTree.gen.ts
Normal file
410
apps/web/src/routeTree.gen.ts
Normal file
@@ -0,0 +1,410 @@
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
|
||||
import { Route as LoginRouteImport } from './routes/login'
|
||||
import { Route as OnboardingRouteImport } from './routes/onboarding'
|
||||
import { Route as SignupRouteImport } from './routes/signup'
|
||||
import { Route as VerifyEmailRouteImport } from './routes/verify-email'
|
||||
import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index'
|
||||
import { Route as AuthenticatedPaymentsRouteImport } from './routes/_authenticated/payments'
|
||||
import { Route as AuthenticatedProfileRouteImport } from './routes/_authenticated/profile'
|
||||
import { Route as AuthenticatedSeguridadRouteImport } from './routes/_authenticated/seguridad'
|
||||
import { Route as AuthenticatedSettingsRouteImport } from './routes/_authenticated/settings'
|
||||
import { Route as AvisarAusenciaTokenRouteImport } from './routes/avisar-ausencia.$token'
|
||||
import { Route as JoinTokenRouteImport } from './routes/join.$token'
|
||||
import { Route as AuthenticatedGroupsIndexRouteImport } from './routes/_authenticated/groups/index'
|
||||
import { Route as AuthenticatedGroupsNewRouteImport } from './routes/_authenticated/groups/new'
|
||||
import { Route as AuthenticatedClassSessionIdAttendanceRouteImport } from './routes/_authenticated/class/$sessionId/attendance'
|
||||
import { Route as AuthenticatedGroupsGroupIdIndexRouteImport } from './routes/_authenticated/groups/$groupId/index'
|
||||
import { Route as AuthenticatedGroupsGroupIdAnalyticsRouteImport } from './routes/_authenticated/groups/$groupId/analytics'
|
||||
|
||||
const AuthenticatedRoute = AuthenticatedRouteImport.update({
|
||||
id: '/_authenticated',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const LoginRoute = LoginRouteImport.update({
|
||||
id: '/login',
|
||||
path: '/login',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const OnboardingRoute = OnboardingRouteImport.update({
|
||||
id: '/onboarding',
|
||||
path: '/onboarding',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const SignupRoute = SignupRouteImport.update({
|
||||
id: '/signup',
|
||||
path: '/signup',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const VerifyEmailRoute = VerifyEmailRouteImport.update({
|
||||
id: '/verify-email',
|
||||
path: '/verify-email',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AuthenticatedIndexRoute = AuthenticatedIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedPaymentsRoute = AuthenticatedPaymentsRouteImport.update({
|
||||
id: '/payments',
|
||||
path: '/payments',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedProfileRoute = AuthenticatedProfileRouteImport.update({
|
||||
id: '/profile',
|
||||
path: '/profile',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedSeguridadRoute = AuthenticatedSeguridadRouteImport.update({
|
||||
id: '/seguridad',
|
||||
path: '/seguridad',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedSettingsRoute = AuthenticatedSettingsRouteImport.update({
|
||||
id: '/settings',
|
||||
path: '/settings',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AvisarAusenciaTokenRoute = AvisarAusenciaTokenRouteImport.update({
|
||||
id: '/avisar-ausencia/$token',
|
||||
path: '/avisar-ausencia/$token',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const JoinTokenRoute = JoinTokenRouteImport.update({
|
||||
id: '/join/$token',
|
||||
path: '/join/$token',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AuthenticatedGroupsIndexRoute =
|
||||
AuthenticatedGroupsIndexRouteImport.update({
|
||||
id: '/groups/',
|
||||
path: '/groups/',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedGroupsNewRoute = AuthenticatedGroupsNewRouteImport.update({
|
||||
id: '/groups/new',
|
||||
path: '/groups/new',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedClassSessionIdAttendanceRoute =
|
||||
AuthenticatedClassSessionIdAttendanceRouteImport.update({
|
||||
id: '/class/$sessionId/attendance',
|
||||
path: '/class/$sessionId/attendance',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedGroupsGroupIdIndexRoute =
|
||||
AuthenticatedGroupsGroupIdIndexRouteImport.update({
|
||||
id: '/groups/$groupId/',
|
||||
path: '/groups/$groupId/',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedGroupsGroupIdAnalyticsRoute =
|
||||
AuthenticatedGroupsGroupIdAnalyticsRouteImport.update({
|
||||
id: '/groups/$groupId/analytics',
|
||||
path: '/groups/$groupId/analytics',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof AuthenticatedIndexRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/onboarding': typeof OnboardingRoute
|
||||
'/signup': typeof SignupRoute
|
||||
'/verify-email': typeof VerifyEmailRoute
|
||||
'/payments': typeof AuthenticatedPaymentsRoute
|
||||
'/profile': typeof AuthenticatedProfileRoute
|
||||
'/seguridad': typeof AuthenticatedSeguridadRoute
|
||||
'/settings': typeof AuthenticatedSettingsRoute
|
||||
'/avisar-ausencia/$token': typeof AvisarAusenciaTokenRoute
|
||||
'/join/$token': typeof JoinTokenRoute
|
||||
'/groups/new': typeof AuthenticatedGroupsNewRoute
|
||||
'/groups/': typeof AuthenticatedGroupsIndexRoute
|
||||
'/class/$sessionId/attendance': typeof AuthenticatedClassSessionIdAttendanceRoute
|
||||
'/groups/$groupId/analytics': typeof AuthenticatedGroupsGroupIdAnalyticsRoute
|
||||
'/groups/$groupId/': typeof AuthenticatedGroupsGroupIdIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/login': typeof LoginRoute
|
||||
'/onboarding': typeof OnboardingRoute
|
||||
'/signup': typeof SignupRoute
|
||||
'/verify-email': typeof VerifyEmailRoute
|
||||
'/payments': typeof AuthenticatedPaymentsRoute
|
||||
'/profile': typeof AuthenticatedProfileRoute
|
||||
'/seguridad': typeof AuthenticatedSeguridadRoute
|
||||
'/settings': typeof AuthenticatedSettingsRoute
|
||||
'/avisar-ausencia/$token': typeof AvisarAusenciaTokenRoute
|
||||
'/join/$token': typeof JoinTokenRoute
|
||||
'/': typeof AuthenticatedIndexRoute
|
||||
'/groups/new': typeof AuthenticatedGroupsNewRoute
|
||||
'/groups': typeof AuthenticatedGroupsIndexRoute
|
||||
'/class/$sessionId/attendance': typeof AuthenticatedClassSessionIdAttendanceRoute
|
||||
'/groups/$groupId/analytics': typeof AuthenticatedGroupsGroupIdAnalyticsRoute
|
||||
'/groups/$groupId': typeof AuthenticatedGroupsGroupIdIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/_authenticated': typeof AuthenticatedRouteWithChildren
|
||||
'/login': typeof LoginRoute
|
||||
'/onboarding': typeof OnboardingRoute
|
||||
'/signup': typeof SignupRoute
|
||||
'/verify-email': typeof VerifyEmailRoute
|
||||
'/_authenticated/payments': typeof AuthenticatedPaymentsRoute
|
||||
'/_authenticated/profile': typeof AuthenticatedProfileRoute
|
||||
'/_authenticated/seguridad': typeof AuthenticatedSeguridadRoute
|
||||
'/_authenticated/settings': typeof AuthenticatedSettingsRoute
|
||||
'/avisar-ausencia/$token': typeof AvisarAusenciaTokenRoute
|
||||
'/join/$token': typeof JoinTokenRoute
|
||||
'/_authenticated/': typeof AuthenticatedIndexRoute
|
||||
'/_authenticated/groups/new': typeof AuthenticatedGroupsNewRoute
|
||||
'/_authenticated/groups/': typeof AuthenticatedGroupsIndexRoute
|
||||
'/_authenticated/class/$sessionId/attendance': typeof AuthenticatedClassSessionIdAttendanceRoute
|
||||
'/_authenticated/groups/$groupId/analytics': typeof AuthenticatedGroupsGroupIdAnalyticsRoute
|
||||
'/_authenticated/groups/$groupId/': typeof AuthenticatedGroupsGroupIdIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/login'
|
||||
| '/onboarding'
|
||||
| '/signup'
|
||||
| '/verify-email'
|
||||
| '/payments'
|
||||
| '/profile'
|
||||
| '/seguridad'
|
||||
| '/settings'
|
||||
| '/avisar-ausencia/$token'
|
||||
| '/join/$token'
|
||||
| '/groups/new'
|
||||
| '/groups/'
|
||||
| '/class/$sessionId/attendance'
|
||||
| '/groups/$groupId/analytics'
|
||||
| '/groups/$groupId/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/login'
|
||||
| '/onboarding'
|
||||
| '/signup'
|
||||
| '/verify-email'
|
||||
| '/payments'
|
||||
| '/profile'
|
||||
| '/seguridad'
|
||||
| '/settings'
|
||||
| '/avisar-ausencia/$token'
|
||||
| '/join/$token'
|
||||
| '/'
|
||||
| '/groups/new'
|
||||
| '/groups'
|
||||
| '/class/$sessionId/attendance'
|
||||
| '/groups/$groupId/analytics'
|
||||
| '/groups/$groupId'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/_authenticated'
|
||||
| '/login'
|
||||
| '/onboarding'
|
||||
| '/signup'
|
||||
| '/verify-email'
|
||||
| '/_authenticated/payments'
|
||||
| '/_authenticated/profile'
|
||||
| '/_authenticated/seguridad'
|
||||
| '/_authenticated/settings'
|
||||
| '/avisar-ausencia/$token'
|
||||
| '/join/$token'
|
||||
| '/_authenticated/'
|
||||
| '/_authenticated/groups/new'
|
||||
| '/_authenticated/groups/'
|
||||
| '/_authenticated/class/$sessionId/attendance'
|
||||
| '/_authenticated/groups/$groupId/analytics'
|
||||
| '/_authenticated/groups/$groupId/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
AuthenticatedRoute: typeof AuthenticatedRouteWithChildren
|
||||
LoginRoute: typeof LoginRoute
|
||||
OnboardingRoute: typeof OnboardingRoute
|
||||
SignupRoute: typeof SignupRoute
|
||||
VerifyEmailRoute: typeof VerifyEmailRoute
|
||||
AvisarAusenciaTokenRoute: typeof AvisarAusenciaTokenRoute
|
||||
JoinTokenRoute: typeof JoinTokenRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/_authenticated': {
|
||||
id: '/_authenticated'
|
||||
path: ''
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof AuthenticatedRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/login': {
|
||||
id: '/login'
|
||||
path: '/login'
|
||||
fullPath: '/login'
|
||||
preLoaderRoute: typeof LoginRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/onboarding': {
|
||||
id: '/onboarding'
|
||||
path: '/onboarding'
|
||||
fullPath: '/onboarding'
|
||||
preLoaderRoute: typeof OnboardingRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/signup': {
|
||||
id: '/signup'
|
||||
path: '/signup'
|
||||
fullPath: '/signup'
|
||||
preLoaderRoute: typeof SignupRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/verify-email': {
|
||||
id: '/verify-email'
|
||||
path: '/verify-email'
|
||||
fullPath: '/verify-email'
|
||||
preLoaderRoute: typeof VerifyEmailRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_authenticated/': {
|
||||
id: '/_authenticated/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof AuthenticatedIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/payments': {
|
||||
id: '/_authenticated/payments'
|
||||
path: '/payments'
|
||||
fullPath: '/payments'
|
||||
preLoaderRoute: typeof AuthenticatedPaymentsRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/profile': {
|
||||
id: '/_authenticated/profile'
|
||||
path: '/profile'
|
||||
fullPath: '/profile'
|
||||
preLoaderRoute: typeof AuthenticatedProfileRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/seguridad': {
|
||||
id: '/_authenticated/seguridad'
|
||||
path: '/seguridad'
|
||||
fullPath: '/seguridad'
|
||||
preLoaderRoute: typeof AuthenticatedSeguridadRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/settings': {
|
||||
id: '/_authenticated/settings'
|
||||
path: '/settings'
|
||||
fullPath: '/settings'
|
||||
preLoaderRoute: typeof AuthenticatedSettingsRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/avisar-ausencia/$token': {
|
||||
id: '/avisar-ausencia/$token'
|
||||
path: '/avisar-ausencia/$token'
|
||||
fullPath: '/avisar-ausencia/$token'
|
||||
preLoaderRoute: typeof AvisarAusenciaTokenRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/join/$token': {
|
||||
id: '/join/$token'
|
||||
path: '/join/$token'
|
||||
fullPath: '/join/$token'
|
||||
preLoaderRoute: typeof JoinTokenRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_authenticated/groups/': {
|
||||
id: '/_authenticated/groups/'
|
||||
path: '/groups'
|
||||
fullPath: '/groups/'
|
||||
preLoaderRoute: typeof AuthenticatedGroupsIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/groups/new': {
|
||||
id: '/_authenticated/groups/new'
|
||||
path: '/groups/new'
|
||||
fullPath: '/groups/new'
|
||||
preLoaderRoute: typeof AuthenticatedGroupsNewRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/class/$sessionId/attendance': {
|
||||
id: '/_authenticated/class/$sessionId/attendance'
|
||||
path: '/class/$sessionId/attendance'
|
||||
fullPath: '/class/$sessionId/attendance'
|
||||
preLoaderRoute: typeof AuthenticatedClassSessionIdAttendanceRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/groups/$groupId/': {
|
||||
id: '/_authenticated/groups/$groupId/'
|
||||
path: '/groups/$groupId'
|
||||
fullPath: '/groups/$groupId/'
|
||||
preLoaderRoute: typeof AuthenticatedGroupsGroupIdIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/groups/$groupId/analytics': {
|
||||
id: '/_authenticated/groups/$groupId/analytics'
|
||||
path: '/groups/$groupId/analytics'
|
||||
fullPath: '/groups/$groupId/analytics'
|
||||
preLoaderRoute: typeof AuthenticatedGroupsGroupIdAnalyticsRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface AuthenticatedRouteChildren {
|
||||
AuthenticatedPaymentsRoute: typeof AuthenticatedPaymentsRoute
|
||||
AuthenticatedProfileRoute: typeof AuthenticatedProfileRoute
|
||||
AuthenticatedSeguridadRoute: typeof AuthenticatedSeguridadRoute
|
||||
AuthenticatedSettingsRoute: typeof AuthenticatedSettingsRoute
|
||||
AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute
|
||||
AuthenticatedGroupsNewRoute: typeof AuthenticatedGroupsNewRoute
|
||||
AuthenticatedGroupsIndexRoute: typeof AuthenticatedGroupsIndexRoute
|
||||
AuthenticatedClassSessionIdAttendanceRoute: typeof AuthenticatedClassSessionIdAttendanceRoute
|
||||
AuthenticatedGroupsGroupIdAnalyticsRoute: typeof AuthenticatedGroupsGroupIdAnalyticsRoute
|
||||
AuthenticatedGroupsGroupIdIndexRoute: typeof AuthenticatedGroupsGroupIdIndexRoute
|
||||
}
|
||||
|
||||
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
|
||||
AuthenticatedPaymentsRoute: AuthenticatedPaymentsRoute,
|
||||
AuthenticatedProfileRoute: AuthenticatedProfileRoute,
|
||||
AuthenticatedSeguridadRoute: AuthenticatedSeguridadRoute,
|
||||
AuthenticatedSettingsRoute: AuthenticatedSettingsRoute,
|
||||
AuthenticatedIndexRoute: AuthenticatedIndexRoute,
|
||||
AuthenticatedGroupsNewRoute: AuthenticatedGroupsNewRoute,
|
||||
AuthenticatedGroupsIndexRoute: AuthenticatedGroupsIndexRoute,
|
||||
AuthenticatedClassSessionIdAttendanceRoute:
|
||||
AuthenticatedClassSessionIdAttendanceRoute,
|
||||
AuthenticatedGroupsGroupIdAnalyticsRoute:
|
||||
AuthenticatedGroupsGroupIdAnalyticsRoute,
|
||||
AuthenticatedGroupsGroupIdIndexRoute: AuthenticatedGroupsGroupIdIndexRoute,
|
||||
}
|
||||
|
||||
const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
|
||||
AuthenticatedRouteChildren,
|
||||
)
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
AuthenticatedRoute: AuthenticatedRouteWithChildren,
|
||||
LoginRoute: LoginRoute,
|
||||
OnboardingRoute: OnboardingRoute,
|
||||
SignupRoute: SignupRoute,
|
||||
VerifyEmailRoute: VerifyEmailRoute,
|
||||
AvisarAusenciaTokenRoute: AvisarAusenciaTokenRoute,
|
||||
JoinTokenRoute: JoinTokenRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
@@ -1,151 +1,5 @@
|
||||
import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router'
|
||||
import { AppLayoutGuard } from './components/layout/AppLayoutGuard'
|
||||
import { HomeView } from './routes/home'
|
||||
import { GroupsView } from './routes/groups'
|
||||
import { PaymentsView } from './routes/payments'
|
||||
import { SettingsView } from './routes/settings'
|
||||
import { ProfileView } from './routes/profile'
|
||||
import { SecurityPage } from './routes/security'
|
||||
import { LoginPage } from './routes/auth/login'
|
||||
import { SignupPage } from './routes/auth/signup'
|
||||
import { VerifyEmailPage } from './routes/auth/verify-email'
|
||||
import { OnboardingView } from './routes/onboarding'
|
||||
import { GroupDetailView } from './routes/group-detail'
|
||||
import { CreateGroupView } from './routes/create-group'
|
||||
import { JoinGroupView } from './routes/join-group'
|
||||
import { ClassAttendanceView } from './routes/class-attendance'
|
||||
import { AbsenceNotifyView } from './routes/absence-notify'
|
||||
import { AnalyticsView } from './routes/analytics'
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
component: () => <Outlet />,
|
||||
})
|
||||
|
||||
const loginRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/login',
|
||||
component: LoginPage,
|
||||
})
|
||||
|
||||
const signupRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/signup',
|
||||
component: SignupPage,
|
||||
})
|
||||
|
||||
const verifyEmailRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/verify-email',
|
||||
component: VerifyEmailPage,
|
||||
})
|
||||
|
||||
const onboardingRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/onboarding',
|
||||
component: OnboardingView,
|
||||
})
|
||||
|
||||
const joinGroupRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/join/$token',
|
||||
component: JoinGroupView,
|
||||
})
|
||||
|
||||
// Página pública: el alumno avisa su ausencia con su token personal.
|
||||
const absenceNotifyRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/avisar-ausencia/$token',
|
||||
component: AbsenceNotifyView,
|
||||
})
|
||||
|
||||
// Capa con la navegación de la app autenticada (Sidebar + BottomNav).
|
||||
// El guard redirige a /onboarding a quien no completó el onboarding.
|
||||
const appLayoutRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
id: 'app',
|
||||
component: AppLayoutGuard,
|
||||
})
|
||||
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => appLayoutRoute,
|
||||
path: '/',
|
||||
component: HomeView,
|
||||
})
|
||||
|
||||
const groupsRoute = createRoute({
|
||||
getParentRoute: () => appLayoutRoute,
|
||||
path: '/groups',
|
||||
component: GroupsView,
|
||||
})
|
||||
|
||||
const createGroupRoute = createRoute({
|
||||
getParentRoute: () => appLayoutRoute,
|
||||
path: '/groups/new',
|
||||
component: CreateGroupView,
|
||||
})
|
||||
|
||||
const groupDetailRoute = createRoute({
|
||||
getParentRoute: () => appLayoutRoute,
|
||||
path: '/groups/$groupId',
|
||||
component: GroupDetailView,
|
||||
})
|
||||
|
||||
const groupAnalyticsRoute = createRoute({
|
||||
getParentRoute: () => appLayoutRoute,
|
||||
path: '/groups/$groupId/analytics',
|
||||
component: AnalyticsView,
|
||||
})
|
||||
|
||||
const paymentsRoute = createRoute({
|
||||
getParentRoute: () => appLayoutRoute,
|
||||
path: '/payments',
|
||||
component: PaymentsView,
|
||||
})
|
||||
|
||||
const settingsRoute = createRoute({
|
||||
getParentRoute: () => appLayoutRoute,
|
||||
path: '/settings',
|
||||
component: SettingsView,
|
||||
})
|
||||
|
||||
const securityRoute = createRoute({
|
||||
getParentRoute: () => appLayoutRoute,
|
||||
path: '/seguridad',
|
||||
component: SecurityPage,
|
||||
})
|
||||
|
||||
const profileRoute = createRoute({
|
||||
getParentRoute: () => appLayoutRoute,
|
||||
path: '/profile',
|
||||
component: ProfileView,
|
||||
})
|
||||
|
||||
const classAttendanceRoute = createRoute({
|
||||
getParentRoute: () => appLayoutRoute,
|
||||
path: '/class/$sessionId/attendance',
|
||||
component: ClassAttendanceView,
|
||||
})
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
loginRoute,
|
||||
signupRoute,
|
||||
verifyEmailRoute,
|
||||
onboardingRoute,
|
||||
joinGroupRoute,
|
||||
absenceNotifyRoute,
|
||||
appLayoutRoute.addChildren([
|
||||
indexRoute,
|
||||
groupsRoute,
|
||||
createGroupRoute,
|
||||
groupDetailRoute,
|
||||
groupAnalyticsRoute,
|
||||
paymentsRoute,
|
||||
settingsRoute,
|
||||
securityRoute,
|
||||
profileRoute,
|
||||
classAttendanceRoute,
|
||||
]),
|
||||
])
|
||||
import { createRouter } from '@tanstack/react-router'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
|
||||
export const router = createRouter({ routeTree })
|
||||
|
||||
|
||||
5
apps/web/src/routes/__root.tsx
Normal file
5
apps/web/src/routes/__root.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createRootRoute, Outlet } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: () => <Outlet />,
|
||||
})
|
||||
10
apps/web/src/routes/_authenticated.tsx
Normal file
10
apps/web/src/routes/_authenticated.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createFileRoute, Outlet } from '@tanstack/react-router'
|
||||
import { AppLayoutGuard } from '../components/layout/AppLayoutGuard'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated')({
|
||||
component: () => (
|
||||
<AppLayoutGuard>
|
||||
<Outlet />
|
||||
</AppLayoutGuard>
|
||||
),
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useParams } from '@tanstack/react-router'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { AttendanceProvider } from '../../../../features/class-attendance/AttendanceProvider'
|
||||
import { ClassAttendanceView } from '../../../../features/class-attendance/ClassAttendanceView'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/class/$sessionId/attendance')({
|
||||
component: () => {
|
||||
const { sessionId } = useParams({ from: '/_authenticated/class/$sessionId/attendance' })
|
||||
return (
|
||||
<AttendanceProvider sessionId={sessionId}>
|
||||
<ClassAttendanceView />
|
||||
</AttendanceProvider>
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { AnalyticsView } from '../../../../features/groups/analytics/AnalyticsView'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/groups/$groupId/analytics')({
|
||||
component: AnalyticsView,
|
||||
})
|
||||
15
apps/web/src/routes/_authenticated/groups/$groupId/index.tsx
Normal file
15
apps/web/src/routes/_authenticated/groups/$groupId/index.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useParams } from '@tanstack/react-router'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { GroupDetailProvider } from '../../../../features/groups/detail/GroupDetailProvider'
|
||||
import { GroupDetailView } from '../../../../features/groups/detail/GroupDetailView'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/groups/$groupId/')({
|
||||
component: () => {
|
||||
const { groupId } = useParams({ from: '/_authenticated/groups/$groupId/' })
|
||||
return (
|
||||
<GroupDetailProvider groupId={groupId}>
|
||||
<GroupDetailView />
|
||||
</GroupDetailProvider>
|
||||
)
|
||||
},
|
||||
})
|
||||
6
apps/web/src/routes/_authenticated/groups/index.tsx
Normal file
6
apps/web/src/routes/_authenticated/groups/index.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { GroupsView } from '../../../features/groups/GroupsView'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/groups/')({
|
||||
component: GroupsView,
|
||||
})
|
||||
6
apps/web/src/routes/_authenticated/groups/new.tsx
Normal file
6
apps/web/src/routes/_authenticated/groups/new.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { CreateGroupView } from '../../../features/groups/CreateGroupView'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/groups/new')({
|
||||
component: CreateGroupView,
|
||||
})
|
||||
6
apps/web/src/routes/_authenticated/index.tsx
Normal file
6
apps/web/src/routes/_authenticated/index.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { HomeView } from '../../features/home/HomeView'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/')({
|
||||
component: HomeView,
|
||||
})
|
||||
6
apps/web/src/routes/_authenticated/payments.tsx
Normal file
6
apps/web/src/routes/_authenticated/payments.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { PaymentsView } from '../../features/payments/PaymentsView'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/payments')({
|
||||
component: PaymentsView,
|
||||
})
|
||||
6
apps/web/src/routes/_authenticated/profile.tsx
Normal file
6
apps/web/src/routes/_authenticated/profile.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { ProfileView } from '../../features/profile/ProfileView'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/profile')({
|
||||
component: ProfileView,
|
||||
})
|
||||
6
apps/web/src/routes/_authenticated/seguridad.tsx
Normal file
6
apps/web/src/routes/_authenticated/seguridad.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { SecurityPage } from '../../features/security/SecurityPage'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/seguridad')({
|
||||
component: SecurityPage,
|
||||
})
|
||||
6
apps/web/src/routes/_authenticated/settings.tsx
Normal file
6
apps/web/src/routes/_authenticated/settings.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { SettingsView } from '../../features/settings/SettingsView'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/settings')({
|
||||
component: SettingsView,
|
||||
})
|
||||
15
apps/web/src/routes/avisar-ausencia.$token.tsx
Normal file
15
apps/web/src/routes/avisar-ausencia.$token.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useParams } from '@tanstack/react-router'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { AbsenceNotifyProvider } from '../features/absence-notify/AbsenceNotifyProvider'
|
||||
import { AbsenceNotifyView } from '../features/absence-notify/AbsenceNotifyView'
|
||||
|
||||
export const Route = createFileRoute('/avisar-ausencia/$token')({
|
||||
component: () => {
|
||||
const { token } = useParams({ from: '/avisar-ausencia/$token' })
|
||||
return (
|
||||
<AbsenceNotifyProvider token={token}>
|
||||
<AbsenceNotifyView />
|
||||
</AbsenceNotifyProvider>
|
||||
)
|
||||
},
|
||||
})
|
||||
15
apps/web/src/routes/join.$token.tsx
Normal file
15
apps/web/src/routes/join.$token.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useParams } from '@tanstack/react-router'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { JoinGroupProvider } from '../features/join/JoinGroupProvider'
|
||||
import { JoinGroupView } from '../features/join/JoinGroupView'
|
||||
|
||||
export const Route = createFileRoute('/join/$token')({
|
||||
component: () => {
|
||||
const { token } = useParams({ from: '/join/$token' })
|
||||
return (
|
||||
<JoinGroupProvider token={token}>
|
||||
<JoinGroupView />
|
||||
</JoinGroupProvider>
|
||||
)
|
||||
},
|
||||
})
|
||||
6
apps/web/src/routes/login.tsx
Normal file
6
apps/web/src/routes/login.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { LoginPage } from '../features/auth/LoginPage'
|
||||
|
||||
export const Route = createFileRoute('/login')({
|
||||
component: LoginPage,
|
||||
})
|
||||
6
apps/web/src/routes/onboarding.tsx
Normal file
6
apps/web/src/routes/onboarding.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { OnboardingView } from '../features/onboarding/OnboardingView'
|
||||
|
||||
export const Route = createFileRoute('/onboarding')({
|
||||
component: OnboardingView,
|
||||
})
|
||||
6
apps/web/src/routes/signup.tsx
Normal file
6
apps/web/src/routes/signup.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { SignupPage } from '../features/auth/SignupPage'
|
||||
|
||||
export const Route = createFileRoute('/signup')({
|
||||
component: SignupPage,
|
||||
})
|
||||
10
apps/web/src/routes/verify-email.tsx
Normal file
10
apps/web/src/routes/verify-email.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { VerifyEmailPage } from '../features/auth/VerifyEmailPage'
|
||||
|
||||
export const Route = createFileRoute('/verify-email')({
|
||||
validateSearch: (search: Record<string, unknown>): { token?: string; email?: string } => ({
|
||||
token: typeof search.token === 'string' ? search.token : undefined,
|
||||
email: typeof search.email === 'string' ? search.email : undefined,
|
||||
}),
|
||||
component: VerifyEmailPage,
|
||||
})
|
||||
@@ -1,10 +1,11 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { tanstackRouter } from '@tanstack/router-plugin/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
plugins: [tanstackRouter({ target: 'react' }), react(), tailwindcss()],
|
||||
server: {
|
||||
port: 6173,
|
||||
},
|
||||
})
|
||||
})
|
||||
37
bun.lock
37
bun.lock
@@ -43,7 +43,7 @@
|
||||
"@gruperly/shared": "workspace:*",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"@tanstack/react-router": "^1.90.0",
|
||||
"@tanstack/react-router": "^1.170.38",
|
||||
"better-auth": "1.7.2",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.34.0",
|
||||
@@ -56,6 +56,7 @@
|
||||
"devDependencies": {
|
||||
"@gruperly/config": "workspace:*",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tanstack/router-plugin": "^1.168.40",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
@@ -408,19 +409,27 @@
|
||||
|
||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="],
|
||||
|
||||
"@tanstack/history": ["@tanstack/history@1.162.2", "", {}, "sha512-Lemp3DJbzNqcin/nZpWxycDaEqySDbnIshDbyHJMMCapD4ZQMe57szRpBXOfzfP6fyWAtHNrLrcBUyANJ6Vlow=="],
|
||||
"@tanstack/history": ["@tanstack/history@1.162.4", "", {}, "sha512-utTS5L2OkeYUzXGohL1Z8sefu1GLNOJcxe8Hd6iIdc/Xo1K1nDB2JEp4iSFhvYh33xKC9V91TxrS8qfrpoKobQ=="],
|
||||
|
||||
"@tanstack/query-core": ["@tanstack/query-core@5.102.8", "", {}, "sha512-ZNjkJ33CqvPNec/6lZBnHqLc3EVGPZ9ySLhYahU9TcuRFdmwXewuj0c4hwSWcGHqEUwcSrKeZ+oGcvPBqXcQcg=="],
|
||||
|
||||
"@tanstack/react-query": ["@tanstack/react-query@5.102.8", "", { "dependencies": { "@tanstack/query-core": "5.102.8" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-TYBea4OuXWD7MhaSHq069TWbFe7rcwWN6kzT7JF0OKi1K6c1gTv2IzD6A6ExJsCMozdkqBWeuIUZmu4KQg0O5A=="],
|
||||
|
||||
"@tanstack/react-router": ["@tanstack/react-router@1.170.33", "", { "dependencies": { "@tanstack/history": "1.162.2", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.171.28", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-iNnI98vH3kO/V4dy6YM0CInhqwWBddU0G5wZK5jiMvr3HsK2avDQSRx3RY/y6v+6zQAqb2kD6hUPHIenrJBTSw=="],
|
||||
"@tanstack/react-router": ["@tanstack/react-router@1.170.38", "", { "dependencies": { "@tanstack/history": "1.162.4", "@tanstack/react-store": "^0.11.0", "@tanstack/router-core": "1.171.32", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-iHM9b0aDJbuvftmkiQXawzoqsdV68p2Re2safbVLCnxafe+iLKV++2i3hI/BMAP6uR92/Mjw94JKJJfD7pbwHQ=="],
|
||||
|
||||
"@tanstack/react-store": ["@tanstack/react-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg=="],
|
||||
"@tanstack/react-store": ["@tanstack/react-store@0.11.1", "", { "dependencies": { "@tanstack/store": "0.11.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-HaIGKI3YLmjBYIvy5DFDY23oNaYZIsTZfngey07Uh5iLVJgM3bIGCnZeOFOqzjFld9JHWcaHJnasD/bKoGKwJQ=="],
|
||||
|
||||
"@tanstack/router-core": ["@tanstack/router-core@1.171.28", "", { "dependencies": { "@tanstack/history": "1.162.2", "cookie-es": "^3.0.0", "seroval": "^1.6.2", "seroval-plugins": "^1.6.2" } }, "sha512-PvPWSklhw6i9b0rzScVh0btQsK5u/gBYN3mBHyDzhC/U4LrB3WzPXPkUunQUKvQOGXCp16UEb7Htc/ITGm5DkQ=="],
|
||||
"@tanstack/router-core": ["@tanstack/router-core@1.171.32", "", { "dependencies": { "@tanstack/history": "1.162.4", "cookie-es": "^3.0.0", "seroval": "^1.6.2", "seroval-plugins": "^1.6.2" } }, "sha512-X86Jqk3vB2KJcUfS6oLi7B7yxbaa59QEhZRPPP1fRx3k+Jw/Fu1UYVBcRtdwK/OccQnrlQdVoKvNO3QXmyEBOw=="],
|
||||
|
||||
"@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="],
|
||||
"@tanstack/router-generator": ["@tanstack/router-generator@1.167.38", "", { "dependencies": { "@babel/types": "^7.29.8", "@tanstack/router-core": "1.171.32", "@tanstack/router-utils": "1.162.3", "@tanstack/virtual-file-routes": "1.162.0", "jiti": "^2.7.0", "magic-string": "^0.30.21", "prettier": "^3.9.6", "zod": "^4.5.4" } }, "sha512-lkqgFleDgfkW+QONVMKBs+ZGDUF0v9R9FkplS/GZvKDFpfnu+tZxmGlV2pryU1TDxyuSyjh56AtIla3NMrlScw=="],
|
||||
|
||||
"@tanstack/router-plugin": ["@tanstack/router-plugin@1.168.40", "", { "dependencies": { "@babel/core": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "@tanstack/router-core": "1.171.32", "@tanstack/router-generator": "1.167.38", "@tanstack/router-utils": "1.162.3", "chokidar": "^5.0.0", "unplugin": "^3.3.0", "zod": "^4.5.4" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2 || ^2.0.0", "@tanstack/react-router": "^1.170.38", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0", "vite-plugin-solid": "^2.11.10 || ^3.0.0-0", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-ifiXjjR4uivxwRDhgYAcfyrcu+Mwx+vlG0QjjQ7N5C5sKmzpt5UtsL21TNVvZDSZl2Vae8DcL84Z02fOs8ZZMQ=="],
|
||||
|
||||
"@tanstack/router-utils": ["@tanstack/router-utils@1.162.3", "", { "dependencies": { "@babel/generator": "^7.29.8", "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "ansis": "^4.3.1", "babel-dead-code-elimination": "^1.0.12", "diff": "^8.0.4", "pathe": "^2.0.3", "tinyglobby": "^0.2.17" } }, "sha512-Icb0xGuG1+54IV0WMLRcc3ErTx2HeJWyPG661FkQ8UT6guoBq1FJjFRqlG/JS0xi4mFFkBhvwO7tbLa32f3yxA=="],
|
||||
|
||||
"@tanstack/store": ["@tanstack/store@0.11.1", "", {}, "sha512-mzTOBhypOuDJAy/D8n2MfUZ1HFkXnmSETviRyhqEC8LUE7/IZQExOTxMANj3KjTofYTkFNpBY67qaVrT41YccA=="],
|
||||
|
||||
"@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.162.0", "", {}, "sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA=="],
|
||||
|
||||
"@turbo/darwin-64": ["@turbo/darwin-64@2.10.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-9nKgKoF6ZOUsM+or0OtNf+TTJSfGvDNP7ZFv/ZGWVwOSCkumyctQiTeHwB4UNljHTnC41AqylgbunLDHoccNrA=="],
|
||||
|
||||
@@ -504,12 +513,16 @@
|
||||
|
||||
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
|
||||
|
||||
"ansis": ["ansis@4.4.0", "", {}, "sha512-9k3v7xcHwgdO/DruxGIg4HtjvlAZlcnsX/mzqUb1t3NkYnl9kK2UJ+Gq0io+vQf7iT//BD/HB/NBkUR1LWxoeA=="],
|
||||
|
||||
"asn1js": ["asn1js@3.0.10", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.5", "tslib": "^2.8.1" } }, "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg=="],
|
||||
|
||||
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
|
||||
|
||||
"aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="],
|
||||
|
||||
"babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.11.22", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-pWc4w51fBFd7mav43/zKRC+RI6f4yfzQoVlfvE8dECePyfkn1bzLp01Fj0QACcyCZyFhiEMyD2qScfKRWgWibA=="],
|
||||
|
||||
"better-auth": ["better-auth@1.7.2", "", { "dependencies": { "@better-auth/core": "1.7.2", "@better-auth/drizzle-adapter": "1.7.2", "@better-auth/kysely-adapter": "1.7.2", "@better-auth/memory-adapter": "1.7.2", "@better-auth/mongo-adapter": "1.7.2", "@better-auth/prisma-adapter": "1.7.2", "@better-auth/telemetry": "1.7.2", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@noble/ciphers": "^2.2.0", "@noble/hashes": "^2.2.0", "better-call": "1.4.0", "defu": "^6.1.4", "jose": "^6.2.3", "kysely": "^0.28.17 || ^0.29.0", "nanostores": "^1.3.0", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4 || >=1.0.0-beta.1", "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-gKapKBEvYIGcMxi74RjQ7EbFLiqyQt58vdoJmL1qAlWSkY1Bc2Vqshl524/3u1NxauiOU03M/Ebh762Brmac9A=="],
|
||||
@@ -582,6 +595,8 @@
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
|
||||
|
||||
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
|
||||
|
||||
"effect": ["effect@3.20.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw=="],
|
||||
@@ -776,6 +791,8 @@
|
||||
|
||||
"postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
|
||||
|
||||
"prettier": ["prettier@3.9.8", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A=="],
|
||||
|
||||
"prisma": ["prisma@7.10.0", "", { "dependencies": { "@prisma/config": "7.10.0", "@prisma/dev": "0.24.17", "@prisma/engines": "7.10.0", "@prisma/studio-core": "0.33.0", "mysql2": "3.15.3", "postgres": "3.4.7" }, "peerDependencies": { "better-sqlite3": ">=9.0.0", "typescript": ">=5.4.0" }, "optionalPeers": ["better-sqlite3", "typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-o0ornyJOWgygVAzGCpr8PdXV8EJLHyVGDDUr/voBQt8Azzw8cYTByzzPGcA/m4tCkPcnJA8raEOv2CslsKhPEw=="],
|
||||
|
||||
"process-warning": ["process-warning@5.1.0", "", {}, "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw=="],
|
||||
@@ -880,6 +897,8 @@
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"unplugin": ["unplugin@3.4.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.7", "webpack-virtual-modules": "^0.6.2" }, "peerDependencies": { "@farmfe/core": "*", "@rsbuild/core": "*", "@rspack/core": "*", "bun-types-no-globals": "*", "esbuild": "*", "rolldown": "*", "rollup": "*", "unloader": "*", "vite": "*", "webpack": "*" }, "optionalPeers": ["@farmfe/core", "@rsbuild/core", "@rspack/core", "bun-types-no-globals", "esbuild", "rolldown", "rollup", "unloader", "vite", "webpack"] }, "sha512-9skdIFlCsPdFV7wUfZxNsFInlW+7nJmGu2gkTu0OUhF56aXGsHab9x52/QhdJ4lC7ZDPWTxbiC4ANqVlsuaW3w=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.3.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ=="],
|
||||
|
||||
"use-sync-external-store": ["use-sync-external-store@1.7.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A=="],
|
||||
@@ -888,6 +907,8 @@
|
||||
|
||||
"vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="],
|
||||
|
||||
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
@@ -922,6 +943,10 @@
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@tanstack/router-generator/zod": ["zod@4.6.2", "", {}, "sha512-lh5RCAGFa1Cm2hjtNwLQhSs/AsqdWnTQaBER9fEwN/88pSh7KOtJavtBx/0VlkN/uFd61SwYmljLMDAsHlvzBQ=="],
|
||||
|
||||
"@tanstack/router-plugin/zod": ["zod@4.6.2", "", {}, "sha512-lh5RCAGFa1Cm2hjtNwLQhSs/AsqdWnTQaBER9fEwN/88pSh7KOtJavtBx/0VlkN/uFd61SwYmljLMDAsHlvzBQ=="],
|
||||
|
||||
"@types/nodemailer/@types/node": ["@types/node@26.5.1", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g=="],
|
||||
|
||||
"@types/pg/@types/node": ["@types/node@26.5.1", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g=="],
|
||||
|
||||
Reference in New Issue
Block a user