diff --git a/apps/web/src/components/auth/AuthShell.tsx b/apps/web/src/components/auth/AuthShell.tsx deleted file mode 100644 index f080c85..0000000 --- a/apps/web/src/components/auth/AuthShell.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import type { ReactNode } from 'react' -import { Logo } from '../brand' - -export type AuthShellProps = { - title: string - subtitle?: string - children: ReactNode -} - -export function AuthShell({ title, subtitle, children }: AuthShellProps) { - return ( -
-
-
- -
-
-

{title}

- {subtitle ?

{subtitle}

: null} -
{children}
-
-
-
- ) -} \ No newline at end of file diff --git a/apps/web/src/components/auth/index.ts b/apps/web/src/components/auth/index.ts deleted file mode 100644 index ecfc46d..0000000 --- a/apps/web/src/components/auth/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { AuthShell } from './AuthShell' \ No newline at end of file diff --git a/apps/web/src/components/groups/GroupForm.tsx b/apps/web/src/components/groups/GroupForm.tsx deleted file mode 100644 index a9548c4..0000000 --- a/apps/web/src/components/groups/GroupForm.tsx +++ /dev/null @@ -1,231 +0,0 @@ -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 '../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({ - 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 ( -
-
-

{heading}

-

{description}

-
- -
- - - {errors.name ?

{errors.name.message}

: null} -
- -
- -
- {WEEK_DAY_CHIPS.map(({ value, label }) => { - const isActive = days.includes(value) - return ( - - ) - })} -
- {errors.days ?

{errors.days.message}

: null} -
- -
- - - {errors.time ?

{errors.time.message}

: null} -
- -
-
- - - {errors.capacity ? ( -

{errors.capacity.message}

- ) : null} -
- -
- -
- - $ - - -
- {errors.price ? ( -

{errors.price.message}

- ) : null} -
-
- -
- -
- {BILLING_TYPES.map(({ value, label, hint }) => { - const isActive = billingType === value - return ( - - ) - })} -
-
- -
- - -

- Los cobros vencerán el día {isFinite(dueDay) && dueDay ? dueDay : '1'} de cada mes. -

- {errors.dueDay ? ( -

{errors.dueDay.message}

- ) : null} -
- - {errorMessage ? ( -

{errorMessage}

- ) : null} - -
- {onBack ? ( - - ) : null} - -
-
- ) -} \ No newline at end of file diff --git a/apps/web/src/components/onboarding/ConfirmationStep.tsx b/apps/web/src/components/onboarding/ConfirmationStep.tsx deleted file mode 100644 index 0060bdb..0000000 --- a/apps/web/src/components/onboarding/ConfirmationStep.tsx +++ /dev/null @@ -1,84 +0,0 @@ -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 '../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 ( -
-
- - - -

¡Todo listo!

-

- Tu grupo se creó y ya podés empezar a cobrar a tus miembros. -

-
- -
-
-
-

{group.name}

-

- {formatSchedule(group.days ?? [], group.time)} -

-
- Activo -
- -
-
-
Precio
-
- {formatPrice(group.price)} - {group.billingType ? ` · ${BILLING_LABELS[group.billingType]}` : ''} -
-
-
-
Cupo
-
{group.capacity ?? '—'} miembros
-
-
-
Vencimiento
-
Día {group.dueDay ?? '—'} de cada mes
-
-
-
- - {providerName ? ( -
- -

- Vas a cobrar con {providerName} en modo - prueba. -

-
- ) : null} - - -
- ) -} \ No newline at end of file diff --git a/apps/web/src/components/onboarding/FirstGroupStep.tsx b/apps/web/src/components/onboarding/FirstGroupStep.tsx deleted file mode 100644 index 32da7bd..0000000 --- a/apps/web/src/components/onboarding/FirstGroupStep.tsx +++ /dev/null @@ -1,30 +0,0 @@ -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 ( - create.mutate(values)} - onBack={onBack} - /> - ) -} \ No newline at end of file diff --git a/apps/web/src/components/onboarding/PaymentStep.tsx b/apps/web/src/components/onboarding/PaymentStep.tsx deleted file mode 100644 index 8f59e12..0000000 --- a/apps/web/src/components/onboarding/PaymentStep.tsx +++ /dev/null @@ -1,171 +0,0 @@ -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 '../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('MERCADO_PAGO') - const [connected, setConnected] = useState(initialResult) - - const connect = useMutation({ - mutationFn: () => connectPayment({ provider: selected, sandbox: true }), - onSuccess: (result) => { - setConnected(result) - onConnected(result) - }, - }) - - if (connected) { - return ( -
-
-

Tu cobro está conectado

-

- Ya podés dejar listo tu primer grupo para cobrar con{' '} - {PAYMENT_PROVIDERS.find((p) => p.value === connected.provider)?.name ?? ''}. -

-
- -
- - - -

Cuenta conectada

-
- - {PAYMENT_PROVIDERS.find((p) => p.value === connected.provider)?.name} - - Modo prueba -
-
- -
- - -
-
- ) - } - - return ( -
-
-

Elegí tu procesador de cobro

-

- Los pagos de tus miembros van a llegar por esta plataforma. -

-
- -
- {PAYMENT_PROVIDERS.map((provider) => { - const isSelected = selected === provider.value - return ( - - ) - })} -
- -
- -

- 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'}. -

-
- - {connect.isError ? ( -

- No pudimos conectar la cuenta. Intentalo de nuevo. -

- ) : null} - -
- - -
-
- ) -} \ No newline at end of file diff --git a/apps/web/src/components/onboarding/Stepper.tsx b/apps/web/src/components/onboarding/Stepper.tsx deleted file mode 100644 index 491d4d3..0000000 --- a/apps/web/src/components/onboarding/Stepper.tsx +++ /dev/null @@ -1,59 +0,0 @@ -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 ( -
    - {steps.map((step, index) => { - const isDone = index < current - const isActive = index === current - - return ( -
  1. -
    - - {isDone ? ( - - ) : ( - {index + 1} - )} - - - {step.label} - -
    - - {index < steps.length - 1 ? ( - - ) : null} -
  2. - ) - })} -
- ) -} \ No newline at end of file diff --git a/apps/web/src/components/onboarding/WelcomeStep.tsx b/apps/web/src/components/onboarding/WelcomeStep.tsx deleted file mode 100644 index 3a219ca..0000000 --- a/apps/web/src/components/onboarding/WelcomeStep.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { ArrowRight, Rocket, Users, Wallet } from 'lucide-react' -import { Button } from '../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 ( -
-
- - - -

¡Hola, {name}!

-

- Vamos a configurar tu cuenta en 3 pasos. Vas a tardar menos de 5 minutos. -

-
- -
    - {STEPS_TO_SETUP.map(({ icon: Icon, title, description }) => ( -
  1. - - - -
    -

    {title}

    -

    {description}

    -
    -
  2. - ))} -
