Files
kakeibo/frontend/src/features/dashboard/overview.tsx
T
suphicakir 64c185ab96 feat(panel): özet kartları, anlık durum ve hızlı harcama ekleme
- 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>
2026-11-12 23:54:32 +03:00

256 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}