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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}`))
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 /> },
|
||||
|
||||
Reference in New Issue
Block a user