diff --git a/docs/MEMORY.md b/docs/MEMORY.md index 0087041..f8fe823 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -1,7 +1,7 @@ # 🧠 Proje Hafızası ## ▶️ Sıradaki Adım -**Faz 8 – Adım 3:** Birikim sayfası (`/birikim`, `features/savings/`): `usePortfolio` (`GET /portfolio`) → özet (toplam değer, maliyet, gerçekleşmemiş + gerçekleşen kâr/zarar, `unpriced` uyarısı, son kur çekimi + eski kur uyarısı) + halka grafik (Recharts `PieChart`, lazy; renkler ham CSS değişkeni), varlık listesi (miktar `formatQuantity`, ortalama maliyet, kur + kaynak/tarih, değer, kâr/zarar ve %), varlık detayı: işlem geçmişi + alım/satım formu (miktar × birim fiyat → tutar önerisi, düzenlenebilir; TL'de yalnız tutar) + düzenle/sil, elle kur girme/silme, döviz ekleme, pasife alma. Kayıt/kur değişince `portfolio` + `assets` + `stats` yenilensin. Miktar/kur alanları için e6 metin ⇄ sayı yardımcıları (`lib/`) + test. +**Faz 8 – Adım 4:** Kapanış entegrasyonu. Backend: `POST /periods/{id}/to-savings` (ya da kapanışta `add_to_savings: true`) → TL varlığına `buy` (`source_period_id`, tutar = `to_savings`, tarih = dönem son günü), **dönem başına tek** (tekrar → 409; yeniden kapatmada tutar değiştiyse kaydı güncelle); `GET /savings/pending` = kapalı, `to_savings > 0`, kaydı olmayan dönemler. Frontend: kapatma sihirbazı sonucunda "Birikime ekle" (onay) + Birikim sayfasında "Bekleyen birikimler" listesi; Panel "Toplam birikim" → `portfolio.value_total` (artık `stats.total_saved` değil; açıklama güncellenmeli). Yeniden açılıp tekrar kapanan dönem: bağlı alım güncellenir mi silinir mi → karar ver/sor. **Bekleyen (kullanıcıda):** Faz 5 – Adım 5 canlı yedek kurulumu (`docs/DEPLOY.md` §6.1–6.3) → çıktılar gelince Faz 5 ✅. ## 📌 Alınan Kararlar @@ -127,3 +127,4 @@ ### 2026-11-13 — Faz 8 - **Adım 1:** Varlıklar backend: `models/asset.rs` (`AssetKind` cash/gold/fx, `TxType` buy/sell), `services/holding.rs` (saf: ağırlıklı ortalama maliyet, tam satışta artık maliyet sıfırlanır, `Oversold`, i128 `mul_div`; 5 birim test), `services/asset.rs` (liste + hesaplanmış durum, döviz ekleme 3 harf/tekil, ad/aktiflik, işlem ekle/düzenle/sil; her yazmada tarih sıralı yeniden denetim; TL: miktar = tutar), `routes/asset.rs` (7 uç nokta). Varsayılan 8 varlık `seed::assets` (kayıtta) + **migration v2** (eski haneler, idempotent → canlıdaki hane uygulama yeniden başlayınca alır). `tests/assets.rs` (4). Mutasyon 3/3 yakalandı. Backend 135 test. - **Adım 2:** Kurlar: `reqwest` 0.12 (`rustls-tls` → MongoDB ile aynı `ring`; 0.13 `aws-lc-rs` getiriyordu), `quick-xml` (serialize). `services/rate_sources.rs` (saf: `parse_tcmb` ForexBuying ÷ Unit, boş alış atlanır, `decimal_e6` kayan noktasız; `parse_gold` xaus `per_gram_usd`; `gold_rates` has gram katsayıları; testler örnek metinle), `services/rates.rs` (`refresh`: iki kaynak bağımsız, TCMB düşerse DB'deki son USD; gün başına upsert; `spawn_refresher` main'de, `RATES_REFRESH_MINUTES` varsayılan 60 / 0 kapalı; `current`: aggregation kod×(oto|hane) en yeni → en yeni tarih, eşitlikte manuel; `set_manual`/`delete_manual`; `portfolio` + `value_of` yarım yukarı). `GET /portfolio`, `POST /rates`, `DELETE /rates/{id}`. `calendar::today_tr` (UTC+3). Testler sahte axum sunucusuyla (internet yok): `tests/rates.rs` (2) + birim (4). Mutasyon: öncelik, USD yedeği yakalandı; yuvarlama kaçtı → `value_of` birim testi eklendi, artık yakalıyor. Gerçek kaynak denemesi (test DB): TCMB 22 döviz, altın 5 kod (gram 6.253,82, çeyrek 10.024,88 ₺). Backend 145 test. DEPLOY §2 + `.env.example`: `RATES_REFRESH_MINUTES`, konteynerin internete çıkışı gerekir. +- **Adım 3:** Birikim sayfası (`features/savings/`): `page.tsx` (özet: toplam değer + son kur zamanı, maliyet, kâr/zarar `stacked`, gerçekleşen; uyarılar: kur hiç yok / 6 saattir çekilemedi / kursuz varlık; dağılım halka grafiği `allocation-chart.tsx` lazy 6 KB, renkli liste = metin özeti; varlık listesi: elde/geçmişi olanlar + `
` "Diğer varlıklar"; `add-currency.tsx`), `asset-page.tsx` (`/birikim/:id`: durum kartları, `RateBox` elle kur gir/kaldır, `TxFormView` `tx-form.tsx` alım/satım: miktar × birim fiyat → tutar önerisi, elle değişen tutar korunur, yeni alımda birim fiyat = güncel kur (kuruşa yuvarlı), TL'de yalnız tutar, satışta eldeki sınırı; geçmiş: düzenle + iki adımlı sil; pasife al/etkinleştir). `lib/decimal.ts` (`parseE6`: virgül ondalık, tek nokta + 3 rakam binlik, `formatE6`, `formatRate`, `valueKurus` BigInt = backend), `lib/savings.ts` (`plRatio`, `formatPlPercent` `-%0,6` ve ≈0 gizli, `isFetchStale` 6 saat, `rateAgeDays`, `splitAssets`, `allocation`, `kindEmoji`, `qty`), `lib/assets.ts` (kancalar, hepsi `['portfolio']` yeniler), `components/decimal-input.tsx`, `formatDateTime`. Frontend 114 test; e2e 37 kontrol (kurlar DB'ye elle, `RATES_REFRESH_MINUTES=0`). Düzeltilen: `%-0,0`, TL satırında tekrar, mobil özet taşması; Tailwind keyfi alt seçici (`[&>*]`) `lg:` ile ezilemedi → sınıflar öğenin kendisine. diff --git a/docs/PHASES.md b/docs/PHASES.md index b2db3b2..b103245 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -75,5 +75,5 @@ ## Faz 8 Adımları - ✅ 1. Backend: varlıklar (hane başına varsayılanlar: TL, gram/çeyrek/yarım/tam altın, ons, USD, EUR; döviz eklenebilir) + alım/satım kayıtları (satış ≤ eldeki miktar) + varlık başına miktar, **ağırlıklı ortalama maliyet**, gerçekleşen kâr/zarar; testler - ✅ 2. Backend: kurlar — TCMB `today.xml` (döviz) + xaus.com ons fiyatı × TCMB USD (altın; çeyrek/yarım/tam has gram katsayısıyla), saatlik arka plan görevi + önbellek (`exchange_rates`), kaynak erişilemezse son kur + zamanı; **manuel kur** (hane); portföy değerleme uç noktası (güncel değer, kâr/zarar tutar ve %, toplamlar, dağılım); kaynak ayrıştırıcıları örnek dosyalarla testli -- ⏳ 3. Frontend: Birikim sayfası — portföy özeti + halka grafik, varlık listesi (miktar, ortalama maliyet, güncel kur, değer, kâr/zarar), alım/satım formu, işlem geçmişi (düzenle/sil), manuel kur, eski kur uyarısı +- ✅ 3. Frontend: Birikim sayfası — portföy özeti + halka grafik, varlık listesi (miktar, ortalama maliyet, güncel kur, değer, kâr/zarar), alım/satım formu, işlem geçmişi (düzenle/sil), manuel kur, eski kur uyarısı - ⏳ 4. Kapanış entegrasyonu: birikime aktarılan tutar onayla TL varlığına eklenir (`source_period_id`, tekrar eklenmez); geçmiş kapanışlar için "bekleyen birikimler" listesi; Panel "Toplam birikim" → portföyün güncel değeri diff --git a/frontend/src/components/decimal-input.tsx b/frontend/src/components/decimal-input.tsx new file mode 100644 index 0000000..f2d19f2 --- /dev/null +++ b/frontend/src/components/decimal-input.tsx @@ -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, '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 ( +
+ 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` }} + /> + + {suffix} + +
+ ) +} diff --git a/frontend/src/components/layout/nav-items.ts b/frontend/src/components/layout/nav-items.ts index 8d6c62a..4dceb09 100644 --- a/frontend/src/components/layout/nav-items.ts +++ b/frontend/src/components/layout/nav-items.ts @@ -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 } diff --git a/frontend/src/features/savings/add-currency.tsx b/frontend/src/features/savings/add-currency.tsx new file mode 100644 index 0000000..a8c0959 --- /dev/null +++ b/frontend/src/features/savings/add-currency.tsx @@ -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({ + resolver: zodResolver(currencySchema), + mode: 'onTouched', + defaultValues: { code: '', name: '', unit_label: '' }, + }) + + if (!open) { + return ( + + ) + } + + 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 ( +
+

+ {s.addCurrency} +

+
+
+ + {(f) => } + + + {(f) => } + + + {(f) => } + +
+ +
+ + +
+ +
+ ) +} diff --git a/frontend/src/features/savings/allocation-chart.tsx b/frontend/src/features/savings/allocation-chart.tsx new file mode 100644 index 0000000..97dc63c --- /dev/null +++ b/frontend/src/features/savings/allocation-chart.tsx @@ -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 }) { + const slices = allocation(p) + const color = (i: number) => COLORS[i % COLORS.length] + return ( +
+

{s.allocationTitle}

+
+ + + 1 ? 2 : 0} + stroke="var(--card)" + isAnimationActive={false} + > + {slices.map((x, i) => ( + + ))} + + (typeof v === 'number' ? formatMoney(v) : '—')} + contentStyle={{ + background: 'var(--popover)', + color: 'var(--popover-foreground)', + border: '1px solid var(--border)', + borderRadius: 12, + fontSize: 13, + }} + /> + + +
+
+
    + {slices.map((x, i) => ( +
  • + + {x.name} + {formatPercent(x.share)} + {formatMoney(x.value)} +
  • + ))} +
