- 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.
231 lines
8.0 KiB
TypeScript
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>
|
|
)
|
|
} |