From ca0d3a505c21a2cf1a2eea3e9f18161c3fba0ff9 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Thu, 12 Nov 2026 08:16:32 +0300 Subject: [PATCH] =?UTF-8?q?feat(plan):=20ilk=20d=C3=B6nem=20sihirbaz=C4=B1?= =?UTF-8?q?=20(d=C3=B6nem,=20gelir,=20gider,=20birikim,=20b=C3=BCt=C3=A7e)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /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 --- CLAUDE.md | 1 + docs/MEMORY.md | 4 +- docs/PHASES.md | 4 +- frontend/src/components/layout/nav-items.ts | 2 + frontend/src/components/money-input.tsx | 43 ++++ frontend/src/components/page.tsx | 32 ++- frontend/src/features/dashboard/page.tsx | 5 +- .../features/period-wizard/no-period-card.tsx | 25 ++ frontend/src/features/period-wizard/page.tsx | 85 +++++++ .../features/period-wizard/schemas.test.ts | 44 ++++ .../src/features/period-wizard/schemas.ts | 55 +++++ .../features/period-wizard/step-budget.tsx | 219 ++++++++++++++++++ .../src/features/period-wizard/step-dates.tsx | 124 ++++++++++ .../src/features/period-wizard/step-items.tsx | 215 +++++++++++++++++ .../features/period-wizard/step-savings.tsx | 110 +++++++++ .../src/features/period-wizard/wizard-ui.tsx | 116 ++++++++++ frontend/src/features/planning/page.tsx | 48 +++- .../src/features/planning/summary-tiles.tsx | 59 +++++ frontend/src/lib/money-schema.test.ts | 35 +++ frontend/src/lib/money-schema.ts | 44 ++++ frontend/src/lib/period-dates.test.ts | 40 ++++ frontend/src/lib/period-dates.ts | 53 +++++ frontend/src/lib/periods.ts | 134 +++++++++++ frontend/src/lib/query.ts | 9 + frontend/src/lib/strings.ts | 93 ++++++++ frontend/src/router.tsx | 2 + 26 files changed, 1594 insertions(+), 7 deletions(-) create mode 100644 frontend/src/components/money-input.tsx create mode 100644 frontend/src/features/period-wizard/no-period-card.tsx create mode 100644 frontend/src/features/period-wizard/page.tsx create mode 100644 frontend/src/features/period-wizard/schemas.test.ts create mode 100644 frontend/src/features/period-wizard/schemas.ts create mode 100644 frontend/src/features/period-wizard/step-budget.tsx create mode 100644 frontend/src/features/period-wizard/step-dates.tsx create mode 100644 frontend/src/features/period-wizard/step-items.tsx create mode 100644 frontend/src/features/period-wizard/step-savings.tsx create mode 100644 frontend/src/features/period-wizard/wizard-ui.tsx create mode 100644 frontend/src/features/planning/summary-tiles.tsx create mode 100644 frontend/src/lib/money-schema.test.ts create mode 100644 frontend/src/lib/money-schema.ts create mode 100644 frontend/src/lib/period-dates.test.ts create mode 100644 frontend/src/lib/period-dates.ts create mode 100644 frontend/src/lib/periods.ts diff --git a/CLAUDE.md b/CLAUDE.md index c5bffc5..7be25bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. - 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`). +- 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`. - 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. diff --git a/docs/MEMORY.md b/docs/MEMORY.md index ec3375e..698fbda 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -1,7 +1,7 @@ # 🧠 Proje Hafızası ## ▶️ 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 - **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 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 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). diff --git a/docs/PHASES.md b/docs/PHASES.md index acfe183..8d56698 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -49,6 +49,6 @@ - ✅ 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 - ✅ 3. Backend: bütçe (aylık/haftalık, öneri), özet (harcanabilir tutar) -- 🔄 4. Frontend: ilk dönem sihirbazı -- ⏳ 5. Frontend: Plan ekranı (görüntüle/düzenle, özet kutucukları, bütçe dağıtımı) +- ✅ 4. Frontend: ilk dönem sihirbazı +- 🔄 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) diff --git a/frontend/src/components/layout/nav-items.ts b/frontend/src/components/layout/nav-items.ts index 6086806..f080065 100644 --- a/frontend/src/components/layout/nav-items.ts +++ b/frontend/src/components/layout/nav-items.ts @@ -18,6 +18,8 @@ export const paths = { dashboard: '/', ledger: '/defter', planning: '/plan', + /** İlk dönem sihirbazı; `?adim=1..5` */ + periodWizard: '/plan/donem-ac', savings: '/birikim', more: '/diger', review: '/degerlendirme', diff --git a/frontend/src/components/money-input.tsx b/frontend/src/components/money-input.tsx new file mode 100644 index 0000000..2c79c3c --- /dev/null +++ b/frontend/src/components/money-input.tsx @@ -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, '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 ( +
+ 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)} + /> + + ₺ + +
+ ) +} diff --git a/frontend/src/components/page.tsx b/frontend/src/components/page.tsx index 72b64ae..c38eab8 100644 --- a/frontend/src/components/page.tsx +++ b/frontend/src/components/page.tsx @@ -1,5 +1,6 @@ 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 { useDocumentTitle } from '@/lib/use-document-title' @@ -25,6 +26,35 @@ export function PageHeader({ ) } +/** Sayfa içi yükleniyor göstergesi */ +export function PageLoading() { + return ( +
+ + {t.common.loading} +
+ ) +} + +/** Veri yüklenemedi: Türkçe mesaj + tekrar dene */ +export function PageError({ message, onRetry }: { message: string; onRetry: () => void }) { + return ( +
+

{message}

+ +
+ ) +} + /** 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 }) { return ( diff --git a/frontend/src/features/dashboard/page.tsx b/frontend/src/features/dashboard/page.tsx index d401154..53b1306 100644 --- a/frontend/src/features/dashboard/page.tsx +++ b/frontend/src/features/dashboard/page.tsx @@ -1,11 +1,14 @@ 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' export function DashboardPage() { + const current = useCurrentPeriod() return ( <> - + {current.data === null ? : } ) } diff --git a/frontend/src/features/period-wizard/no-period-card.tsx b/frontend/src/features/period-wizard/no-period-card.tsx new file mode 100644 index 0000000..694f087 --- /dev/null +++ b/frontend/src/features/period-wizard/no-period-card.tsx @@ -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 ( +
+
+ +
+
+

{s.title}

+

{s.description}

+
+ +
+ ) +} diff --git a/frontend/src/features/period-wizard/page.tsx b/frontend/src/features/period-wizard/page.tsx new file mode 100644 index 0000000..90ab759 --- /dev/null +++ b/frontend/src/features/period-wizard/page.tsx @@ -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 = + if (current.isPending) + return ( + <> + {header} + + + ) + if (current.isError) { + return ( + <> + {header} + + + ) + } + + 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 ( +
+ {header} + + {step === 1 && go(2)} />} + {period && step === 2 && ( + go(1)} onNext={() => go(3)} /> + )} + {period && step === 3 && ( + go(2)} onNext={() => go(4)} /> + )} + {period && step === 4 && ( + go(3)} onNext={() => go(5)} /> + )} + {period && step === 5 && ( + go(4)} + onDone={() => { + toast.success(s.budget.done) + navigate(paths.planning) + }} + /> + )} +
+ ) +} diff --git a/frontend/src/features/period-wizard/schemas.test.ts b/frontend/src/features/period-wizard/schemas.test.ts new file mode 100644 index 0000000..c5f0834 --- /dev/null +++ b/frontend/src/features/period-wizard/schemas.test.ts @@ -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) + }) +}) diff --git a/frontend/src/features/period-wizard/schemas.ts b/frontend/src/features/period-wizard/schemas.ts new file mode 100644 index 0000000..6681f5c --- /dev/null +++ b/frontend/src/features/period-wizard/schemas.ts @@ -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 + +/** 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> + +/** 4. adım */ +export const savingsSchema = z.object({ goal: moneyText('optional') }) +export type SavingsForm = z.infer + +/** 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 diff --git a/frontend/src/features/period-wizard/step-budget.tsx b/frontend/src/features/period-wizard/step-budget.tsx new file mode 100644 index 0000000..78eb0b4 --- /dev/null +++ b/frontend/src/features/period-wizard/step-budget.tsx @@ -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 + if (!summary.data || !budget.data || !suggestion.data) return + + const saved = Object.fromEntries(budget.data.map((g) => [g.group, g.monthly])) as GroupAmounts + const hasSaved = budget.data.some((g) => g.monthly > 0) + + return ( + + ) +} + +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('default') + const suggestion = useSuggestion(period.id, method) + + const { + control, + handleSubmit, + reset, + setError, + formState: { errors, isSubmitting }, + } = useForm({ + 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 ( +
+ + + + {/* Öneri seçimi */} +
+ {s.method} +
+ {METHODS.map((m) => ( + + ))} +
+ {suggestion.isPending ? ( + + ) : suggestion.data?.available ? ( + + ) : ( +

+ {suggestion.data?.message ?? suggestion.error?.message} +

+ )} +
+ + {/* 4 grup */} +
+ {GROUPS.map((g) => ( + + {(f) => ( +
+ ( + + )} + /> +

{g.description}

+
+ )} +
+ ))} +
+ + {/* Dağıtım durumu */} +
+
+ {s.distributed} + + {formatMoney(distributed)} / {formatMoney(summary.spendable)} + +
+
+
0 ? Math.min(100, (distributed / summary.spendable) * 100) : 0}%`, + }} + /> +
+

+ {remaining < 0 + ? s.over(formatMoney(-remaining)) + : remaining > 0 + ? s.unallocated(formatMoney(remaining)) + : s.balanced} +

+
+ + + + + ) +} diff --git a/frontend/src/features/period-wizard/step-dates.tsx b/frontend/src/features/period-wizard/step-dates.tsx new file mode 100644 index 0000000..6170e6c --- /dev/null +++ b/frontend/src/features/period-wizard/step-dates.tsx @@ -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({ + 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 ( +
+ + +
+
+ {presets.map((p) => { + const selected = start === p.range.start && last === p.range.last + return ( + + ) + })} +
+ +
+ + {(f) => } + + + {(f) => } + +
+ {days !== null && ( +

+ {s.days(days)} +

+ )} + + + {(f) => } + + + +
+ + + + ) +} diff --git a/frontend/src/features/period-wizard/step-items.tsx b/frontend/src/features/period-wizard/step-items.tsx new file mode 100644 index 0000000..48f7ebc --- /dev/null +++ b/frontend/src/features/period-wizard/step-items.tsx @@ -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 + if (items.isError) return + + const list = items.data.filter((i) => i.kind === kind) + const total = list.reduce((sum, i) => sum + i.amount, 0) + + return ( +
+ + + + + +
+ {list.length === 0 ? ( +

+ {text.empty} +

+ ) : ( +
    + {list.map((item) => ( + + ))} +
+ )} +
+ {text.total} + + {formatMoney(total)} + +
+
+ + +
+ ) +} + +function AddItemForm({ period, kind }: { period: Period; kind: PlanItemKind }) { + const create = useCreatePlanItem(period.id) + const { + register, + control, + handleSubmit, + reset, + setFocus, + setError, + formState: { errors, isSubmitting }, + } = useForm({ + 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 ( +
+ + {(f) => ( + + )} + +
+ + {(f) => ( + } + /> + )} + + + {(f) => ( + + )} + +
+ + + + ) +} + +function ItemRow({ item, periodId }: { item: PlanItem; periodId: string }) { + const remove = useDeletePlanItem(periodId) + return ( +
  • +
    +

    {item.name}

    + {item.date &&

    {formatDate(item.date)}

    } +
    + {formatMoney(item.amount)} + +
  • + ) +} + +/** Ö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 ( +
    +

    {s.copyHint}

    + +
    + ) +} diff --git a/frontend/src/features/period-wizard/step-savings.tsx b/frontend/src/features/period-wizard/step-savings.tsx new file mode 100644 index 0000000..80ec690 --- /dev/null +++ b/frontend/src/features/period-wizard/step-savings.tsx @@ -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({ + 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 + if (summary.isError) + return + + // 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 ( +
    + + +
    + + + {(f) => ( + ( + + )} + /> + )} + + + {after < 0 && ( +

    + {s.negative} +

    + )} + +
    + + + + ) +} + +function Row({ label, amount, strong }: { label: string; amount: number; strong?: boolean }) { + return ( +
    + {label} + + {formatMoney(amount)} + +
    + ) +} diff --git a/frontend/src/features/period-wizard/wizard-ui.tsx b/frontend/src/features/period-wizard/wizard-ui.tsx new file mode 100644 index 0000000..8191bb8 --- /dev/null +++ b/frontend/src/features/period-wizard/wizard-ui.tsx @@ -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 ( + + ) +} + +/** Adım başlığı + açıklama */ +export function StepIntro({ title, hint }: { title: string; hint: string }) { + return ( +
    +

    {title}

    +

    {hint}

    +
    + ) +} + +/** + * 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 ( +
    + {onBack && ( + + )} + {extra} + +
    + ) +} diff --git a/frontend/src/features/planning/page.tsx b/frontend/src/features/planning/page.tsx index eb3d2b4..72ab092 100644 --- a/frontend/src/features/planning/page.tsx +++ b/frontend/src/features/planning/page.tsx @@ -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 type { Period } from '@/types/Period' +import { SummaryTiles } from './summary-tiles' export function PlanningPage() { + const current = useCurrentPeriod() return ( <> - + {current.isPending ? ( + + ) : current.isError ? ( + + ) : current.data ? ( + + ) : ( + + )} ) } + +/** 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 ( +
    +
    +
    +

    {period.name}

    +

    + {formatDate(period.start_date)} – {formatDate(period.last_date)} · {period.days} gün ·{' '} + {period.weeks.length} hafta +

    +
    + +
    + {summary.data && } + +
    + ) +} diff --git a/frontend/src/features/planning/summary-tiles.tsx b/frontend/src/features/planning/summary-tiles.tsx new file mode 100644 index 0000000..739d207 --- /dev/null +++ b/frontend/src/features/planning/summary-tiles.tsx @@ -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 ( +
    + {tiles.map((tile) => ( +
    +
    + {tile.sign && {tile.sign} } + {tile.label} +
    +
    {formatMoney(tile.amount)}
    +
    + ))} +
    +
    + = + {s.spendable} +
    +
    + {formatMoney(summary.spendable)} +
    +
    +
    + ) +} diff --git a/frontend/src/lib/money-schema.test.ts b/frontend/src/lib/money-schema.test.ts new file mode 100644 index 0000000..c833bcb --- /dev/null +++ b/frontend/src/lib/money-schema.test.ts @@ -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) + }) +}) diff --git a/frontend/src/lib/money-schema.ts b/frontend/src/lib/money-schema.ts new file mode 100644 index 0000000..f235dbf --- /dev/null +++ b/frontend/src/lib/money-schema.ts @@ -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 +} diff --git a/frontend/src/lib/period-dates.test.ts b/frontend/src/lib/period-dates.test.ts new file mode 100644 index 0000000..848b471 --- /dev/null +++ b/frontend/src/lib/period-dates.test.ts @@ -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') + }) +}) diff --git a/frontend/src/lib/period-dates.ts b/frontend/src/lib/period-dates.ts new file mode 100644 index 0000000..70cd157 --- /dev/null +++ b/frontend/src/lib/period-dates.ts @@ -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) +} diff --git a/frontend/src/lib/periods.ts b/frontend/src/lib/periods.ts new file mode 100644 index 0000000..d222bf1 --- /dev/null +++ b/frontend/src/lib/periods.ts @@ -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('/periods/current'), + }) +} + +export function usePeriods() { + return useQuery({ + queryKey: queryKeys.periods, + queryFn: () => api.get('/periods'), + }) +} + +export function useCreatePeriod() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (body: PeriodCreateRequest) => api.post('/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(`/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(`/periods/${periodId}/plan-items`), + }) +} + +export function useCreatePlanItem(periodId: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (body: PlanItemCreateRequest) => + api.post(`/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(`/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(`/periods/${periodId}/summary`), + }) +} + +export function useBudget(periodId: string) { + return useQuery({ + queryKey: queryKeys.budget(periodId), + queryFn: () => api.get(`/periods/${periodId}/budget`), + }) +} + +export function useSuggestion(periodId: string, method: SuggestMethod) { + return useQuery({ + queryKey: queryKeys.suggestion(periodId, method), + queryFn: () => + api.get(`/periods/${periodId}/budget/suggestion?method=${method}`), + }) +} + +export function useSetBudget(periodId: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (body: GroupAmounts) => api.put(`/periods/${periodId}/budget`, body), + onSuccess: (groups) => { + qc.setQueryData(queryKeys.budget(periodId), groups) + void qc.invalidateQueries({ queryKey: queryKeys.summary(periodId) }) + }, + }) +} diff --git a/frontend/src/lib/query.ts b/frontend/src/lib/query.ts index 36c6511..23e9e72 100644 --- a/frontend/src/lib/query.ts +++ b/frontend/src/lib/query.ts @@ -12,6 +12,15 @@ export const queryKeys = { household: ['household'] as const, categories: (includeInactive = false) => ['categories', { 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. */ diff --git a/frontend/src/lib/strings.ts b/frontend/src/lib/strings.ts index 19fa003..d1dbe90 100644 --- a/frontend/src/lib/strings.ts +++ b/frontend/src/lib/strings.ts @@ -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: { loading: 'Yükleniyor…', + retry: 'Tekrar dene', }, } as const diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index f7d2073..def6c1c 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -9,6 +9,7 @@ import { DesignPreview } from '@/features/dev/design-preview' import { NotFoundPage } from '@/features/errors/not-found-page' import { LedgerPage } from '@/features/ledger/page' import { MorePage } from '@/features/more/page' +import { PeriodWizardPage } from '@/features/period-wizard/page' import { PlanningPage } from '@/features/planning/page' import { ReportsPage } from '@/features/reports/page' import { ReviewPage } from '@/features/review/page' @@ -34,6 +35,7 @@ const routes: RouteObject[] = [ { index: true, element: }, { path: paths.ledger, element: }, { path: paths.planning, element: }, + { path: paths.periodWizard, element: }, { path: paths.savings, element: }, { path: paths.review, element: }, { path: paths.reports, element: },