- - -
- ) -} \ No newline at end of file diff --git a/apps/web/src/components/onboarding/constants.ts b/apps/web/src/components/onboarding/constants.ts deleted file mode 100644 index 308199a..0000000 --- a/apps/web/src/components/onboarding/constants.ts +++ /dev/null @@ -1,44 +0,0 @@ -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' }, -] \ No newline at end of file diff --git a/apps/web/src/components/onboarding/index.ts b/apps/web/src/components/onboarding/index.ts deleted file mode 100644 index 02224ec..0000000 --- a/apps/web/src/components/onboarding/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { Stepper, type StepperProps } from './Stepper' -export { WelcomeStep } from './WelcomeStep' -export { PaymentStep } from './PaymentStep' -export { FirstGroupStep } from './FirstGroupStep' -export { ConfirmationStep } from './ConfirmationStep' \ No newline at end of file diff --git a/apps/web/src/routes/absence-notify.tsx b/apps/web/src/routes/absence-notify.tsx deleted file mode 100644 index 034babe..0000000 --- a/apps/web/src/routes/absence-notify.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import { useState } from 'react' -import { useMutation, useQuery } from '@tanstack/react-query' -import { Link, useParams } from '@tanstack/react-router' -import { CalendarX2, CheckCircle2, Loader2, Moon, Sun } from 'lucide-react' -import { Badge, Button, useToast } from '../components/ui' -import { Logo } from '../components/brand' -import { useTheme } from '../context/ThemeProvider' -import { ApiError, getAbsenceDetails, publicNotifyAbsence } from '../lib/api' - -export function AbsenceNotifyView() { - const { token } = useParams({ strict: false }) as { token: string } - const { setTheme, isDark } = useTheme() - const toast = useToast() - - const [locallyNotified, setLocallyNotified] = useState([]) - - 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 details = detailsQuery.data - - return ( -
-
- - - - -
- -
-
- {detailsQuery.isPending ? ( -
- -

Cargando tus clases...

-
- ) : null} - - {detailsQuery.isError || !details ? ( -
-

Enlace no válido

-

- No pudimos identificarte con este enlace. Consultá con tu profesor para - solicitar uno nuevo. -

-
- ) : null} - - {details ? ( -
-
-
- -
- - Aviso de ausencia - -

Hola, {details.fullName}

-

- Grupo: {details.groupName} -

-
- - {details.sessions.length === 0 ? ( -

- No tenés clases programadas para hoy. Si necesitás avisar de todas formas, - contactá a tu profesor. -

- ) : ( -
    - {details.sessions.map((session) => { - const time = new Date(session.startsAt).toLocaleTimeString('es-MX', { - hour: '2-digit', - minute: '2-digit', - hour12: false, - }) - const notified = - session.notified || locallyNotified.includes(session.sessionId) - - return ( -
  • -
    -

    - Hoy · {time} hs -

    -

    - {session.groupName} -

    -
    - {notified ? ( - - - Ya avisaste - - ) : ( - - )} -
  • - ) - })} -
- )} - -

- Si avisás con anticipación, tu cupo se libera para una clase de recuperación. -

-
- ) : null} -
-
- -
- Gruperly — Gestión sencilla de cobros y grupos -
-
- ) -} diff --git a/apps/web/src/routes/analytics.tsx b/apps/web/src/routes/analytics.tsx deleted file mode 100644 index 0e148d2..0000000 --- a/apps/web/src/routes/analytics.tsx +++ /dev/null @@ -1,473 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Link, useParams } from '@tanstack/react-router'; -import type { AttendeeStatus, StudentAtRiskDto } from '@gruperly/shared'; -import { - ArrowLeft, - CalendarCheck, - DoorOpen, - Loader2, - MessageCircle, - MoreVertical, - Pause, - Percent, - UserMinus, - Users, -} from 'lucide-react'; -import { useState, type ReactNode } from 'react'; -import { Avatar, Badge, Button, Modal, useToast } from '../components/ui'; -import { - getAttendeeHistory, - getGroupAnalytics, - getStudentsAtRisk, - updateAttendeeStatus, -} from '../lib/api'; -import { cn } from '../lib/utils'; - -type StatusAction = { student: StudentAtRiskDto; status: AttendeeStatus } | null; - -function KpiCard({ - icon, - label, - value, - hint, -}: { - icon: ReactNode; - label: string; - value: string; - hint?: string; -}) { - return ( -
-
- {icon} -
-
-

{label}

-

{value}

- {hint ?

{hint}

: null} -
-
- ); -} - -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'; - } -} - -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'; - } -} - -function formatSessionDate(iso: string): string { - return new Date(iso).toLocaleDateString('es-MX', { - weekday: 'short', - day: 'numeric', - month: 'short', - }); -} - -export function AnalyticsView() { - const { groupId } = useParams({ strict: false }) as { groupId: string }; - const queryClient = useQueryClient(); - const toast = useToast(); - - const [historyStudent, setHistoryStudent] = useState(null); - const [statusAction, setStatusAction] = useState(null); - const [menuOpenFor, setMenuOpenFor] = useState(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) => - 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 ( -
- -
- ); - } - - if (analyticsQuery.isError || riskQuery.isError || !analyticsQuery.data) { - return ( -
-

No pudimos cargar las estadísticas

-

Verificá tu conexión e intentá de nuevo.

- -
- ); - } - - const analytics = analyticsQuery.data; - const atRisk = riskQuery.data?.data ?? []; - - return ( -
- - - Volver a {groupName ?? 'el grupo'} - - -
-
-

Estadísticas

-

- Asistencia y gestión de riesgo de {groupName ?? 'tu grupo'}. -

-
- - Últimos 30 días - -
- - {/* KPIs */} -
- } - label="Asistencia promedio" - value={`${analytics.attendanceRate}%`} - hint="últimos 30 días" - /> - } - label="Asistencias del mes" - value={analytics.totalPresent.toLocaleString('es-MX')} - hint={`${analytics.totalClasses} clases dictadas`} - /> - } - label="Cupos recuperados" - value={analytics.recoveredSlots.toLocaleString('es-MX')} - hint="por ausencias avisadas" - /> -
- - {/* Alumnos en riesgo */} -
-
- -

Alumnos en riesgo

- {atRisk.length > 0 ? ( - s.riskLevel === 'HIGH') ? 'danger' : 'warning'}> - {atRisk.length} {atRisk.length === 1 ? 'alumno' : 'alumnos'} - - ) : null} -
-

- Alumnos con ausencias consecutivas o baja asistencia mensual. -

