diff --git a/CLAUDE.md b/CLAUDE.md index 7be25bf..a7d184e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,7 +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=`. +- 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=`. Plan düzenleme: taslak → `diffPlan` → `useSavePlan`; haftalık önizleme `lib/budget-preview.ts` (backend hesabıyla aynı tutulmalı). - 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 698fbda..fb66a29 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -1,7 +1,7 @@ # 🧠 Proje Hafızası ## ▶️ Sıradaki Adım -**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ı). +**Faz 4 – Adım 6:** Notlar (Markdown): dönem notları — planlama ve değerlendirme bağlamı (`NoteContext`), liste + ekle/düzenle/sil, düzenle/önizle sekmeleri, react-markdown + remark-gfm (yeni bağımlılık, gerekçe verilecek). Plan ekranına "Notlar" bölümü. Backend hazır (`/api/periods/{id}/notes`, `/api/notes/{id}`). ## 📌 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`). @@ -93,3 +93,5 @@ - **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). +- **Adım 5:** Plan ekranı (`features/planning/`): `plan-read.tsx` (salt okunur: kalemler, 4 grup + haftalık döküm, "elle" etiketi), `plan-edit.tsx` (taslak formu, Kaydet/Vazgeç, `useBlocker` + beforeunload ile kaydedilmemiş değişiklik onayı). `lib/plan-draft.ts` (`toDraft`/`diffPlan`: yalnızca değişenler gönderilir), `lib/budget-preview.ts` (backend `split_proportional` + `view` ile birebir; aynı test örnekleri), `useSavePlan` (sıralı istekler; hata olursa yeniden yükler, transaction değil). Ortak: `DistributionBar`, `SuggestionPicker` (sihirbaz da kullanıyor). Frontend 69 test. +- E2E artık ayrı sunucuda: `REGISTRATION=open PORT=8091 STATIC_DIR=frontend/dist MONGODB_URI=…/kakeibo_test_e2e` (kullanıcının dev verisine dokunulmaz; bitince DB silinir). 31 kontrol geçti; ekran görüntüsüyle 3 düzen sorunu bulundu/düzeltildi (dar satırlar, mobil tutar/tarih, hafta etiketi). diff --git a/docs/PHASES.md b/docs/PHASES.md index 8d56698..71be70d 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -50,5 +50,5 @@ - ✅ 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ı) -- ⏳ 6. Frontend: notlar (Markdown, düzenle/önizle) +- ✅ 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/features/period-wizard/step-budget.tsx b/frontend/src/features/period-wizard/step-budget.tsx index 78eb0b4..561da69 100644 --- a/frontend/src/features/period-wizard/step-budget.tsx +++ b/frontend/src/features/period-wizard/step-budget.tsx @@ -1,36 +1,33 @@ -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 { DistributionBar } from '@/features/planning/distribution-bar' +import { SuggestionPicker } from '@/features/planning/suggestion-picker' import { SummaryTiles } from '@/features/planning/summary-tiles' import { ApiError } from '@/lib/api' -import { formatMoney, formatNumber, parseMoney } from '@/lib/format' +import { parseMoney } from '@/lib/format' import { GROUPS } from '@/lib/groups' import { toKurus } from '@/lib/money-schema' import { useBudget, useSetBudget, useSuggestion, useSummary } from '@/lib/periods' +import { moneyToText } from '@/lib/plan-draft' 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), + needs: moneyToText(a.needs), + wants: moneyToText(a.wants), + culture: moneyToText(a.culture), + unexpected: moneyToText(a.unexpected), } } @@ -84,9 +81,6 @@ function BudgetStepForm({ onDone: () => void }) { const setBudget = useSetBudget(period.id) - const [method, setMethod] = useState('default') - const suggestion = useSuggestion(period.id, method) - const { control, handleSubmit, @@ -103,7 +97,6 @@ function BudgetStepForm({ (sum, g) => sum + Math.max(parseMoney(values[g.code] ?? '') ?? 0, 0), 0, ) - const remaining = summary.spendable - distributed const onSubmit = handleSubmit(async (v) => { try { @@ -124,45 +117,10 @@ function BudgetStepForm({ - {/* Öneri seçimi */} -
- {s.method} -
- {METHODS.map((m) => ( - - ))} -
- {suggestion.isPending ? ( - - ) : suggestion.data?.available ? ( - - ) : ( -

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

- )} -
+
+ reset(toForm(a))} /> +
- {/* 4 grup */}
{GROUPS.map((g) => ( @@ -182,34 +140,8 @@ function BudgetStepForm({ ))}
- {/* 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-items.tsx b/frontend/src/features/period-wizard/step-items.tsx index 48f7ebc..415c270 100644 --- a/frontend/src/features/period-wizard/step-items.tsx +++ b/frontend/src/features/period-wizard/step-items.tsx @@ -187,7 +187,7 @@ function ItemRow({ item, periodId }: { item: PlanItem; periodId: string }) { } /** Önceki dönem varsa görünür; gelir ve giderleri birlikte kopyalar. */ -function CopyPrevious({ period }: { period: Period }) { +export 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) diff --git a/frontend/src/features/planning/distribution-bar.tsx b/frontend/src/features/planning/distribution-bar.tsx new file mode 100644 index 0000000..084a6bb --- /dev/null +++ b/frontend/src/features/planning/distribution-bar.tsx @@ -0,0 +1,45 @@ +import { formatMoney } from '@/lib/format' +import { t } from '@/lib/strings' +import { cn } from '@/lib/utils' + +const s = t.wizard.budget + +/** Dağıtılan / harcanabilir çubuğu + "dağıtılmadı / aşıldı / tamamı dağıtıldı" durumu. */ +export function DistributionBar({ + distributed, + spendable, +}: { + distributed: number + spendable: number +}) { + const remaining = spendable - distributed + const pct = spendable > 0 ? Math.min(100, (distributed / spendable) * 100) : 0 + return ( +
+
+ {s.distributed} + + {formatMoney(distributed)} / {formatMoney(spendable)} + +
+
+
+
+

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

+
+ ) +} diff --git a/frontend/src/features/planning/page.tsx b/frontend/src/features/planning/page.tsx index 72ab092..c532253 100644 --- a/frontend/src/features/planning/page.tsx +++ b/frontend/src/features/planning/page.tsx @@ -1,13 +1,14 @@ +import { useState } from 'react' 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 { PageError, PageHeader, PageLoading } from '@/components/page' import { Button } from '@/components/ui/button' +import { CopyPrevious } from '@/features/period-wizard/step-items' import { NoPeriodCard } from '@/features/period-wizard/no-period-card' -import { formatDate } from '@/lib/format' -import { useCurrentPeriod, useSummary } from '@/lib/periods' +import { useBudget, useCurrentPeriod, usePlanItems, useSummary } from '@/lib/periods' import { t } from '@/lib/strings' import type { Period } from '@/types/Period' +import { PlanEdit } from './plan-edit' +import { BudgetRead, ItemsList, PeriodHeading } from './plan-read' import { SummaryTiles } from './summary-tiles' export function PlanningPage() { @@ -20,7 +21,7 @@ export function PlanningPage() { ) : current.isError ? ( ) : current.data ? ( - + ) : ( )} @@ -28,28 +29,53 @@ export function PlanningPage() { ) } -/** Geçici görünüm: ayrıntılı Plan ekranı Faz 4 – Adım 5'te. */ -function OpenPeriod({ period }: { period: Period }) { +/** Varsayılan salt okunur; "Düzenle" → düzenleme modu (Kaydet / Vazgeç). */ +function PlanView({ period }: { period: Period }) { + const items = usePlanItems(period.id) const summary = useSummary(period.id) + const budget = useBudget(period.id) + const [editing, setEditing] = useState(false) + + const failed = [items, summary, budget].find((q) => q.isError) + if (failed?.error) return + if (!items.data || !summary.data || !budget.data) return + + if (editing) { + return ( + { + setEditing(false) + window.scrollTo({ top: 0 }) + }} + /> + ) + } + + const editable = period.status === 'open' return (
-
-
-

{period.name}

-

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

-
- + setEditing(true)}> + + {t.plan.edit} + + ) + } + /> + +
+ i.kind === 'income')} /> + i.kind === 'expense')} />
- {summary.data && } - + {editable && } +
) } diff --git a/frontend/src/features/planning/plan-edit.tsx b/frontend/src/features/planning/plan-edit.tsx new file mode 100644 index 0000000..d2fd593 --- /dev/null +++ b/frontend/src/features/planning/plan-edit.tsx @@ -0,0 +1,476 @@ +import { useEffect } from 'react' +import { zodResolver } from '@hookform/resolvers/zod' +import { CalendarCog, Loader2, Plus, RotateCcw, Save, Trash2, X } from 'lucide-react' +import { + Controller, + useFieldArray, + useForm, + useWatch, + type Control, + type FieldErrors, + type UseFormRegister, + type UseFormSetValue, +} from 'react-hook-form' +import { Link, useBlocker } from 'react-router' +import { toast } from 'sonner' +import { FormField } from '@/components/form-field' +import { paths } from '@/components/layout/nav-items' +import { MoneyInput } from '@/components/money-input' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { previewWeeks } from '@/lib/budget-preview' +import { formatDayMonth, formatMoney, formatNumber, parseMoney } from '@/lib/format' +import { group, GROUPS } from '@/lib/groups' +import { useSavePlan } from '@/lib/periods' +import { diffPlan, GROUP_CODES, hasChanges, moneyToText, toDraft } from '@/lib/plan-draft' +import { t } from '@/lib/strings' +import { cn } from '@/lib/utils' +import type { GroupBudget } from '@/types/GroupBudget' +import type { Period } from '@/types/Period' +import type { PeriodSummary } from '@/types/PeriodSummary' +import type { PlanItem } from '@/types/PlanItem' +import type { PlanItemKind } from '@/types/PlanItemKind' +import { DistributionBar } from './distribution-bar' +import { PeriodHeading, Section } from './plan-read' +import { planSchema, type PlanForm } from './schema' +import { SuggestionPicker } from './suggestion-picker' +import { SummaryTiles } from './summary-tiles' + +const s = t.plan +const itemText = t.wizard.items + +/** Metin → kuruş (geçersiz / boş / eksi → 0): önizleme hesapları için */ +const k = (text: string | undefined) => Math.max(parseMoney(text ?? '') ?? 0, 0) + +/** + * Düzenleme modu. Hiçbir şey anında kaydedilmez: "Kaydet" yalnızca değişenleri gönderir, + * "Vazgeç" taslağı atar. Kaydedilmemiş değişiklik varken sayfadan çıkılırsa onay istenir. + */ +export function PlanEdit({ + period, + items, + budget, + summary, + onClose, +}: { + period: Period + items: PlanItem[] + budget: GroupBudget[] + summary: PeriodSummary + onClose: () => void +}) { + const save = useSavePlan(period) + const { + control, + register, + handleSubmit, + setValue, + formState: { errors, isDirty, isSubmitting }, + } = useForm({ + resolver: zodResolver(planSchema(period.start_date, period.last_date)), + mode: 'onTouched', + defaultValues: toDraft(period, items, budget), + }) + const v = useWatch({ control }) + + // ---- Canlı önizleme: kaydedilmiş özet + taslaktaki farklar (formül backend'de kalır) + const incomeTotal = (v.incomes ?? []).reduce((a, r) => a + k(r?.amount), 0) + const fixedTotal = (v.expenses ?? []).reduce((a, r) => a + k(r?.amount), 0) + const savings = k(v.savings) + const spendable = + summary.spendable + + (incomeTotal - summary.income_total) - + (fixedTotal - summary.fixed_expense_total) - + (savings - summary.savings_goal) + const budgetTotal = GROUP_CODES.reduce((a, g) => a + k(v.budget?.[g]), 0) + const preview: PeriodSummary = { + ...summary, + income_total: incomeTotal, + fixed_expense_total: fixedTotal, + savings_goal: savings, + spendable, + budget_total: budgetTotal, + unallocated: spendable - budgetTotal, + } + + // ---- Kaydedilmemiş değişiklik koruması + const blocker = useBlocker( + ({ currentLocation, nextLocation }) => + isDirty && !isSubmitting && currentLocation.pathname !== nextLocation.pathname, + ) + useEffect(() => { + if (blocker.state !== 'blocked') return + if (window.confirm(s.leaveConfirm)) blocker.proceed() + else blocker.reset() + }, [blocker]) + useEffect(() => { + if (!isDirty) return + const warn = (e: BeforeUnloadEvent) => e.preventDefault() + window.addEventListener('beforeunload', warn) + return () => window.removeEventListener('beforeunload', warn) + }, [isDirty]) + + const onSubmit = handleSubmit(async (draft) => { + const ops = diffPlan(period, items, budget, draft) + if (!hasChanges(ops)) { + toast(s.noChanges) + return onClose() + } + try { + await save.mutateAsync(ops) + toast.success(s.saved) + } catch (e) { + toast.error(s.saveFailed(e instanceof Error ? e.message : String(e))) + } + onClose() + }) + + const actions = ( + <> + + + + ) + + return ( +
+
+ ✏️ {s.editing} + · {s.editingHint} +
+ + + + +
+ + +
+ +
+ + + {s.changeDates} + + + } + > + + {(f) => ( + } + /> + )} + +
+ + + +
{actions}
+ + ) +} + +// ---------- Gelir / gider satırları ---------- + +function ItemsEdit({ + kind, + period, + control, + register, + errors, +}: { + kind: PlanItemKind + period: Period + control: Control + register: UseFormRegister + errors: FieldErrors +}) { + const name = kind === 'income' ? 'incomes' : 'expenses' + // keyName: kalemlerin kendi `id` alanı var, RHF'nin iç anahtarı onu ezmesin + const { fields, append, remove } = useFieldArray({ control, name, keyName: 'key' }) + const rows = useWatch({ control, name }) + const total = (rows ?? []).reduce((a, r) => a + k(r?.amount), 0) + const text = itemText[kind] + + return ( +
+ {fields.length === 0 &&

{text.empty}

} +
    + {fields.map((field, i) => { + const e = errors[name]?.[i] + const messages = [e?.name?.message, e?.amount?.message, e?.date?.message].filter(Boolean) + const label = rows?.[i]?.name || text.placeholder + return ( +
  • + {/* Mobilde ad + sil aynı satırda; geniş ekranda `contents` → ızgaraya katılır */} +
    + + +
    + ( + + )} + /> + + {messages.length > 0 && ( +

    + {messages.join(' · ')} +

    + )} +
  • + ) + })} +
