Files
kakeibo/frontend/src/features/savings/page.tsx
T
suphicakir 6b7b69c987 feat(birikim): birikimler sayfası, varlık ayrıntısı ve alım/satım
- Portföy özeti, kâr/zarar, dağılım halka grafiği, kur uyarıları
- Varlık ayrıntısı: alım/satım formu (tutar önerisi), geçmiş, düzenle/sil
- Elle kur girme/kaldırma, döviz ekleme, pasife alma
- Miktar/kur alanı (6 ondalık, Türkçe biçim)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 01:14:50 +03:00

205 lines
6.8 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 { lazy, Suspense, useState, type ReactNode } from 'react'
import { ChevronRight } from 'lucide-react'
import { Link } from 'react-router'
import { assetPath } from '@/components/layout/nav-items'
import { PageError, PageHeader } from '@/components/page'
import { Skeleton } from '@/components/skeleton'
import { usePortfolio } from '@/lib/assets'
import { formatDateTime, formatMoney, toIsoDate } from '@/lib/format'
import { isFetchStale, kindEmoji, qty, splitAssets } from '@/lib/savings'
import { t } from '@/lib/strings'
import { cn } from '@/lib/utils'
import type { Portfolio } from '@/types/Portfolio'
import type { PortfolioItem } from '@/types/PortfolioItem'
import { AddCurrency } from './add-currency'
import { Notice, Pl, RateLine } from './parts'
const s = t.assets
/** Recharts büyük → dağılım grafiği ayrı parça */
const AllocationChart = lazy(() => import('./allocation-chart'))
export function SavingsPage() {
const portfolio = usePortfolio()
return (
<>
<PageHeader title={t.pages.savings.title} description={t.pages.savings.description} />
{portfolio.isError ? (
<PageError message={portfolio.error.message} onRetry={portfolio.refetch} />
) : !portfolio.data ? (
<div role="status" aria-label={t.common.loading} className="space-y-4">
<Skeleton className="h-32" />
<Skeleton className="h-64" />
</div>
) : (
<SavingsView p={portfolio.data} />
)}
</>
)
}
function SavingsView({ p }: { p: Portfolio }) {
const [now] = useState(() => new Date())
const today = toIsoDate(now)
const { held, others } = splitAssets(p.items)
const hasValue = p.value_total > 0
return (
<div className="space-y-6">
<Summary p={p} />
{p.last_fetch === null ? (
<Notice tone="warning">{s.noFetch}</Notice>
) : isFetchStale(p.last_fetch, now) ? (
<Notice tone="warning">{s.staleFetch}</Notice>
) : null}
{p.unpriced > 0 && <Notice tone="warning">{s.unpriced(p.unpriced)}</Notice>}
<div className={cn('grid items-start gap-4', hasValue && 'lg:grid-cols-[2fr_3fr]')}>
{hasValue && (
<Suspense fallback={<Skeleton className="h-80" />}>
<AllocationChart p={p} />
</Suspense>
)}
<section aria-labelledby="assets-title" className="rounded-2xl border bg-card p-4 sm:p-5">
<h2 id="assets-title" className="mb-2 font-semibold">
{s.listTitle}
</h2>
{held.length === 0 ? (
<p className="text-sm text-muted-foreground">{s.empty}</p>
) : (
<ul className="divide-y">
{held.map((i) => (
<AssetRow key={i.asset.id} item={i} today={today} />
))}
</ul>
)}
</section>
</div>
{others.length > 0 && (
<details className="group rounded-2xl border bg-card p-4 sm:p-5" open={held.length === 0}>
<summary className="flex min-h-11 cursor-pointer items-center justify-between font-semibold">
{s.othersTitle(others.length)}
<ChevronRight
className="size-4 transition-transform group-open:rotate-90"
aria-hidden
/>
</summary>
<p className="mb-2 text-sm text-muted-foreground">{s.othersHint}</p>
<ul className="divide-y">
{others.map((i) => (
<AssetRow key={i.asset.id} item={i} today={today} compact />
))}
</ul>
</details>
)}
<AddCurrency />
</div>
)
}
function Summary({ p }: { p: Portfolio }) {
return (
<section aria-label={s.totalValue} className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<div className="col-span-2 rounded-2xl border-2 border-primary/40 bg-primary/5 p-4 lg:col-span-1">
<p className="text-sm text-muted-foreground">{s.totalValue}</p>
<p className="money text-3xl font-bold tracking-tight">{formatMoney(p.value_total)}</p>
{p.last_fetch && (
<p className="mt-1 text-xs text-muted-foreground">
{s.ratesAt(formatDateTime(p.last_fetch))}
</p>
)}
</div>
<Tile label={s.cost}>
<span className="money">{formatMoney(p.cost_total)}</span>
</Tile>
<Tile label={s.unrealized} hint={s.unrealizedHint}>
<Pl pl={p.unrealized_pl_total} cost={p.cost_total} stacked />
</Tile>
{/* Mobilde 3. küçük kart tek kalmasın: tam genişlik */}
<Tile label={s.realized} hint={s.realizedHint} className="col-span-2 lg:col-span-1">
<Pl pl={p.realized_pl_total} cost={0} />
</Tile>
</section>
)
}
function Tile({
label,
hint,
className,
children,
}: {
label: string
hint?: string
className?: string
children: ReactNode
}) {
return (
<div className={cn('rounded-2xl border bg-card p-4', className)}>
<p className="text-sm text-muted-foreground">{label}</p>
<p className="mt-1 text-lg font-semibold">{children}</p>
{hint && <p className="mt-1 text-xs text-muted-foreground">{hint}</p>}
</div>
)
}
function AssetRow({
item: i,
today,
compact,
}: {
item: PortfolioItem
today: string
compact?: boolean
}) {
const a = i.asset
return (
<li>
<Link
to={assetPath(a.id)}
className="-mx-2 flex min-h-14 items-center gap-3 rounded-lg px-2 py-2.5 hover:bg-muted"
>
<span aria-hidden className="text-2xl">
{kindEmoji[a.kind]}
</span>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">
{a.name}
{!a.is_active && (
<span className="ml-2 rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{s.inactive}
</span>
)}
</p>
{!compact && a.kind !== 'cash' && (
<p className="truncate text-xs text-muted-foreground">
<span className="money">{qty(a)}</span>
{a.avg_cost_e6 !== null && (
<> · {s.avgCost(formatMoney(Math.round(a.avg_cost_e6 / 10_000)))}</>
)}
</p>
)}
{a.kind !== 'cash' && (
<p className="truncate text-xs text-muted-foreground">
<RateLine rate={i.rate} today={today} />
</p>
)}
</div>
{!compact && (
<div className="shrink-0 text-right">
<p className="money font-semibold">{i.value === null ? '—' : formatMoney(i.value)}</p>
{i.unrealized_pl !== null && a.quantity_e6 > 0 && a.kind !== 'cash' && (
<p className="text-xs">
<Pl pl={i.unrealized_pl} cost={a.cost} />
</p>
)}
</div>
)}
<ChevronRight className="size-4 shrink-0 text-muted-foreground" aria-hidden />
</Link>
</li>
)
}