+
+
+ ) +} diff --git a/frontend/src/features/savings/asset-page.tsx b/frontend/src/features/savings/asset-page.tsx new file mode 100644 index 0000000..62927bd --- /dev/null +++ b/frontend/src/features/savings/asset-page.tsx @@ -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 ( +
+ + + {s.back} + + {portfolio.isError ? ( + + ) : !portfolio.data ? ( + + ) : !item ? ( +

+ {s.notFound} +

+ ) : ( + + )} +
+ ) +} + +function AssetView({ item }: { item: PortfolioItem }) { + const a = item.asset + const [today] = useState(() => toIsoDate(new Date())) + const [editing, setEditing] = useState(null) + const formRef = useRef(null) + const cash = a.kind === 'cash' + + return ( +
+ +
+ {qty(a)} + {item.value === null ? '—' : formatMoney(item.value)} + {formatMoney(a.cost)} + + {item.unrealized_pl === null ? '—' : } + +
+ {a.realized_pl !== 0 && ( +

+ {s.realized}: +

+ )} + + {!cash && } + + {a.is_active || editing ? ( +
+
+ setEditing(null)} + /> +
+
+ ) : null} + + { + setEditing(tx) + formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + }} + onDeleted={(id) => editing?.id === id && setEditing(null)} + /> + {!cash && } +
+ ) +} + +function Stat({ label, children }: { label: string; children: ReactNode }) { + return ( +
+
{label}
+
{children}
+
+ ) +} + +/** 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({ + 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 ( +
+

+ +

+ {open ? ( +
+ ( + + {(f) => ( + + )} + + )} + /> + +
+ + +
+ + ) : ( +
+ + {manual && ( + + )} +
+ )} +
+ ) +} + +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(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 ( +
+ {txs.isError ? ( + + ) : !txs.data ? ( + + ) : txs.data.length === 0 ? ( +

{s.historyEmpty}

+ ) : ( +
    + {txs.data.map((tx) => { + const label = tx.type === 'buy' ? s.buy : s.sell + return ( +
  • + + {label} + +
    +

    + {asset.kind === 'cash' ? formatMoney(tx.total) : qty(asset, tx.quantity_e6)} +

    +

    + {formatDate(tx.date)} + {asset.kind !== 'cash' && ` · ${formatMoney(tx.total)}`} + {tx.source_period_id && ` · ${s.fromClosing}`} + {tx.note && ` · ${tx.note}`} +

    +
    +
    + + {confirming === tx.id ? ( + + ) : ( + + )} +
    +
  • + ) + })} +
+ )} +
+ ) +} + +function StatusToggle({ asset }: { asset: Asset }) { + const update = useUpdateAsset() + return ( +
+ + {s.statusHint} +
+ ) +} diff --git a/frontend/src/features/savings/page.tsx b/frontend/src/features/savings/page.tsx index f7e867e..2b7f963 100644 --- a/frontend/src/features/savings/page.tsx +++ b/frontend/src/features/savings/page.tsx @@ -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 ( <> - + {portfolio.isError ? ( + + ) : !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 ( +
+ + {p.last_fetch === null ? ( + {s.noFetch} + ) : isFetchStale(p.last_fetch, now) ? ( + {s.staleFetch} + ) : null} + {p.unpriced > 0 && {s.unpriced(p.unpriced)}} + +
+ {hasValue && ( + }> + + + )} +
+

+ {s.listTitle} +

+ {held.length === 0 ? ( +

{s.empty}

+ ) : ( +
    + {held.map((i) => ( + + ))} +
+ )} +
+
+ + {others.length > 0 && ( +
+ + {s.othersTitle(others.length)} + + +

{s.othersHint}

+
    + {others.map((i) => ( + + ))} +
+
+ )} + + +
+ ) +} + +function Summary({ p }: { p: Portfolio }) { + return ( +
+
+

{s.totalValue}

+

{formatMoney(p.value_total)}

+ {p.last_fetch && ( +

+ {s.ratesAt(formatDateTime(p.last_fetch))} +

+ )} +
+ + {formatMoney(p.cost_total)} + + + + + {/* Mobilde 3. küçük kart tek kalmasın: tam genişlik */} + + + +
+ ) +} + +function Tile({ + label, + hint, + className, + children, +}: { + label: string + hint?: string + className?: string + children: ReactNode +}) { + return ( +
+

{label}

+

{children}

+ {hint &&

{hint}

} +
+ ) +} + +function AssetRow({ + item: i, + today, + compact, +}: { + item: PortfolioItem + today: string + compact?: boolean +}) { + const a = i.asset + return ( +
  • + + + {kindEmoji[a.kind]} + +
    +

    + {a.name} + {!a.is_active && ( + + {s.inactive} + + )} +

    + {!compact && a.kind !== 'cash' && ( +

    + {qty(a)} + {a.avg_cost_e6 !== null && ( + <> · {s.avgCost(formatMoney(Math.round(a.avg_cost_e6 / 10_000)))} + )} +

    + )} + {a.kind !== 'cash' && ( +

    + +

    + )} +
    + {!compact && ( +
    +

    {i.value === null ? '—' : formatMoney(i.value)}

    + {i.unrealized_pl !== null && a.quantity_e6 > 0 && a.kind !== 'cash' && ( +

    + +

    + )} +
    + )} + + +
  • + ) +} diff --git a/frontend/src/features/savings/parts.tsx b/frontend/src/features/savings/parts.tsx new file mode 100644 index 0000000..2c61e14 --- /dev/null +++ b/frontend/src/features/savings/parts.tsx @@ -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 ( + 0 ? 'text-income' : pl < 0 ? 'text-expense' : 'text-muted-foreground', + className, + )} + > + {formatMoney(pl, 'TRY', { signed: true })} + {pct && (stacked ? {pct} : ` (${pct})`)} + + ) +} + +/** `6.253,8241 ₺ · xaus.com · 13.11` (+ eskiyse uyarı rengi) */ +export function RateLine({ rate, today }: { rate: Rate | null; today: string }) { + if (!rate) return {s.noRate} + const old = rateAgeDays(rate.rate_date, today) > 3 + return ( + + {s.rateLine(formatRate(rate.rate_e6), s.source[rate.source], formatDayMonth(rate.rate_date))} + {old && ` · ${s.rateOld(rateAgeDays(rate.rate_date, today))}`} + + ) +} + +export function Notice({ + tone = 'info', + children, +}: { + tone?: 'info' | 'warning' + children: ReactNode +}) { + const Icon = tone === 'warning' ? TriangleAlert : Info + return ( +

    + + {children} +

    + ) +} diff --git a/frontend/src/features/savings/schema.test.ts b/frontend/src/features/savings/schema.test.ts new file mode 100644 index 0000000..ae04be7 --- /dev/null +++ b/frontend/src/features/savings/schema.test.ts @@ -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) + }) +}) diff --git a/frontend/src/features/savings/schema.ts b/frontend/src/features/savings/schema.ts new file mode 100644 index 0000000..6c91969 --- /dev/null +++ b/frontend/src/features/savings/schema.ts @@ -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> + +/** 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 + +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 diff --git a/frontend/src/features/savings/tx-form.tsx b/frontend/src/features/savings/tx-form.tsx new file mode 100644 index 0000000..2cba1d1 --- /dev/null +++ b/frontend/src/features/savings/tx-form.tsx @@ -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({ + 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 ( +
    + {!tx && ( + ( + + )} + /> + )} + {cash &&

    {s.cashHint}

    } +
    + {!cash && ( + <> + ( + + {(f) => ( + { + field.onChange(x) + suggestTotal() + }} + onBlur={field.onBlur} + /> + )} + + )} + /> + ( + + {(f) => ( + { + field.onChange(x) + suggestTotal() + }} + onBlur={field.onBlur} + /> + )} + + )} + /> + + )} + ( + + {(f) => ( + { + setTotalTouched(true) + field.onChange(x) + }} + onBlur={field.onBlur} + placeholder="0,00" + /> + )} + + )} + /> + + {(f) => } + +
    + + {(f) => } + + +
    + {tx && ( + + )} + +
    + + ) +} diff --git a/frontend/src/lib/assets.ts b/frontend/src/lib/assets.ts new file mode 100644 index 0000000..4180f51 --- /dev/null +++ b/frontend/src/lib/assets.ts @@ -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'), + }) +} + +export function useAssetTxs(assetId: string) { + return useQuery({ + queryKey: queryKeys.assetTxs(assetId), + queryFn: () => api.get(`/assets/${assetId}/transactions`), + }) +} + +/** Ortak: başarı ya da hata sonrası portföyü yenile (hata: başkası değiştirmiş olabilir) */ +function useAssetMutation(fn: (v: V) => Promise) { + const qc = useQueryClient() + return useMutation({ + mutationFn: fn, + onSettled: () => qc.invalidateQueries({ queryKey: queryKeys.portfolio }), + }) +} + +export const useCreateTx = (assetId: string) => + useAssetMutation((body: AssetTxCreateRequest) => + api.post(`/assets/${assetId}/transactions`, body), + ) + +export const useUpdateTx = () => + useAssetMutation(({ id, ...body }: AssetTxUpdateRequest & { id: string }) => + api.patch(`/asset-transactions/${id}`, body), + ) + +export const useDeleteTx = () => + useAssetMutation((id: string) => api.delete(`/asset-transactions/${id}`)) + +export const useCreateAsset = () => + useAssetMutation((body: AssetCreateRequest) => api.post('/assets', body)) + +export const useUpdateAsset = () => + useAssetMutation(({ id, ...body }: AssetUpdateRequest & { id: string }) => + api.patch(`/assets/${id}`, body), + ) + +export const useSetRate = () => + useAssetMutation((body: RateCreateRequest) => api.post('/rates', body)) + +export const useDeleteRate = () => useAssetMutation((id: string) => api.delete(`/rates/${id}`)) diff --git a/frontend/src/lib/decimal.test.ts b/frontend/src/lib/decimal.test.ts new file mode 100644 index 0000000..fbccc6f --- /dev/null +++ b/frontend/src/lib/decimal.test.ts @@ -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) + }) +}) diff --git a/frontend/src/lib/decimal.ts b/frontend/src/lib/decimal.ts new file mode 100644 index 0000000..964efc4 --- /dev/null +++ b/frontend/src/lib/decimal.ts @@ -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) +} diff --git a/frontend/src/lib/format.test.ts b/frontend/src/lib/format.test.ts index 8114391..5988ab3 100644 --- a/frontend/src/lib/format.test.ts +++ b/frontend/src/lib/format.test.ts @@ -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') }) }) diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index 26b1832..9dc2a8c 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -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') diff --git a/frontend/src/lib/query.ts b/frontend/src/lib/query.ts index dc50c06..73f66c4 100644 --- a/frontend/src/lib/query.ts +++ b/frontend/src/lib/query.ts @@ -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, diff --git a/frontend/src/lib/savings.test.ts b/frontend/src/lib/savings.test.ts new file mode 100644 index 0000000..e9794a4 --- /dev/null +++ b/frontend/src/lib/savings.test.ts @@ -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 }, + ]) + }) +}) diff --git a/frontend/src/lib/savings.ts b/frontend/src/lib/savings.ts new file mode 100644 index 0000000..c0c8020 --- /dev/null +++ b/frontend/src/lib/savings.ts @@ -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 = { cash: '🇹🇷', gold: '🥇', fx: '💱' } + +/** `10 gr`, `1,5 adet`, `100 $` */ +export const qty = (a: Pick, 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): 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) +} diff --git a/frontend/src/lib/strings.ts b/frontend/src/lib/strings.ts index f81b98b..ebe2e37 100644 --- a/frontend/src/lib/strings.ts +++ b/frontend/src/lib/strings.ts @@ -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' }, diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 3ea2064..5579651 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -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: }, { path: paths.periodWizard, element: }, { path: paths.savings, element: }, + { path: paths.asset, element: }, { path: paths.review, element: }, { path: paths.closePeriod, element: }, { path: paths.reports, element: },