6b7b69c987
- 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>
205 lines
6.8 KiB
TypeScript
205 lines
6.8 KiB
TypeScript
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>
|
||
)
|
||
}
|