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>
This commit is contained in:
2026-11-13 01:14:50 +03:00
parent 3f60efdc33
commit 6b7b69c987
22 changed files with 1601 additions and 4 deletions
+195 -2
View File
@@ -1,11 +1,204 @@
import { ComingSoon, PageHeader } from '@/components/page'
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} />
<ComingSoon when="Faz 8'de" />
{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>
)
}