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:
@@ -70,6 +70,7 @@ npm run icons # frontend/brand/*.svg → favicon, PWA, Apple, Tauri ikonl
|
|||||||
- Yönlendirme: `RequireAuth` (oturum yok → `/giris?donus=`), `RequireGuest` (oturum var → `donus` ya da panel; formlar ayrıca navigate etmez). `donus` daima `safeReturnPath` ile.
|
- Yönlendirme: `RequireAuth` (oturum yok → `/giris?donus=`), `RequireGuest` (oturum var → `donus` ya da panel; formlar ayrıca navigate etmez). `donus` daima `safeReturnPath` ile.
|
||||||
- Geliştirmede tasarım önizlemesi: `/dev/tasarim`.
|
- Geliştirmede tasarım önizlemesi: `/dev/tasarim`.
|
||||||
- Formlar: react-hook-form + zod (`mode: 'onTouched'`), alanlar `components/form-field.tsx` (`FormField`, `PasswordInput`, `FormError`); sunucu hatası `setError` ile ilgili alana. Bildirim: `toast` (sonner, `components/toaster.tsx`).
|
- Formlar: react-hook-form + zod (`mode: 'onTouched'`), alanlar `components/form-field.tsx` (`FormField`, `PasswordInput`, `FormError`); sunucu hatası `setError` ile ilgili alana. Bildirim: `toast` (sonner, `components/toaster.tsx`).
|
||||||
|
- Dönem verisi: `lib/periods.ts` (Query kancaları), tarih `lib/period-dates.ts` (arayüz son günü dahil gösterir, backend `end_date` hariç), tutar alanı `MoneyInput` + `moneyText`/`toKurus` (`lib/money-schema.ts`). İlk dönem sihirbazı `/plan/donem-ac?adim=`.
|
||||||
- Grup tanımları: `lib/groups.ts`; tema: `lib/theme.tsx` (+ `index.html` içindeki ilk-boya betiği); biçimlendirme: `lib/format.ts`.
|
- Grup tanımları: `lib/groups.ts`; tema: `lib/theme.tsx` (+ `index.html` içindeki ilk-boya betiği); biçimlendirme: `lib/format.ts`.
|
||||||
- API örnekleri: `backend/api.http`
|
- API örnekleri: `backend/api.http`
|
||||||
- Canlı: `Dockerfile` (tek imaj), `docs/DEPLOY.md` (ortam değişkenleri, Dokploy, MongoDB, yedek). `routes::app()` = API + `STATIC_DIR` varsa SPA (`routes/spa.rs`) + sıkıştırma + TraceLayer. Testler `routes::router()` kullanır.
|
- Canlı: `Dockerfile` (tek imaj), `docs/DEPLOY.md` (ortam değişkenleri, Dokploy, MongoDB, yedek). `routes::app()` = API + `STATIC_DIR` varsa SPA (`routes/spa.rs`) + sıkıştırma + TraceLayer. Testler `routes::router()` kullanır.
|
||||||
|
|||||||
+3
-1
@@ -1,7 +1,7 @@
|
|||||||
# 🧠 Proje Hafızası
|
# 🧠 Proje Hafızası
|
||||||
|
|
||||||
## ▶️ Sıradaki Adım
|
## ▶️ Sıradaki Adım
|
||||||
**Faz 4 – Adım 4:** Frontend ilk dönem sihirbazı: açık dönem yoksa (Panel/Plan) → adımlar: 1) tarihler (varsayılan: içinde bulunulan ay, ya da önceki dönemin bitişi), 2) sabit gelirler, 3) sabit giderler (+ önceki dönemden kopyala varsa), 4) birikim hedefi, 5) bütçe dağıtımı (öneriyle) + özet. `lib/format.ts` parseMoney ile tutar girişi (inputMode=decimal). TanStack Query anahtarları: period/current, plan-items, summary, budget.
|
**Faz 4 – Adım 5:** Frontend Plan ekranı: görüntüleme modu (salt okunur) + "✏️ Düzenle" → Kaydet/Vazgeç; sabit gelir/gider listeleri (ekle/düzenle/sil, önceki dönemden kopyala), birikim hedefi, `SummaryTiles`, bütçe dağıtımı (aylık + haftalık döküm, hafta elle ayarlama/otomatiğe dönme, `weekly_over` uyarısı). Şu an `features/planning/page.tsx` geçici görünüm (sihirbaz bağlantısı).
|
||||||
|
|
||||||
## 📌 Alınan Kararlar
|
## 📌 Alınan Kararlar
|
||||||
- **Yerel demo hesapları** (`kakeibo_dev`): `demo@ornek.com` / `demo12345` (owner, "Demo Hanesi") · `es@ornek.com` / `demo12345` (member, aynı hane). `npm run db:reset` silerse API ile yeniden oluştur (ileride `db:seed`).
|
- **Yerel demo hesapları** (`kakeibo_dev`): `demo@ornek.com` / `demo12345` (owner, "Demo Hanesi") · `es@ornek.com` / `demo12345` (member, aynı hane). `npm run db:reset` silerse API ile yeniden oluştur (ileride `db:seed`).
|
||||||
@@ -91,3 +91,5 @@
|
|||||||
- **Adım 1:** `services/calendar.rs` (parse/format, `weeks` Pazartesi başlangıçlı + kısmi haftalar, Türkçe ay adları; 4 test), `models/period.rs`, `services/period.rs` (2 test), `routes/period.rs` (list/current/create/get/patch; DTO'da `last_date`, `days`, `weeks`). `tests/periods.rs` (5) — çakışma kontrolü bilerek bozulunca test yakaladı. Backend toplam 58 test.
|
- **Adım 1:** `services/calendar.rs` (parse/format, `weeks` Pazartesi başlangıçlı + kısmi haftalar, Türkçe ay adları; 4 test), `models/period.rs`, `services/period.rs` (2 test), `routes/period.rs` (list/current/create/get/patch; DTO'da `last_date`, `days`, `weeks`). `tests/periods.rs` (5) — çakışma kontrolü bilerek bozulunca test yakaladı. Backend toplam 58 test.
|
||||||
- **Adım 2:** `models/plan_item.rs`, `models/note.rs`, `services/plan.rs` (list/create/update/delete/copy_previous), `services/note.rs`, `routes/plan.rs`, `routes/note.rs`, `period::get_open` + `closed()`. `tests/plan_notes.rs` (5): CRUD + doğrulama, kopyalama (göreli gün, 28 Şubat'a kırpma, tekrar yok), kapalı dönem salt okunur, not filtresi, izolasyon. Backend 72 test.
|
- **Adım 2:** `models/plan_item.rs`, `models/note.rs`, `services/plan.rs` (list/create/update/delete/copy_previous), `services/note.rs`, `routes/plan.rs`, `routes/note.rs`, `period::get_open` + `closed()`. `tests/plan_notes.rs` (5): CRUD + doğrulama, kopyalama (göreli gün, 28 Şubat'a kırpma, tekrar yok), kapalı dönem salt okunur, not filtresi, izolasyon. Backend 72 test.
|
||||||
- **Adım 3:** `services/money.rs` (`split_proportional` + 3 test; beklenen değer hatası testteydi, elle hesapla düzeltildi), `services/budget.rs` (summary, view, set_monthly, set_week, suggest), `routes/budget.rs` (5 uç nokta, 9 TS tipi). `tests/budget.rs` (6) — özet formülü bilerek bozulunca yakaladı. Backend 88 test.
|
- **Adım 3:** `services/money.rs` (`split_proportional` + 3 test; beklenen değer hatası testteydi, elle hesapla düzeltildi), `services/budget.rs` (summary, view, set_monthly, set_week, suggest), `routes/budget.rs` (5 uç nokta, 9 TS tipi). `tests/budget.rs` (6) — özet formülü bilerek bozulunca yakaladı. Backend 88 test.
|
||||||
|
- **Adım 4:** İlk dönem sihirbazı `/plan/donem-ac?adim=1..5` (`features/period-wizard/`): Dönem → Gelirler → Giderler → Birikim → Bütçe. Dönem 1. adımda açılır, her adım hemen kaydedilir; adım adreste (yenileme/geri tuşu çalışır). `lib/periods.ts` (Query kancaları; değişince `queryKeys.period(id)` öneki yenilenir), `lib/period-dates.ts` (son gün dahil ↔ backend bitiş hariç), `lib/money-schema.ts` (`moneyText`, `toKurus`), `components/money-input.tsx` (inputMode=decimal, blur'da `1.250,50`), `PageLoading`/`PageError`, `SummaryTiles`, `NoPeriodCard` (Panel + Plan). Frontend 60 test; CDP e2e 43 kontrol geçti, test verisi demo hanesinden silindi.
|
||||||
|
- Not: üretim JS paketi ~620 kB (gzip 195 kB) → Faz 10'da sayfa bazlı bölme (lazy route).
|
||||||
|
|||||||
+2
-2
@@ -49,6 +49,6 @@
|
|||||||
- ✅ 1. Backend: dönem (aç, açık dönem, liste, detay + haftalar, düzenle), takvim hesapları, testler
|
- ✅ 1. Backend: dönem (aç, açık dönem, liste, detay + haftalar, düzenle), takvim hesapları, testler
|
||||||
- ✅ 2. Backend: plan kalemleri (sabit gelir/gider, önceki dönemden kopyala) + notlar
|
- ✅ 2. Backend: plan kalemleri (sabit gelir/gider, önceki dönemden kopyala) + notlar
|
||||||
- ✅ 3. Backend: bütçe (aylık/haftalık, öneri), özet (harcanabilir tutar)
|
- ✅ 3. Backend: bütçe (aylık/haftalık, öneri), özet (harcanabilir tutar)
|
||||||
- 🔄 4. Frontend: ilk dönem sihirbazı
|
- ✅ 4. Frontend: ilk dönem sihirbazı
|
||||||
- ⏳ 5. Frontend: Plan ekranı (görüntüle/düzenle, özet kutucukları, bütçe dağıtımı)
|
- 🔄 5. Frontend: Plan ekranı (görüntüle/düzenle, özet kutucukları, bütçe dağıtımı)
|
||||||
- ⏳ 6. Frontend: notlar (Markdown, düzenle/önizle)
|
- ⏳ 6. Frontend: notlar (Markdown, düzenle/önizle)
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export const paths = {
|
|||||||
dashboard: '/',
|
dashboard: '/',
|
||||||
ledger: '/defter',
|
ledger: '/defter',
|
||||||
planning: '/plan',
|
planning: '/plan',
|
||||||
|
/** İlk dönem sihirbazı; `?adim=1..5` */
|
||||||
|
periodWizard: '/plan/donem-ac',
|
||||||
savings: '/birikim',
|
savings: '/birikim',
|
||||||
more: '/diger',
|
more: '/diger',
|
||||||
review: '/degerlendirme',
|
review: '/degerlendirme',
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { ComponentProps } from 'react'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { formatNumber, parseMoney } from '@/lib/format'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
type Props = Omit<ComponentProps<typeof Input>, 'value' | 'onChange' | 'type'> & {
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tutar alanı: telefonda sayısal klavye açar, sağda `₺` gösterir.
|
||||||
|
* Alandan çıkınca yazılanı Türkçe biçime getirir: `1250,5` → `1.250,50`.
|
||||||
|
* Değer metin olarak tutulur; kuruşa çevirme `toKurus` ile gönderirken yapılır.
|
||||||
|
*
|
||||||
|
* react-hook-form ile `Controller` üzerinden kullanılır (kontrollü bileşen).
|
||||||
|
*/
|
||||||
|
export function MoneyInput({ value, onChange, onBlur, 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 k = parseMoney(value)
|
||||||
|
if (k !== null && value.trim() !== '') onChange(formatNumber(k / 100))
|
||||||
|
onBlur?.(e)
|
||||||
|
}}
|
||||||
|
className={cn('money pr-9 text-right', className)}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
₺
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { Construction } from 'lucide-react'
|
import { Construction, Loader2, RotateCw } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
import { t } from '@/lib/strings'
|
import { t } from '@/lib/strings'
|
||||||
import { useDocumentTitle } from '@/lib/use-document-title'
|
import { useDocumentTitle } from '@/lib/use-document-title'
|
||||||
|
|
||||||
@@ -25,6 +26,35 @@ export function PageHeader({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sayfa içi yükleniyor göstergesi */
|
||||||
|
export function PageLoading() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className="flex items-center justify-center gap-2 p-10 text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Loader2 className="size-5 animate-spin" aria-hidden />
|
||||||
|
{t.common.loading}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Veri yüklenemedi: Türkçe mesaj + tekrar dene */
|
||||||
|
export function PageError({ message, onRetry }: { message: string; onRetry: () => void }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="flex flex-col items-center gap-3 rounded-xl border border-expense/30 bg-expense/5 p-8 text-center"
|
||||||
|
>
|
||||||
|
<p className="text-expense">{message}</p>
|
||||||
|
<Button variant="outline" onClick={onRetry}>
|
||||||
|
<RotateCw aria-hidden />
|
||||||
|
{t.common.retry}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** Henüz geliştirilmemiş ekranlar için yer tutucu. `when`: "Faz 4'te" gibi (Türkçe ek uyumu için tam ifade). */
|
/** Henüz geliştirilmemiş ekranlar için yer tutucu. `when`: "Faz 4'te" gibi (Türkçe ek uyumu için tam ifade). */
|
||||||
export function ComingSoon({ when }: { when: string }) {
|
export function ComingSoon({ when }: { when: string }) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { ComingSoon, PageHeader } from '@/components/page'
|
import { ComingSoon, PageHeader } from '@/components/page'
|
||||||
|
import { NoPeriodCard } from '@/features/period-wizard/no-period-card'
|
||||||
|
import { useCurrentPeriod } from '@/lib/periods'
|
||||||
import { t } from '@/lib/strings'
|
import { t } from '@/lib/strings'
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
|
const current = useCurrentPeriod()
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title={t.pages.dashboard.title} description={t.pages.dashboard.description} />
|
<PageHeader title={t.pages.dashboard.title} description={t.pages.dashboard.description} />
|
||||||
<ComingSoon when="Faz 7'de" />
|
{current.data === null ? <NoPeriodCard /> : <ComingSoon when="Faz 7'de" />}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { CalendarPlus } from 'lucide-react'
|
||||||
|
import { Link } from 'react-router'
|
||||||
|
import { paths } from '@/components/layout/nav-items'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { t } from '@/lib/strings'
|
||||||
|
|
||||||
|
const s = t.noPeriod
|
||||||
|
|
||||||
|
/** Açık dönem yokken Panel ve Plan'da görünür → sihirbaza götürür. */
|
||||||
|
export function NoPeriodCard() {
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col items-center gap-4 rounded-2xl border bg-card p-8 text-center">
|
||||||
|
<div className="grid size-14 place-items-center rounded-full bg-primary/10 text-primary">
|
||||||
|
<CalendarPlus className="size-7" aria-hidden />
|
||||||
|
</div>
|
||||||
|
<div className="max-w-md space-y-2">
|
||||||
|
<h2 className="text-lg font-semibold">{s.title}</h2>
|
||||||
|
<p className="text-muted-foreground">{s.description}</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild size="lg">
|
||||||
|
<Link to={paths.periodWizard}>{s.action}</Link>
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { useNavigate, useSearchParams } from 'react-router'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { paths } from '@/components/layout/nav-items'
|
||||||
|
import { PageError, PageHeader, PageLoading } from '@/components/page'
|
||||||
|
import { useCurrentPeriod } from '@/lib/periods'
|
||||||
|
import { t } from '@/lib/strings'
|
||||||
|
import { StepBudget } from './step-budget'
|
||||||
|
import { StepDates } from './step-dates'
|
||||||
|
import { StepItems } from './step-items'
|
||||||
|
import { StepSavings } from './step-savings'
|
||||||
|
import { Stepper } from './wizard-ui'
|
||||||
|
|
||||||
|
const s = t.wizard
|
||||||
|
const LAST_STEP = s.steps.length
|
||||||
|
|
||||||
|
/**
|
||||||
|
* İlk dönem sihirbazı: Dönem → Gelirler → Giderler → Birikim → Bütçe.
|
||||||
|
*
|
||||||
|
* Dönem 1. adımda açılır; sonraki her adım hemen kaydedilir. Adım adreste tutulur
|
||||||
|
* (`?adim=3`): sayfa yenilense ya da yarıda bırakılsa da kalınan yerden devam edilir,
|
||||||
|
* tarayıcının geri tuşu bir önceki adıma döner.
|
||||||
|
*/
|
||||||
|
export function PeriodWizardPage() {
|
||||||
|
const current = useCurrentPeriod()
|
||||||
|
const [params, setParams] = useSearchParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const header = <PageHeader title={s.title} description={s.description} />
|
||||||
|
if (current.isPending)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{header}
|
||||||
|
<PageLoading />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
if (current.isError) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{header}
|
||||||
|
<PageError message={current.error.message} onRetry={current.refetch} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const period = current.data
|
||||||
|
const requested = Number(params.get('adim'))
|
||||||
|
// Dönem yoksa yalnızca 1. adım; varsa adres yoksa 2. adımdan devam
|
||||||
|
const step = !period
|
||||||
|
? 1
|
||||||
|
: Number.isInteger(requested) && requested >= 1 && requested <= LAST_STEP
|
||||||
|
? requested
|
||||||
|
: 2
|
||||||
|
|
||||||
|
const go = (n: number) => {
|
||||||
|
setParams({ adim: String(n) })
|
||||||
|
window.scrollTo({ top: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl">
|
||||||
|
{header}
|
||||||
|
<Stepper current={step} />
|
||||||
|
{step === 1 && <StepDates period={period} onDone={() => go(2)} />}
|
||||||
|
{period && step === 2 && (
|
||||||
|
<StepItems period={period} kind="income" onBack={() => go(1)} onNext={() => go(3)} />
|
||||||
|
)}
|
||||||
|
{period && step === 3 && (
|
||||||
|
<StepItems period={period} kind="expense" onBack={() => go(2)} onNext={() => go(4)} />
|
||||||
|
)}
|
||||||
|
{period && step === 4 && (
|
||||||
|
<StepSavings period={period} onBack={() => go(3)} onNext={() => go(5)} />
|
||||||
|
)}
|
||||||
|
{period && step === 5 && (
|
||||||
|
<StepBudget
|
||||||
|
period={period}
|
||||||
|
onBack={() => go(4)}
|
||||||
|
onDone={() => {
|
||||||
|
toast.success(s.budget.done)
|
||||||
|
navigate(paths.planning)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { t } from '@/lib/strings'
|
||||||
|
import { datesSchema, itemSchema } from './schemas'
|
||||||
|
|
||||||
|
const s = t.wizard
|
||||||
|
|
||||||
|
function datesError(start: string, last: string, name = '') {
|
||||||
|
const r = datesSchema.safeParse({ start, last, name })
|
||||||
|
return r.success ? null : r.error.issues[0].message
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('datesSchema', () => {
|
||||||
|
it('accepts a calendar month and a salary-day period', () => {
|
||||||
|
expect(datesError('2026-12-01', '2026-12-31')).toBeNull()
|
||||||
|
expect(datesError('2026-12-15', '2027-01-14', 'Maaş dönemi')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects missing, reversed and too long ranges', () => {
|
||||||
|
expect(datesError('', '2026-12-31')).toBe(s.dates.startRequired)
|
||||||
|
expect(datesError('2026-12-01', '')).toBe(s.dates.lastRequired)
|
||||||
|
expect(datesError('2026-12-10', '2026-12-09')).toBe(s.dates.lastBeforeStart)
|
||||||
|
expect(datesError('2026-12-01', '2027-02-01')).toBe(s.dates.tooLong(62))
|
||||||
|
expect(datesError('2026-12-01', '2027-01-31')).toBeNull() // tam 62 gün
|
||||||
|
expect(datesError('2026-12-01', '2026-12-31', 'x'.repeat(61))).toBe(s.dates.nameTooLong)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('itemSchema', () => {
|
||||||
|
const schema = itemSchema('2026-12-01', '2026-12-31')
|
||||||
|
const err = (v: { name: string; amount: string; date: string }) => {
|
||||||
|
const r = schema.safeParse(v)
|
||||||
|
return r.success ? null : r.error.issues[0].message
|
||||||
|
}
|
||||||
|
|
||||||
|
it('accepts items with or without a date', () => {
|
||||||
|
expect(err({ name: 'Maaş', amount: '45.000', date: '' })).toBeNull()
|
||||||
|
expect(err({ name: 'Kira', amount: '15000,50', date: '2026-12-05' })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects empty names and dates outside the period', () => {
|
||||||
|
expect(err({ name: ' ', amount: '10', date: '' })).toBe(s.items.nameRequired)
|
||||||
|
expect(err({ name: 'Kira', amount: '10', date: '2027-01-01' })).toBe(s.items.dateOutside)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
import { moneyText } from '@/lib/money-schema'
|
||||||
|
import { daysInclusive, isIsoDate, MAX_PERIOD_DAYS } from '@/lib/period-dates'
|
||||||
|
import { t } from '@/lib/strings'
|
||||||
|
|
||||||
|
const s = t.wizard
|
||||||
|
|
||||||
|
/** 1. adım: dönem tarihleri. `last` = son gün (dahil). */
|
||||||
|
export const datesSchema = z
|
||||||
|
.object({
|
||||||
|
start: z.string(),
|
||||||
|
last: z.string(),
|
||||||
|
name: z.string().trim().max(60, s.dates.nameTooLong),
|
||||||
|
})
|
||||||
|
.superRefine((v, ctx) => {
|
||||||
|
if (!isIsoDate(v.start)) {
|
||||||
|
ctx.addIssue({ code: 'custom', path: ['start'], message: s.dates.startRequired })
|
||||||
|
}
|
||||||
|
if (!isIsoDate(v.last)) {
|
||||||
|
ctx.addIssue({ code: 'custom', path: ['last'], message: s.dates.lastRequired })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!isIsoDate(v.start)) return
|
||||||
|
if (v.last < v.start) {
|
||||||
|
ctx.addIssue({ code: 'custom', path: ['last'], message: s.dates.lastBeforeStart })
|
||||||
|
} else if (daysInclusive(v.start, v.last) > MAX_PERIOD_DAYS) {
|
||||||
|
ctx.addIssue({ code: 'custom', path: ['last'], message: s.dates.tooLong(MAX_PERIOD_DAYS) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
export type DatesForm = z.infer<typeof datesSchema>
|
||||||
|
|
||||||
|
/** 2–3. adım: gelir / gider kalemi. Tarih boş olabilir; doluysa dönem içinde olmalı. */
|
||||||
|
export function itemSchema(start: string, last: string) {
|
||||||
|
return z.object({
|
||||||
|
name: z.string().trim().min(1, s.items.nameRequired).max(60, s.items.nameTooLong),
|
||||||
|
amount: moneyText('positive'),
|
||||||
|
date: z
|
||||||
|
.string()
|
||||||
|
.refine((d) => d === '' || (isIsoDate(d) && d >= start && d <= last), s.items.dateOutside),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
export type ItemForm = z.infer<ReturnType<typeof itemSchema>>
|
||||||
|
|
||||||
|
/** 4. adım */
|
||||||
|
export const savingsSchema = z.object({ goal: moneyText('optional') })
|
||||||
|
export type SavingsForm = z.infer<typeof savingsSchema>
|
||||||
|
|
||||||
|
/** 5. adım: 4 grubun aylık bütçesi */
|
||||||
|
export const budgetSchema = z.object({
|
||||||
|
needs: moneyText('optional'),
|
||||||
|
wants: moneyText('optional'),
|
||||||
|
culture: moneyText('optional'),
|
||||||
|
unexpected: moneyText('optional'),
|
||||||
|
})
|
||||||
|
export type BudgetForm = z.infer<typeof budgetSchema>
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { Loader2, Sparkles } from 'lucide-react'
|
||||||
|
import { Controller, useForm, useWatch } from 'react-hook-form'
|
||||||
|
import { FormError, FormField } from '@/components/form-field'
|
||||||
|
import { MoneyInput } from '@/components/money-input'
|
||||||
|
import { PageError, PageLoading } from '@/components/page'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { SummaryTiles } from '@/features/planning/summary-tiles'
|
||||||
|
import { ApiError } from '@/lib/api'
|
||||||
|
import { formatMoney, formatNumber, parseMoney } from '@/lib/format'
|
||||||
|
import { GROUPS } from '@/lib/groups'
|
||||||
|
import { toKurus } from '@/lib/money-schema'
|
||||||
|
import { useBudget, useSetBudget, useSuggestion, useSummary } from '@/lib/periods'
|
||||||
|
import { t } from '@/lib/strings'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import type { GroupAmounts } from '@/types/GroupAmounts'
|
||||||
|
import type { Period } from '@/types/Period'
|
||||||
|
import type { PeriodSummary } from '@/types/PeriodSummary'
|
||||||
|
import type { SuggestMethod } from '@/types/SuggestMethod'
|
||||||
|
import { budgetSchema, type BudgetForm } from './schemas'
|
||||||
|
import { StepActions, StepIntro } from './wizard-ui'
|
||||||
|
|
||||||
|
const s = t.wizard.budget
|
||||||
|
const METHODS: SuggestMethod[] = ['default', 'previous', 'avg3']
|
||||||
|
|
||||||
|
function toForm(a: GroupAmounts): BudgetForm {
|
||||||
|
const f = (k: number) => (k ? formatNumber(k / 100) : '')
|
||||||
|
return {
|
||||||
|
needs: f(a.needs),
|
||||||
|
wants: f(a.wants),
|
||||||
|
culture: f(a.culture),
|
||||||
|
unexpected: f(a.unexpected),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 5. adım: harcanabilir tutarı 4 gruba dağıt.
|
||||||
|
* Daha önce bütçe girilmişse o gösterilir; yoksa varsayılan öneriyle doldurulur.
|
||||||
|
*/
|
||||||
|
export function StepBudget({
|
||||||
|
period,
|
||||||
|
onBack,
|
||||||
|
onDone,
|
||||||
|
}: {
|
||||||
|
period: Period
|
||||||
|
onBack: () => void
|
||||||
|
onDone: () => void
|
||||||
|
}) {
|
||||||
|
const summary = useSummary(period.id)
|
||||||
|
const budget = useBudget(period.id)
|
||||||
|
const suggestion = useSuggestion(period.id, 'default')
|
||||||
|
|
||||||
|
const queries = [summary, budget, suggestion]
|
||||||
|
const failed = queries.find((q) => q.isError)
|
||||||
|
if (failed?.error) return <PageError message={failed.error.message} onRetry={failed.refetch} />
|
||||||
|
if (!summary.data || !budget.data || !suggestion.data) return <PageLoading />
|
||||||
|
|
||||||
|
const saved = Object.fromEntries(budget.data.map((g) => [g.group, g.monthly])) as GroupAmounts
|
||||||
|
const hasSaved = budget.data.some((g) => g.monthly > 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BudgetStepForm
|
||||||
|
period={period}
|
||||||
|
summary={summary.data}
|
||||||
|
initial={toForm(hasSaved ? saved : suggestion.data.amounts)}
|
||||||
|
onBack={onBack}
|
||||||
|
onDone={onDone}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BudgetStepForm({
|
||||||
|
period,
|
||||||
|
summary,
|
||||||
|
initial,
|
||||||
|
onBack,
|
||||||
|
onDone,
|
||||||
|
}: {
|
||||||
|
period: Period
|
||||||
|
summary: PeriodSummary
|
||||||
|
initial: BudgetForm
|
||||||
|
onBack: () => void
|
||||||
|
onDone: () => void
|
||||||
|
}) {
|
||||||
|
const setBudget = useSetBudget(period.id)
|
||||||
|
const [method, setMethod] = useState<SuggestMethod>('default')
|
||||||
|
const suggestion = useSuggestion(period.id, method)
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
setError,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<BudgetForm>({
|
||||||
|
resolver: zodResolver(budgetSchema),
|
||||||
|
mode: 'onTouched',
|
||||||
|
defaultValues: initial,
|
||||||
|
})
|
||||||
|
const values = useWatch({ control })
|
||||||
|
const distributed = GROUPS.reduce(
|
||||||
|
(sum, g) => sum + Math.max(parseMoney(values[g.code] ?? '') ?? 0, 0),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
const remaining = summary.spendable - distributed
|
||||||
|
|
||||||
|
const onSubmit = handleSubmit(async (v) => {
|
||||||
|
try {
|
||||||
|
await setBudget.mutateAsync({
|
||||||
|
needs: toKurus(v.needs),
|
||||||
|
wants: toKurus(v.wants),
|
||||||
|
culture: toKurus(v.culture),
|
||||||
|
unexpected: toKurus(v.unexpected),
|
||||||
|
})
|
||||||
|
onDone()
|
||||||
|
} catch (e) {
|
||||||
|
setError('root', { message: e instanceof ApiError ? e.message : String(e) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={onSubmit} noValidate>
|
||||||
|
<StepIntro title={s.title} hint={s.hint} />
|
||||||
|
<SummaryTiles summary={summary} />
|
||||||
|
|
||||||
|
{/* Öneri seçimi */}
|
||||||
|
<fieldset className="mt-6 space-y-3 rounded-xl border p-4">
|
||||||
|
<legend className="px-1 text-sm font-medium">{s.method}</legend>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{METHODS.map((m) => (
|
||||||
|
<Button
|
||||||
|
key={m}
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={method === m ? 'default' : 'outline'}
|
||||||
|
aria-pressed={method === m}
|
||||||
|
onClick={() => setMethod(m)}
|
||||||
|
>
|
||||||
|
{s.methods[m]}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{suggestion.isPending ? (
|
||||||
|
<Loader2
|
||||||
|
className="size-5 animate-spin text-muted-foreground"
|
||||||
|
aria-label={t.common.loading}
|
||||||
|
/>
|
||||||
|
) : suggestion.data?.available ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => reset(toForm(suggestion.data.amounts))}
|
||||||
|
>
|
||||||
|
<Sparkles aria-hidden />
|
||||||
|
{s.apply}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{suggestion.data?.message ?? suggestion.error?.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{/* 4 grup */}
|
||||||
|
<div className="mt-6 grid gap-4 sm:grid-cols-2">
|
||||||
|
{GROUPS.map((g) => (
|
||||||
|
<FormField key={g.code} label={`${g.emoji} ${g.label}`} error={errors[g.code]}>
|
||||||
|
{(f) => (
|
||||||
|
<div className={cn('rounded-xl p-3', g.className.soft)}>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={g.code}
|
||||||
|
render={({ field }) => (
|
||||||
|
<MoneyInput {...f} {...field} placeholder="0,00" className="bg-background" />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<p className="mt-1.5 text-xs text-muted-foreground">{g.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</FormField>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dağıtım durumu */}
|
||||||
|
<div className="mt-6 space-y-2 rounded-xl border p-4" aria-live="polite">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span>{s.distributed}</span>
|
||||||
|
<span className="money font-medium">
|
||||||
|
{formatMoney(distributed)} / {formatMoney(summary.spendable)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 overflow-hidden rounded-full bg-muted" aria-hidden>
|
||||||
|
<div
|
||||||
|
className={cn('h-full rounded-full', remaining < 0 ? 'bg-warning' : 'bg-primary')}
|
||||||
|
style={{
|
||||||
|
width: `${summary.spendable > 0 ? Math.min(100, (distributed / summary.spendable) * 100) : 0}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'text-sm',
|
||||||
|
remaining < 0 ? 'font-medium text-warning' : 'text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{remaining < 0
|
||||||
|
? s.over(formatMoney(-remaining))
|
||||||
|
: remaining > 0
|
||||||
|
? s.unallocated(formatMoney(remaining))
|
||||||
|
: s.balanced}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormError message={errors.root?.message} />
|
||||||
|
<StepActions onBack={onBack} pending={isSubmitting} nextLabel={s.finish} />
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { useForm, useWatch } 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 { ApiError } from '@/lib/api'
|
||||||
|
import { daysInclusive, isIsoDate, lastToEnd, monthRange } from '@/lib/period-dates'
|
||||||
|
import { useCreatePeriod, useUpdatePeriod } from '@/lib/periods'
|
||||||
|
import { t } from '@/lib/strings'
|
||||||
|
import type { Period } from '@/types/Period'
|
||||||
|
import { datesSchema, type DatesForm } from './schemas'
|
||||||
|
import { StepActions, StepIntro } from './wizard-ui'
|
||||||
|
|
||||||
|
const s = t.wizard.dates
|
||||||
|
|
||||||
|
const monthName = new Intl.DateTimeFormat('tr-TR', { month: 'long', year: 'numeric' })
|
||||||
|
|
||||||
|
/** 1. adım: dönem yoksa açar, varsa (geri gelindiyse) günceller. */
|
||||||
|
export function StepDates({ period, onDone }: { period: Period | null; onDone: () => void }) {
|
||||||
|
const create = useCreatePeriod()
|
||||||
|
const update = useUpdatePeriod()
|
||||||
|
|
||||||
|
const [today] = useState(() => new Date()) // render başına değil, bir kez
|
||||||
|
const presets = [0, 1].map((offset) => ({
|
||||||
|
label: monthName.format(new Date(today.getFullYear(), today.getMonth() + offset, 1)),
|
||||||
|
range: monthRange(today, offset),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
setValue,
|
||||||
|
setError,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<DatesForm>({
|
||||||
|
resolver: zodResolver(datesSchema),
|
||||||
|
mode: 'onTouched',
|
||||||
|
defaultValues: period
|
||||||
|
? { start: period.start_date, last: period.last_date, name: period.name }
|
||||||
|
: { ...presets[0].range, name: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const [start, last] = useWatch({ control, name: ['start', 'last'] })
|
||||||
|
const days =
|
||||||
|
isIsoDate(start) && isIsoDate(last) && last >= start ? daysInclusive(start, last) : null
|
||||||
|
|
||||||
|
const onSubmit = handleSubmit(async (v) => {
|
||||||
|
const end = lastToEnd(v.last)
|
||||||
|
try {
|
||||||
|
if (!period) {
|
||||||
|
await create.mutateAsync({ start_date: v.start, end_date: end, name: v.name || undefined })
|
||||||
|
toast.success(s.created)
|
||||||
|
} else if (
|
||||||
|
v.start !== period.start_date ||
|
||||||
|
end !== period.end_date ||
|
||||||
|
v.name !== period.name
|
||||||
|
) {
|
||||||
|
await update.mutateAsync({
|
||||||
|
id: period.id,
|
||||||
|
version: period.version,
|
||||||
|
start_date: v.start,
|
||||||
|
end_date: end,
|
||||||
|
name: v.name, // boş → backend tarihlere göre otomatik ad verir
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onDone()
|
||||||
|
} catch (e) {
|
||||||
|
setError('root', { message: e instanceof ApiError ? e.message : String(e) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={onSubmit} noValidate>
|
||||||
|
<StepIntro title={s.title} hint={s.hint} />
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div role="group" aria-label={s.presetsLabel} className="flex flex-wrap gap-2">
|
||||||
|
{presets.map((p) => {
|
||||||
|
const selected = start === p.range.start && last === p.range.last
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={p.label}
|
||||||
|
type="button"
|
||||||
|
variant={selected ? 'default' : 'outline'}
|
||||||
|
aria-pressed={selected}
|
||||||
|
onClick={() => {
|
||||||
|
setValue('start', p.range.start, { shouldValidate: true })
|
||||||
|
setValue('last', p.range.last, { shouldValidate: true })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<FormField label={s.start} error={errors.start}>
|
||||||
|
{(f) => <Input {...f} type="date" {...register('start')} />}
|
||||||
|
</FormField>
|
||||||
|
<FormField label={s.last} error={errors.last}>
|
||||||
|
{(f) => <Input {...f} type="date" min={start || undefined} {...register('last')} />}
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
{days !== null && (
|
||||||
|
<p className="text-sm text-muted-foreground" aria-live="polite">
|
||||||
|
{s.days(days)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField label={s.name} error={errors.name} hint={s.nameHint}>
|
||||||
|
{(f) => <Input {...f} maxLength={60} {...register('name')} />}
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormError message={errors.root?.message} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StepActions pending={isSubmitting} />
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { Copy, Loader2, Plus, Trash2 } from 'lucide-react'
|
||||||
|
import { Controller, useForm } from 'react-hook-form'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { FormError, FormField } from '@/components/form-field'
|
||||||
|
import { MoneyInput } from '@/components/money-input'
|
||||||
|
import { PageError, PageLoading } from '@/components/page'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { ApiError } from '@/lib/api'
|
||||||
|
import { formatDate, formatMoney } from '@/lib/format'
|
||||||
|
import { toKurus } from '@/lib/money-schema'
|
||||||
|
import {
|
||||||
|
useCopyPrevious,
|
||||||
|
useCreatePlanItem,
|
||||||
|
useDeletePlanItem,
|
||||||
|
usePeriods,
|
||||||
|
usePlanItems,
|
||||||
|
} from '@/lib/periods'
|
||||||
|
import { t } from '@/lib/strings'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import type { Period } from '@/types/Period'
|
||||||
|
import type { PlanItem } from '@/types/PlanItem'
|
||||||
|
import type { PlanItemKind } from '@/types/PlanItemKind'
|
||||||
|
import { itemSchema, type ItemForm } from './schemas'
|
||||||
|
import { StepActions, StepIntro } from './wizard-ui'
|
||||||
|
|
||||||
|
const s = t.wizard.items
|
||||||
|
|
||||||
|
/** 2–3. adım: sabit gelir / gider kalemleri. Her ekleme ve silme hemen kaydedilir. */
|
||||||
|
export function StepItems({
|
||||||
|
period,
|
||||||
|
kind,
|
||||||
|
onBack,
|
||||||
|
onNext,
|
||||||
|
}: {
|
||||||
|
period: Period
|
||||||
|
kind: PlanItemKind
|
||||||
|
onBack: () => void
|
||||||
|
onNext: () => void
|
||||||
|
}) {
|
||||||
|
const items = usePlanItems(period.id)
|
||||||
|
const text = s[kind]
|
||||||
|
|
||||||
|
if (items.isPending) return <PageLoading />
|
||||||
|
if (items.isError) return <PageError message={items.error.message} onRetry={items.refetch} />
|
||||||
|
|
||||||
|
const list = items.data.filter((i) => i.kind === kind)
|
||||||
|
const total = list.reduce((sum, i) => sum + i.amount, 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<StepIntro title={text.title} hint={text.hint} />
|
||||||
|
<CopyPrevious period={period} />
|
||||||
|
|
||||||
|
<AddItemForm period={period} kind={kind} />
|
||||||
|
|
||||||
|
<section aria-label={text.title} className="mt-6">
|
||||||
|
{list.length === 0 ? (
|
||||||
|
<p className="rounded-xl border border-dashed p-6 text-center text-muted-foreground">
|
||||||
|
{text.empty}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y rounded-xl border">
|
||||||
|
{list.map((item) => (
|
||||||
|
<ItemRow key={item.id} item={item} periodId={period.id} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<div className="mt-3 flex justify-between px-1 font-medium">
|
||||||
|
<span>{text.total}</span>
|
||||||
|
<span className={cn('money', kind === 'income' ? 'text-income' : 'text-expense')}>
|
||||||
|
{formatMoney(total)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<StepActions onBack={onBack} onNext={onNext} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddItemForm({ period, kind }: { period: Period; kind: PlanItemKind }) {
|
||||||
|
const create = useCreatePlanItem(period.id)
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
setFocus,
|
||||||
|
setError,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<ItemForm>({
|
||||||
|
resolver: zodResolver(itemSchema(period.start_date, period.last_date)),
|
||||||
|
mode: 'onTouched',
|
||||||
|
defaultValues: { name: '', amount: '', date: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const onSubmit = handleSubmit(async (v) => {
|
||||||
|
try {
|
||||||
|
await create.mutateAsync({
|
||||||
|
kind,
|
||||||
|
name: v.name,
|
||||||
|
amount: toKurus(v.amount),
|
||||||
|
date: v.date || undefined,
|
||||||
|
})
|
||||||
|
reset()
|
||||||
|
setFocus('name') // art arda hızlı giriş
|
||||||
|
} catch (e) {
|
||||||
|
setError('root', { message: e instanceof ApiError ? e.message : String(e) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={onSubmit} noValidate className="space-y-4 rounded-xl border bg-card p-4">
|
||||||
|
<FormField label={s.name} error={errors.name}>
|
||||||
|
{(f) => (
|
||||||
|
<Input {...f} maxLength={60} placeholder={s[kind].placeholder} {...register('name')} />
|
||||||
|
)}
|
||||||
|
</FormField>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<FormField label={s.amount} error={errors.amount}>
|
||||||
|
{(f) => (
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="amount"
|
||||||
|
render={({ field }) => <MoneyInput {...f} {...field} placeholder="0,00" />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FormField>
|
||||||
|
<FormField label={s.date} error={errors.date}>
|
||||||
|
{(f) => (
|
||||||
|
<Input
|
||||||
|
{...f}
|
||||||
|
type="date"
|
||||||
|
min={period.start_date}
|
||||||
|
max={period.last_date}
|
||||||
|
{...register('date')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<FormError message={errors.root?.message} />
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="secondary"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? <Loader2 className="animate-spin" aria-hidden /> : <Plus aria-hidden />}
|
||||||
|
{s.add}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemRow({ item, periodId }: { item: PlanItem; periodId: string }) {
|
||||||
|
const remove = useDeletePlanItem(periodId)
|
||||||
|
return (
|
||||||
|
<li className="flex items-center gap-3 py-2 pr-1 pl-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate font-medium">{item.name}</p>
|
||||||
|
{item.date && <p className="text-sm text-muted-foreground">{formatDate(item.date)}</p>}
|
||||||
|
</div>
|
||||||
|
<span className="money font-medium">{formatMoney(item.amount)}</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={s.remove(item.name)}
|
||||||
|
disabled={remove.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
remove.mutate(item.id, {
|
||||||
|
onSuccess: () => toast.success(s.removed(item.name)),
|
||||||
|
onError: (e) => toast.error(e.message),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{remove.isPending ? (
|
||||||
|
<Loader2 className="animate-spin" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="text-muted-foreground" aria-hidden />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Önceki dönem varsa görünür; gelir ve giderleri birlikte kopyalar. */
|
||||||
|
function CopyPrevious({ period }: { period: Period }) {
|
||||||
|
const periods = usePeriods()
|
||||||
|
const copy = useCopyPrevious(period.id)
|
||||||
|
const hasPrevious = periods.data?.some((p) => p.start_date < period.start_date)
|
||||||
|
if (!hasPrevious) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-5 flex flex-wrap items-center gap-3 rounded-xl bg-muted/50 p-3">
|
||||||
|
<p className="flex-1 text-sm text-muted-foreground">{s.copyHint}</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={copy.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
copy.mutate(undefined, {
|
||||||
|
onSuccess: (r) => toast.success(s.copied(r.added)),
|
||||||
|
onError: (e) => toast.error(e.message),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{copy.isPending ? <Loader2 className="animate-spin" aria-hidden /> : <Copy aria-hidden />}
|
||||||
|
{s.copy}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { Controller, useForm, useWatch } from 'react-hook-form'
|
||||||
|
import { FormError, FormField } from '@/components/form-field'
|
||||||
|
import { MoneyInput } from '@/components/money-input'
|
||||||
|
import { PageError, PageLoading } from '@/components/page'
|
||||||
|
import { ApiError } from '@/lib/api'
|
||||||
|
import { formatMoney, formatNumber, parseMoney } from '@/lib/format'
|
||||||
|
import { toKurus } from '@/lib/money-schema'
|
||||||
|
import { useSummary, useUpdatePeriod } from '@/lib/periods'
|
||||||
|
import { t } from '@/lib/strings'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import type { Period } from '@/types/Period'
|
||||||
|
import { savingsSchema, type SavingsForm } from './schemas'
|
||||||
|
import { StepActions, StepIntro } from './wizard-ui'
|
||||||
|
|
||||||
|
const s = t.wizard.savings
|
||||||
|
|
||||||
|
/** 4. adım: birikim hedefi (harcamadan önce ayrılır). */
|
||||||
|
export function StepSavings({
|
||||||
|
period,
|
||||||
|
onBack,
|
||||||
|
onNext,
|
||||||
|
}: {
|
||||||
|
period: Period
|
||||||
|
onBack: () => void
|
||||||
|
onNext: () => void
|
||||||
|
}) {
|
||||||
|
const summary = useSummary(period.id)
|
||||||
|
const update = useUpdatePeriod()
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
setError,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<SavingsForm>({
|
||||||
|
resolver: zodResolver(savingsSchema),
|
||||||
|
mode: 'onTouched',
|
||||||
|
defaultValues: { goal: period.savings_goal ? formatNumber(period.savings_goal / 100) : '' },
|
||||||
|
})
|
||||||
|
const goalText = useWatch({ control, name: 'goal' })
|
||||||
|
|
||||||
|
if (summary.isPending) return <PageLoading />
|
||||||
|
if (summary.isError)
|
||||||
|
return <PageError message={summary.error.message} onRetry={summary.refetch} />
|
||||||
|
|
||||||
|
// Birikimden önceki kalan = harcanabilir + (kayıtlı) birikim hedefi → formül yalnızca backend'de
|
||||||
|
const available = summary.data.spendable + summary.data.savings_goal
|
||||||
|
const goal = parseMoney(goalText) ?? 0
|
||||||
|
const after = available - Math.max(goal, 0)
|
||||||
|
|
||||||
|
const onSubmit = handleSubmit(async (v) => {
|
||||||
|
const kurus = toKurus(v.goal)
|
||||||
|
try {
|
||||||
|
if (kurus !== period.savings_goal) {
|
||||||
|
await update.mutateAsync({ id: period.id, version: period.version, savings_goal: kurus })
|
||||||
|
}
|
||||||
|
onNext()
|
||||||
|
} catch (e) {
|
||||||
|
setError('root', { message: e instanceof ApiError ? e.message : String(e) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={onSubmit} noValidate>
|
||||||
|
<StepIntro title={s.title} hint={s.hint} />
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
<Row label={s.available} amount={available} />
|
||||||
|
<FormField label={s.goal} error={errors.goal}>
|
||||||
|
{(f) => (
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="goal"
|
||||||
|
render={({ field }) => (
|
||||||
|
<MoneyInput {...f} {...field} placeholder="0,00" className="h-12 text-lg" />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FormField>
|
||||||
|
<Row label={s.after} amount={after} strong />
|
||||||
|
{after < 0 && (
|
||||||
|
<p role="status" className="text-sm text-warning">
|
||||||
|
{s.negative}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<FormError message={errors.root?.message} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StepActions onBack={onBack} pending={isSubmitting} />
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, amount, strong }: { label: string; amount: number; strong?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-between rounded-xl border px-4 py-3',
|
||||||
|
strong && 'border-primary/40 bg-primary/5',
|
||||||
|
)}
|
||||||
|
aria-live={strong ? 'polite' : undefined}
|
||||||
|
>
|
||||||
|
<span className={cn(strong ? 'font-medium' : 'text-muted-foreground')}>{label}</span>
|
||||||
|
<span className={cn('money', strong && 'text-lg font-bold', amount < 0 && 'text-expense')}>
|
||||||
|
{formatMoney(amount)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { ArrowLeft, ArrowRight, Check, Loader2 } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { t } from '@/lib/strings'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const s = t.wizard
|
||||||
|
|
||||||
|
/** Adım göstergesi: mobilde "Adım 2/5 · Gelirler" + çubuk, geniş ekranda numaralı liste. */
|
||||||
|
export function Stepper({ current }: { current: number }) {
|
||||||
|
const total = s.steps.length
|
||||||
|
return (
|
||||||
|
<nav aria-label={s.title} className="mb-6">
|
||||||
|
<p className="mb-2 text-sm font-medium sm:hidden">
|
||||||
|
{s.stepOf(current, total)} · {s.steps[current - 1]}
|
||||||
|
</p>
|
||||||
|
<div className="h-1.5 overflow-hidden rounded-full bg-muted sm:hidden" aria-hidden>
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary transition-[width]"
|
||||||
|
style={{ width: `${(current / total) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ol className="hidden gap-2 sm:flex">
|
||||||
|
{s.steps.map((label, i) => {
|
||||||
|
const n = i + 1
|
||||||
|
const done = n < current
|
||||||
|
const active = n === current
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={label}
|
||||||
|
aria-current={active ? 'step' : undefined}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-1 items-center gap-2 rounded-lg border px-3 py-2 text-sm',
|
||||||
|
active && 'border-primary bg-primary/5 font-medium',
|
||||||
|
!active && !done && 'text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'grid size-6 shrink-0 place-items-center rounded-full text-xs',
|
||||||
|
done || active ? 'bg-primary text-primary-foreground' : 'bg-muted',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{done ? <Check className="size-3.5" aria-hidden /> : n}
|
||||||
|
</span>
|
||||||
|
{label}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adım başlığı + açıklama */
|
||||||
|
export function StepIntro({ title, hint }: { title: string; hint: string }) {
|
||||||
|
return (
|
||||||
|
<div className="mb-5 space-y-1">
|
||||||
|
<h2 className="text-xl font-semibold">{title}</h2>
|
||||||
|
<p className="text-muted-foreground">{hint}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alt düğmeler. `onNext` verilmezse İleri düğmesi `type="submit"` olur (formu gönderir).
|
||||||
|
* Mobilde düğmeler tam genişlik, İleri sağda (başparmak erişimi).
|
||||||
|
*/
|
||||||
|
export function StepActions({
|
||||||
|
onBack,
|
||||||
|
onNext,
|
||||||
|
pending,
|
||||||
|
nextLabel = s.next,
|
||||||
|
form,
|
||||||
|
extra,
|
||||||
|
}: {
|
||||||
|
onBack?: () => void
|
||||||
|
onNext?: () => void
|
||||||
|
pending?: boolean
|
||||||
|
nextLabel?: string
|
||||||
|
/** Düğme form dışındaysa formun `id`'si */
|
||||||
|
form?: string
|
||||||
|
extra?: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mt-8 flex items-center gap-3 border-t pt-5">
|
||||||
|
{onBack && (
|
||||||
|
<Button type="button" variant="outline" size="lg" onClick={onBack} disabled={pending}>
|
||||||
|
<ArrowLeft aria-hidden />
|
||||||
|
{s.back}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{extra}
|
||||||
|
<Button
|
||||||
|
type={onNext ? 'button' : 'submit'}
|
||||||
|
form={form}
|
||||||
|
size="lg"
|
||||||
|
className="ml-auto min-w-32 flex-1 sm:flex-none"
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={pending}
|
||||||
|
>
|
||||||
|
{pending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="animate-spin" aria-hidden />
|
||||||
|
{s.saving}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{nextLabel}
|
||||||
|
<ArrowRight aria-hidden />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,11 +1,55 @@
|
|||||||
import { ComingSoon, PageHeader } from '@/components/page'
|
import { Pencil } from 'lucide-react'
|
||||||
|
import { Link } from 'react-router'
|
||||||
|
import { paths } from '@/components/layout/nav-items'
|
||||||
|
import { ComingSoon, PageError, PageHeader, PageLoading } from '@/components/page'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { NoPeriodCard } from '@/features/period-wizard/no-period-card'
|
||||||
|
import { formatDate } from '@/lib/format'
|
||||||
|
import { useCurrentPeriod, useSummary } from '@/lib/periods'
|
||||||
import { t } from '@/lib/strings'
|
import { t } from '@/lib/strings'
|
||||||
|
import type { Period } from '@/types/Period'
|
||||||
|
import { SummaryTiles } from './summary-tiles'
|
||||||
|
|
||||||
export function PlanningPage() {
|
export function PlanningPage() {
|
||||||
|
const current = useCurrentPeriod()
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title={t.pages.planning.title} description={t.pages.planning.description} />
|
<PageHeader title={t.pages.planning.title} description={t.pages.planning.description} />
|
||||||
<ComingSoon when="Faz 4'te" />
|
{current.isPending ? (
|
||||||
|
<PageLoading />
|
||||||
|
) : current.isError ? (
|
||||||
|
<PageError message={current.error.message} onRetry={current.refetch} />
|
||||||
|
) : current.data ? (
|
||||||
|
<OpenPeriod period={current.data} />
|
||||||
|
) : (
|
||||||
|
<NoPeriodCard />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Geçici görünüm: ayrıntılı Plan ekranı Faz 4 – Adım 5'te. */
|
||||||
|
function OpenPeriod({ period }: { period: Period }) {
|
||||||
|
const summary = useSummary(period.id)
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">{period.name}</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{formatDate(period.start_date)} – {formatDate(period.last_date)} · {period.days} gün ·{' '}
|
||||||
|
{period.weeks.length} hafta
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link to={`${paths.periodWizard}?adim=2`}>
|
||||||
|
<Pencil aria-hidden />
|
||||||
|
Sihirbazla düzenle
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{summary.data && <SummaryTiles summary={summary.data} />}
|
||||||
|
<ComingSoon when="bir sonraki adımda" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { formatMoney } from '@/lib/format'
|
||||||
|
import { t } from '@/lib/strings'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import type { PeriodSummary } from '@/types/PeriodSummary'
|
||||||
|
|
||||||
|
const s = t.summary
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plan özeti: Gelir − Sabit giderler − Birikim − Devreden borç (+ Devreden artı) = Harcanabilir.
|
||||||
|
* Mobilde 2 sütun, geniş ekranda tek satır. Devir kutucukları yalnızca tutar varsa görünür.
|
||||||
|
*/
|
||||||
|
export function SummaryTiles({ summary }: { summary: PeriodSummary }) {
|
||||||
|
const tiles: { label: string; amount: number; sign?: '−' | '+'; tone?: string }[] = [
|
||||||
|
{ label: s.income, amount: summary.income_total, tone: 'text-income' },
|
||||||
|
{ label: s.fixed, amount: summary.fixed_expense_total, sign: '−' },
|
||||||
|
{ label: s.savings, amount: summary.savings_goal, sign: '−' },
|
||||||
|
]
|
||||||
|
if (summary.carryover_debt > 0) {
|
||||||
|
tiles.push({ label: s.debt, amount: summary.carryover_debt, sign: '−', tone: 'text-expense' })
|
||||||
|
}
|
||||||
|
if (summary.carryover_surplus > 0) {
|
||||||
|
tiles.push({ label: s.surplus, amount: summary.carryover_surplus, sign: '+' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dl className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
|
||||||
|
{tiles.map((tile) => (
|
||||||
|
<div key={tile.label} className="rounded-xl border bg-card p-3 sm:min-w-32 sm:flex-1">
|
||||||
|
<dt className="text-xs text-muted-foreground">
|
||||||
|
{tile.sign && <span aria-hidden>{tile.sign} </span>}
|
||||||
|
{tile.label}
|
||||||
|
</dt>
|
||||||
|
<dd className={cn('money mt-1 font-semibold', tile.tone)}>{formatMoney(tile.amount)}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'col-span-2 rounded-xl border-2 p-3 sm:min-w-40 sm:flex-[1.4]',
|
||||||
|
summary.spendable < 0
|
||||||
|
? 'border-expense/50 bg-expense/5'
|
||||||
|
: 'border-primary/40 bg-primary/5',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<dt className="text-xs font-medium text-muted-foreground">
|
||||||
|
<span aria-hidden>= </span>
|
||||||
|
{s.spendable}
|
||||||
|
</dt>
|
||||||
|
<dd
|
||||||
|
className={cn(
|
||||||
|
'money mt-1 text-xl font-bold',
|
||||||
|
summary.spendable < 0 ? 'text-expense' : 'text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatMoney(summary.spendable)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { moneyMessages, moneyText, toKurus } from './money-schema'
|
||||||
|
|
||||||
|
function error(mode: 'positive' | 'optional', value: string) {
|
||||||
|
const r = moneyText(mode).safeParse(value)
|
||||||
|
return r.success ? null : r.error.issues[0].message
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('moneyText', () => {
|
||||||
|
it('accepts Turkish and phone-keyboard formats', () => {
|
||||||
|
for (const v of ['1250', '1.250', '1.250,50', '1250.5', ' 45 ']) {
|
||||||
|
expect(error('positive', v)).toBeNull()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects bad input with Turkish messages', () => {
|
||||||
|
expect(error('positive', '')).toBe(moneyMessages.required)
|
||||||
|
expect(error('positive', '0')).toBe(moneyMessages.positive)
|
||||||
|
expect(error('positive', '12a')).toBe(moneyMessages.invalid)
|
||||||
|
expect(error('positive', '-5')).toBe(moneyMessages.negative)
|
||||||
|
expect(error('positive', '200.000.000.000')).toBe(moneyMessages.tooLarge)
|
||||||
|
expect(error('positive', '100.000.000.000')).toBeNull() // tam sınır
|
||||||
|
})
|
||||||
|
|
||||||
|
it('optional allows empty and zero', () => {
|
||||||
|
expect(error('optional', '')).toBeNull()
|
||||||
|
expect(error('optional', '0')).toBeNull()
|
||||||
|
expect(error('optional', 'abc')).toBe(moneyMessages.invalid)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('converts to kuruş', () => {
|
||||||
|
expect(toKurus('1.250,50')).toBe(125_050)
|
||||||
|
expect(toKurus('')).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { addDays, daysInclusive, isIsoDate, lastToEnd, monthRange } from './period-dates'
|
||||||
|
|
||||||
|
describe('period-dates', () => {
|
||||||
|
it('validates ISO dates', () => {
|
||||||
|
expect(isIsoDate('2026-12-31')).toBe(true)
|
||||||
|
expect(isIsoDate('2028-02-29')).toBe(true)
|
||||||
|
expect(isIsoDate('2026-02-29')).toBe(false)
|
||||||
|
expect(isIsoDate('2026-13-01')).toBe(false)
|
||||||
|
expect(isIsoDate('31.12.2026')).toBe(false)
|
||||||
|
expect(isIsoDate('')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds days across month and year boundaries', () => {
|
||||||
|
expect(addDays('2026-12-31', 1)).toBe('2027-01-01')
|
||||||
|
expect(addDays('2027-03-01', -1)).toBe('2027-02-28')
|
||||||
|
// Yaz saati geçişi (Avrupa: 29 Mart 2026) gün kaydırmaz
|
||||||
|
expect(addDays('2026-03-28', 2)).toBe('2026-03-30')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('counts days inclusively', () => {
|
||||||
|
expect(daysInclusive('2026-12-01', '2026-12-31')).toBe(31)
|
||||||
|
expect(daysInclusive('2026-12-15', '2027-01-14')).toBe(31)
|
||||||
|
expect(daysInclusive('2026-12-01', '2026-12-01')).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gives month ranges', () => {
|
||||||
|
const nov12 = new Date(2026, 10, 12)
|
||||||
|
expect(monthRange(nov12)).toEqual({ start: '2026-11-01', last: '2026-11-30' })
|
||||||
|
expect(monthRange(nov12, 1)).toEqual({ start: '2026-12-01', last: '2026-12-31' })
|
||||||
|
expect(monthRange(new Date(2026, 11, 5), 2)).toEqual({
|
||||||
|
start: '2027-02-01',
|
||||||
|
last: '2027-02-28',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('converts last day to exclusive end', () => {
|
||||||
|
expect(lastToEnd('2026-12-31')).toBe('2027-01-01')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Dönem tarih hesapları. Tarihler backend ile aynı biçimde: `"YYYY-MM-DD"` metni.
|
||||||
|
*
|
||||||
|
* Hesaplar UTC üzerinden yapılır: yaz saati geçişlerinde gün kayması olmasın.
|
||||||
|
* Not: backend dönem sonunu **hariç** saklar (`end_date`), arayüz kullanıcıya
|
||||||
|
* **son günü** (dahil) gösterir. Çeviri yalnızca bu dosyadan yapılır.
|
||||||
|
*/
|
||||||
|
import { toIsoDate } from './format'
|
||||||
|
|
||||||
|
/** Backend ile aynı sınır (`services/period.rs` → `MAX_DAYS`) */
|
||||||
|
export const MAX_PERIOD_DAYS = 62
|
||||||
|
|
||||||
|
const DAY_MS = 86_400_000
|
||||||
|
|
||||||
|
function toUtc(iso: string): number {
|
||||||
|
const [y, m, d] = iso.split('-').map(Number)
|
||||||
|
return Date.UTC(y, m - 1, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromUtc(ms: number): string {
|
||||||
|
return new Date(ms).toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Geçerli bir `"YYYY-MM-DD"` mi? (`2026-02-30` geçersiz) */
|
||||||
|
export function isIsoDate(value: string): boolean {
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
|
||||||
|
return fromUtc(toUtc(value)) === value
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `"2026-12-31"` + 1 → `"2027-01-01"` */
|
||||||
|
export function addDays(iso: string, days: number): string {
|
||||||
|
return fromUtc(toUtc(iso) + days * DAY_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** İki tarih arasındaki gün sayısı, ikisi de dahil (`01.12` – `31.12` → 31) */
|
||||||
|
export function daysInclusive(start: string, last: string): number {
|
||||||
|
return Math.round((toUtc(last) - toUtc(start)) / DAY_MS) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verilen günün ayı: ilk gün ve son gün (dahil) */
|
||||||
|
export function monthRange(today: Date, monthOffset = 0): { start: string; last: string } {
|
||||||
|
const y = today.getFullYear()
|
||||||
|
const m = today.getMonth() + monthOffset
|
||||||
|
return {
|
||||||
|
start: toIsoDate(new Date(y, m, 1)),
|
||||||
|
last: toIsoDate(new Date(y, m + 1, 0)), // sonraki ayın 0. günü = bu ayın son günü
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Son gün (dahil) → backend'in beklediği bitiş (hariç) */
|
||||||
|
export function lastToEnd(last: string): string {
|
||||||
|
return addDays(last, 1)
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* Dönem, plan kalemleri, özet ve bütçe: sunucu verisi kancaları (TanStack Query).
|
||||||
|
*
|
||||||
|
* Kural: bir dönemde bir şey değişince o döneme bağlı tüm sorgular
|
||||||
|
* (`queryKeys.period(id)` önekiyle) yenilenir → özet ve öneri hep güncel kalır.
|
||||||
|
*/
|
||||||
|
import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'
|
||||||
|
import type { BudgetSuggestion } from '@/types/BudgetSuggestion'
|
||||||
|
import type { CopyPreviousResult } from '@/types/CopyPreviousResult'
|
||||||
|
import type { GroupAmounts } from '@/types/GroupAmounts'
|
||||||
|
import type { GroupBudget } from '@/types/GroupBudget'
|
||||||
|
import type { Period } from '@/types/Period'
|
||||||
|
import type { PeriodCreateRequest } from '@/types/PeriodCreateRequest'
|
||||||
|
import type { PeriodSummary } from '@/types/PeriodSummary'
|
||||||
|
import type { PeriodUpdateRequest } from '@/types/PeriodUpdateRequest'
|
||||||
|
import type { PlanItem } from '@/types/PlanItem'
|
||||||
|
import type { PlanItemCreateRequest } from '@/types/PlanItemCreateRequest'
|
||||||
|
import type { SuggestMethod } from '@/types/SuggestMethod'
|
||||||
|
import { api } from './api'
|
||||||
|
import { queryKeys } from './query'
|
||||||
|
|
||||||
|
function refreshPeriod(qc: QueryClient, id: string) {
|
||||||
|
return qc.invalidateQueries({ queryKey: queryKeys.period(id) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Dönem ----------
|
||||||
|
|
||||||
|
/** Açık dönem: `undefined` yükleniyor, `null` açık dönem yok */
|
||||||
|
export function useCurrentPeriod() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.currentPeriod,
|
||||||
|
queryFn: () => api.get<Period | null>('/periods/current'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePeriods() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.periods,
|
||||||
|
queryFn: () => api.get<Period[]>('/periods'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreatePeriod() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: PeriodCreateRequest) => api.post<Period>('/periods', body),
|
||||||
|
onSuccess: (p) => {
|
||||||
|
qc.setQueryData(queryKeys.currentPeriod, p)
|
||||||
|
void qc.invalidateQueries({ queryKey: queryKeys.periods, exact: true })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdatePeriod() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, ...body }: PeriodUpdateRequest & { id: string }) =>
|
||||||
|
api.patch<Period>(`/periods/${id}`, body),
|
||||||
|
onSuccess: (p) => {
|
||||||
|
if (p.status === 'open') qc.setQueryData(queryKeys.currentPeriod, p)
|
||||||
|
void qc.invalidateQueries({ queryKey: queryKeys.periods, exact: true })
|
||||||
|
void refreshPeriod(qc, p.id)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Plan kalemleri ----------
|
||||||
|
|
||||||
|
export function usePlanItems(periodId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.planItems(periodId),
|
||||||
|
queryFn: () => api.get<PlanItem[]>(`/periods/${periodId}/plan-items`),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreatePlanItem(periodId: string) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: PlanItemCreateRequest) =>
|
||||||
|
api.post<PlanItem>(`/periods/${periodId}/plan-items`, body),
|
||||||
|
onSuccess: () => refreshPeriod(qc, periodId),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeletePlanItem(periodId: string) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (itemId: string) => api.delete(`/plan-items/${itemId}`),
|
||||||
|
onSuccess: () => refreshPeriod(qc, periodId),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCopyPrevious(periodId: string) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => api.post<CopyPreviousResult>(`/periods/${periodId}/plan-items/copy-previous`),
|
||||||
|
onSuccess: () => refreshPeriod(qc, periodId),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Özet ve bütçe ----------
|
||||||
|
|
||||||
|
export function useSummary(periodId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.summary(periodId),
|
||||||
|
queryFn: () => api.get<PeriodSummary>(`/periods/${periodId}/summary`),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBudget(periodId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.budget(periodId),
|
||||||
|
queryFn: () => api.get<GroupBudget[]>(`/periods/${periodId}/budget`),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSuggestion(periodId: string, method: SuggestMethod) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.suggestion(periodId, method),
|
||||||
|
queryFn: () =>
|
||||||
|
api.get<BudgetSuggestion>(`/periods/${periodId}/budget/suggestion?method=${method}`),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetBudget(periodId: string) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: GroupAmounts) => api.put<GroupBudget[]>(`/periods/${periodId}/budget`, body),
|
||||||
|
onSuccess: (groups) => {
|
||||||
|
qc.setQueryData(queryKeys.budget(periodId), groups)
|
||||||
|
void qc.invalidateQueries({ queryKey: queryKeys.summary(periodId) })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -12,6 +12,15 @@ export const queryKeys = {
|
|||||||
household: ['household'] as const,
|
household: ['household'] as const,
|
||||||
categories: (includeInactive = false) => ['categories', { includeInactive }] as const,
|
categories: (includeInactive = false) => ['categories', { includeInactive }] as const,
|
||||||
paymentMethods: (includeInactive = false) => ['payment-methods', { includeInactive }] as const,
|
paymentMethods: (includeInactive = false) => ['payment-methods', { includeInactive }] as const,
|
||||||
|
/** Dönem listesi; `currentPeriod` da bu önekle başlar (birlikte yenilenir) */
|
||||||
|
periods: ['periods'] as const,
|
||||||
|
currentPeriod: ['periods', 'current'] as const,
|
||||||
|
/** Bir döneme bağlı tüm veriler bu önekle başlar → `invalidateQueries({ queryKey: period(id) })` */
|
||||||
|
period: (id: string) => ['period', id] as const,
|
||||||
|
planItems: (id: string) => ['period', id, 'plan-items'] as const,
|
||||||
|
summary: (id: string) => ['period', id, 'summary'] as const,
|
||||||
|
budget: (id: string) => ['period', id, 'budget'] as const,
|
||||||
|
suggestion: (id: string, method: string) => ['period', id, 'suggestion', method] as const,
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 4xx hataları tekrar denemenin anlamı yok (yanlış istek / yetki); ağ ve 5xx için 2 kez dene. */
|
/** 4xx hataları tekrar denemenin anlamı yok (yanlış istek / yetki); ağ ve 5xx için 2 kez dene. */
|
||||||
|
|||||||
@@ -102,7 +102,100 @@ export const t = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
noPeriod: {
|
||||||
|
title: 'Henüz açık bir dönem yok',
|
||||||
|
description:
|
||||||
|
'Kakeibo her ay bir planla başlar: gelirini, sabit giderlerini ve birikim hedefini yaz, kalanı 4 gruba dağıt. Birkaç dakika sürer.',
|
||||||
|
action: 'Dönemi başlat',
|
||||||
|
},
|
||||||
|
|
||||||
|
wizard: {
|
||||||
|
title: 'Yeni dönem',
|
||||||
|
description:
|
||||||
|
'Dönemini birkaç adımda planla. Her adım hemen kaydedilir; istediğin an ara verebilirsin.',
|
||||||
|
steps: ['Dönem', 'Gelirler', 'Giderler', 'Birikim', 'Bütçe'],
|
||||||
|
stepOf: (n: number, total: number) => `Adım ${n}/${total}`,
|
||||||
|
back: 'Geri',
|
||||||
|
next: 'İleri',
|
||||||
|
saving: 'Kaydediliyor…',
|
||||||
|
dates: {
|
||||||
|
title: 'Dönemin tarihleri',
|
||||||
|
hint: 'Çoğu kişi takvim ayını seçer. Maaşın ayın ortasında yatıyorsa dönemi o güne göre de başlatabilirsin.',
|
||||||
|
presetsLabel: 'Hızlı seçim',
|
||||||
|
start: 'Başlangıç',
|
||||||
|
last: 'Son gün',
|
||||||
|
name: 'Dönem adı',
|
||||||
|
nameHint: 'Boş bırakırsan tarihlere göre otomatik verilir (ör. "Aralık 2026").',
|
||||||
|
days: (n: number) => `${n} gün · haftalar Pazartesi başlar`,
|
||||||
|
created: 'Dönem açıldı',
|
||||||
|
startRequired: 'Başlangıç tarihini seçin',
|
||||||
|
lastRequired: 'Son günü seçin',
|
||||||
|
lastBeforeStart: 'Son gün, başlangıçtan önce olamaz',
|
||||||
|
tooLong: (max: number) => `Dönem en fazla ${max} gün olabilir`,
|
||||||
|
nameTooLong: 'Dönem adı en fazla 60 karakter olabilir',
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
income: {
|
||||||
|
title: 'Sabit gelirler',
|
||||||
|
hint: 'Bu dönem beklediğin gelirler: maaş, kira geliri, burs… Tarih isteğe bağlı.',
|
||||||
|
placeholder: 'ör. Maaş',
|
||||||
|
empty: 'Henüz gelir eklenmedi.',
|
||||||
|
total: 'Toplam gelir',
|
||||||
|
},
|
||||||
|
expense: {
|
||||||
|
title: 'Sabit giderler',
|
||||||
|
hint: 'Her ay ödediğin, tutarı belli giderler: kira, aidat, faturalar, abonelikler, kredi taksiti.',
|
||||||
|
placeholder: 'ör. Kira',
|
||||||
|
empty: 'Henüz sabit gider eklenmedi.',
|
||||||
|
total: 'Toplam sabit gider',
|
||||||
|
},
|
||||||
|
name: 'Ad',
|
||||||
|
amount: 'Tutar',
|
||||||
|
date: 'Tarih (isteğe bağlı)',
|
||||||
|
add: 'Ekle',
|
||||||
|
remove: (name: string) => `${name} kalemini sil`,
|
||||||
|
removed: (name: string) => `${name} silindi`,
|
||||||
|
copy: 'Önceki dönemden kopyala',
|
||||||
|
copyHint: 'Önceki dönemin gelir ve giderleri eklenir; aynı adda olanlar atlanır.',
|
||||||
|
copied: (n: number) => (n > 0 ? `${n} kalem kopyalandı` : 'Kopyalanacak yeni kalem yok'),
|
||||||
|
nameRequired: 'Adını yazın',
|
||||||
|
nameTooLong: 'Ad en fazla 60 karakter olabilir',
|
||||||
|
dateOutside: 'Tarih dönemin içinde olmalı',
|
||||||
|
},
|
||||||
|
savings: {
|
||||||
|
title: 'Birikim hedefi',
|
||||||
|
hint: 'Kakeibo’da birikim, harcamadan önce ayrılır. Bu dönem kenara koymak istediğin tutarı yaz.',
|
||||||
|
available: 'Gelir − sabit giderler',
|
||||||
|
goal: 'Birikim hedefi',
|
||||||
|
after: 'Harcanabilir kalan',
|
||||||
|
negative: 'Birikim hedefi bu dönemin gelirini aşıyor. Tutarı azaltmayı düşünebilirsin.',
|
||||||
|
},
|
||||||
|
budget: {
|
||||||
|
title: 'Bütçe dağıtımı',
|
||||||
|
hint: 'Harcanabilir tutarı 4 gruba böl. Haftalık bütçeler, haftaların gün sayısına göre otomatik hesaplanır.',
|
||||||
|
method: 'Öneri',
|
||||||
|
methods: { default: 'Varsayılan %', previous: 'Önceki dönem', avg3: 'Son 3 dönem' },
|
||||||
|
apply: 'Öneriyi uygula',
|
||||||
|
distributed: 'Dağıtılan',
|
||||||
|
unallocated: (amount: string) => `${amount} dağıtılmadı`,
|
||||||
|
over: (amount: string) => `Harcanabilir tutar ${amount} aşıldı`,
|
||||||
|
balanced: 'Tamamı dağıtıldı 👌',
|
||||||
|
finish: 'Planı tamamla',
|
||||||
|
done: 'Dönemin hazır! Harcamalarını Defter’e yazmaya başlayabilirsin 📒',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
summary: {
|
||||||
|
income: 'Gelir',
|
||||||
|
fixed: 'Sabit giderler',
|
||||||
|
savings: 'Birikim',
|
||||||
|
debt: 'Devreden borç',
|
||||||
|
surplus: 'Devreden artı',
|
||||||
|
spendable: 'Harcanabilir',
|
||||||
|
},
|
||||||
|
|
||||||
common: {
|
common: {
|
||||||
loading: 'Yükleniyor…',
|
loading: 'Yükleniyor…',
|
||||||
|
retry: 'Tekrar dene',
|
||||||
},
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { DesignPreview } from '@/features/dev/design-preview'
|
|||||||
import { NotFoundPage } from '@/features/errors/not-found-page'
|
import { NotFoundPage } from '@/features/errors/not-found-page'
|
||||||
import { LedgerPage } from '@/features/ledger/page'
|
import { LedgerPage } from '@/features/ledger/page'
|
||||||
import { MorePage } from '@/features/more/page'
|
import { MorePage } from '@/features/more/page'
|
||||||
|
import { PeriodWizardPage } from '@/features/period-wizard/page'
|
||||||
import { PlanningPage } from '@/features/planning/page'
|
import { PlanningPage } from '@/features/planning/page'
|
||||||
import { ReportsPage } from '@/features/reports/page'
|
import { ReportsPage } from '@/features/reports/page'
|
||||||
import { ReviewPage } from '@/features/review/page'
|
import { ReviewPage } from '@/features/review/page'
|
||||||
@@ -34,6 +35,7 @@ const routes: RouteObject[] = [
|
|||||||
{ index: true, element: <DashboardPage /> },
|
{ index: true, element: <DashboardPage /> },
|
||||||
{ path: paths.ledger, element: <LedgerPage /> },
|
{ path: paths.ledger, element: <LedgerPage /> },
|
||||||
{ path: paths.planning, element: <PlanningPage /> },
|
{ path: paths.planning, element: <PlanningPage /> },
|
||||||
|
{ path: paths.periodWizard, element: <PeriodWizardPage /> },
|
||||||
{ path: paths.savings, element: <SavingsPage /> },
|
{ path: paths.savings, element: <SavingsPage /> },
|
||||||
{ path: paths.review, element: <ReviewPage /> },
|
{ path: paths.review, element: <ReviewPage /> },
|
||||||
{ path: paths.reports, element: <ReportsPage /> },
|
{ path: paths.reports, element: <ReportsPage /> },
|
||||||
|
|||||||
Reference in New Issue
Block a user