Files
gruperly/apps/web/src/routes/home.tsx
Jose Selesan f1e0fc5aae feat(home): implement home summary feature with routes and use case
- Added home routes and corresponding route handler for fetching home summary.
- Created GetHomeSummary use case to aggregate user data including groups, attendees, and upcoming payments.
- Introduced HomeSummaryDto and related schemas for data validation.
- Implemented nextClassOccurrence function to determine the next class based on user timezone.
- Added tests for home summary functionality and next class occurrence logic.
2026-09-23 10:35:24 -03:00

231 lines
8.0 KiB
TypeScript

import { useQuery } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { CalendarClock, Loader2, Plus, UserCheck, Users, Wallet, type LucideIcon } from 'lucide-react'
import type { HomeSummaryDto, NextClassDto, PaymentDto, UpcomingPaymentDto } from '@gruperly/shared'
import { Badge, Button } from '../components/ui'
import { 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 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>
)
}
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>
)
}
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 (
<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>
)
}
export function HomeView() {
const navigate = useNavigate()
const summaryQuery = useQuery({
queryKey: ['home-summary'],
queryFn: getHomeSummary,
})
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">
{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>
)
}