64c185ab96
- Gelir, sabit gider, toplam birikim, devreden borç kartları - Anlık durum: kalan harcanabilir ve günlük harcama önerisi - Grup planlanan/gerçekleşen (Haftalık/Aylık) - "Harcama Ekle": masaüstünde başlıkta, mobilde yüzen düğme - Yükleniyor iskeleti, boş durumlar, açık dönem yokken son dönem özeti Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
256 lines
8.4 KiB
TypeScript
256 lines
8.4 KiB
TypeScript
import { useState } from 'react'
|
||
import { Link } from 'react-router'
|
||
import { paths } from '@/components/layout/nav-items'
|
||
import { PageError } from '@/components/page'
|
||
import { Skeleton } from '@/components/skeleton'
|
||
import { Button } from '@/components/ui/button'
|
||
import { WeekSummary } from '@/features/ledger/week-summary'
|
||
import { PeriodHeading } from '@/features/planning/plan-read'
|
||
import { dailyAllowance } from '@/lib/dashboard'
|
||
import { formatDayMonth, formatMoney, toIsoDate } from '@/lib/format'
|
||
import { weekIndexFor } from '@/lib/ledger'
|
||
import { periodPath } from '@/lib/period-select'
|
||
import { useBudget, useStats, useSummary } from '@/lib/periods'
|
||
import { t } from '@/lib/strings'
|
||
import { cn } from '@/lib/utils'
|
||
import type { GroupBudget } from '@/types/GroupBudget'
|
||
import type { Period } from '@/types/Period'
|
||
import type { PeriodSummary } from '@/types/PeriodSummary'
|
||
|
||
const s = t.dashboard
|
||
|
||
/** Özet kartları: gelir, sabit gider, toplam birikim, devreden borç, anlık durum, gruplar */
|
||
export function Overview({ period, writable }: { period: Period; writable: boolean }) {
|
||
const summary = useSummary(period.id)
|
||
const budget = useBudget(period.id)
|
||
const stats = useStats()
|
||
const [today] = useState(() => toIsoDate(new Date()))
|
||
|
||
const failed = [summary, budget].find((q) => q.isError)
|
||
if (failed?.error) return <PageError message={failed.error.message} onRetry={failed.refetch} />
|
||
if (!summary.data || !budget.data) return <DashboardSkeleton />
|
||
const sum = summary.data
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<PeriodHeading period={period} />
|
||
{/* Borç kartı yalnızca varsa: 3 kartta mobilde sonuncusu tam genişlik, masaüstünde 3 sütun */}
|
||
<dl
|
||
className={cn(
|
||
'grid grid-cols-2 gap-3',
|
||
sum.carryover_debt > 0
|
||
? 'lg:grid-cols-4'
|
||
: 'lg:grid-cols-3 [&>*:last-child]:col-span-2 lg:[&>*:last-child]:col-span-1',
|
||
)}
|
||
>
|
||
<Tile emoji="💰" label={s.income} amount={sum.income_total} tone="text-income" />
|
||
<Tile emoji="📌" label={s.fixed} amount={sum.fixed_expense_total} />
|
||
<Tile
|
||
emoji="🐷"
|
||
label={s.saved}
|
||
amount={stats.data?.total_saved}
|
||
hint={s.savedHint}
|
||
tone="text-income"
|
||
/>
|
||
{sum.carryover_debt > 0 && (
|
||
<Tile
|
||
emoji="⚠️"
|
||
label={s.debt}
|
||
amount={sum.carryover_debt}
|
||
hint={s.debtHint}
|
||
tone="text-expense"
|
||
/>
|
||
)}
|
||
</dl>
|
||
{/* Anlık durum DOM'da önce (ekran okuyucu ve mobilde ilk), masaüstünde sağ sütunda */}
|
||
<div className="grid items-start gap-4 lg:grid-cols-[3fr_2fr]">
|
||
<StatusCard period={period} summary={sum} today={today} writable={writable} />
|
||
<GroupsCard
|
||
period={period}
|
||
budget={budget.data}
|
||
summary={sum}
|
||
today={today}
|
||
writable={writable}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function Tile({
|
||
emoji,
|
||
label,
|
||
amount,
|
||
hint,
|
||
tone,
|
||
}: {
|
||
emoji: string
|
||
label: string
|
||
/** `undefined` → yükleniyor */
|
||
amount: number | undefined
|
||
hint?: string
|
||
tone?: string
|
||
}) {
|
||
return (
|
||
<div className="rounded-2xl border bg-card p-4">
|
||
<dt className="text-sm text-muted-foreground">
|
||
<span aria-hidden>{emoji}</span> {label}
|
||
</dt>
|
||
<dd className={cn('money mt-1 text-lg font-semibold sm:text-xl', tone)}>
|
||
{amount === undefined ? <Skeleton className="h-7 w-28" /> : formatMoney(amount)}
|
||
</dd>
|
||
{hint && <dd className="mt-1 text-xs text-muted-foreground">{hint}</dd>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** En belirgin kart: kalan harcanabilir + günlük öneri */
|
||
function StatusCard({
|
||
period,
|
||
summary,
|
||
today,
|
||
writable,
|
||
}: {
|
||
period: Period
|
||
summary: PeriodSummary
|
||
today: string
|
||
writable: boolean
|
||
}) {
|
||
const over = summary.remaining < 0
|
||
const { daysLeft, perDay } = dailyAllowance(summary.remaining, period, today)
|
||
return (
|
||
<section
|
||
aria-labelledby="status-title"
|
||
className={cn(
|
||
'rounded-2xl border-2 p-5 lg:col-start-2 lg:row-start-1',
|
||
over ? 'border-expense/50 bg-expense/5' : 'border-primary/40 bg-primary/5',
|
||
)}
|
||
>
|
||
<h2 id="status-title" className="text-sm font-medium text-muted-foreground">
|
||
<span aria-hidden>📍</span> {s.statusTitle}
|
||
</h2>
|
||
<p className="mt-2 text-sm">{over ? s.overspent : s.remaining}</p>
|
||
<p
|
||
className={cn(
|
||
'money text-4xl font-bold tracking-tight',
|
||
over ? 'text-expense' : 'text-foreground',
|
||
)}
|
||
>
|
||
{formatMoney(over ? -summary.remaining : summary.remaining)}
|
||
</p>
|
||
<p className="mt-2 text-sm text-muted-foreground">
|
||
{s.statusDetail(formatMoney(summary.spendable), formatMoney(summary.spent_total))}
|
||
</p>
|
||
{writable &&
|
||
(daysLeft === 0 ? (
|
||
<div className="mt-3 space-y-2">
|
||
<p className="text-sm">{s.ended}</p>
|
||
<Button asChild variant="outline">
|
||
<Link to={periodPath(paths.review, period)}>{s.toReview}</Link>
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<p className="mt-3 text-sm font-medium">
|
||
{perDay !== null ? s.perDay(daysLeft, formatMoney(perDay)) : s.daysLeft(daysLeft)}
|
||
</p>
|
||
))}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
/** 4 grup: planlanan / gerçekleşen; Haftalık (bugünün haftası) ↔ Aylık (tüm dönem) */
|
||
function GroupsCard({
|
||
period,
|
||
budget,
|
||
summary,
|
||
today,
|
||
writable,
|
||
}: {
|
||
period: Period
|
||
budget: GroupBudget[]
|
||
summary: PeriodSummary
|
||
today: string
|
||
writable: boolean
|
||
}) {
|
||
const [mode, setMode] = useState<'week' | 'month'>('week')
|
||
const index = weekIndexFor(period.weeks, today)
|
||
const week = period.weeks[index - 1]
|
||
const range = (a: string, b: string) => `${formatDayMonth(a)}–${formatDayMonth(b)}`
|
||
|
||
return (
|
||
<section
|
||
aria-labelledby="groups-title"
|
||
className="rounded-2xl border bg-card p-4 sm:p-5 lg:col-start-1 lg:row-start-1"
|
||
>
|
||
<div className="mb-1 flex flex-wrap items-center justify-between gap-2">
|
||
<h2 id="groups-title" className="font-semibold">
|
||
<span aria-hidden>🎯</span> {s.groupsTitle}
|
||
</h2>
|
||
<div role="group" aria-label={s.groupsTitle} className="flex rounded-lg border p-0.5">
|
||
{(['week', 'month'] as const).map((m) => (
|
||
<button
|
||
key={m}
|
||
type="button"
|
||
aria-pressed={mode === m}
|
||
onClick={() => setMode(m)}
|
||
className={cn(
|
||
'min-h-11 rounded-md px-4 text-sm font-medium',
|
||
mode === m ? 'bg-primary text-primary-foreground' : 'hover:bg-muted',
|
||
)}
|
||
>
|
||
{m === 'week' ? s.weekly : s.monthly}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<p className="mb-3 text-sm text-muted-foreground">
|
||
{mode === 'week' && week
|
||
? s.weekLabel(index, range(week.start_date, week.last_date))
|
||
: s.monthLabel(range(period.start_date, period.last_date))}
|
||
</p>
|
||
|
||
{summary.budget_total === 0 ? (
|
||
<Empty text={s.noBudget} to={periodPath(paths.planning, period)} action={s.toPlan} />
|
||
) : (
|
||
<WeekSummary budget={budget} index={mode === 'week' ? index : 'month'} />
|
||
)}
|
||
{writable && summary.spent_total === 0 && summary.budget_total > 0 && (
|
||
<p className="mt-3 text-sm text-muted-foreground">
|
||
<Link to={paths.ledger} state={{ focus: true }} className="underline">
|
||
{s.noEntries}
|
||
</Link>
|
||
</p>
|
||
)}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function Empty({ text, to, action }: { text: string; to: string; action: string }) {
|
||
return (
|
||
<div className="flex flex-col items-start gap-3 rounded-xl bg-muted p-4">
|
||
<p className="text-sm text-muted-foreground">{text}</p>
|
||
<Button asChild variant="outline">
|
||
<Link to={to}>{action}</Link>
|
||
</Button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** Yükleniyor: kartların şeklinde yer tutucular */
|
||
export function DashboardSkeleton() {
|
||
return (
|
||
<div role="status" aria-label={t.common.loading} className="space-y-4">
|
||
<Skeleton className="h-12 w-56" />
|
||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||
{[0, 1, 2, 3].map((i) => (
|
||
<Skeleton key={i} className="h-24" />
|
||
))}
|
||
</div>
|
||
<div className="grid gap-4 lg:grid-cols-[3fr_2fr]">
|
||
<Skeleton className="h-72" />
|
||
<Skeleton className="h-48" />
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|