feat(plan): ilk dönem sihirbazı (dönem, gelir, gider, birikim, bütçe)

- /plan/donem-ac?adim=1..5: her adım hemen kaydedilir, adım adreste
- Panel ve Plan'da açık dönem yoksa "Dönemi başlat" kartı
- MoneyInput (sayısal klavye, Türkçe biçim), moneyText/toKurus, period-dates
- lib/periods.ts: dönem, plan kalemi, özet, bütçe ve öneri kancaları
- SummaryTiles, PageLoading/PageError; 13 yeni vitest

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 08:16:32 +03:00
parent 95231cd4d6
commit ca0d3a505c
26 changed files with 1594 additions and 7 deletions
+44
View File
@@ -0,0 +1,44 @@
/**
* Formlarda tutar alanı: kullanıcı metin yazar (`1.250,50`), doğrulama `parseMoney` ile yapılır.
* Gönderirken `toKurus(metin)` ile kuruşa çevrilir.
*/
import { z } from 'zod'
import { parseMoney } from './format'
/** Backend ile aynı üst sınır (`services/period.rs` → `MAX_AMOUNT`): 100 milyar ₺ */
export const MAX_AMOUNT_KURUS = 10_000_000_000_000
export const moneyMessages = {
required: 'Tutarı yazın',
invalid: 'Geçerli bir tutar yazın (ör. 1.250,50)',
positive: 'Tutar sıfırdan büyük olmalı',
negative: 'Tutar eksi olamaz',
tooLarge: 'Tutar çok büyük',
}
/**
* - `positive`: 0'dan büyük olmalı (gelir/gider kalemi)
* - `optional`: boş bırakılabilir → 0 sayılır (birikim hedefi, bütçe)
*/
export function moneyText(mode: 'positive' | 'optional') {
return z
.string()
.trim()
.superRefine((value, ctx) => {
if (value === '') {
if (mode === 'positive') ctx.addIssue({ code: 'custom', message: moneyMessages.required })
return
}
const k = parseMoney(value)
if (k === null) return ctx.addIssue({ code: 'custom', message: moneyMessages.invalid })
if (k < 0) return ctx.addIssue({ code: 'custom', message: moneyMessages.negative })
if (mode === 'positive' && k === 0)
return ctx.addIssue({ code: 'custom', message: moneyMessages.positive })
if (k > MAX_AMOUNT_KURUS) ctx.addIssue({ code: 'custom', message: moneyMessages.tooLarge })
})
}
/** Doğrulanmış metin → kuruş (boş → 0) */
export function toKurus(value: string): number {
return parseMoney(value) ?? 0
}