- - {atRisk.length === 0 ? ( -
-

¡Sin alumnos en riesgo!

-

- No hay ausencias consecutivas ni asistencia por debajo del 50%. -

-
- ) : ( -
    - {atRisk.map((student) => ( -
  • -
    - - {student.riskLevel === 'HIGH' ? ( - - {student.consecutiveAbsences} faltas seguidas - - ) : ( - Baja asistencia - )} -
    - -
    - - -
    - - - {menuOpenFor === student.attendeeId ? ( - <> -
    setMenuOpenFor(null)} - aria-hidden="true" - /> -
    - - -
    - - ) : null} -
    -
    -
  • - ))} -
- )} -
- - {/* Modal: historial del alumno */} - setHistoryStudent(null)} - title={historyStudent?.fullName ?? 'Historial'} - description={historyStudent ? 'Historial de presentismo de las últimas clases.' : undefined} - maxWidth="md" - > - {historyStudent ? ( -
- {historyQuery.isPending ? ( -
- -
- ) : historyQuery.isError || !historyQuery.data ? ( -
- No pudimos cargar el historial de este alumno. -
- ) : ( -
-
- - {historyQuery.data.attendanceRate}% - - - Presentismo general ({historyQuery.data.sessions.length} clases) - -
- -
    - {historyQuery.data.sessions.map((session) => ( -
  • -
  • - ))} -
-
- )} -
- ) : null} -
- - {/* Modal: confirmar cambio de estado */} - setStatusAction(null)} - title={statusAction?.status === 'DROPPED' ? 'Dar de baja' : 'Pausar vacante'} - maxWidth="md" - > - {statusAction ? ( -
-

- {statusAction.student.fullName}{' '} - {statusAction.status === 'DROPPED' ? ( - <> - dejará de asistir al grupo y {groupName}. Su - vacante quedará disponible para otro alumno, y - su historial de asistencia se conservará. - - ) : ( - <> - será pausado en{' '} - {groupName}. Su vacante quedará{' '} - libre y podés reactivarla en cualquier - momento. - - )} -

-
- - -
-
- ) : null} -
-
- ); -} \ No newline at end of file diff --git a/apps/web/src/routes/auth/login.tsx b/apps/web/src/routes/auth/login.tsx deleted file mode 100644 index cb62413..0000000 --- a/apps/web/src/routes/auth/login.tsx +++ /dev/null @@ -1,113 +0,0 @@ -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 '../../components/auth' - -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 - -export function LoginPage() { - const navigate = useNavigate() - const { - register, - handleSubmit, - setError, - formState: { errors, isSubmitting }, - } = useForm({ 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 ( - -
-
-
- - - {errors.email ?

{errors.email.message}

: null} -
- -
- - - {errors.password ? ( -

{errors.password.message}

- ) : null} -
- - {errors.root ?

{errors.root.message}

: null} - - -
- -
-
- o -
-
- -
- - -
- -

- ¿No tenés cuenta?{' '} - - Registrate - -

-
- - ) -} \ No newline at end of file diff --git a/apps/web/src/routes/auth/signup.tsx b/apps/web/src/routes/auth/signup.tsx deleted file mode 100644 index 939f02c..0000000 --- a/apps/web/src/routes/auth/signup.tsx +++ /dev/null @@ -1,142 +0,0 @@ -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 '../../components/auth' - -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 - -export function SignupPage() { - const navigate = useNavigate() - const { - register, - handleSubmit, - setError, - formState: { errors, isSubmitting }, - } = useForm({ 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 ( - -
-
-
- - - {errors.name ?

{errors.name.message}

: null} -
- -
- - - {errors.email ?

{errors.email.message}

: null} -
- -
- - - {errors.password ? ( -

{errors.password.message}

- ) : null} -
- -
- - - {errors.confirmPassword ? ( -

{errors.confirmPassword.message}

- ) : null} -
- - {errors.root ?

{errors.root.message}

: null} - - -
- -
-
- o -
-
- - - -

- ¿Ya tenés cuenta?{' '} - - Iniciá sesión - -

-
- - ) -} \ No newline at end of file diff --git a/apps/web/src/routes/auth/verify-email.tsx b/apps/web/src/routes/auth/verify-email.tsx deleted file mode 100644 index 3e1ba09..0000000 --- a/apps/web/src/routes/auth/verify-email.tsx +++ /dev/null @@ -1,91 +0,0 @@ -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 '../../components/auth' - -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(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 ( - -
- {status === 'verifying' ? ( - - ) : status === 'success' ? ( - - ) : ( - - )} - - {status === 'success' ? ( -

- Tu cuenta quedó verificada. Te estamos llevando a Gruperly… -

- ) : status === 'error' ? ( -

- El vínculo de verificación no es válido o expiró. -

- ) : ( -

- Te enviamos un correo a {email} para - confirmar tu cuenta. -

- )} - - {status === 'success' ? ( - - Ir a Gruperly - - ) : null} - - {status === 'error' ? ( - - Intentar iniciar sesión - - ) : null} -
-
- ) -} \ No newline at end of file diff --git a/apps/web/src/routes/class-attendance.tsx b/apps/web/src/routes/class-attendance.tsx deleted file mode 100644 index a29af96..0000000 --- a/apps/web/src/routes/class-attendance.tsx +++ /dev/null @@ -1,259 +0,0 @@ -import { useEffect, useMemo, useState } from 'react' -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { Link, useParams } from '@tanstack/react-router' -import type { SessionStudentDto } from '@gruperly/shared' -import { ArrowLeft, Check, Loader2, X } from 'lucide-react' -import { Avatar, Badge, Button, useToast } from '../components/ui' -import { getSessionStudents, markAttendance } from '../lib/api' -import { cn } from '../lib/utils' - -type AttendanceChoice = 'PRESENT' | 'ABSENT' - -function isLocked(student: SessionStudentDto): boolean { - return student.notifiedAbsence || student.attendanceStatus === 'EXCUSED' -} - -function PaymentBadge({ status }: { status: SessionStudentDto['paymentStatus'] }) { - return status === 'UP_TO_DATE' ? ( - Al día - ) : ( - Pendiente - ) -} - -function AttendanceToggle({ - choice, - disabled, - onToggle, - fullName, -}: { - choice: AttendanceChoice - disabled?: boolean - onToggle: () => void - fullName: string -}) { - const isPresent = choice === 'PRESENT' - - return ( - - ) -} - -function StudentRow({ - student, - choice, - onToggle, -}: { - student: SessionStudentDto - choice: AttendanceChoice - onToggle: () => void -}) { - const locked = isLocked(student) - - return ( -
  • - -
    -

    {student.fullName}

    -
    - - {locked ? Avisó ausencia : null} -
    -
    - -
  • - ) -} - -export function ClassAttendanceView() { - const { sessionId } = useParams({ strict: false }) as { sessionId: string } - const queryClient = useQueryClient() - const toast = useToast() - - const studentsQuery = useQuery({ - queryKey: ['class-students', sessionId], - queryFn: () => getSessionStudents(sessionId), - staleTime: 0, - }) - - const students = studentsQuery.data?.students - - // Estado local de la toma: todos parten PRESENT (1-tap para marcar ausentes). - // Alumnas con ausencia avisada quedan bloqueadas en EXCUSED. - const [choices, setChoices] = useState>({}) - - useEffect(() => { - if (!students) return - const initial: Record = {} - for (const student of students) { - if (isLocked(student)) continue - initial[student.attendeeId] = student.attendanceStatus === 'ABSENT' ? 'ABSENT' : 'PRESENT' - } - setChoices(initial) - }, [students]) - - const presentCount = useMemo( - () => - (students ?? []).filter( - (student) => !isLocked(student) && choices[student.attendeeId] === 'PRESENT', - ).length, - [students, choices], - ) - - const toggleStudent = (attendeeId: string) => { - setChoices((prev) => ({ - ...prev, - [attendeeId]: prev[attendeeId] === 'ABSENT' ? 'PRESENT' : 'ABSENT', - })) - } - - const saveMutation = useMutation({ - mutationFn: () => { - const records = (students ?? []) - .filter((student) => !isLocked(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 sessionInfo = studentsQuery.data - const startTime = sessionInfo - ? new Date(sessionInfo.startsAt).toLocaleTimeString('es-MX', { - hour: '2-digit', - minute: '2-digit', - hour12: false, - }) - : null - - return ( -
    - - - Volver al inicio - - - {studentsQuery.isPending ? ( -
    - -
    - ) : null} - - {studentsQuery.isError ? ( -
    -

    No pudimos cargar la lista de alumnos.

    - -
    - ) : null} - - {sessionInfo ? ( - <> -
    -
    -
    -

    - {sessionInfo.groupName} -

    -

    - {startTime ? `Hoy · ${startTime} hs` : 'Clase de hoy'} -

    -
    - - {presentCount}/{sessionInfo.students.length} presentes - -
    -
    - - {sessionInfo.students.length === 0 ? ( -
    -

    Este grupo no tiene alumnos

    -

    - Agregá miembros desde la ficha del grupo para tomar asistencia. -

    -
    - ) : ( -
      - {sessionInfo.students.map((student) => ( - toggleStudent(student.attendeeId)} - /> - ))} -
    - )} - - {sessionInfo.students.length > 0 ? ( -
    - -
    - ) : null} - - ) : null} -
    - ) -} diff --git a/apps/web/src/routes/create-group.tsx b/apps/web/src/routes/create-group.tsx deleted file mode 100644 index 4af6940..0000000 --- a/apps/web/src/routes/create-group.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query' -import { useNavigate, Link } from '@tanstack/react-router' -import type { CreateFirstGroup } from '@gruperly/shared' -import { ChevronLeft } from 'lucide-react' -import { GroupForm } from '../components/groups/GroupForm' -import { createGroup } from '../lib/api' - -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 ( -
    - - - Grupos - - - create.mutate(values)} - /> -
    - ) -} \ No newline at end of file diff --git a/apps/web/src/routes/group-detail.tsx b/apps/web/src/routes/group-detail.tsx deleted file mode 100644 index 7616bb3..0000000 --- a/apps/web/src/routes/group-detail.tsx +++ /dev/null @@ -1,1680 +0,0 @@ -import { useState, useRef, type ReactNode } from 'react'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useParams, useNavigate, Link } from '@tanstack/react-router'; -import type { - AttendeeDto, - CreateAttendee, - CreateAttendeeResult, - CreateGroupWaitlistEntry, - GroupWaitlistEntryDto, - GroupWaitlistList, -} from '@gruperly/shared'; -import { - ArrowLeft, - BarChart3, - CalendarClock, - Check, - ChevronRight, - Clock, - Copy, - Download, - FileSpreadsheet, - Loader2, - Mail, - MessageCircle, - Phone, - Plus, - RefreshCw, - Search, - Share2, - ShieldCheck, - StickyNote, - Trash2, - UploadCloud, - UserCheck, - UserMinus, - UserPlus, - Users, -} from 'lucide-react'; -import { Badge, Button, Input, Label, Modal, useToast } from '../components/ui'; -import { - addToGroupWaitlist, - ApiError, - bulkCreateAttendees, - createAttendee, - getGroup, - getGroupAttendees, - getGroupsRiskOverview, - getGroupWaitlist, - getInviteToken, - promoteGroupWaitlistEntry, - removeGroupAttendee, - removeGroupWaitlistEntry, -} from '../lib/api'; -import { - downloadAttendeeTemplateCsv, - normalizeRowsToContacts, - parseCsvContent, - parseXlsxContent, - type ParsedContactRow, -} from '../lib/file-parser'; -import { BILLING_LABELS, formatPrice, formatSchedule } from '../lib/format'; - -export function GroupDetailView() { - const { groupId } = useParams({ strict: false }) as { groupId: string }; - const navigate = useNavigate(); - const queryClient = useQueryClient(); - const toast = useToast(); - - // Modals & Tabs - const [isInviteModalOpen, setIsInviteModalOpen] = useState(false); - const [isAddAttendeeModalOpen, setIsAddAttendeeModalOpen] = useState(false); - const [selectedAttendee, setSelectedAttendee] = useState(null); - const absenceNotifyUrl = selectedAttendee?.notifyToken - ? `${window.location.origin}/avisar-ausencia/${selectedAttendee.notifyToken}` - : null; - const [activeTab, setActiveTab] = useState<'quick' | 'bulk'>('quick'); - const [listSection, setListSection] = useState<'members' | 'waitlist'>('members'); - const [attendeeToRemove, setAttendeeToRemove] = useState(null); - const [selectedWaitlistEntry, setSelectedWaitlistEntry] = useState(null); - - // Quick form state - const [firstName, setFirstName] = useState(''); - const [lastName, setLastName] = useState(''); - const [phone, setPhone] = useState(''); - const [email, setEmail] = useState(''); - const [notes, setNotes] = useState(''); - - // Bulk upload state - const [parsedContacts, setParsedContacts] = useState([]); - const [fileName, setFileName] = useState(null); - const [isParsingFile, setIsParsingFile] = useState(false); - const [isDragging, setIsDragging] = useState(false); - const fileInputRef = useRef(null); - - // Capacity & waitlist state - const [isCapacityModalOpen, setIsCapacityModalOpen] = useState(false); - const [pendingCapacityPayload, setPendingCapacityPayload] = useState(null); - const [isBulkCapacityModalOpen, setIsBulkCapacityModalOpen] = useState(false); - - // Attendees search filter - const [searchFilter, setSearchFilter] = useState(''); - - // Queries - 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), - }); - - // Regenerate invite token mutation - 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.'); - }, - }); - - // Quick add attendee mutation - const resetQuickForm = () => { - setFirstName(''); - setLastName(''); - setPhone(''); - setEmail(''); - setNotes(''); - }; - - const closeAddAttendeeFlow = () => { - resetQuickForm(); - setPendingCapacityPayload(null); - setIsCapacityModalOpen(false); - setIsAddAttendeeModalOpen(false); - }; - - const handleCreateSuccess = (result: CreateAttendeeResult) => { - if (result.outcome === 'created') { - toast.success(`Miembro ${result.attendee.fullName} agregado con éxito.`); - } else { - toast.info(result.message); - } - queryClient.invalidateQueries({ queryKey: ['group-attendees', groupId] }); - closeAddAttendeeFlow(); - }; - - 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.'); - }, - }); - - // Force add (owner overrides the full capacity) - const createAttendeeForceMutation = useMutation({ - mutationFn: (payload: CreateAttendee) => createAttendee(groupId, payload, { allowOverflow: true }), - onSuccess: handleCreateSuccess, - onError: (err: Error) => { - toast.error(err.message || 'Error al agregar miembro.'); - }, - }); - - // Add to group waitlist - const addToWaitlistMutation = useMutation({ - mutationFn: (payload: CreateGroupWaitlistEntry) => addToGroupWaitlist(groupId, payload), - onMutate: async (payload: CreateGroupWaitlistEntry) => { - await queryClient.cancelQueries({ queryKey: ['group-waitlist', groupId] }); - const previousWaitlist = queryClient.getQueryData(['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(['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.`); - queryClient.invalidateQueries({ queryKey: ['group-waitlist', groupId] }); - queryClient.invalidateQueries({ queryKey: ['group', groupId] }); - closeAddAttendeeFlow(); - }, - onError: (err: Error, _payload, context) => { - if (context?.previousWaitlist) { - queryClient.setQueryData(['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); - }, - }); - - // Bulk import mutation - const bulkImportMutation = useMutation({ - mutationFn: (attendees: Array<{ firstName: string; lastName: string; fullName: string; phone: string; email?: string; notes?: string }>) => - bulkCreateAttendees(groupId, { attendees }), - onSuccess: (res) => { - toast.success(res.message); - queryClient.invalidateQueries({ queryKey: ['group-attendees', groupId] }); - setParsedContacts([]); - setFileName(null); - setIsAddAttendeeModalOpen(false); - }, - onError: (err: Error) => { - toast.error(err.message || 'Error al importar miembros.'); - }, - }); - - // Waitlist & removal mutations - const invalidateGroupQueries = () => { - queryClient.invalidateQueries({ queryKey: ['group-attendees', groupId] }); - queryClient.invalidateQueries({ queryKey: ['group-waitlist', groupId] }); - queryClient.invalidateQueries({ queryKey: ['group', groupId] }); - }; - - 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 handleCopyLink = async () => { - const url = inviteTokenQuery.data?.inviteUrl; - if (!url) return; - try { - await navigator.clipboard.writeText(url); - toast.success('¡Enlace de invitación copiado al portapapeles!'); - } catch { - toast.error('No se pudo copiar automáticamente. Copia el texto manualmente.'); - } - }; - - const handleCopyMessage = 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 handleOpenWhatsApp = async () => { - if (!whatsappShareUrl) return; - try { - if (whatsappMessage) { - await navigator.clipboard.writeText(whatsappMessage); - toast.success('¡Abriendo WhatsApp! Mensaje copiado al portapapeles.'); - } - } catch { - // Ignorar si clipboard falla - } - window.open(whatsappShareUrl, '_blank', 'noopener,noreferrer'); - }; - - const handleQuickSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (!firstName.trim() || !phone.trim()) { - toast.error('Nombre y teléfono son obligatorios.'); - return; - } - createAttendeeMutation.mutate({ - firstName: firstName.trim(), - lastName: lastName.trim(), - phone: phone.trim(), - email: email.trim() || undefined, - notes: notes.trim() || undefined, - }); - }; - - const processFile = async (file: File) => { - setIsParsingFile(true); - setFileName(file.name); - try { - let rawRows: string[][] = []; - const lower = file.name.toLowerCase(); - - if (lower.endsWith('.csv') || lower.endsWith('.txt')) { - const text = await file.text(); - rawRows = parseCsvContent(text); - } else if (lower.endsWith('.xlsx') || lower.endsWith('.xls')) { - const buffer = await file.arrayBuffer(); - rawRows = await parseXlsxContent(buffer); - } else { - toast.error('Formato no compatible. Sube un archivo .csv o .xlsx'); - setIsParsingFile(false); - return; - } - - 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); - if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { - void processFile(e.dataTransfer.files[0]); - } - }; - - const handleFileSelect = (e: React.ChangeEvent) => { - if (e.target.files && e.target.files.length > 0) { - void processFile(e.target.files[0]); - } - }; - - const removeContactRow = (id: string) => { - setParsedContacts((prev) => prev.filter((c) => c.id !== id)); - }; - - const doBulkImport = () => { - const validRows = parsedContacts.filter((c) => c.isValid); - if (validRows.length === 0) { - toast.error('No hay miembros válidos para importar.'); - return; - } - bulkImportMutation.mutate( - validRows.map((c) => ({ - firstName: c.firstName, - lastName: c.lastName, - fullName: c.fullName, - phone: c.phone, - email: c.email || undefined, - notes: c.notes || undefined, - })), - ); - }; - - const handleConfirmBulkImport = () => { - if (validRowsCount === 0) { - toast.error('No hay miembros válidos para importar.'); - return; - } - if (group?.capacity != null && attendeesTotal + validRowsCount > group.capacity) { - setIsBulkCapacityModalOpen(true); - return; - } - doBulkImport(); - }; - - const group = groupQuery.data; - const attendees = attendeesQuery.data?.data ?? []; - const attendeesTotal = attendeesQuery.data?.pagination?.total ?? attendees.length; - const waitlistEntries = waitlistQuery.data?.data ?? []; - const waitlistTotal = waitlistQuery.data?.pagination?.total ?? waitlistEntries.length; - const firstWaitlistEntry = waitlistEntries[0] ?? null; - const hasFreeCapacity = group?.capacity == null || attendeesTotal < group.capacity; - const filteredAttendees = attendees.filter((a) => { - const q = searchFilter.toLowerCase(); - return ( - a.fullName.toLowerCase().includes(q) || - (a.phone && a.phone.includes(q)) || - (a.email && a.email.toLowerCase().includes(q)) - ); - }); - - const validRowsCount = parsedContacts.filter((c) => c.isValid).length; - const invalidRowsCount = parsedContacts.length - validRowsCount; - - const whatsappMessage = - group && inviteTokenQuery.data?.inviteUrl - ? `¡Hola! 👋 Te invito a unirte al grupo *${group.name}*.\n\nCompleta tus datos de inscripción en el siguiente enlace:\n${inviteTokenQuery.data.inviteUrl}` - : ''; - - const whatsappShareUrl = whatsappMessage - ? `https://wa.me/?text=${encodeURIComponent(whatsappMessage)}` - : ''; - - if (groupQuery.isPending) { - return ( -
    - -
    - ); - } - - if (groupQuery.isError || !group) { - return ( -
    -

    Grupo no encontrado

    -

    No se pudo cargar la información de este grupo.

    - -
    - ); - } - - const riskLevel = - riskOverviewQuery.data?.items.find((item) => item.groupId === groupId)?.riskLevel ?? 'NONE'; - - return ( -
    - {/* Navigation & Header */} -
    - - - Volver a grupos - - -
    -
    -
    -

    {group.name}

    - Activo - {riskLevel === 'HIGH' || riskLevel === 'MEDIUM' ? ( - - - ) : null} -
    - {group.description ? ( -

    {group.description}

    - ) : null} -
    - -
    - - - -
    -
    -
    - - {/* Group Details Card */} -
    -
    - -
    -

    Horario

    -

    - {(group.days?.length ?? 0) > 0 ? formatSchedule(group.days ?? [], group.time) : 'Sin horario definido'} -

    -
    -
    - -
    - -
    -

    Miembros & Cupo

    -

    - {attendees.length} inscritos {waitlistTotal > 0 ? ` · ${waitlistTotal} en espera` : ''} - {group.capacity ? ` · Cupo de ${group.capacity}` : ''} -

    -
    -
    - -
    -
    - $ -
    -
    -

    Cobro

    -

    - {group.price != null ? formatPrice(group.price) : 'Sin precio'} - {group.billingType ? ` · ${BILLING_LABELS[group.billingType]}` : ''} - {group.dueDay ? ` · Vence día ${group.dueDay}` : ''} -

    -
    -
    -
    - - {/* Miembros & Lista de espera */} -
    -
    -
    -
    - - -
    - - {listSection === 'members' ? ( -
    - - setSearchFilter(e.target.value)} - className="pl-9 h-9 text-xs" - /> -
    - ) : null} -
    - -

    - {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.'} -

    -
    - - {listSection === 'members' ? ( - <> - {attendeesQuery.isPending ? ( -
    - -
    - ) : null} - - {attendeesQuery.isSuccess && attendees.length === 0 ? ( -
    - -

    Todavía no hay miembros en este grupo

    -

    - Puedes compartir el enlace de invitación único o agregar miembros de forma manual o masiva. -

    -
    - - -
    -
    - ) : null} - - {attendeesQuery.isSuccess && attendees.length > 0 && filteredAttendees.length === 0 ? ( -
    - No se encontraron miembros que coincidan con la búsqueda. -
    - ) : null} - - {filteredAttendees.length > 0 ? ( -
    - - - - - - - - - {filteredAttendees.map((attendee) => ( - setSelectedAttendee(attendee)} - > - - - - - ))} - -
    NombreTeléfono -
    -
    -
    - {attendee.fullName.charAt(0).toUpperCase()} -
    - {attendee.fullName} -
    -
    - {attendee.phone || —} - - -
    -
    - ) : null} - - ) : ( -
    - {waitlistQuery.isPending ? ( -
    - -
    - ) : null} - - {waitlistQuery.isSuccess && waitlistEntries.length === 0 ? ( -
    - -

    No hay nadie en la lista de espera

    -

    - Cuando el grupo alcance su cupo, las personas podrán sumarse a la espera y podrás pasarlas al grupo desde aquí. -

    -
    - ) : null} - - {waitlistEntries.length > 0 ? ( -
    - - - - - - - - - {waitlistEntries.map((entry) => ( - setSelectedWaitlistEntry(entry)} - > - - - - - ))} - -
    NombreTeléfono -
    -
    -
    - {entry.fullName.charAt(0).toUpperCase()} -
    -
    -

    {entry.fullName}

    - {entry.notes ? ( -

    {entry.notes}

    - ) : null} -
    -
    -
    - {entry.phone} - - -
    -
    - ) : null} -
    - )} -
    - - {/* MODAL: COMPARTIR LINK DE INVITACION */} - setIsInviteModalOpen(false)} - 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" - > -
    - {inviteTokenQuery.isPending ? ( -
    - -
    - ) : ( - <> -
    - -
    - - -
    -
    - -
    -
    -

    Vista previa del mensaje para WhatsApp:

    - -
    -
    - {`¡Hola! 👋 Te invito a unirte al grupo `} - {group.name}. - {'\n\n'} - Completa tus datos de inscripción en el siguiente enlace: - {'\n'} - - {inviteTokenQuery.data?.inviteUrl} - -
    -
    - -
    - - - -
    - -

    - 💡 Al hacer clic en Abrir en WhatsApp, el mensaje completo se copia automáticamente al portapapeles. Si WhatsApp Web solo carga el enlace, puedes pegarlo directamente con Ctrl+V o Cmd+V. -

    - - )} -
    -
    - - {/* MODAL: AGREGAR ALUMNOS (CARGA INDIVIDUAL / MASIVA) */} - setIsAddAttendeeModalOpen(false)} - title="Agregar Miembros al Grupo" - description="Agrega miembros rápidamente completando sus datos o importa una lista de contactos." - maxWidth="lg" - > -
    - {/* Tabs Selector */} -
    - - -
    - - {/* TAB 1: CARGA RAPIDA */} - {activeTab === 'quick' ? ( -
    -
    -
    - - setFirstName(e.target.value)} - required - /> -
    -
    - - setLastName(e.target.value)} - /> -
    -
    - -
    - - setPhone(e.target.value)} - required - /> -

    - Usado para contacto y validación de duplicados. -

    -
    - -
    - - setEmail(e.target.value)} - /> -
    - -
    - - setNotes(e.target.value)} - /> -
    - -
    - - -
    -
    - ) : null} - - {/* TAB 2: CARGA MASIVA */} - {activeTab === 'bulk' ? ( -
    - {/* Dropzone */} -
    { - e.preventDefault(); - setIsDragging(true); - }} - onDragLeave={() => setIsDragging(false)} - onDrop={handleFileDrop} - onClick={() => fileInputRef.current?.click()} - className={`relative flex flex-col items-center justify-center p-6 rounded-xl border-2 border-dashed cursor-pointer transition-all ${ - isDragging - ? 'border-accent bg-accent/5' - : 'border-border hover:border-accent/60 bg-primary-soft/30' - }`} - > - - -

    - Arrastra tu archivo aquí o haz clic para seleccionarlo -

    -

    - Formatos soportados: CSV (.csv) o Excel (.xlsx) -

    - - {fileName ? ( -
    - - {fileName} -
    - ) : null} -
    - - {/* Template Download Link */} -
    - - ¿No tienes el archivo listo? Usa nuestra plantilla. - - -
    - - {isParsingFile ? ( -
    - - Leyendo y analizando archivo... -
    - ) : null} - - {/* Interactive Preview Table */} - {parsedContacts.length > 0 ? ( -
    -
    -
    - Vista Previa: - {validRowsCount} listos para importar - {invalidRowsCount > 0 ? ( - {invalidRowsCount} con errores - ) : null} -
    - - -
    - -
    - - - - - - - - - - - - {parsedContacts.map((c) => ( - - - - - - - - ))} - -
    NombreTeléfonoEmailEstado
    - {c.fullName || Sin nombre} - - {c.phone || Sin teléfono} - - {c.email || '—'} - - {c.isValid ? ( - Válido - ) : ( - - {c.error} - - )} - - -
    -
    - -
    - - -
    -
    - ) : null} -
    - ) : null} -
    -
    - - {/* MODAL: CUPO ALCANZADO (ALTA INDIVIDUAL) */} - { - setPendingCapacityPayload(null); - setIsCapacityModalOpen(false); - }} - title="Cupo alcanzado" - description="El grupo llegó a su cupo máximo de miembros." - maxWidth="md" - > - {pendingCapacityPayload ? ( -
    -

    - El grupo {group.name} ya alcanzó su cupo de{' '} - {group.capacity} miembros. ¿Qué deseas hacer con{' '} - - {`${pendingCapacityPayload.firstName.trim()} ${pendingCapacityPayload.lastName.trim()}`.trim()} - - ? -

    -
    - - -
    -

    - Si eliges agregarlo de todos modos, el grupo quedará por encima de su cupo. -

    -
    - ) : null} -
    - - {/* MODAL: AVISO DE CUPO EN CARGA MASIVA */} - setIsBulkCapacityModalOpen(false)} - title="Aviso de cupo" - description="La carga supera el cupo del grupo." - maxWidth="md" - > -
    -

    - Estás por importar {validRowsCount} miembro(s), pero el grupo ya - tiene {attendeesTotal} inscrito(s) y su cupo es de{' '} - {group.capacity}. ¿Deseas importarlos de todos modos? -

    -
    - - -
    -
    -
    - - {/* MODAL: DETALLE DEL PARTICIPANTE */} - setSelectedAttendee(null)} - title="Detalle del Participante" - maxWidth="sm" - > - {selectedAttendee ? ( -
    - {/* Header: avatar + name */} -
    -
    - {selectedAttendee.fullName.charAt(0).toUpperCase()} -
    -
    -

    - {selectedAttendee.fullName} -

    -

    - Incorporado el{' '} - {new Date(selectedAttendee.createdAt).toLocaleDateString('es-ES', { - day: 'numeric', - month: 'long', - year: 'numeric', - })} -

    -
    -
    - - {/* Info rows */} -
    - } - label="Teléfono" - > - {selectedAttendee.phone ? ( -
    - {selectedAttendee.phone} - e.stopPropagation()} - > - - WhatsApp - -
    - ) : ( - Sin teléfono - )} -
    - - } - label="Email" - > - {selectedAttendee.email ? ( - e.stopPropagation()} - > - {selectedAttendee.email} - - ) : ( - Sin email - )} - - - {(selectedAttendee.guardianName || selectedAttendee.guardianPhone) ? ( - <> - } - label="Responsable" - > - - {selectedAttendee.guardianName || —} - - - {selectedAttendee.guardianPhone ? ( - } - label="Tel. Responsable" - > -
    - {selectedAttendee.guardianPhone} - e.stopPropagation()} - > - - -
    -
    - ) : null} - - ) : null} - - } - label="Notas" - > - {selectedAttendee.notes ? ( - {selectedAttendee.notes} - ) : ( - Sin notas - )} - -
    - - {absenceNotifyUrl ? ( -
    -

    - Link para avisar ausencias -

    -
    - - {absenceNotifyUrl} - - -
    - {selectedAttendee.phone ? ( - - - Enviar por WhatsApp - - ) : null} -
    - ) : null} - -
    - -
    -
    - ) : null} -
    - {/* MODAL: CONFIRMAR QUITAR MIEMBRO */} - setAttendeeToRemove(null)} - title="Quitar del grupo" - maxWidth="md" - > - {attendeeToRemove ? ( -
    -

    - {attendeeToRemove.fullName} dejará de ser miembro del grupo{' '} - {group.name} - {firstWaitlistEntry ? ' y el cupo quedará libre.' : '.'} - {attendeeToRemove.phone ? ( - - Teléfono: {attendeeToRemove.phone} - - ) : null} -

    - - {firstWaitlistEntry ? ( -
    -
    - - - Hay {waitlistTotal} persona{waitlistTotal === 1 ? '' : 's'} esperando un cupo - -
    -

    - ¿Quieres pasar a{' '} - {firstWaitlistEntry.fullName}, el primero de - la lista de espera, al grupo? -

    -
    - - -
    -
    - ) : ( -
    - - -
    - )} -
    - ) : null} -
    - {/* MODAL: DETALLE LISTA DE ESPERA */} - setSelectedWaitlistEntry(null)} - title="Detalle de la lista de espera" - maxWidth="sm" - > - {selectedWaitlistEntry ? ( -
    - {/* Header: avatar + name */} -
    -
    - {selectedWaitlistEntry.fullName.charAt(0).toUpperCase()} -
    -
    -

    - {selectedWaitlistEntry.fullName} -

    -

    - En espera desde el{' '} - {new Date(selectedWaitlistEntry.createdAt).toLocaleDateString('es-ES', { - day: 'numeric', - month: 'long', - year: 'numeric', - })} -

    -
    -
    - - {/* Info rows */} -
    - } label="Teléfono"> - {selectedWaitlistEntry.phone ? ( -
    - {selectedWaitlistEntry.phone} - e.stopPropagation()} - > - - WhatsApp - -
    - ) : ( - Sin teléfono - )} -
    - - {selectedWaitlistEntry.email ? ( - } label="Email"> - e.stopPropagation()} - > - {selectedWaitlistEntry.email} - - - ) : null} - - } label="Notas"> - {selectedWaitlistEntry.notes ? ( - - {selectedWaitlistEntry.notes} - - ) : ( - Sin notas - )} - -
    - - {/* Footer: status + actions */} - {!hasFreeCapacity ? ( -

    - El grupo alcanzó su cupo de miembros. Quita un miembro o aumenta el cupo para poder pasar - a esta persona al grupo. -

    - ) : null} -
    - - -
    -
    - ) : null} -
    -
    - ); -} - -function DetailRow({ icon, label, children }: { icon: ReactNode; label: string; children: ReactNode }) { - return ( -
    -
    {icon}
    -
    -

    - {label} -

    -
    {children}
    -
    -
    - ); -} diff --git a/apps/web/src/routes/groups.tsx b/apps/web/src/routes/groups.tsx deleted file mode 100644 index 0087786..0000000 --- a/apps/web/src/routes/groups.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { useNavigate } from '@tanstack/react-router' -import { CalendarClock, Loader2, Plus, Users } from 'lucide-react' -import type { GroupDto, GroupRiskLevel } from '@gruperly/shared' -import { Badge, Button } from '../components/ui' -import { getGroups, getGroupsRiskOverview } from '../lib/api' -import { BILLING_LABELS, formatPrice, formatSchedule } from '../lib/format' -import { cn } from '../lib/utils' - -const RISK_LABELS: Record, string> = { - HIGH: 'Riesgo alto', - MEDIUM: 'Riesgo moderado', -} - -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 ( -
    -
    -
    -

    {group.name}

    - {group.description ? ( -

    {group.description}

    - ) : null} -
    -
    - {hasRisk ? ( - - - ) : null} - Activo -
    -
    - - {hasSchedule ? ( -
    -
    - - {formatSchedule(group.days ?? [], group.time)} -
    -
    - - - 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}` : ''} - -
    -
    - ) : ( -

    Aún sin plan de cobro configurado.

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

    Grupos

    -

    Tus grupos de cobranza.

    -
    - -
    - - {groupsQuery.isPending ? ( -
    - -
    - ) : null} - - {groupsQuery.isError ? ( -
    -

    No pudimos cargar tus grupos.

    - -
    - ) : null} - - {groupsQuery.isSuccess && groupsQuery.data.data.length === 0 ? ( -
    -

    Todavía no tenés grupos

    -

    - Crea tu primer grupo para empezar a cobrar. -

    - -
    - ) : null} - - {groupsQuery.isSuccess && groupsQuery.data.data.length > 0 ? ( -
    - {groupsQuery.data.data.map((group) => ( - - ))} -
    - ) : null} -
    - ) -} \ No newline at end of file diff --git a/apps/web/src/routes/home.tsx b/apps/web/src/routes/home.tsx deleted file mode 100644 index 431768a..0000000 --- a/apps/web/src/routes/home.tsx +++ /dev/null @@ -1,310 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { useNavigate } from '@tanstack/react-router' -import { - CalendarClock, - ClipboardCheck, - Loader2, - Plus, - UserCheck, - Users, - Wallet, - type LucideIcon, -} from 'lucide-react' -import type { - ClassTodayDto, - HomeSummaryDto, - NextClassDto, - PaymentDto, - UpcomingPaymentDto, -} from '@gruperly/shared' -import { Badge, Button } from '../components/ui' -import { getClassesToday, getHomeSummary } from '../lib/api' -import { - formatPrice, - formatRelativeDateTime, - formatSchedule, - PAYMENT_STATUS_LABELS, -} from '../lib/format' -import { cn } from '../lib/utils' - -const PAYMENT_BADGE_VARIANT: Record< - PaymentDto['status'], - 'success' | 'warning' | 'danger' | 'neutral' -> = { - PENDING: 'warning', - OVERDUE: 'danger', - PAID: 'success', - CANCELLED: 'neutral', -} - -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 ( -
    -
    -
    -

    - Clase de hoy -

    -

    {item.groupName}

    -
    - - Hoy · {time} hs -
    -

    - {item.enrolledCount} inscrito{item.enrolledCount === 1 ? '' : 's'} - {item.availableSlots != null ? ` · ${item.availableSlots} cupo${item.availableSlots === 1 ? '' : 's'} disponible${item.availableSlots === 1 ? '' : 's'}` : ''} -

    -
    -
    - - {item.hasAttendance ? 'Asistencia tomada' : 'Por tomar'} - -
    -
    -
    - - -
    -
    - ) -} - -function NextClassHero({ nextClass }: { nextClass: NextClassDto }) { - const navigate = useNavigate() - const hasSchedule = (nextClass.days?.length ?? 0) > 0 - - return ( -
    -
    -
    -

    - {nextClass.isNow ? 'Clase en curso' : 'Próxima clase'} -

    -

    {nextClass.name}

    - {hasSchedule ? ( -
    - - {formatSchedule(nextClass.days ?? [], nextClass.time)} -
    - ) : null} -
    -
    - - {nextClass.isNow ? 'En curso ahora' : formatRelativeDateTime(nextClass.occurrenceAt)} - -
    -
    -
    - -
    -
    - ) -} - -function UpcomingPaymentRow({ payment }: { payment: UpcomingPaymentDto }) { - return ( -
  • -
    -

    {payment.attendeeName}

    -

    - {payment.groupName} · {formatRelativeDateTime(payment.dueDate)} -

    -
    -
    - {formatPrice(payment.amount)} - - {PAYMENT_STATUS_LABELS[payment.status]} - -
    -
  • - ) -} - -function SummaryStats({ summary }: { summary: HomeSummaryDto }) { - const navigate = useNavigate() - const stats: { - label: string - value: string - sub?: string - icon: LucideIcon - to: '/groups' | '/payments' - }[] = [ - { - 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 ( -
    - {stats.map((stat) => { - const Icon = stat.icon - return ( - - ) - })} -
    - ) -} - -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 ( -
    -
    -

    Inicio

    -

    Tu actividad de cobros de un vistazo.

    -
    - - {summaryQuery.isPending ? ( -
    - -
    - ) : null} - - {summaryQuery.isError ? ( -
    -

    No pudimos cargar tu resumen.

    - -
    - ) : null} - - {summaryQuery.isSuccess && summaryQuery.data.stats.groups === 0 ? ( -
    -

    Todavía no tenés grupos

    -

    - Crea tu primer grupo para empezar a cobrar. -

    - -
    - ) : null} - - {summaryQuery.isSuccess && summaryQuery.data.stats.groups > 0 ? ( -
    - {todayClasses.length > 0 ? ( -
    - {todayClasses.map((classItem) => ( - - ))} -
    - ) : summaryQuery.data.nextClass ? ( - - ) : ( -
    -

    Sin clases programadas

    -

    - Agregá días y horario a tus grupos para ver tu próxima clase acá. -

    -
    - )} - - - -
    -
    -

    Próximos cobros

    - -
    - - {summaryQuery.data.upcomingPayments.length > 0 ? ( -
      - {summaryQuery.data.upcomingPayments.map((payment) => ( - - ))} -
    - ) : ( -

    - Sin cobros pendientes por ahora. -

    - )} -
    -
    - ) : null} -
    - ) -} \ No newline at end of file diff --git a/apps/web/src/routes/join-group.tsx b/apps/web/src/routes/join-group.tsx deleted file mode 100644 index c84e236..0000000 --- a/apps/web/src/routes/join-group.tsx +++ /dev/null @@ -1,343 +0,0 @@ -import { useState } from 'react'; -import { useQuery, useMutation } from '@tanstack/react-query'; -import { useParams, Link } from '@tanstack/react-router'; -import { - CalendarClock, - CheckCircle2, - Clock, - GraduationCap, - Loader2, - Moon, - Sun, - Users, -} from 'lucide-react'; -import { Badge, Button, Input, Label } from '../components/ui'; -import { Logo } from '../components/brand'; -import { useTheme } from '../context/ThemeProvider'; -import { getInviteInfo, joinViaInvite, ApiError } from '../lib/api'; -import { BILLING_LABELS, formatPrice, formatSchedule } from '../lib/format'; - -export function JoinGroupView() { - const { token } = useParams({ strict: false }) as { token: string }; - const { theme, setTheme, isDark } = useTheme(); - - const [firstName, setFirstName] = useState(''); - const [lastName, setLastName] = useState(''); - const [phone, setPhone] = useState(''); - const [email, setEmail] = useState(''); - const [errorMessage, setErrorMessage] = useState(null); - - // Success state - const [registeredAttendee, setRegisteredAttendee] = useState<{ - fullName: string; - phone: string | null; - } | null>(null); - - // Waitlist state (group is full) - const [waitlistInfo, setWaitlistInfo] = useState<{ message: 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); - setWaitlistInfo({ message: data.message }); - return; - } - setWaitlistInfo(null); - setRegisteredAttendee({ - fullName: data.attendee!.fullName, - phone: data.attendee!.phone, - }); - }, - 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.'); - } else if (error instanceof Error) { - setErrorMessage(error.message); - } else { - setErrorMessage('Ocurrió un error inesperado. Por favor, intenta de nuevo.'); - } - }, - }); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - 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 group = inviteQuery.data; - - return ( -
    - {/* Public Header */} -
    - - - - - -
    - - {/* Main Content */} -
    -
    - {inviteQuery.isPending ? ( -
    - -

    Cargando información del grupo...

    -
    - ) : null} - - {inviteQuery.isError || !group ? ( -
    -

    Enlace no válido o expirado

    -

    - No pudimos encontrar este grupo. Por favor consulta con tu profesor para solicitar un nuevo enlace de invitación. -

    -
    - ) : null} - - {/* Registration Success Confirmation */} - {registeredAttendee && group ? ( -
    -
    - -
    - -
    - - Registro Confirmado - -

    ¡Inscripción Exitosa!

    -

    - Tus datos han sido registrados con éxito en el grupo{' '} - {group.name}. -

    -
    - -
    -
    - Profesor: - {group.teacherName} -
    -
    - Alumno: - {registeredAttendee.fullName} -
    - {registeredAttendee.phone ? ( -
    - Teléfono registrado: - {registeredAttendee.phone} -
    - ) : null} -
    - -

    - El profesor se pondrá en contacto contigo a la brevedad por WhatsApp para darte la bienvenida y coordinar los detalles de la clase. -

    -
    - ) : null} - - {/* Waitlist Confirmation (group full) */} - {waitlistInfo && group ? ( -
    -
    - -
    - -
    - - Lista de Espera - -

    Cupo completo

    -

    - {waitlistInfo.message} -

    -
    - -

    - El profesor se pondrá en contacto contigo cuando haya un lugar disponible en{' '} - {group.name}. -

    -
    - ) : null} - - {/* Inscription Form */} - {!registeredAttendee && !waitlistInfo && group ? ( -
    - {/* Group Info Header */} -
    -
    - - - Invitación Oficial - -
    -

    {group.name}

    -

    - Profesor: {group.teacherName} -

    - {group.description ? ( -

    {group.description}

    - ) : null} - - {/* Schedule & Price Details */} -
    - {(group.days?.length ?? 0) > 0 ? ( -
    - - {formatSchedule(group.days ?? [], group.time)} -
    - ) : null} - - {group.price != null ? ( -
    - {formatPrice(group.price)} - {group.billingType ? · {BILLING_LABELS[group.billingType]} : ''} -
    - ) : null} - - {group.capacity ? ( -
    - - Cupo {group.capacity} -
    - ) : null} -
    -
    - - {/* Form Header */} -
    -

    Completa tus datos

    -

    - Ingresa tus datos para registrarte en la lista de alumnos de este grupo. -

    -
    - - {/* Error banner */} - {errorMessage ? ( -
    - {errorMessage} -
    - ) : null} - - {/* Form */} -
    -
    -
    - - setFirstName(e.target.value)} - required - /> -
    - -
    - - setLastName(e.target.value)} - required - /> -
    -
    - -
    - - setPhone(e.target.value)} - required - /> -

    - El profesor se comunicará contigo por WhatsApp a este número. -

    -
    - -
    - - setEmail(e.target.value)} - /> -
    - -
    - -
    -
    -
    - ) : null} -
    -
    - - {/* Footer */} -
    - Gruperly — Gestión sencilla de cobros y grupos -
    -
    - ); -} diff --git a/apps/web/src/routes/onboarding.tsx b/apps/web/src/routes/onboarding.tsx deleted file mode 100644 index 7bbc76c..0000000 --- a/apps/web/src/routes/onboarding.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import { useEffect, useState } from 'react' -import { useQuery } from '@tanstack/react-query' -import { useNavigate } from '@tanstack/react-router' -import { Loader2 } from 'lucide-react' -import type { ConnectPaymentResult, OnboardingGroupDto } from '@gruperly/shared' -import { Logo, LogoIcon } from '../components/brand' -import { - ConfirmationStep, - FirstGroupStep, - PaymentStep, - Stepper, - WelcomeStep, -} from '../components/onboarding' -import { PAYMENT_PROVIDERS } from '../components/onboarding/constants' -import { Button } from '../components/ui' -import { useAuth } from '../context/AuthProvider' -import { getOnboardingStatus } from '../lib/api' - -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] = useState(0) - const [paymentResult, setPaymentResult] = useState(null) - const [createdGroup, setCreatedGroup] = useState(null) - - const statusQuery = useQuery({ - queryKey: ['onboarding', 'status'], - queryFn: getOnboardingStatus, - enabled: !!user, - }) - - useEffect(() => { - const status = statusQuery.data - if (!status) return - if (status.completed) { - void navigate({ to: '/' }) - 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, navigate]) - - if (authPending) { - return ( -
    - -
    - ) - } - - if (!user) { - return ( -
    -
    - - - -

    Tu cuenta, lista

    -

    - Iniciá sesión para configurar tus cobros y crear tu primer grupo. -

    - -
    -
    - ) - } - - if (statusQuery.isPending) { - return ( -
    - -
    - ) - } - - const firstName = user.name?.trim().split(/\s+/)[0] ?? 'profesor/a' - const providerName = paymentResult - ? PAYMENT_PROVIDERS.find((p) => p.value === paymentResult.provider)?.name - : undefined - - return ( -
    -
    - -
    - - - -
    - {step === 0 ? ( - setStep(1)} /> - ) : null} - {step === 1 ? ( - { - setPaymentResult(result) - setStep(2) - }} - onBack={() => setStep(0)} - /> - ) : null} - {step === 2 ? ( - setStep(1)} - onCompleted={(group) => { - setCreatedGroup(group) - setStep(3) - }} - /> - ) : null} - {step === 3 && createdGroup ? ( - - ) : null} -
    -
    - ) -} \ No newline at end of file diff --git a/apps/web/src/routes/payments.tsx b/apps/web/src/routes/payments.tsx deleted file mode 100644 index efcd8eb..0000000 --- a/apps/web/src/routes/payments.tsx +++ /dev/null @@ -1,8 +0,0 @@ -export function PaymentsView() { - return ( -
    -

    Cobros

    -

    Sigue los pagos de tus grupos.

    -
    - ) -} diff --git a/apps/web/src/routes/profile.tsx b/apps/web/src/routes/profile.tsx deleted file mode 100644 index a015713..0000000 --- a/apps/web/src/routes/profile.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { useAuth } from '../context/AuthProvider' -import { Avatar } from '../components/ui' - -export function ProfileView() { - const { user } = useAuth() - - return ( -
    -
    -

    Mi perfil

    -

    Tus datos personales.

    -
    - -
    -
    - -
    -

    - {user?.name ?? 'Usuario'} -

    -

    {user?.email}

    -
    -
    -
    -
    - ) -} \ No newline at end of file diff --git a/apps/web/src/routes/security.tsx b/apps/web/src/routes/security.tsx deleted file mode 100644 index 1892595..0000000 --- a/apps/web/src/routes/security.tsx +++ /dev/null @@ -1,183 +0,0 @@ -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, Trash2 } from 'lucide-react' -import { authClient } from '../lib/auth-client' -import { Button, Input, Label } from '../components/ui' - -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'], - }) - -type ChangePasswordValues = z.infer - -function PasskeyList({ onRefresh }: { onRefresh: () => void }) { - const { data, isPending } = authClient.useListPasskeys() - const [deletingId, setDeletingId] = useState(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 - } - - const passkeys = data ?? [] - if (passkeys.length === 0) { - return

    Todavía no registraste ninguna passkey.

    - } - - return ( -
      - {passkeys.map((passkey) => ( -
    • - - - -
      -

      - {passkey.name ?? 'Passkey'} -

      -

      - {passkey.deviceType === 'singleDevice' ? 'Dispositivo' : 'Llave de seguridad'} ·{' '} - {new Date(passkey.createdAt).toLocaleDateString('es-AR')} -

      -
      - -
    • - ))} -
    - ) -} - -export function SecurityPage() { - const [passkeyKeys, setPasskeyKeys] = useState(0) - const [feedback, setFeedback] = useState(null) - - const { - register, - handleSubmit, - reset, - setError, - formState: { errors, isSubmitting }, - } = useForm({ 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: ${error.message}`) - 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 ( -
    -
    -

    Seguridad

    -

    - Gestioná tu contraseña y tus llaves de acceso (passkeys). -

    -
    - - {feedback ? ( -

    {feedback}

    - ) : null} - -
    -
    - -

    Cambiar contraseña

    -
    -
    -
    - - - {errors.currentPassword ? ( -

    {errors.currentPassword.message}

    - ) : null} -
    -
    - - - {errors.newPassword ? ( -

    {errors.newPassword.message}

    - ) : null} -
    - -
    -
    - -
    -
    -
    - -

    Passkeys

    -
    - -
    - -
    -
    - ) -} \ No newline at end of file diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx deleted file mode 100644 index 8777eca..0000000 --- a/apps/web/src/routes/settings.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { Link } from '@tanstack/react-router' -import { Check, Fingerprint, Monitor, Moon, Sun } from 'lucide-react' -import { signOut } from '../lib/auth-client' -import { Button } from '../components/ui' -import { useTheme, type Theme } from '../context/ThemeProvider' -import { cn } from '../lib/utils' - -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' }, -] - -const THEME_ICONS: Record = { - light: Sun, - dark: Moon, - system: Monitor, -} - -function AppearanceCard() { - const { theme, setTheme } = useTheme() - - return ( -
    -
    -

    Apariencia

    -

    Tema claro u oscuro

    -
    - {THEME_OPTIONS.map((option) => { - const Icon = THEME_ICONS[option.value] - const isSelected = theme === option.value - return ( - - ) - })} -
    - ) -} - -export function SettingsView() { - const handleSignOut = async () => { - await signOut({ - fetchOptions: { headers: { 'Cache-Control': 'no-cache' } }, - }) - window.location.assign('/login') - } - - return ( -
    -
    -

    Ajustes

    -

    Configurá tu cuenta y tus grupos.

    -
    - -
    - - - - -
    -

    Seguridad

    -

    Contraseña y passkeys

    -
    - -
    - - - - -
    - ) -} \ No newline at end of file