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
+43
View File
@@ -0,0 +1,43 @@
import type { ComponentProps } from 'react'
import { Input } from '@/components/ui/input'
import { formatE6, parseE6 } from '@/lib/decimal'
import { cn } from '@/lib/utils'
type Props = Omit<ComponentProps<typeof Input>, 'value' | 'onChange' | 'type'> & {
value: string
onChange: (value: string) => void
/** Sağda gösterilen birim: `gr`, `$`, `₺`… */
suffix: string
}
/**
* Miktar / kur alanı (6 ondalığa kadar). `MoneyInput` gibi: sayısal klavye, alandan çıkınca
* Türkçe biçim (`5.5` → `5,5`, `1000` → `1.000`). Değer metin; `parseE6` ile × 1e6'ya çevrilir.
*/
export function DecimalInput({ value, onChange, onBlur, suffix, className, ...props }: Props) {
return (
<div className="relative">
<Input
{...props}
type="text"
inputMode="decimal"
autoComplete="off"
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={(e) => {
const v = parseE6(value)
if (v !== null && value.trim() !== '') onChange(formatE6(v))
onBlur?.(e)
}}
className={cn('money text-right', className)}
style={{ paddingRight: `${Math.max(2.25, suffix.length * 0.6 + 1.25)}rem` }}
/>
<span
aria-hidden
className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-muted-foreground"
>
{suffix}
</span>
</div>
)
}
@@ -21,6 +21,8 @@ export const paths = {
/** İlk dönem sihirbazı; `?adim=1..5` */
periodWizard: '/plan/donem-ac',
savings: '/birikim',
/** Varlık ayrıntısı; adres için `assetPath(id)` */
asset: '/birikim/:id',
more: '/diger',
review: '/degerlendirme',
/** Dönem kapatma sihirbazı */
@@ -31,6 +33,9 @@ export const paths = {
register: '/kayit',
} as const
/** Varlık ayrıntı sayfası adresi */
export const assetPath = (id: string) => `/birikim/${id}`
const dashboard: NavItem = { to: paths.dashboard, label: t.nav.dashboard, icon: LayoutDashboard }
const ledger: NavItem = { to: paths.ledger, label: t.nav.ledger, icon: NotebookPen }
const planning: NavItem = { to: paths.planning, label: t.nav.planning, icon: CalendarDays }
@@ -0,0 +1,85 @@
import { useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { Plus } from 'lucide-react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { FormError, FormField } from '@/components/form-field'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useCreateAsset } from '@/lib/assets'
import { t } from '@/lib/strings'
import { currencySchema, type CurrencyForm } from './schema'
const s = t.assets
/** "Döviz ekle": düğmeye basınca açılan küçük form */
export function AddCurrency() {
const [open, setOpen] = useState(false)
const create = useCreateAsset()
const {
register,
handleSubmit,
reset,
setError,
formState: { errors, isSubmitting },
} = useForm<CurrencyForm>({
resolver: zodResolver(currencySchema),
mode: 'onTouched',
defaultValues: { code: '', name: '', unit_label: '' },
})
if (!open) {
return (
<Button variant="outline" onClick={() => setOpen(true)}>
<Plus aria-hidden />
{s.addCurrency}
</Button>
)
}
const onSubmit = handleSubmit(async (v) => {
try {
const a = await create.mutateAsync({
code: v.code,
name: v.name,
unit_label: v.unit_label || undefined,
})
toast.success(s.currencyAdded(a.name))
reset()
setOpen(false)
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
setError(message.includes('kod') || message.includes('zaten') ? 'code' : 'root', { message })
}
})
return (
<section aria-labelledby="add-fx-title" className="rounded-2xl border bg-card p-4 sm:p-5">
<h2 id="add-fx-title" className="mb-3 font-semibold">
{s.addCurrency}
</h2>
<form onSubmit={onSubmit} noValidate className="space-y-4">
<div className="grid gap-4 sm:grid-cols-[8rem_1fr_8rem]">
<FormField label={s.code} error={errors.code} hint={s.codeHint}>
{(f) => <Input {...f} maxLength={3} className="uppercase" {...register('code')} />}
</FormField>
<FormField label={s.name} error={errors.name}>
{(f) => <Input {...f} maxLength={60} {...register('name')} />}
</FormField>
<FormField label={s.unitLabel} error={errors.unit_label}>
{(f) => <Input {...f} maxLength={8} {...register('unit_label')} />}
</FormField>
</div>
<FormError message={errors.root?.message} />
<div className="flex flex-col-reverse gap-2 sm:flex-row">
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
{s.cancel}
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? s.saving : s.save}
</Button>
</div>
</form>
</section>
)
}
@@ -0,0 +1,77 @@
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts'
import { formatMoney, formatPercent } from '@/lib/format'
import { allocation } from '@/lib/savings'
import { t } from '@/lib/strings'
import type { Portfolio } from '@/types/Portfolio'
const s = t.assets
/**
* Ayırt edilebilir sıra (renk körlüğüne uygun grup paleti + ana renk). Ham CSS değişkenleri:
* `@theme inline` yüzünden `--color-*` derlenmiş CSS'te olmayabilir.
*/
const COLORS = [
'var(--primary)',
'var(--unexpected)',
'var(--culture)',
'var(--needs)',
'var(--wants)',
'var(--muted-foreground)',
]
/** Halka grafik + yanında renkli liste (liste aynı zamanda metin özeti) */
export default function AllocationChart({ p }: { p: Pick<Portfolio, 'items'> }) {
const slices = allocation(p)
const color = (i: number) => COLORS[i % COLORS.length]
return (
<figure className="rounded-2xl border bg-card p-4 sm:p-5">
<h2 className="mb-2 font-semibold">{s.allocationTitle}</h2>
<div className="h-56" aria-hidden>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={slices}
dataKey="value"
nameKey="name"
innerRadius="58%"
outerRadius="90%"
paddingAngle={slices.length > 1 ? 2 : 0}
stroke="var(--card)"
isAnimationActive={false}
>
{slices.map((x, i) => (
<Cell key={x.code} fill={color(i)} />
))}
</Pie>
<Tooltip
formatter={(v) => (typeof v === 'number' ? formatMoney(v) : '—')}
contentStyle={{
background: 'var(--popover)',
color: 'var(--popover-foreground)',
border: '1px solid var(--border)',
borderRadius: 12,
fontSize: 13,
}}
/>
</PieChart>
</ResponsiveContainer>
</div>
<figcaption>
<ul className="mt-3 space-y-1.5 text-sm">
{slices.map((x, i) => (
<li key={x.code} className="flex items-center gap-2">
<span
aria-hidden
className="size-3 shrink-0 rounded-full"
style={{ background: color(i) }}
/>
<span className="min-w-0 flex-1 truncate">{x.name}</span>
<span className="money text-muted-foreground">{formatPercent(x.share)}</span>
<span className="money w-28 text-right">{formatMoney(x.value)}</span>
</li>
))}
</ul>
</figcaption>
</figure>
)
}
@@ -0,0 +1,343 @@
import { useRef, useState, type ReactNode } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { ArrowLeft, Pencil, Trash2 } from 'lucide-react'
import { Controller, useForm } from 'react-hook-form'
import { Link, useParams } from 'react-router'
import { toast } from 'sonner'
import { DecimalInput } from '@/components/decimal-input'
import { FormError, FormField } from '@/components/form-field'
import { paths } from '@/components/layout/nav-items'
import { PageError, PageHeader, PageLoading } from '@/components/page'
import { Button } from '@/components/ui/button'
import { Section } from '@/features/planning/plan-read'
import {
useAssetTxs,
useDeleteRate,
useDeleteTx,
usePortfolio,
useSetRate,
useUpdateAsset,
} from '@/lib/assets'
import { formatE6, parseE6 } from '@/lib/decimal'
import { formatDate, formatMoney, toIsoDate } from '@/lib/format'
import { kindEmoji, qty } from '@/lib/savings'
import { t } from '@/lib/strings'
import { cn } from '@/lib/utils'
import type { Asset } from '@/types/Asset'
import type { AssetTransaction } from '@/types/AssetTransaction'
import type { PortfolioItem } from '@/types/PortfolioItem'
import { Pl, RateLine } from './parts'
import { rateSchema, type RateForm } from './schema'
import { TxFormView } from './tx-form'
const s = t.assets
/** `/birikim/:id`: varlığın durumu, kuru, alım/satım formu ve geçmişi */
export function AssetPage() {
const { id = '' } = useParams()
const portfolio = usePortfolio()
const item = portfolio.data?.items.find((i) => i.asset.id === id)
return (
<div className="mx-auto max-w-3xl">
<Link
to={paths.savings}
className="mb-3 inline-flex min-h-11 items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-4" aria-hidden />
{s.back}
</Link>
{portfolio.isError ? (
<PageError message={portfolio.error.message} onRetry={portfolio.refetch} />
) : !portfolio.data ? (
<PageLoading />
) : !item ? (
<p role="alert" className="rounded-xl bg-muted p-4 text-muted-foreground">
{s.notFound}
</p>
) : (
<AssetView item={item} />
)}
</div>
)
}
function AssetView({ item }: { item: PortfolioItem }) {
const a = item.asset
const [today] = useState(() => toIsoDate(new Date()))
const [editing, setEditing] = useState<AssetTransaction | null>(null)
const formRef = useRef<HTMLDivElement>(null)
const cash = a.kind === 'cash'
return (
<div className="space-y-4">
<PageHeader title={`${kindEmoji[a.kind]} ${a.name}`} />
<dl className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Stat label={s.holding}>{qty(a)}</Stat>
<Stat label={s.value}>{item.value === null ? '—' : formatMoney(item.value)}</Stat>
<Stat label={s.cost}>{formatMoney(a.cost)}</Stat>
<Stat label={s.unrealized}>
{item.unrealized_pl === null ? '—' : <Pl pl={item.unrealized_pl} cost={a.cost} />}
</Stat>
</dl>
{a.realized_pl !== 0 && (
<p className="text-sm text-muted-foreground">
{s.realized}: <Pl pl={a.realized_pl} cost={0} />
</p>
)}
{!cash && <RateBox item={item} today={today} />}
{a.is_active || editing ? (
<div ref={formRef} className="scroll-mt-4">
<Section title={editing ? s.editTx : s.txTitle}>
<TxFormView
key={editing?.id ?? 'new'}
asset={a}
rateE6={item.rate?.rate_e6 ?? null}
today={today}
tx={editing ?? undefined}
onDone={() => setEditing(null)}
/>
</Section>
</div>
) : null}
<History
asset={a}
editingId={editing?.id}
onEdit={(tx) => {
setEditing(tx)
formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}}
onDeleted={(id) => editing?.id === id && setEditing(null)}
/>
{!cash && <StatusToggle asset={a} />}
</div>
)
}
function Stat({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="rounded-2xl border bg-card p-3">
<dt className="text-xs text-muted-foreground">{label}</dt>
<dd className="money mt-1 font-semibold">{children}</dd>
</div>
)
}
/** Güncel kur + elle kur girme / kaldırma */
function RateBox({ item, today }: { item: PortfolioItem; today: string }) {
const [open, setOpen] = useState(false)
const setRate = useSetRate()
const delRate = useDeleteRate()
const manual = item.rate?.is_manual ? item.rate : null
const {
control,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = useForm<RateForm>({
resolver: zodResolver(rateSchema),
mode: 'onTouched',
defaultValues: { rate: item.rate ? formatE6(item.rate.rate_e6) : '' },
})
const onSubmit = handleSubmit(async (v) => {
try {
await setRate.mutateAsync({ code: item.asset.code, rate_e6: parseE6(v.rate) ?? 0 })
toast.success(s.rateSaved)
setOpen(false)
} catch (e) {
setError('rate', { message: e instanceof Error ? e.message : String(e) })
}
})
return (
<Section title={s.rateTitle}>
<p className="text-sm">
<RateLine rate={item.rate} today={today} />
</p>
{open ? (
<form onSubmit={onSubmit} noValidate className="mt-3 space-y-3">
<Controller
control={control}
name="rate"
render={({ field }) => (
<FormField label={s.rateLabel} error={errors.rate} hint={s.manualRateHint}>
{(f) => (
<DecimalInput
{...f}
ref={field.ref}
name={field.name}
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
suffix="₺"
/>
)}
</FormField>
)}
/>
<FormError message={errors.root?.message} />
<div className="flex flex-col-reverse gap-2 sm:flex-row">
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
{s.cancel}
</Button>
<Button type="submit" disabled={isSubmitting}>
{s.setRate}
</Button>
</div>
</form>
) : (
<div className="mt-3 flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setOpen(true)}>
{s.manualRate}
</Button>
{manual && (
<Button
variant="ghost"
disabled={delRate.isPending}
onClick={() =>
delRate.mutate(manual.id, {
onSuccess: () => toast.success(s.rateRemoved),
onError: (e) => toast.error(e.message),
})
}
>
{s.removeManual}
</Button>
)}
</div>
)}
</Section>
)
}
function History({
asset,
editingId,
onEdit,
onDeleted,
}: {
asset: Asset
editingId?: string
onEdit: (tx: AssetTransaction) => void
onDeleted: (id: string) => void
}) {
const txs = useAssetTxs(asset.id)
const del = useDeleteTx()
/** İki adımlı silme: ilk dokunuş onay ister */
const [confirming, setConfirming] = useState<string | null>(null)
async function remove(tx: AssetTransaction) {
try {
await del.mutateAsync(tx.id)
onDeleted(tx.id)
toast.success(s.deleted)
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e))
} finally {
setConfirming(null)
}
}
return (
<Section title={s.historyTitle}>
{txs.isError ? (
<PageError message={txs.error.message} onRetry={txs.refetch} />
) : !txs.data ? (
<PageLoading />
) : txs.data.length === 0 ? (
<p className="text-sm text-muted-foreground">{s.historyEmpty}</p>
) : (
<ul className="divide-y">
{txs.data.map((tx) => {
const label = tx.type === 'buy' ? s.buy : s.sell
return (
<li
key={tx.id}
className={cn(
'flex flex-wrap items-center gap-x-3 gap-y-1 py-2.5',
tx.id === editingId && '-mx-2 rounded-lg bg-muted px-2',
)}
>
<span
className={cn(
'rounded-full px-2 py-0.5 text-xs font-medium',
tx.type === 'buy' ? 'bg-income/10 text-income' : 'bg-expense/10 text-expense',
)}
>
{label}
</span>
<div className="min-w-0 flex-1">
<p className="money text-sm font-medium">
{asset.kind === 'cash' ? formatMoney(tx.total) : qty(asset, tx.quantity_e6)}
</p>
<p className="truncate text-xs text-muted-foreground">
{formatDate(tx.date)}
{asset.kind !== 'cash' && ` · ${formatMoney(tx.total)}`}
{tx.source_period_id && ` · ${s.fromClosing}`}
{tx.note && ` · ${tx.note}`}
</p>
</div>
<div className="flex shrink-0 items-center">
<Button
variant="ghost"
size="icon"
aria-label={s.edit(`${formatDate(tx.date)} ${label}`)}
aria-pressed={tx.id === editingId}
onClick={() => onEdit(tx)}
>
<Pencil aria-hidden />
</Button>
{confirming === tx.id ? (
<Button
variant="destructive"
size="sm"
disabled={del.isPending}
onClick={() => void remove(tx)}
>
{s.confirmRemove}
</Button>
) : (
<Button
variant="ghost"
size="icon"
aria-label={`${formatDate(tx.date)} ${label}: ${s.remove}`}
className="text-muted-foreground hover:text-expense"
onClick={() => setConfirming(tx.id)}
>
<Trash2 aria-hidden />
</Button>
)}
</div>
</li>
)
})}
</ul>
)}
</Section>
)
}
function StatusToggle({ asset }: { asset: Asset }) {
const update = useUpdateAsset()
return (
<div className="flex flex-wrap items-center gap-3 pt-2 text-sm text-muted-foreground">
<Button
variant="outline"
disabled={update.isPending}
onClick={() =>
update.mutate(
{ id: asset.id, is_active: !asset.is_active, version: asset.version },
{ onError: (e) => toast.error(e.message) },
)
}
>
{asset.is_active ? s.deactivate : s.activate}
</Button>
<span>{s.statusHint}</span>
</div>
)
}
+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>
)
}
+74
View File
@@ -0,0 +1,74 @@
import type { ReactNode } from 'react'
import { Info, TriangleAlert } from 'lucide-react'
import { formatRate } from '@/lib/decimal'
import { formatDayMonth, formatMoney } from '@/lib/format'
import { formatPlPercent, plRatio, rateAgeDays } from '@/lib/savings'
import { t } from '@/lib/strings'
import { cn } from '@/lib/utils'
import type { Rate } from '@/types/Rate'
const s = t.assets
/** Kâr/zarar: işaretli tutar + (varsa) yüzde, renkli */
export function Pl({
pl,
cost,
stacked,
className,
}: {
pl: number
cost: number
/** Yüzde alt satırda (dar kartlarda taşmasın) */
stacked?: boolean
className?: string
}) {
const pct = formatPlPercent(plRatio(pl, cost))
return (
<span
className={cn(
'money',
pl > 0 ? 'text-income' : pl < 0 ? 'text-expense' : 'text-muted-foreground',
className,
)}
>
{formatMoney(pl, 'TRY', { signed: true })}
{pct && (stacked ? <span className="block text-sm">{pct}</span> : ` (${pct})`)}
</span>
)
}
/** `6.253,8241 ₺ · xaus.com · 13.11` (+ eskiyse uyarı rengi) */
export function RateLine({ rate, today }: { rate: Rate | null; today: string }) {
if (!rate) return <span className="text-warning">{s.noRate}</span>
const old = rateAgeDays(rate.rate_date, today) > 3
return (
<span className={cn(old && 'text-warning')}>
{s.rateLine(formatRate(rate.rate_e6), s.source[rate.source], formatDayMonth(rate.rate_date))}
{old && ` · ${s.rateOld(rateAgeDays(rate.rate_date, today))}`}
</span>
)
}
export function Notice({
tone = 'info',
children,
}: {
tone?: 'info' | 'warning'
children: ReactNode
}) {
const Icon = tone === 'warning' ? TriangleAlert : Info
return (
<p
className={cn(
'flex items-start gap-2 rounded-xl px-3 py-2 text-sm',
tone === 'warning' ? 'bg-warning/10' : 'bg-muted text-muted-foreground',
)}
>
<Icon
className={cn('mt-0.5 size-4 shrink-0', tone === 'warning' && 'text-warning')}
aria-hidden
/>
<span>{children}</span>
</p>
)
}
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import { currencySchema, rateSchema, txFieldForError, txSchema } from './schema'
const base = {
type: 'buy',
date: '2026-11-13',
quantity: '5,5',
unit_price: '',
total: '34.000',
note: '',
}
describe('savings forms', () => {
const gold = txSchema({ cash: false, maxSellE6: 10_000_000, heldText: '10 gr' })
it('buy: quantity required, price optional', () => {
expect(gold.safeParse(base).success).toBe(true)
expect(gold.safeParse({ ...base, quantity: '' }).success).toBe(false)
expect(gold.safeParse({ ...base, unit_price: 'abc' }).success).toBe(false)
expect(gold.safeParse({ ...base, unit_price: '6.250,50' }).success).toBe(true)
expect(gold.safeParse({ ...base, total: '0' }).success).toBe(false)
expect(gold.safeParse({ ...base, date: '2026-02-30' }).success).toBe(false)
})
it('sell cannot exceed what is held', () => {
const r = gold.safeParse({ ...base, type: 'sell', quantity: '10,5' })
expect(r.success).toBe(false)
expect(r.error?.issues[0]).toMatchObject({
path: ['quantity'],
message: 'Eldekinden fazla satılamaz (10 gr)',
})
expect(gold.safeParse({ ...base, type: 'sell', quantity: '10' }).success).toBe(true)
// Düzenlemede sınır backend'de
const edit = txSchema({ cash: false, maxSellE6: null, heldText: '' })
expect(edit.safeParse({ ...base, type: 'sell', quantity: '99' }).success).toBe(true)
})
it('cash: only total matters', () => {
const cash = txSchema({ cash: true, maxSellE6: 0, heldText: '' })
expect(cash.safeParse({ ...base, quantity: '', unit_price: '' }).success).toBe(true)
expect(cash.safeParse({ ...base, quantity: '', total: '' }).success).toBe(false)
})
it('server errors go to the right field', () => {
expect(txFieldForError('Satış miktarı o tarihte eldeki miktarı aşıyor', false)).toBe('quantity')
expect(txFieldForError('Satış miktarı o tarihte eldeki miktarı aşıyor', true)).toBe('total')
expect(txFieldForError('Tarih geçersiz', false)).toBe('date')
expect(txFieldForError('Kayıt başka biri tarafından değiştirildi', false)).toBe('root')
})
it('rate and currency forms', () => {
expect(rateSchema.safeParse({ rate: '10.250' }).success).toBe(true)
expect(rateSchema.safeParse({ rate: '0' }).success).toBe(false)
expect(currencySchema.safeParse({ code: 'gbp', name: 'Sterlin', unit_label: '' }).success).toBe(
true,
)
expect(currencySchema.safeParse({ code: 'GB', name: 'Sterlin', unit_label: '' }).success).toBe(
false,
)
expect(currencySchema.safeParse({ code: 'GBP', name: ' ', unit_label: '' }).success).toBe(false)
})
})
+73
View File
@@ -0,0 +1,73 @@
import { z } from 'zod'
import { parseE6 } from '@/lib/decimal'
import { moneyText } from '@/lib/money-schema'
import { isIsoDate } from '@/lib/period-dates'
import { t } from '@/lib/strings'
const s = t.assets
export const MAX_NOTE = 200
/** Miktar / kur metni: `optional` boş olabilir; doluysa > 0 */
export function e6Text(mode: 'required' | 'optional', invalid: string = s.quantityInvalid) {
return z
.string()
.trim()
.superRefine((v, ctx) => {
if (v === '') {
if (mode === 'required') ctx.addIssue({ code: 'custom', message: s.quantityRequired })
return
}
const n = parseE6(v)
if (n === null) return ctx.addIssue({ code: 'custom', message: invalid })
if (n <= 0) ctx.addIssue({ code: 'custom', message: s.quantityPositive })
})
}
/**
* Alım/satım formu. TL'de (`cash`) miktar ve fiyat yok. Satışta miktar eldekini aşamaz
* (`maxSellE6`; düzenlemede `null` → denetimi backend yapar, tarih sırasına göre).
*/
export function txSchema(opts: { cash: boolean; maxSellE6: number | null; heldText: string }) {
return z
.object({
type: z.enum(['buy', 'sell']),
date: z.string().refine(isIsoDate, s.dateInvalid),
quantity: opts.cash ? z.string() : e6Text('required'),
unit_price: opts.cash ? z.string() : e6Text('optional', s.priceInvalid),
total: moneyText('positive'),
note: z.string().max(MAX_NOTE, s.noteTooLong),
})
.superRefine((v, ctx) => {
if (opts.cash || v.type !== 'sell' || opts.maxSellE6 === null) return
const q = parseE6(v.quantity)
if (q !== null && q > opts.maxSellE6) {
ctx.addIssue({ code: 'custom', path: ['quantity'], message: s.tooMuch(opts.heldText) })
}
})
}
export type TxForm = z.infer<ReturnType<typeof txSchema>>
/** Sunucu hata mesajı → ilgili alan */
export function txFieldForError(message: string, cash: boolean): keyof TxForm | 'root' {
if (message.startsWith('Satış')) return cash ? 'total' : 'quantity'
if (message.startsWith('Tarih')) return 'date'
if (message.startsWith('Tutar')) return 'total'
if (message.startsWith('Miktar')) return 'quantity'
if (message.startsWith('Birim')) return 'unit_price'
if (message.startsWith('Not')) return 'note'
return 'root'
}
export const rateSchema = z.object({ rate: e6Text('required', s.priceInvalid) })
export type RateForm = z.infer<typeof rateSchema>
export const currencySchema = z.object({
code: z
.string()
.trim()
.regex(/^[A-Za-z]{3}$/, s.codeInvalid),
name: z.string().trim().min(1, s.nameRequired).max(60),
unit_label: z.string().trim().max(8),
})
export type CurrencyForm = z.infer<typeof currencySchema>
+249
View File
@@ -0,0 +1,249 @@
import { useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { Loader2 } from 'lucide-react'
import { Controller, useForm, useWatch } from 'react-hook-form'
import { toast } from 'sonner'
import { DecimalInput } from '@/components/decimal-input'
import { FormError, FormField } from '@/components/form-field'
import { MoneyInput } from '@/components/money-input'
import { Segmented } from '@/components/segmented'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useCreateTx, useUpdateTx } from '@/lib/assets'
import { formatE6, parseE6, valueKurus } from '@/lib/decimal'
import { formatNumber } from '@/lib/format'
import { toKurus } from '@/lib/money-schema'
import { qty } from '@/lib/savings'
import { t } from '@/lib/strings'
import type { Asset } from '@/types/Asset'
import type { AssetTransaction } from '@/types/AssetTransaction'
import { MAX_NOTE, txFieldForError, txSchema, type TxForm } from './schema'
const s = t.assets
/** Kuruşa yuvarlanmış kur (öneri olarak; `6.253,82`) */
const priceSuggestion = (rateE6: number | null) =>
rateE6 ? formatE6(Math.round(rateE6 / 10_000) * 10_000) : ''
/**
* Alım / satım formu. Miktar × birim fiyat → tutar önerilir; tutar elle değiştirildiyse
* (komisyon, işçilik) artık üzerine yazılmaz. TL'de yalnızca tutar.
* `tx` verilirse düzenleme (bileşen `key` ile yeniden oluşturulur).
*/
export function TxFormView({
asset,
rateE6,
today,
tx,
onDone,
}: {
asset: Asset
/** Güncel kur: yeni alımda birim fiyat önerisi */
rateE6: number | null
today: string
tx?: AssetTransaction
onDone?: () => void
}) {
const cash = asset.kind === 'cash'
const create = useCreateTx(asset.id)
const update = useUpdateTx()
// Düzenlenen tutar zaten "gerçek" tutar → üzerine yazılmasın
const [totalTouched, setTotalTouched] = useState(!!tx)
const {
control,
register,
handleSubmit,
setValue,
getValues,
setError,
reset,
formState: { errors, isSubmitting },
} = useForm<TxForm>({
resolver: zodResolver(
txSchema({
cash,
maxSellE6: tx ? null : asset.quantity_e6,
heldText: qty(asset),
}),
),
mode: 'onTouched',
defaultValues: tx
? {
type: tx.type,
date: tx.date,
quantity: formatE6(tx.quantity_e6),
unit_price: formatE6(tx.unit_price_e6),
total: formatNumber(tx.total / 100),
note: tx.note ?? '',
}
: {
type: 'buy',
date: today,
quantity: '',
unit_price: priceSuggestion(rateE6),
total: '',
note: '',
},
})
const type = useWatch({ control, name: 'type' })
/** Miktar ya da fiyat değişince tutarı öner (elle değiştirilmediyse) */
function suggestTotal() {
if (cash || totalTouched) return
const q = parseE6(getValues('quantity'))
const p = parseE6(getValues('unit_price'))
if (q && p) setValue('total', formatNumber(valueKurus(q, p) / 100), { shouldValidate: true })
}
const onSubmit = handleSubmit(async (v) => {
const unitPrice = parseE6(v.unit_price)
try {
if (tx) {
await update.mutateAsync({
id: tx.id,
version: tx.version,
date: v.date,
total: toKurus(v.total),
note: v.note.trim(), // '' → kaldır
...(cash ? {} : { quantity_e6: parseE6(v.quantity) ?? 0 }),
...(cash || unitPrice === null ? {} : { unit_price_e6: unitPrice }),
})
toast.success(s.updated)
} else {
await create.mutateAsync({
type: v.type,
date: v.date,
total: toKurus(v.total),
note: v.note.trim() || undefined,
...(cash ? {} : { quantity_e6: parseE6(v.quantity) ?? 0 }),
...(cash || unitPrice === null ? {} : { unit_price_e6: unitPrice }),
})
toast.success(s.saved)
reset({ ...v, quantity: '', total: '', note: '' })
setTotalTouched(false)
}
onDone?.()
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
setError(txFieldForError(message, cash), { message })
}
})
return (
<form onSubmit={onSubmit} noValidate className="@container space-y-4">
{!tx && (
<Controller
control={control}
name="type"
render={({ field }) => (
<Segmented
label={s.type}
value={field.value}
onChange={field.onChange}
options={[
{ value: 'buy', label: s.buy },
{ value: 'sell', label: s.sell },
]}
/>
)}
/>
)}
{cash && <p className="text-sm text-muted-foreground">{s.cashHint}</p>}
<div className="grid gap-4 @md:grid-cols-2">
{!cash && (
<>
<Controller
control={control}
name="quantity"
render={({ field }) => (
<FormField
label={s.quantity}
error={errors.quantity}
hint={type === 'sell' && !tx ? s.held(qty(asset)) : undefined}
>
{(f) => (
<DecimalInput
{...f}
ref={field.ref}
name={field.name}
value={field.value}
suffix={asset.unit_label}
onChange={(x) => {
field.onChange(x)
suggestTotal()
}}
onBlur={field.onBlur}
/>
)}
</FormField>
)}
/>
<Controller
control={control}
name="unit_price"
render={({ field }) => (
<FormField label={s.unitPrice} error={errors.unit_price} hint={s.unitPriceHint}>
{(f) => (
<DecimalInput
{...f}
ref={field.ref}
name={field.name}
value={field.value}
suffix="₺"
onChange={(x) => {
field.onChange(x)
suggestTotal()
}}
onBlur={field.onBlur}
/>
)}
</FormField>
)}
/>
</>
)}
<Controller
control={control}
name="total"
render={({ field }) => (
<FormField label={s.total} error={errors.total} hint={cash ? undefined : s.totalHint}>
{(f) => (
<MoneyInput
{...f}
ref={field.ref}
name={field.name}
value={field.value}
onChange={(x) => {
setTotalTouched(true)
field.onChange(x)
}}
onBlur={field.onBlur}
placeholder="0,00"
/>
)}
</FormField>
)}
/>
<FormField label={s.date} error={errors.date}>
{(f) => <Input {...f} type="date" max={today} {...register('date')} />}
</FormField>
</div>
<FormField label={s.note} error={errors.note}>
{(f) => <Input {...f} maxLength={MAX_NOTE * 2} {...register('note')} />}
</FormField>
<FormError message={errors.root?.message} />
<div className="flex flex-col-reverse gap-2 sm:flex-row">
{tx && (
<Button type="button" variant="outline" size="lg" onClick={onDone}>
{s.cancel}
</Button>
)}
<Button type="submit" size="lg" disabled={isSubmitting} className="w-full sm:w-auto">
{isSubmitting && <Loader2 className="animate-spin" aria-hidden />}
{isSubmitting ? s.saving : tx ? s.update : s.save}
</Button>
</div>
</form>
)
}
+65
View File
@@ -0,0 +1,65 @@
/**
* Birikimler: portföy, varlık işlemleri ve kurlar (TanStack Query).
* Herhangi bir değişiklik `queryKeys.portfolio` önekini yeniler → değer, kâr/zarar ve geçmiş güncel.
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { Asset } from '@/types/Asset'
import type { AssetCreateRequest } from '@/types/AssetCreateRequest'
import type { AssetTransaction } from '@/types/AssetTransaction'
import type { AssetTxCreateRequest } from '@/types/AssetTxCreateRequest'
import type { AssetTxUpdateRequest } from '@/types/AssetTxUpdateRequest'
import type { AssetUpdateRequest } from '@/types/AssetUpdateRequest'
import type { Portfolio } from '@/types/Portfolio'
import type { Rate } from '@/types/Rate'
import type { RateCreateRequest } from '@/types/RateCreateRequest'
import { api } from './api'
import { queryKeys } from './query'
export function usePortfolio() {
return useQuery({
queryKey: queryKeys.portfolio,
queryFn: () => api.get<Portfolio>('/portfolio'),
})
}
export function useAssetTxs(assetId: string) {
return useQuery({
queryKey: queryKeys.assetTxs(assetId),
queryFn: () => api.get<AssetTransaction[]>(`/assets/${assetId}/transactions`),
})
}
/** Ortak: başarı ya da hata sonrası portföyü yenile (hata: başkası değiştirmiş olabilir) */
function useAssetMutation<V, R>(fn: (v: V) => Promise<R>) {
const qc = useQueryClient()
return useMutation({
mutationFn: fn,
onSettled: () => qc.invalidateQueries({ queryKey: queryKeys.portfolio }),
})
}
export const useCreateTx = (assetId: string) =>
useAssetMutation((body: AssetTxCreateRequest) =>
api.post<AssetTransaction>(`/assets/${assetId}/transactions`, body),
)
export const useUpdateTx = () =>
useAssetMutation(({ id, ...body }: AssetTxUpdateRequest & { id: string }) =>
api.patch<AssetTransaction>(`/asset-transactions/${id}`, body),
)
export const useDeleteTx = () =>
useAssetMutation((id: string) => api.delete(`/asset-transactions/${id}`))
export const useCreateAsset = () =>
useAssetMutation((body: AssetCreateRequest) => api.post<Asset>('/assets', body))
export const useUpdateAsset = () =>
useAssetMutation(({ id, ...body }: AssetUpdateRequest & { id: string }) =>
api.patch<Asset>(`/assets/${id}`, body),
)
export const useSetRate = () =>
useAssetMutation((body: RateCreateRequest) => api.post<Rate>('/rates', body))
export const useDeleteRate = () => useAssetMutation((id: string) => api.delete(`/rates/${id}`))
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { formatE6, formatRate, parseE6, valueKurus } from './decimal'
describe('decimal (e6)', () => {
it('parses Turkish numbers without floats', () => {
expect(parseE6('5,5')).toBe(5_500_000)
expect(parseE6('5.5')).toBe(5_500_000) // telefon klavyesi
expect(parseE6('1.250,123456')).toBe(1_250_123_456)
expect(parseE6('1.000')).toBe(1_000_000_000) // binlik
expect(parseE6('0.125')).toBe(125_000) // 0 ile başlıyorsa ondalık
expect(parseE6('2.000.000')).toBe(2_000_000_000_000)
expect(parseE6('50,1691 ₺')).toBe(50_169_100)
expect(parseE6(',5')).toBe(500_000)
})
it('rejects invalid input', () => {
expect(parseE6('')).toBeNull()
expect(parseE6('1,2,3')).toBeNull()
expect(parseE6('0,1234567')).toBeNull() // 7 ondalık
expect(parseE6('abc')).toBeNull()
expect(parseE6('-5')).toBeNull()
})
it('formats', () => {
expect(formatE6(5_500_000)).toBe('5,5')
expect(formatE6(1_000_000_000)).toBe('1.000')
expect(formatE6(125_000)).toBe('0,125')
expect(formatRate(50_169_100)).toBe('50,1691 ₺')
expect(formatRate(6_253_824_143)).toBe('6.253,8241 ₺')
expect(formatRate(1_000_000)).toBe('1,00 ₺')
})
it('value like the backend (round half up)', () => {
expect(valueKurus(10_000_000, 6_248_822_284)).toBe(6_248_822)
expect(valueKurus(1_000_000, 123_456_789)).toBe(12_346)
expect(valueKurus(500_000, 6_248_822_284)).toBe(312_441)
// büyük değerler güvenli
expect(valueKurus(1_000_000_000_000, 6_000_000_000)).toBe(600_000_000_000)
})
})
+59
View File
@@ -0,0 +1,59 @@
/**
* Miktar ve kur alanları: ölçekli tam sayı (`_e6` = × 1.000.000), kayan nokta yok.
* Para (kuruş) için `parseMoney` / `formatMoney` kullanılır; bunlar 6 basamağa kadar ondalık içindir.
*/
const E6 = 1_000_000
/**
* Türkçe yazılmış sayı → × 1e6 (en fazla 6 ondalık). Geçersizse `null`.
* - Virgül ondalık ayırıcıdır, noktalar binlik: `1.250,5` → 1250,5
* - Virgül yoksa tek nokta: ardından tam 3 rakam varsa binlik (`1.000` → 1000; ama `0.125` ondalık),
* değilse ondalık (`5.5` → 5,5 — telefonun sayı klavyesi nokta verebilir)
*/
export function parseE6(input: string): number | null {
const s = input.replace(/[\s₺]/g, '')
if (!s) return null
let int: string
let frac = ''
if (s.includes(',')) {
const parts = s.split(',')
if (parts.length !== 2) return null
int = parts[0].replace(/\./g, '')
frac = parts[1]
} else {
const dots = s.split('.')
if (dots.length === 2 && !(dots[1].length === 3 && dots[0] !== '0' && dots[0] !== '')) {
int = dots[0]
frac = dots[1]
} else {
int = s.replace(/\./g, '')
}
}
if (int === '') int = '0'
if (!/^\d+$/.test(int) || !/^\d{0,6}$/.test(frac)) return null
const v = Number(int) * E6 + Number(frac.padEnd(6, '0'))
return Number.isSafeInteger(v) ? v : null
}
const nf = (min: number, max: number) =>
new Intl.NumberFormat('tr-TR', { minimumFractionDigits: min, maximumFractionDigits: max })
const plain = nf(0, 6)
const rate = nf(2, 4)
/** × 1e6 → `1.250,5` (gereksiz sıfırlar yok; alan içi ve miktar gösterimi) */
export function formatE6(e6: number): string {
return plain.format(e6 / E6)
}
/** Kur (TL × 1e6) → `50,1691 ₺` (2–4 ondalık) */
export function formatRate(e6: number): string {
return `${rate.format(e6 / E6)} ₺`
}
/** miktar × birim fiyat → kuruş (backend `value_of` ile aynı: yarım yukarı) */
export function valueKurus(quantityE6: number, priceE6: number): number {
// 1e6 × 1e6 güvenli tam sayı aralığını aşabilir → BigInt
const n = BigInt(quantityE6) * BigInt(priceE6)
return Number((n + 5_000_000_000n) / 10_000_000_000n)
}
+2
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
formatCompact,
formatDateTime,
formatWeekday,
formatDate,
formatMoney,
@@ -46,6 +47,7 @@ describe('diğer biçimler', () => {
expect(formatDate('2026-12-09')).toBe('09.12.2026')
expect(formatDate(new Date(2027, 0, 1))).toBe('01.01.2027')
expect(toIsoDate(new Date(2026, 11, 9))).toBe('2026-12-09')
expect(formatDateTime(new Date(2026, 10, 13, 0, 55).toISOString())).toBe('13.11.2026 00:55')
})
})
+8
View File
@@ -117,6 +117,14 @@ export function formatDate(value: string | Date): string {
}
/** Yerel tarih → `"2026-12-09"` (backend'in takvim tarihi biçimi) */
const time = new Intl.DateTimeFormat('tr-TR', { hour: '2-digit', minute: '2-digit' })
/** ISO zaman → `13.11.2026 00:55` (yerel saat) */
export function formatDateTime(iso: string): string {
const d = new Date(iso)
return `${formatDate(d)} ${time.format(d)}`
}
export function toIsoDate(date: Date): string {
const mm = String(date.getMonth() + 1).padStart(2, '0')
const dd = String(date.getDate()).padStart(2, '0')
+3
View File
@@ -18,6 +18,9 @@ export const queryKeys = {
/** Panel: son dönemler + toplam birikim (dönem listesiyle birlikte yenilenir) */
stats: ['periods', 'stats'] as const,
/** Bir döneme bağlı tüm veriler bu önekle başlar → `invalidateQueries({ queryKey: period(id) })` */
/** Birikimler: portföy ve varlık işlemleri bu önekle başlar (birlikte yenilenir) */
portfolio: ['portfolio'] as const,
assetTxs: (assetId: string) => ['portfolio', 'txs', assetId] as const,
period: (id: string) => ['period', id] as const,
planItems: (id: string) => ['period', id, 'plan-items'] as const,
summary: (id: string) => ['period', id, 'summary'] as const,
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest'
import type { PortfolioItem } from '@/types/PortfolioItem'
import {
allocation,
formatPlPercent,
isFetchStale,
plRatio,
rateAgeDays,
splitAssets,
} from './savings'
const item = (code: string, value: number | null, q = 0, tx = 0, active = true) =>
({
asset: { code, name: code, quantity_e6: q, tx_count: tx, is_active: active },
value,
}) as PortfolioItem
describe('savings', () => {
it('profit ratio against cost', () => {
expect(plRatio(250, 1_000)).toBe(0.25)
expect(plRatio(-100, 1_000)).toBe(-0.1)
expect(plRatio(50, 0)).toBeNull()
expect(formatPlPercent(0.041)).toBe('+%4,1')
expect(formatPlPercent(-0.0058)).toBe('-%0,6')
expect(formatPlPercent(-0.0000179)).toBeNull() // 9 kuruş / 5.017 TL
expect(formatPlPercent(null)).toBeNull()
})
it('stale fetch and rate age', () => {
const now = new Date('2026-11-13T12:00:00Z')
expect(isFetchStale('2026-11-13T09:00:00Z', now)).toBe(false)
expect(isFetchStale('2026-11-13T05:00:00Z', now)).toBe(true)
expect(isFetchStale(null, now)).toBe(true)
expect(rateAgeDays('2026-11-13', '2026-11-13')).toBe(0)
expect(rateAgeDays('2026-11-10', '2026-11-13')).toBe(3)
})
it('held first, untouched active ones under "others"', () => {
const items = [
item('TRY', 0),
item('XAU_GRAM', 500, 1_000_000, 1),
item('USD', 0, 0, 2), // hepsi satılmış ama geçmişi var
item('GBP', null, 0, 0, false), // pasif ve boş → gizli
]
const { held, others } = splitAssets(items)
expect(held.map((i) => i.asset.code)).toEqual(['XAU_GRAM', 'USD'])
expect(others.map((i) => i.asset.code)).toEqual(['TRY'])
})
it('allocation by value, biggest first', () => {
const a = allocation({
items: [item('TRY', 100), item('XAU_GRAM', 300), item('EUR', null), item('USD', 0)],
})
expect(a).toEqual([
{ code: 'XAU_GRAM', name: 'XAU_GRAM', value: 300, share: 0.75 },
{ code: 'TRY', name: 'TRY', value: 100, share: 0.25 },
])
})
})
+69
View File
@@ -0,0 +1,69 @@
/**
* Birikim sayfasının saf hesapları. Tutarlar backend'den gelir (`GET /portfolio`).
*/
import type { Asset } from '@/types/Asset'
import type { AssetKind } from '@/types/AssetKind'
import type { Portfolio } from '@/types/Portfolio'
import type { PortfolioItem } from '@/types/PortfolioItem'
import { formatE6 } from './decimal'
import { formatPercent } from './format'
import { daysInclusive } from './period-dates'
export const kindEmoji: Record<AssetKind, string> = { cash: '🇹🇷', gold: '🥇', fx: '💱' }
/** `10 gr`, `1,5 adet`, `100 $` */
export const qty = (a: Pick<Asset, 'quantity_e6' | 'unit_label'>, e6 = a.quantity_e6) =>
`${formatE6(e6)} ${a.unit_label}`
/** Kâr/zarar oranı (maliyete göre); maliyet yoksa `null` */
export function plRatio(pl: number, cost: number): number | null {
return cost > 0 ? pl / cost : null
}
/**
* Kâr/zarar yüzdesi: `+%4,1` · `-%0,6`; yuvarlanınca sıfırsa (ör. 9 kuruş) `null` → gösterilmez.
* İşaret yüzde işaretinin önünde (Türkçe yazımda % sayıdan önce gelir).
*/
export function formatPlPercent(ratio: number | null): string | null {
if (ratio === null || Math.abs(ratio) < 0.0005) return null
return `${ratio > 0 ? '+' : '-'}${formatPercent(Math.abs(ratio))}`
}
/** Otomatik kur çekimi bu kadar saattir yoksa kaynak sorunlu sayılır */
export const STALE_HOURS = 6
export function isFetchStale(lastFetch: string | null, now: Date): boolean {
if (!lastFetch) return true
return now.getTime() - new Date(lastFetch).getTime() > STALE_HOURS * 3_600_000
}
/** Kurun tarihi bugünden kaç gün önce (aynı gün 0). TCMB hafta sonu yayımlamaz → 3 güne kadar normal */
export function rateAgeDays(rateDate: string, today: string): number {
return rateDate >= today ? 0 : daysInclusive(rateDate, today) - 1
}
/** Elde olan ya da geçmişi olan varlıklar önce; geri kalanı "diğer" */
export function splitAssets(items: PortfolioItem[]): {
held: PortfolioItem[]
others: PortfolioItem[]
} {
const held = items.filter((i) => i.asset.quantity_e6 > 0 || i.asset.tx_count > 0)
const others = items.filter((i) => !held.includes(i) && i.asset.is_active)
return { held, others }
}
export type Slice = { code: string; name: string; value: number; share: number }
/** Değere göre dağılım (yalnızca değeri olanlar), büyükten küçüğe */
export function allocation(p: Pick<Portfolio, 'items'>): Slice[] {
const valued = p.items.filter((i) => (i.value ?? 0) > 0)
const total = valued.reduce((s, i) => s + (i.value ?? 0), 0)
return valued
.map((i) => ({
code: i.asset.code,
name: i.asset.name,
value: i.value ?? 0,
share: total > 0 ? (i.value ?? 0) / total : 0,
}))
.sort((a, b) => b.value - a.value)
}
+85
View File
@@ -404,6 +404,91 @@ export const t = {
noData: 'Henüz harcama yok.',
},
assets: {
totalValue: 'Toplam değer',
cost: 'Maliyet',
unrealized: 'Kâr / zarar',
unrealizedHint: 'Eldekilerin güncel değeri − maliyeti',
realized: 'Gerçekleşen kâr / zarar',
realizedHint: 'Satışlardan',
ratesAt: (when: string) => `Kurlar: ${when}`,
staleFetch:
'Kurlar bir süredir güncellenemedi; son alınan kurlar gösteriliyor. Gerekirse elle kur girebilirsin.',
noFetch:
'Henüz otomatik kur alınamadı. Değer görmek için varlığın sayfasından elle kur girebilirsin.',
unpriced: (n: number) => `${n} varlığın kuru yok; toplam değere katılmadı.`,
allocationTitle: 'Dağılım',
allocationSummary: (parts: string) => `Birikimlerin dağılımı: ${parts}.`,
listTitle: 'Varlıklar',
othersTitle: (n: number) => `Diğer varlıklar (${n})`,
othersHint: 'İlk alımını eklemek için bir varlık seç.',
empty: 'Henüz birikim kaydı yok. Aşağıdan bir varlık seçip ilk alımını ekle.',
avgCost: (x: string) => `ort. ${x}`,
noRate: 'Kur yok',
rateLine: (rate: string, source: string, date: string) => `${rate} · ${source} · ${date}`,
source: { tcmb: 'TCMB', xaus: 'xaus.com', manual: 'elle' },
rateOld: (days: number) => `${days} gün önceki kur`,
inactive: 'Pasif',
back: 'Birikimler',
holding: 'Eldeki',
value: 'Güncel değer',
txTitle: 'Alım / satım ekle',
editTx: 'İşlemi düzenle',
historyTitle: 'Geçmiş',
historyEmpty: 'Bu varlıkta henüz işlem yok.',
fromClosing: 'Dönem kapanışından',
buy: 'Alım',
sell: 'Satım',
type: 'İşlem',
date: 'Tarih',
quantity: 'Miktar',
unitPrice: 'Birim fiyat',
unitPriceHint: 'Boş bırakırsan tutardan hesaplanır',
total: 'Tutar',
totalHint: 'Ödediğin ya da aldığın gerçek tutar (komisyon, işçilik dahil)',
note: 'Not',
save: 'Kaydet',
saving: 'Kaydediliyor…',
update: 'Güncelle',
cancel: 'Vazgeç',
edit: (what: string) => `${what} işlemini düzenle`,
remove: 'Sil',
confirmRemove: 'Emin misin? Sil',
saved: 'İşlem kaydedildi',
updated: 'İşlem güncellendi',
deleted: 'İşlem silindi',
held: (x: string) => `Eldeki: ${x}`,
tooMuch: (x: string) => `Eldekinden fazla satılamaz (${x})`,
quantityRequired: 'Miktarı yazın',
quantityInvalid: 'Geçerli bir miktar yazın (ör. 5,5)',
quantityPositive: 'Miktar sıfırdan büyük olmalı',
priceInvalid: 'Geçerli bir fiyat yazın (ör. 6.250,50)',
noteTooLong: 'Not en fazla 200 karakter olabilir',
dateInvalid: 'Geçerli bir tarih seçin',
cashHint: 'TL birikimlerinde yalnızca tutar yeterli.',
rateTitle: 'Kur',
manualRate: 'Elle kur gir',
manualRateHint:
'Bugün için geçerli olur (ör. kuyumcu fiyatı). Ertesi gün otomatik kur yeniden geçerli olur.',
rateLabel: 'Birim başına kur',
setRate: 'Kuru kaydet',
removeManual: 'Elle girilen kuru kaldır',
rateSaved: 'Kur kaydedildi',
rateRemoved: 'Otomatik kura dönüldü',
addCurrency: 'Döviz ekle',
code: 'Kod',
codeHint: 'TCMB kodu, ör. GBP, CHF',
codeInvalid: 'Kod 3 harf olmalı (ör. GBP)',
name: 'Ad',
nameRequired: 'Adını yazın',
unitLabel: 'Birim (isteğe bağlı)',
currencyAdded: (name: string) => `${name} eklendi`,
deactivate: 'Pasife al',
activate: 'Etkinleştir',
statusHint: 'Pasif varlık listede kalır ama yeni işlem alınmaz.',
notFound: 'Varlık bulunamadı.',
},
periods: {
picker: 'Dönem',
status: { open: 'Açık', closed: '🔒 Kapalı', correcting: '✏️ Düzeltmede' },
+2
View File
@@ -14,6 +14,7 @@ import { PlanningPage } from '@/features/planning/page'
import { ReportsPage } from '@/features/reports/page'
import { ClosePeriodPage } from '@/features/review/close-page'
import { ReviewPage } from '@/features/review/page'
import { AssetPage } from '@/features/savings/asset-page'
import { SavingsPage } from '@/features/savings/page'
import { SettingsPage } from '@/features/settings/page'
@@ -38,6 +39,7 @@ const routes: RouteObject[] = [
{ path: paths.planning, element: <PlanningPage /> },
{ path: paths.periodWizard, element: <PeriodWizardPage /> },
{ path: paths.savings, element: <SavingsPage /> },
{ path: paths.asset, element: <AssetPage /> },
{ path: paths.review, element: <ReviewPage /> },
{ path: paths.closePeriod, element: <ClosePeriodPage /> },
{ path: paths.reports, element: <ReportsPage /> },