+ +
+ {text.total} + + {formatMoney(total)} + +
+
+ ) +} + +// ---------- Bütçe: aylık + haftalık ---------- + +function BudgetEdit({ + period, + control, + setValue, + errors, + values, + spendable, + budgetTotal, +}: { + period: Period + control: Control + setValue: UseFormSetValue + errors: FieldErrors + values: ReturnType> + spendable: number + budgetTotal: number +}) { + return ( +
+
+ + GROUP_CODES.forEach((g) => + setValue(`budget.${g}`, moneyToText(a[g]), { + shouldDirty: true, + shouldValidate: true, + }), + ) + } + /> + + +
+ {GROUPS.map((g) => { + const weekTexts = values.weeks?.[g.code] ?? [] + const pv = previewWeeks( + k(values.budget?.[g.code]), + period.weeks.map((w, i) => { + const txt = weekTexts[i] ?? '' + return { days: w.days, manual: txt.trim() === '' ? null : k(txt) } + }), + ) + return ( +
+
+ + {(f) => ( + ( + + )} + /> + )} + +
+
w.isManual)} className="group/weeks"> + + {s.weekly} + +

{s.weekHint}

+
    + {period.weeks.map((w, i) => ( + + ))} +
+
+ {pv.weeklyOver && ( +

+ {s.weeklyOver} +

+ )} +
+ ) + })} +
+
+
+ ) +} + +function WeekRow({ + control, + setValue, + groupCode, + index, + label, + range, + days, + auto, + manual, + error, +}: { + control: Control + setValue: UseFormSetValue + groupCode: (typeof GROUP_CODES)[number] + index: number + label: string + range: string + days: number + auto: number + manual: boolean + error?: string +}) { + const fieldName = `weeks.${groupCode}.${index}` as const + return ( +
  • + + {label} + + {range} · {s.weekDays(days)} + {manual && ` · ${s.manual}`} + + +
    + ( + + )} + /> +
    + {manual ? ( + + ) : ( + + )} + {error &&

    {error}

    } +
  • + ) +} diff --git a/frontend/src/features/planning/plan-read.tsx b/frontend/src/features/planning/plan-read.tsx new file mode 100644 index 0000000..691c85b --- /dev/null +++ b/frontend/src/features/planning/plan-read.tsx @@ -0,0 +1,139 @@ +import type { ReactNode } from 'react' +import { formatDate, formatDayMonth, formatMoney } from '@/lib/format' +import { group, GROUPS } from '@/lib/groups' +import { t } from '@/lib/strings' +import { cn } from '@/lib/utils' +import type { GroupBudget } from '@/types/GroupBudget' +import type { Period } from '@/types/Period' +import type { PeriodSummary } from '@/types/PeriodSummary' +import type { PlanItem } from '@/types/PlanItem' +import type { PlanItemKind } from '@/types/PlanItemKind' +import { DistributionBar } from './distribution-bar' + +const s = t.plan +const items = t.wizard.items + +/** Dönem adı, tarihleri + sağda eylemler (Düzenle / Kaydet…) */ +export function PeriodHeading({ period, actions }: { period: Period; actions?: ReactNode }) { + return ( +
    +
    +

    {period.name}

    +

    + {formatDate(period.start_date)} – {formatDate(period.last_date)} ·{' '} + {s.periodInfo(period.days, period.weeks.length)} +

    +
    + {actions &&
    {actions}
    } +
    + ) +} + +/** Bölüm kutusu: başlık + (isteğe bağlı) sağ üst eylem */ +export function Section({ + title, + action, + children, + className, +}: { + title: string + action?: ReactNode + children: ReactNode + className?: string +}) { + return ( +
    +
    +

    {title}

    + {action} +
    + {children} +
    + ) +} + +/** Salt okunur gelir / gider listesi */ +export function ItemsList({ kind, list }: { kind: PlanItemKind; list: PlanItem[] }) { + const text = items[kind] + const total = list.reduce((a, i) => a + i.amount, 0) + return ( +
    + {list.length === 0 ? ( +

    {text.empty}

    + ) : ( +
      + {list.map((i) => ( +
    • +
      +

      {i.name}

      + {i.date &&

      {formatDate(i.date)}

      } +
      + {formatMoney(i.amount)} +
    • + ))} +
    + )} +
    + {text.total} + + {formatMoney(total)} + +
    +
    + ) +} + +/** Salt okunur bütçe: dağıtım çubuğu + 4 grup (aylık + haftalık döküm) */ +export function BudgetRead({ budget, summary }: { budget: GroupBudget[]; summary: PeriodSummary }) { + return ( +
    +
    + + {summary.budget_total === 0 && ( +

    {s.emptyBudget}

    + )} +
    + {GROUPS.map((g) => { + const b = budget.find((x) => x.group === g.code) + return b ? : null + })} +
    +
    +
    + ) +} + +function GroupBudgetCard({ budget }: { budget: GroupBudget }) { + const g = group(budget.group) + return ( +
    +
    + + {g.emoji} + {g.label} + + {formatMoney(budget.monthly)} +
    +
      + {budget.weeks.map((w) => ( +
    • + + {s.week(w.index)} · {formatDayMonth(w.start_date)}–{formatDayMonth(w.last_date)} + + {w.is_manual && ( + + {s.manual} + + )} + {formatMoney(w.amount)} +
    • + ))} +
    + {budget.weekly_over && ( +

    + {s.weeklyOver} +

    + )} +
    + ) +} diff --git a/frontend/src/features/planning/schema.ts b/frontend/src/features/planning/schema.ts new file mode 100644 index 0000000..aa3d774 --- /dev/null +++ b/frontend/src/features/planning/schema.ts @@ -0,0 +1,25 @@ +import { z } from 'zod' +import { itemSchema } from '@/features/period-wizard/schemas' +import { moneyText } from '@/lib/money-schema' + +/** Plan ekranı düzenleme formu (`lib/plan-draft.ts` → `PlanDraft` ile aynı biçim). */ +export function planSchema(start: string, last: string) { + const item = itemSchema(start, last).extend({ + id: z.string().optional(), + version: z.number().optional(), + }) + const money = moneyText('optional') + return z.object({ + incomes: z.array(item), + expenses: z.array(item), + savings: money, + budget: z.object({ needs: money, wants: money, culture: money, unexpected: money }), + weeks: z.object({ + needs: z.array(money), + wants: z.array(money), + culture: z.array(money), + unexpected: z.array(money), + }), + }) +} +export type PlanForm = z.infer> diff --git a/frontend/src/features/planning/suggestion-picker.tsx b/frontend/src/features/planning/suggestion-picker.tsx new file mode 100644 index 0000000..e3b75b3 --- /dev/null +++ b/frontend/src/features/planning/suggestion-picker.tsx @@ -0,0 +1,60 @@ +import { useState } from 'react' +import { Loader2, Sparkles } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { useSuggestion } from '@/lib/periods' +import { t } from '@/lib/strings' +import type { GroupAmounts } from '@/types/GroupAmounts' +import type { SuggestMethod } from '@/types/SuggestMethod' + +const s = t.wizard.budget +const METHODS: SuggestMethod[] = ['default', 'previous', 'avg3'] + +/** Bütçe önerisi: yöntem seç → "Öneriyi uygula" (veri yoksa nedeni yazılır). */ +export function SuggestionPicker({ + periodId, + onApply, + hint, +}: { + periodId: string + onApply: (amounts: GroupAmounts) => void + hint?: string +}) { + const [method, setMethod] = useState('default') + const suggestion = useSuggestion(periodId, method) + + return ( +
    + {s.method} +
    + {METHODS.map((m) => ( + + ))} +
    + {suggestion.isPending ? ( + + ) : suggestion.data?.available ? ( + + ) : ( +

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

    + )} + {hint &&

    {hint}

    } +
    + ) +} diff --git a/frontend/src/lib/budget-preview.test.ts b/frontend/src/lib/budget-preview.test.ts new file mode 100644 index 0000000..cc24753 --- /dev/null +++ b/frontend/src/lib/budget-preview.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import { previewWeeks, splitProportional } from './budget-preview' + +// Beklenen değerler backend testleriyle aynı (services/money.rs) → iki taraf aynı sonucu verir +describe('splitProportional (backend ile aynı)', () => { + it('splits December weeks exactly', () => { + const p = splitProportional(100_000, [6, 7, 7, 7, 4]) + expect(p).toEqual([19_355, 22_581, 22_581, 22_580, 12_903]) + expect(p.reduce((a, b) => a + b)).toBe(100_000) + }) + + it('default percentages', () => { + expect(splitProportional(1_234_567, [50, 30, 10, 10])).toEqual([ + 617_283, 370_370, 123_457, 123_457, + ]) + expect(splitProportional(1, [50, 30, 10, 10])).toEqual([1, 0, 0, 0]) + }) + + it('edge cases', () => { + expect(splitProportional(0, [1, 2])).toEqual([0, 0]) + expect(splitProportional(-500, [1, 2])).toEqual([0, 0]) + expect(splitProportional(100, [0, 0])).toEqual([0, 0]) + expect(splitProportional(100, [])).toEqual([]) + expect(splitProportional(7, [0, 3, 0])).toEqual([0, 7, 0]) + const big = 10_000_000_000_000 + expect(splitProportional(big, [31, 31]).reduce((a, b) => a + b)).toBe(big) + }) +}) + +describe('previewWeeks', () => { + const dec = [6, 7, 7, 7, 4] + + it('all automatic', () => { + const r = previewWeeks( + 100_000, + dec.map((days) => ({ days, manual: null })), + ) + expect(r.weeks.map((w) => w.amount)).toEqual([19_355, 22_581, 22_581, 22_580, 12_903]) + expect(r.weeklyOver).toBe(false) + }) + + it('manual week: rest is split over the other weeks by days', () => { + const r = previewWeeks( + 100_000, + dec.map((days, i) => ({ days, manual: i === 1 ? 40_000 : null })), + ) + expect(r.weeks[1]).toEqual({ amount: 40_000, isManual: true }) + // kalan 60.000 → 6,7,7,4 gün + expect(r.weeks.filter((w) => !w.isManual).map((w) => w.amount)).toEqual([ + 15_000, 17_500, 17_500, 10_000, + ]) + expect(r.weeks.reduce((a, w) => a + w.amount, 0)).toBe(100_000) + }) + + it('flags manual weeks exceeding the monthly amount', () => { + const r = previewWeeks( + 10_000, + dec.map((days, i) => ({ days, manual: i === 0 ? 12_000 : null })), + ) + expect(r.weeklyOver).toBe(true) + expect(r.weeks.slice(1).every((w) => w.amount === 0)).toBe(true) + }) +}) diff --git a/frontend/src/lib/budget-preview.ts b/frontend/src/lib/budget-preview.ts new file mode 100644 index 0000000..1e2baab --- /dev/null +++ b/frontend/src/lib/budget-preview.ts @@ -0,0 +1,64 @@ +/** + * Haftalık bütçe önizlemesi (düzenleme modunda, kaydetmeden önce canlı göstermek için). + * + * Backend'in hesabının birebir aynısı — `services/money.rs` (`split_proportional`) + * ve `services/budget.rs` (`view`). Asıl değer her zaman backend'den gelir; + * bu dosya yalnızca "kaydedersen böyle olacak" önizlemesidir. Testler aynı örnekleri kullanır. + */ + +/** + * `total` kuruşu ağırlıklara orantılı böler; toplam her zaman `total`'a eşittir. + * "En büyük kalan" yöntemi: herkes tam payını alır, artan kuruşlar küsuratı + * en büyük olana (eşitlikte öndekine) verilir. `total ≤ 0` ya da ağırlık yoksa hepsi 0. + */ +export function splitProportional(total: number, weights: number[]): number[] { + const sum = weights.reduce((a, w) => a + w, 0) + if (sum === 0 || total <= 0) return weights.map(() => 0) + // BigInt: büyük tutarlarda (1e13 × 31) bile kesin tamsayı + const T = BigInt(total) + const S = BigInt(sum) + const parts: number[] = [] + const rems: { rem: bigint; i: number }[] = [] + weights.forEach((w, i) => { + const exact = T * BigInt(w) + parts.push(Number(exact / S)) + rems.push({ rem: exact % S, i }) + }) + let left = total - parts.reduce((a, p) => a + p, 0) + rems.sort((a, b) => (a.rem === b.rem ? a.i - b.i : a.rem > b.rem ? -1 : 1)) + for (const { i } of rems) { + if (left === 0) break + parts[i] += 1 + left -= 1 + } + return parts +} + +export type WeekInput = { + days: number + /** Elle girilen tutar; `null` → otomatik */ + manual: number | null +} +export type WeekOutput = { amount: number; isManual: boolean } + +/** Aylık tutar + elle girilen haftalar → her haftanın tutarı (backend `view` ile aynı). */ +export function previewWeeks( + monthly: number, + weeks: WeekInput[], +): { weeks: WeekOutput[]; manualTotal: number; weeklyOver: boolean } { + const manualTotal = weeks.reduce((a, w) => a + (w.manual ?? 0), 0) + const auto = splitProportional( + monthly - manualTotal, + weeks.filter((w) => w.manual === null).map((w) => w.days), + ) + let k = 0 + return { + weeks: weeks.map((w) => + w.manual === null + ? { amount: auto[k++] ?? 0, isManual: false } + : { amount: w.manual, isManual: true }, + ), + manualTotal, + weeklyOver: manualTotal > monthly, + } +} diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index fbc2233..d6aaa5b 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -115,3 +115,9 @@ export function toIsoDate(date: Date): string { const dd = String(date.getDate()).padStart(2, '0') return `${date.getFullYear()}-${mm}-${dd}` } + +/** `"2026-12-07"` → `07.12` (hafta aralıkları gibi kısa gösterimler) */ +export function formatDayMonth(iso: string): string { + const m = /^\d{4}-(\d{2})-(\d{2})$/.exec(iso) + return m ? `${m[2]}.${m[1]}` : iso +} diff --git a/frontend/src/lib/periods.ts b/frontend/src/lib/periods.ts index d222bf1..5602d66 100644 --- a/frontend/src/lib/periods.ts +++ b/frontend/src/lib/periods.ts @@ -17,6 +17,7 @@ import type { PlanItem } from '@/types/PlanItem' import type { PlanItemCreateRequest } from '@/types/PlanItemCreateRequest' import type { SuggestMethod } from '@/types/SuggestMethod' import { api } from './api' +import type { PlanOps } from './plan-draft' import { queryKeys } from './query' function refreshPeriod(qc: QueryClient, id: string) { @@ -132,3 +133,35 @@ export function useSetBudget(periodId: string) { }, }) } + +// ---------- Plan ekranı: toplu kaydet ---------- + +/** + * Düzenleme modundaki değişiklikleri sırayla gönderir (`diffPlan` çıktısı). + * Tek transaction değildir: biri hata verirse kalanlar gönderilmez, hata fırlatılır; + * her durumda dönem verisi yeniden yüklenir → ekran sunucudaki gerçek durumu gösterir. + */ +export function useSavePlan(period: Period) { + const qc = useQueryClient() + return useMutation({ + mutationFn: async (ops: PlanOps) => { + const pid = period.id + if (ops.savings !== null) { + await api.patch(`/periods/${pid}`, { + savings_goal: ops.savings, + version: period.version, + }) + } + for (const id of ops.deletes) await api.delete(`/plan-items/${id}`) + for (const { id, ...body } of ops.updates) await api.patch(`/plan-items/${id}`, body) + for (const body of ops.creates) await api.post(`/periods/${pid}/plan-items`, body) + if (ops.monthly) await api.put(`/periods/${pid}/budget`, ops.monthly) + for (const body of ops.weeks) await api.put(`/periods/${pid}/budget/weeks`, body) + }, + onSettled: () => + Promise.all([ + qc.invalidateQueries({ queryKey: queryKeys.periods }), + refreshPeriod(qc, period.id), + ]), + }) +} diff --git a/frontend/src/lib/plan-draft.test.ts b/frontend/src/lib/plan-draft.test.ts new file mode 100644 index 0000000..e6e5fc2 --- /dev/null +++ b/frontend/src/lib/plan-draft.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest' +import type { GroupBudget } from '@/types/GroupBudget' +import type { Period } from '@/types/Period' +import type { PlanItem } from '@/types/PlanItem' +import { diffPlan, hasChanges, toDraft } from './plan-draft' + +const period: Period = { + id: 'p1', + name: 'Aralık 2026', + start_date: '2026-12-01', + end_date: '2027-01-01', + last_date: '2026-12-31', + days: 31, + status: 'open', + savings_goal: 500_000, + version: 3, + weeks: [ + { index: 1, start_date: '2026-12-01', last_date: '2026-12-06', days: 6 }, + { index: 2, start_date: '2026-12-07', last_date: '2026-12-13', days: 7 }, + ], +} + +const items: PlanItem[] = [ + { + id: 'i1', + kind: 'income', + name: 'Maaş', + amount: 4_500_000, + date: null, + sort_order: 10, + version: 1, + }, + { + id: 'e1', + kind: 'expense', + name: 'Kira', + amount: 1_500_000, + date: '2026-12-05', + sort_order: 10, + version: 2, + }, + { + id: 'e2', + kind: 'expense', + name: 'Aidat', + amount: 125_050, + date: null, + sort_order: 20, + version: 1, + }, +] + +const week = (start: string, amount: number, is_manual = false) => ({ + index: 1, + start_date: start, + last_date: start, + days: 7, + amount, + is_manual, +}) +const budget: GroupBudget[] = (['needs', 'wants', 'culture', 'unexpected'] as const).map((g) => ({ + group: g, + monthly: g === 'needs' ? 1_000_000 : 0, + weekly_over: false, + weeks: + g === 'needs' + ? [week('2026-12-01', 300_000, true), week('2026-12-07', 700_000)] + : [week('2026-12-01', 0), week('2026-12-07', 0)], +})) + +describe('plan draft', () => { + it('round-trips without changes', () => { + const draft = toDraft(period, items, budget) + expect(draft.incomes[0]).toMatchObject({ name: 'Maaş', amount: '45.000,00', date: '' }) + expect(draft.expenses[1].amount).toBe('1.250,50') + expect(draft.savings).toBe('5.000,00') + expect(draft.budget.needs).toBe('10.000,00') + expect(draft.budget.wants).toBe('') + expect(draft.weeks.needs).toEqual(['3.000,00', '']) // 1. hafta elle, 2. otomatik + expect(hasChanges(diffPlan(period, items, budget, draft))).toBe(false) + }) + + it('collects only what changed', () => { + const draft = toDraft(period, items, budget) + draft.savings = '6000' + draft.incomes[0].amount = '46.000' + draft.expenses[0].date = '' // tarihi kaldır + draft.expenses.splice(1, 1) // Aidat silindi + draft.expenses.push({ name: ' İnternet ', amount: '499,90', date: '' }) + draft.budget.wants = '2.000' + draft.weeks.needs = ['', '5.000'] // 1. hafta otomatiğe, 2. hafta elle + + const ops = diffPlan(period, items, budget, draft) + expect(ops.savings).toBe(600_000) + expect(ops.updates).toEqual([ + { id: 'i1', version: 1, amount: 4_600_000 }, + { id: 'e1', version: 2, date: '' }, + ]) + expect(ops.deletes).toEqual(['e2']) + expect(ops.creates).toEqual([ + { kind: 'expense', name: 'İnternet', amount: 49_990, date: undefined }, + ]) + expect(ops.monthly).toEqual({ needs: 1_000_000, wants: 200_000, culture: 0, unexpected: 0 }) + expect(ops.weeks).toEqual([ + { group: 'needs', week_start: '2026-12-01', amount: null }, + { group: 'needs', week_start: '2026-12-07', amount: 500_000 }, + ]) + }) + + it('a manual zero week is kept as manual', () => { + const draft = toDraft(period, items, budget) + draft.weeks.wants = ['0', ''] + const ops = diffPlan(period, items, budget, draft) + expect(ops.weeks).toEqual([{ group: 'wants', week_start: '2026-12-01', amount: 0 }]) + }) +}) diff --git a/frontend/src/lib/plan-draft.ts b/frontend/src/lib/plan-draft.ts new file mode 100644 index 0000000..c51d5c9 --- /dev/null +++ b/frontend/src/lib/plan-draft.ts @@ -0,0 +1,152 @@ +/** + * Plan ekranının düzenleme modu: sunucu verisi → taslak (form) → değişiklik listesi. + * + * Düzenleme sırasında hiçbir şey kaydedilmez; "Kaydet"e basınca `diffPlan` yalnızca + * değişenleri çıkarır ve sırayla gönderilir. "Vazgeç" taslağı atar. + * Taslaktaki tutarlar metindir (`1.250,50`), kuruşa çevirme `toKurus` ile. + */ +import type { BudgetWeekRequest } from '@/types/BudgetWeekRequest' +import type { Group } from '@/types/Group' +import type { GroupAmounts } from '@/types/GroupAmounts' +import type { GroupBudget } from '@/types/GroupBudget' +import type { Period } from '@/types/Period' +import type { PlanItem } from '@/types/PlanItem' +import type { PlanItemCreateRequest } from '@/types/PlanItemCreateRequest' +import type { PlanItemUpdateRequest } from '@/types/PlanItemUpdateRequest' +import { formatNumber } from './format' +import { toKurus } from './money-schema' + +export const GROUP_CODES: readonly Group[] = ['needs', 'wants', 'culture', 'unexpected'] + +export type ItemDraft = { + /** Yeni satırda yok */ + id?: string + version?: number + name: string + amount: string + /** `""` → tarihsiz */ + date: string +} + +export type PlanDraft = { + incomes: ItemDraft[] + expenses: ItemDraft[] + savings: string + budget: Record + /** Her grup için dönemin haftaları sırasıyla; `""` → otomatik */ + weeks: Record +} + +export type PlanOps = { + savings: number | null + deletes: string[] + updates: (PlanItemUpdateRequest & { id: string })[] + creates: PlanItemCreateRequest[] + monthly: GroupAmounts | null + weeks: BudgetWeekRequest[] +} + +/** Kuruş → form metni (0 → boş) */ +export function moneyToText(kurus: number): string { + return kurus ? formatNumber(kurus / 100) : '' +} + +function itemToDraft(i: PlanItem): ItemDraft { + return { + id: i.id, + version: i.version, + name: i.name, + amount: moneyToText(i.amount), + date: i.date ?? '', + } +} + +function budgetFor(budget: GroupBudget[], g: Group): GroupBudget | undefined { + return budget.find((b) => b.group === g) +} + +export function toDraft(period: Period, items: PlanItem[], budget: GroupBudget[]): PlanDraft { + const rec = (f: (g: Group) => T) => + Object.fromEntries(GROUP_CODES.map((g) => [g, f(g)])) as Record + return { + incomes: items.filter((i) => i.kind === 'income').map(itemToDraft), + expenses: items.filter((i) => i.kind === 'expense').map(itemToDraft), + savings: moneyToText(period.savings_goal), + budget: rec((g) => moneyToText(budgetFor(budget, g)?.monthly ?? 0)), + weeks: rec((g) => + period.weeks.map((w) => { + const wb = budgetFor(budget, g)?.weeks.find((x) => x.start_date === w.start_date) + return wb?.is_manual ? moneyToText(wb.amount) || '0' : '' + }), + ), + } +} + +/** Taslak ile sunucu durumu arasındaki farklar (değişmeyen hiçbir şey gönderilmez). */ +export function diffPlan( + period: Period, + items: PlanItem[], + budget: GroupBudget[], + draft: PlanDraft, +): PlanOps { + const ops: PlanOps = { + savings: null, + deletes: [], + updates: [], + creates: [], + monthly: null, + weeks: [], + } + + const savings = toKurus(draft.savings) + if (savings !== period.savings_goal) ops.savings = savings + + const byId = new Map(items.map((i) => [i.id, i])) + const kept = new Set() + const rows = [ + ...draft.incomes.map((d) => ({ d, kind: 'income' as const })), + ...draft.expenses.map((d) => ({ d, kind: 'expense' as const })), + ] + for (const { d, kind } of rows) { + const name = d.name.trim() + const amount = toKurus(d.amount) + const old = d.id ? byId.get(d.id) : undefined + if (!old) { + ops.creates.push({ kind, name, amount, date: d.date || undefined }) + continue + } + kept.add(old.id) + const u: PlanItemUpdateRequest & { id: string } = { id: old.id, version: old.version } + if (name !== old.name) u.name = name + if (amount !== old.amount) u.amount = amount + if (d.date !== (old.date ?? '')) u.date = d.date // "" → tarihi kaldır + if (Object.keys(u).length > 2) ops.updates.push(u) + } + ops.deletes = items.filter((i) => !kept.has(i.id)).map((i) => i.id) + + const monthly = Object.fromEntries( + GROUP_CODES.map((g) => [g, toKurus(draft.budget[g])]), + ) as GroupAmounts + if (GROUP_CODES.some((g) => monthly[g] !== (budgetFor(budget, g)?.monthly ?? 0))) { + ops.monthly = monthly + } + + for (const g of GROUP_CODES) { + period.weeks.forEach((w, i) => { + const text = draft.weeks[g][i] ?? '' + const next = text.trim() === '' ? null : toKurus(text) + const wb = budgetFor(budget, g)?.weeks.find((x) => x.start_date === w.start_date) + const prev = wb?.is_manual ? wb.amount : null + if (next !== prev) ops.weeks.push({ group: g, week_start: w.start_date, amount: next }) + }) + } + return ops +} + +export function hasChanges(ops: PlanOps): boolean { + return ( + ops.savings !== null || + ops.deletes.length + ops.updates.length + ops.creates.length + ops.weeks.length > 0 || + ops.monthly !== null + ) +} diff --git a/frontend/src/lib/strings.ts b/frontend/src/lib/strings.ts index d1dbe90..abaa1f8 100644 --- a/frontend/src/lib/strings.ts +++ b/frontend/src/lib/strings.ts @@ -185,6 +185,33 @@ export const t = { }, }, + plan: { + edit: 'Düzenle', + save: 'Kaydet', + cancel: 'Vazgeç', + saving: 'Kaydediliyor…', + saved: 'Plan kaydedildi', + noChanges: 'Değişiklik yok', + editing: 'Düzenleme modu', + editingHint: 'Değişiklikler "Kaydet"e basınca kaydedilir.', + saveFailed: (msg: string) => `${msg} Ekranda kaydedilmiş son durum gösteriliyor.`, + leaveConfirm: 'Kaydedilmemiş değişiklikler var. Yine de çıkılsın mı?', + periodInfo: (days: number, weeks: number) => `${days} gün · ${weeks} hafta`, + changeDates: 'Tarihleri değiştir', + add: { income: 'Gelir ekle', expense: 'Gider ekle' }, + savings: 'Birikim hedefi', + budget: 'Bütçe dağıtımı', + weekly: 'Haftalık döküm', + week: (n: number) => `${n}. hafta`, + weekDays: (n: number) => `${n} gün`, + manual: 'elle', + resetWeek: (n: number) => `${n}. haftayı otomatiğe döndür`, + weekHint: 'Boş bırakılan haftalar kalan tutarı gün sayısına göre paylaşır.', + weeklyOver: 'Elle girilen haftaların toplamı aylık tutarı aşıyor.', + suggestionHint: 'Öneri, kaydedilmiş gelir ve giderlere göre hesaplanır.', + emptyBudget: 'Henüz bütçe dağıtılmadı. "Düzenle" ile 4 gruba dağıtabilirsin.', + }, + summary: { income: 'Gelir', fixed: 'Sabit giderler',