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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user