456 lines
19 KiB
React
456 lines
19 KiB
React
|
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||
|
|
import { Link } from 'react-router-dom';
|
||
|
|
import { CalendarDays, ChevronLeft, ChevronRight, CircleDollarSign, RefreshCw } from 'lucide-react';
|
||
|
|
import { toast } from 'sonner';
|
||
|
|
import { api } from '@/api';
|
||
|
|
import { cn, fmt, fmtDate, todayStr } from '@/lib/utils';
|
||
|
|
import { Button } from '@/components/ui/button';
|
||
|
|
import { Badge } from '@/components/ui/badge';
|
||
|
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||
|
|
|
||
|
|
const MONTHS = [
|
||
|
|
'January', 'February', 'March', 'April', 'May', 'June',
|
||
|
|
'July', 'August', 'September', 'October', 'November', 'December',
|
||
|
|
];
|
||
|
|
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||
|
|
|
||
|
|
function currentMonth() {
|
||
|
|
const now = new Date();
|
||
|
|
return { year: now.getFullYear(), month: now.getMonth() + 1 };
|
||
|
|
}
|
||
|
|
|
||
|
|
function shiftMonth(year, month, delta) {
|
||
|
|
const next = new Date(year, month - 1 + delta, 1);
|
||
|
|
return { year: next.getFullYear(), month: next.getMonth() + 1 };
|
||
|
|
}
|
||
|
|
|
||
|
|
function displayStatus(status) {
|
||
|
|
if (status === 'due_soon') return 'Due';
|
||
|
|
if (status === 'late') return 'Late';
|
||
|
|
return status ? status.charAt(0).toUpperCase() + status.slice(1) : 'Due';
|
||
|
|
}
|
||
|
|
|
||
|
|
function statusTone(status) {
|
||
|
|
if (status === 'paid' || status === 'autodraft') return 'bg-emerald-500/15 text-emerald-500 border-emerald-500/25';
|
||
|
|
if (status === 'skipped') return 'bg-muted text-muted-foreground border-border';
|
||
|
|
if (status === 'late' || status === 'missed') return 'bg-destructive/15 text-destructive border-destructive/25';
|
||
|
|
return 'bg-primary/10 text-primary border-primary/25';
|
||
|
|
}
|
||
|
|
|
||
|
|
function LegendItem({ className, label }) {
|
||
|
|
return (
|
||
|
|
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||
|
|
<span className={cn('h-2.5 w-2.5 rounded-full border', className)} />
|
||
|
|
{label}
|
||
|
|
</span>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function SummaryProgress({ summary }) {
|
||
|
|
const percent = Number(summary?.paid_percent || 0);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Card className="overflow-hidden">
|
||
|
|
<CardHeader className="pb-3">
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<CircleDollarSign className="h-4 w-4 text-emerald-500" />
|
||
|
|
<CardTitle className="text-base">Total Expenses Paid</CardTitle>
|
||
|
|
</div>
|
||
|
|
<CardDescription>Monthly progress across active, unskipped bills.</CardDescription>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
<div className="flex items-end justify-between gap-4">
|
||
|
|
<div>
|
||
|
|
<p className="font-mono text-2xl font-semibold tracking-tight">
|
||
|
|
{fmt(summary?.paid_total)}
|
||
|
|
<span className="mx-2 text-sm font-normal text-muted-foreground">/</span>
|
||
|
|
<span className="text-base text-muted-foreground">{fmt(summary?.expected_total)}</span>
|
||
|
|
</p>
|
||
|
|
<p className="mt-1 text-sm text-muted-foreground">{fmt(summary?.remaining_total)} remaining</p>
|
||
|
|
</div>
|
||
|
|
<div className="text-right">
|
||
|
|
<p className="text-2xl font-semibold">{percent}%</p>
|
||
|
|
<p className="text-xs text-muted-foreground">paid</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="mt-4 h-3 overflow-hidden rounded-full bg-muted">
|
||
|
|
<div
|
||
|
|
className="h-full rounded-full bg-emerald-500 transition-all duration-500"
|
||
|
|
style={{ width: `${percent}%` }}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="mt-3 flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||
|
|
<span>{summary?.bill_count || 0} active bills</span>
|
||
|
|
<span>{summary?.paid_count || 0} paid</span>
|
||
|
|
{!!summary?.skipped_count && <span>{summary.skipped_count} skipped</span>}
|
||
|
|
{!!summary?.missed_count && <span className="text-destructive">{summary.missed_count} late or missed</span>}
|
||
|
|
</div>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function DayIndicators({ day }) {
|
||
|
|
const summary = day.status_summary;
|
||
|
|
const hasPaid = summary.paid_count > 0;
|
||
|
|
const hasDue = summary.due_count > summary.paid_count + summary.skipped_count + summary.missed_count;
|
||
|
|
const hasSkipped = summary.skipped_count > 0;
|
||
|
|
const hasMissed = summary.missed_count > 0;
|
||
|
|
const paymentOnly = day.payments.length > 0 && day.bills_due.length === 0;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="mt-auto flex flex-wrap gap-1">
|
||
|
|
{hasPaid && <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" title="Paid" />}
|
||
|
|
{(hasDue || paymentOnly) && <span className="h-1.5 w-1.5 rounded-full bg-primary" title="Due or payment" />}
|
||
|
|
{hasSkipped && <span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/50" title="Skipped" />}
|
||
|
|
{hasMissed && <span className="h-1.5 w-1.5 rounded-full bg-destructive" title="Missed or late" />}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function CalendarGrid({ data, selectedDate, onSelectDay }) {
|
||
|
|
const firstWeekday = new Date(data.year, data.month - 1, 1).getDay();
|
||
|
|
const cells = [
|
||
|
|
...Array.from({ length: firstWeekday }, (_, index) => ({ type: 'blank', key: `blank-${index}` })),
|
||
|
|
...data.days.map(day => ({ type: 'day', key: day.date, day })),
|
||
|
|
];
|
||
|
|
const today = todayStr();
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Card className="overflow-hidden">
|
||
|
|
<div className="grid grid-cols-7 border-b border-border/70 bg-muted/30">
|
||
|
|
{WEEKDAYS.map(day => (
|
||
|
|
<div key={day} className="px-1 py-2 text-center text-[11px] font-semibold uppercase tracking-wide text-muted-foreground sm:text-xs">
|
||
|
|
{day}
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="grid grid-cols-7">
|
||
|
|
{cells.map(cell => {
|
||
|
|
if (cell.type === 'blank') {
|
||
|
|
return <div key={cell.key} className="min-h-16 border-b border-r border-border/50 bg-muted/10 sm:min-h-24" />;
|
||
|
|
}
|
||
|
|
|
||
|
|
const day = cell.day;
|
||
|
|
const isToday = day.date === today;
|
||
|
|
const isSelected = day.date === selectedDate;
|
||
|
|
const summary = day.status_summary;
|
||
|
|
const hasActivity = day.bills_due.length > 0 || day.payments.length > 0;
|
||
|
|
const isPaidDay = summary.due_count > 0 && summary.paid_count >= summary.due_count - summary.skipped_count;
|
||
|
|
const hasMissed = summary.missed_count > 0;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<button
|
||
|
|
key={day.date}
|
||
|
|
type="button"
|
||
|
|
onClick={() => onSelectDay(day)}
|
||
|
|
className={cn(
|
||
|
|
'flex min-h-16 flex-col border-b border-r border-border/50 p-1.5 text-left transition-colors sm:min-h-24 sm:p-2',
|
||
|
|
'focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
|
||
|
|
hasActivity && 'bg-primary/[0.03] hover:bg-accent/60',
|
||
|
|
isPaidDay && 'bg-emerald-500/[0.07]',
|
||
|
|
hasMissed && 'bg-destructive/[0.08]',
|
||
|
|
isSelected && 'ring-2 ring-primary ring-inset',
|
||
|
|
)}
|
||
|
|
aria-label={`View ${fmtDate(day.date)}`}
|
||
|
|
>
|
||
|
|
<div className="flex items-start justify-between gap-1">
|
||
|
|
<span className={cn(
|
||
|
|
'flex h-6 w-6 items-center justify-center rounded-full text-xs font-medium sm:text-sm',
|
||
|
|
isToday && 'border border-primary bg-primary/10 text-primary',
|
||
|
|
)}>
|
||
|
|
{day.day}
|
||
|
|
</span>
|
||
|
|
{summary.due_count > 0 && (
|
||
|
|
<span className="rounded bg-background/75 px-1 font-mono text-[10px] text-muted-foreground">
|
||
|
|
{summary.due_count}
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="mt-1 hidden min-w-0 space-y-0.5 sm:block">
|
||
|
|
{day.bills_due.slice(0, 2).map(bill => (
|
||
|
|
<p key={bill.bill_id} className="truncate text-[11px] text-muted-foreground">
|
||
|
|
{bill.name}
|
||
|
|
</p>
|
||
|
|
))}
|
||
|
|
{day.bills_due.length > 2 && (
|
||
|
|
<p className="text-[11px] text-muted-foreground">+{day.bills_due.length - 2} more</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<DayIndicators day={day} />
|
||
|
|
</button>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
</Card>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function DayDetailDialog({ day, open, onOpenChange }) {
|
||
|
|
return (
|
||
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
|
|
<DialogContent className="sm:max-w-lg border-border/60 bg-card/95 backdrop-blur-xl">
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle className="text-base font-semibold">{day ? fmtDate(day.date) : 'Day details'}</DialogTitle>
|
||
|
|
<p className="text-sm text-muted-foreground">Bills due and payments recorded for this date.</p>
|
||
|
|
</DialogHeader>
|
||
|
|
|
||
|
|
{day && (
|
||
|
|
<div className="space-y-5">
|
||
|
|
<section>
|
||
|
|
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Bills Due</h3>
|
||
|
|
{day.bills_due.length === 0 ? (
|
||
|
|
<div className="rounded-lg border border-dashed border-border/70 p-4 text-sm text-muted-foreground">
|
||
|
|
No bills are due on this day.
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="space-y-2">
|
||
|
|
{day.bills_due.map(bill => (
|
||
|
|
<div key={bill.bill_id} className="rounded-lg border border-border/60 bg-background/60 p-3">
|
||
|
|
<div className="flex items-start justify-between gap-3">
|
||
|
|
<div className="min-w-0">
|
||
|
|
<p className="truncate text-sm font-medium">{bill.name}</p>
|
||
|
|
<p className="mt-0.5 text-xs text-muted-foreground">{bill.category_name || 'Uncategorized'}</p>
|
||
|
|
</div>
|
||
|
|
<Badge variant="outline" className={cn('shrink-0 capitalize', statusTone(bill.status))}>
|
||
|
|
{displayStatus(bill.status)}
|
||
|
|
</Badge>
|
||
|
|
</div>
|
||
|
|
<div className="mt-3 grid grid-cols-3 gap-2 text-xs text-muted-foreground">
|
||
|
|
<div>
|
||
|
|
<p>Expected</p>
|
||
|
|
<p className="font-mono text-sm text-foreground">{fmt(bill.effective_amount)}</p>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<p>Paid</p>
|
||
|
|
<p className="font-mono text-sm text-emerald-500">{fmt(bill.paid_amount)}</p>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<p>Due</p>
|
||
|
|
<p className="font-mono text-sm text-foreground">{fmtDate(bill.due_date)}</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<section>
|
||
|
|
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Payments</h3>
|
||
|
|
{day.payments.length === 0 ? (
|
||
|
|
<div className="rounded-lg border border-dashed border-border/70 p-4 text-sm text-muted-foreground">
|
||
|
|
No payments were recorded on this day.
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="space-y-2">
|
||
|
|
{day.payments.map(payment => (
|
||
|
|
<div key={payment.payment_id} className="flex items-center justify-between gap-3 rounded-lg border border-border/60 bg-background/60 p-3">
|
||
|
|
<div className="min-w-0">
|
||
|
|
<p className="truncate text-sm font-medium">{payment.bill_name}</p>
|
||
|
|
<p className="text-xs text-muted-foreground">{payment.method || 'Payment'}</p>
|
||
|
|
</div>
|
||
|
|
<span className="font-mono text-sm text-emerald-500">{fmt(payment.amount)}</span>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<div className="flex flex-wrap justify-end gap-2 border-t border-border/60 pt-4">
|
||
|
|
<Button asChild variant="outline" size="sm">
|
||
|
|
<Link to="/">Open Tracker</Link>
|
||
|
|
</Button>
|
||
|
|
<Button asChild size="sm">
|
||
|
|
<Link to="/bills">Manage Bills</Link>
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function CalendarPage() {
|
||
|
|
const initial = currentMonth();
|
||
|
|
const [year, setYear] = useState(initial.year);
|
||
|
|
const [month, setMonth] = useState(initial.month);
|
||
|
|
const [data, setData] = useState(null);
|
||
|
|
const [loading, setLoading] = useState(true);
|
||
|
|
const [error, setError] = useState('');
|
||
|
|
const [selectedDay, setSelectedDay] = useState(null);
|
||
|
|
const [detailOpen, setDetailOpen] = useState(false);
|
||
|
|
|
||
|
|
const load = useCallback(async () => {
|
||
|
|
setLoading(true);
|
||
|
|
setError('');
|
||
|
|
try {
|
||
|
|
const result = await api.calendar(year, month);
|
||
|
|
setData(result);
|
||
|
|
setSelectedDay(current => current ? result.days.find(day => day.date === current.date) || null : null);
|
||
|
|
} catch (err) {
|
||
|
|
setError(err.message || 'Calendar data could not be loaded.');
|
||
|
|
toast.error(err.message || 'Calendar data could not be loaded.');
|
||
|
|
} finally {
|
||
|
|
setLoading(false);
|
||
|
|
}
|
||
|
|
}, [year, month]);
|
||
|
|
|
||
|
|
useEffect(() => { load(); }, [load]);
|
||
|
|
|
||
|
|
const monthLabel = useMemo(() => `${MONTHS[month - 1]} ${year}`, [year, month]);
|
||
|
|
const hasAnyBills = Number(data?.summary?.bill_count || 0) + Number(data?.summary?.skipped_count || 0) > 0;
|
||
|
|
|
||
|
|
function navigate(delta) {
|
||
|
|
const next = shiftMonth(year, month, delta);
|
||
|
|
setYear(next.year);
|
||
|
|
setMonth(next.month);
|
||
|
|
setSelectedDay(null);
|
||
|
|
setDetailOpen(false);
|
||
|
|
}
|
||
|
|
|
||
|
|
function goToday() {
|
||
|
|
const next = currentMonth();
|
||
|
|
setYear(next.year);
|
||
|
|
setMonth(next.month);
|
||
|
|
setSelectedDay(null);
|
||
|
|
setDetailOpen(false);
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-5">
|
||
|
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||
|
|
<div>
|
||
|
|
<p className="mb-1 text-[11px] font-semibold uppercase tracking-[0.15em] text-muted-foreground">
|
||
|
|
Monthly Calendar
|
||
|
|
</p>
|
||
|
|
<h1 className="text-3xl font-semibold tracking-tight">Calendar</h1>
|
||
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
||
|
|
View bills, payments, and monthly progress by date.
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="flex flex-wrap items-center gap-2">
|
||
|
|
<div className="flex items-center rounded-full border border-border/70 bg-card/90 p-1 shadow-sm">
|
||
|
|
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full" onClick={() => navigate(-1)} aria-label="Previous month">
|
||
|
|
<ChevronLeft className="h-4 w-4" />
|
||
|
|
</Button>
|
||
|
|
<div className="min-w-40 px-3 text-center text-sm font-semibold">{monthLabel}</div>
|
||
|
|
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full" onClick={() => navigate(1)} aria-label="Next month">
|
||
|
|
<ChevronRight className="h-4 w-4" />
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
<Button variant="outline" size="sm" onClick={goToday}>Today</Button>
|
||
|
|
<Button variant="ghost" size="icon" className="h-9 w-9 rounded-full" onClick={load} aria-label="Refresh calendar">
|
||
|
|
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||
|
|
<div className="space-y-4">
|
||
|
|
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-border/70 bg-card/70 px-4 py-3">
|
||
|
|
<LegendItem className="border-emerald-500 bg-emerald-500" label="Paid" />
|
||
|
|
<LegendItem className="border-primary bg-primary" label="Due" />
|
||
|
|
<LegendItem className="border-muted-foreground/50 bg-muted-foreground/50" label="Skipped" />
|
||
|
|
<LegendItem className="border-destructive bg-destructive" label="Missed/Late" />
|
||
|
|
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||
|
|
<span className="h-5 w-5 rounded-full border border-primary bg-primary/10" />
|
||
|
|
Today
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{loading && (
|
||
|
|
<Card>
|
||
|
|
<CardContent className="flex min-h-[360px] items-center justify-center p-6 text-sm text-muted-foreground">
|
||
|
|
Loading calendar...
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{!loading && error && (
|
||
|
|
<Card>
|
||
|
|
<CardContent className="flex min-h-[260px] flex-col items-center justify-center gap-3 p-6 text-center">
|
||
|
|
<p className="text-sm text-destructive">{error}</p>
|
||
|
|
<Button variant="outline" size="sm" onClick={load}>Try again</Button>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{!loading && !error && data && (
|
||
|
|
<>
|
||
|
|
<CalendarGrid
|
||
|
|
data={data}
|
||
|
|
selectedDate={selectedDay?.date}
|
||
|
|
onSelectDay={day => {
|
||
|
|
setSelectedDay(day);
|
||
|
|
setDetailOpen(true);
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
{!hasAnyBills && (
|
||
|
|
<Card>
|
||
|
|
<CardContent className="flex flex-col items-center justify-center gap-3 p-6 text-center">
|
||
|
|
<CalendarDays className="h-8 w-8 text-muted-foreground" />
|
||
|
|
<div>
|
||
|
|
<p className="text-sm font-medium">No bills on this calendar yet.</p>
|
||
|
|
<p className="mt-1 text-sm text-muted-foreground">Add a bill to start seeing due dates and payment progress.</p>
|
||
|
|
</div>
|
||
|
|
<Button asChild size="sm">
|
||
|
|
<Link to="/bills">Add bill</Link>
|
||
|
|
</Button>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
)}
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="space-y-4">
|
||
|
|
<SummaryProgress summary={data?.summary} />
|
||
|
|
<Card>
|
||
|
|
<CardHeader className="pb-3">
|
||
|
|
<CardTitle className="text-base">Selected Day</CardTitle>
|
||
|
|
<CardDescription>Tap a date to inspect bills and payments.</CardDescription>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
{selectedDay ? (
|
||
|
|
<div className="space-y-3">
|
||
|
|
<p className="text-sm font-semibold">{fmtDate(selectedDay.date)}</p>
|
||
|
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||
|
|
<div className="rounded-lg bg-muted/40 p-3">
|
||
|
|
<p className="text-xs text-muted-foreground">Due</p>
|
||
|
|
<p className="font-mono font-semibold">{fmt(selectedDay.status_summary.total_due)}</p>
|
||
|
|
</div>
|
||
|
|
<div className="rounded-lg bg-muted/40 p-3">
|
||
|
|
<p className="text-xs text-muted-foreground">Paid</p>
|
||
|
|
<p className="font-mono font-semibold text-emerald-500">{fmt(selectedDay.status_summary.total_paid)}</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<Button className="w-full" size="sm" onClick={() => setDetailOpen(true)}>
|
||
|
|
View day details
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<p className="text-sm text-muted-foreground">No day selected.</p>
|
||
|
|
)}
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<DayDetailDialog
|
||
|
|
day={selectedDay}
|
||
|
|
open={detailOpen}
|
||
|
|
onOpenChange={setDetailOpen}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|