feat(plan): plan ekranı — görüntüleme/düzenleme modu ve haftalık bütçe

- Salt okunur görünüm: özet kutucukları, gelir/gider, 4 grup + haftalık döküm
- Düzenle → taslak; Kaydet yalnızca değişenleri gönderir, Vazgeç geri alır
- Kaydedilmemiş değişiklikle çıkışta onay
- Hafta elle ayarlama / otomatiğe döndürme, canlı önizleme (backend ile aynı hesap)
- DistributionBar ve SuggestionPicker ortak bileşen; 9 yeni vitest

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 08:47:00 +03:00
parent ca0d3a505c
commit 9004a7cc42
18 changed files with 1276 additions and 110 deletions
+1 -1
View File
@@ -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. - Yönlendirme: `RequireAuth` (oturum yok → `/giris?donus=`), `RequireGuest` (oturum var → `donus` ya da panel; formlar ayrıca navigate etmez). `donus` daima `safeReturnPath` ile.
- Geliştirmede tasarım önizlemesi: `/dev/tasarim`. - Geliştirmede tasarım önizlemesi: `/dev/tasarim`.
- Formlar: react-hook-form + zod (`mode: 'onTouched'`), alanlar `components/form-field.tsx` (`FormField`, `PasswordInput`, `FormError`); sunucu hatası `setError` ile ilgili alana. Bildirim: `toast` (sonner, `components/toaster.tsx`). - Formlar: react-hook-form + zod (`mode: 'onTouched'`), alanlar `components/form-field.tsx` (`FormField`, `PasswordInput`, `FormError`); sunucu hatası `setError` ile ilgili alana. Bildirim: `toast` (sonner, `components/toaster.tsx`).
- Dönem verisi: `lib/periods.ts` (Query kancaları), tarih `lib/period-dates.ts` (arayüz son günü dahil gösterir, backend `end_date` hariç), tutar alanı `MoneyInput` + `moneyText`/`toKurus` (`lib/money-schema.ts`). İlk dönem sihirbazı `/plan/donem-ac?adim=`. - 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`. - Grup tanımları: `lib/groups.ts`; tema: `lib/theme.tsx` (+ `index.html` içindeki ilk-boya betiği); biçimlendirme: `lib/format.ts`.
- API örnekleri: `backend/api.http` - API örnekleri: `backend/api.http`
- Canlı: `Dockerfile` (tek imaj), `docs/DEPLOY.md` (ortam değişkenleri, Dokploy, MongoDB, yedek). `routes::app()` = API + `STATIC_DIR` varsa SPA (`routes/spa.rs`) + sıkıştırma + TraceLayer. Testler `routes::router()` kullanır. - Canlı: `Dockerfile` (tek imaj), `docs/DEPLOY.md` (ortam değişkenleri, Dokploy, MongoDB, yedek). `routes::app()` = API + `STATIC_DIR` varsa SPA (`routes/spa.rs`) + sıkıştırma + TraceLayer. Testler `routes::router()` kullanır.
+3 -1
View File
@@ -1,7 +1,7 @@
# 🧠 Proje Hafızası # 🧠 Proje Hafızası
## ▶️ Sıradaki Adım ## ▶️ 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 ## 📌 Alınan Kararlar
- **Yerel demo hesapları** (`kakeibo_dev`): `demo@ornek.com` / `demo12345` (owner, "Demo Hanesi") · `es@ornek.com` / `demo12345` (member, aynı hane). `npm run db:reset` silerse API ile yeniden oluştur (ileride `db:seed`). - **Yerel demo hesapları** (`kakeibo_dev`): `demo@ornek.com` / `demo12345` (owner, "Demo Hanesi") · `es@ornek.com` / `demo12345` (member, aynı hane). `npm run db:reset` silerse API ile yeniden oluştur (ileride `db:seed`).
@@ -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 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. - **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). - 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).
+2 -2
View File
@@ -50,5 +50,5 @@
- ✅ 2. Backend: plan kalemleri (sabit gelir/gider, önceki dönemden kopyala) + notlar - ✅ 2. Backend: plan kalemleri (sabit gelir/gider, önceki dönemden kopyala) + notlar
- ✅ 3. Backend: bütçe (aylık/haftalık, öneri), özet (harcanabilir tutar) - ✅ 3. Backend: bütçe (aylık/haftalık, öneri), özet (harcanabilir tutar)
- ✅ 4. Frontend: ilk dönem sihirbazı - ✅ 4. Frontend: ilk dönem sihirbazı
- 🔄 5. Frontend: Plan ekranı (görüntüle/düzenle, özet kutucukları, bütçe dağıtımı) - ✅ 5. Frontend: Plan ekranı (görüntüle/düzenle, özet kutucukları, bütçe dağıtımı)
- ⏳ 6. Frontend: notlar (Markdown, düzenle/önizle) - 🔄 6. Frontend: notlar (Markdown, düzenle/önizle)
@@ -1,36 +1,33 @@
import { useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { Loader2, Sparkles } from 'lucide-react'
import { Controller, useForm, useWatch } from 'react-hook-form' import { Controller, useForm, useWatch } from 'react-hook-form'
import { FormError, FormField } from '@/components/form-field' import { FormError, FormField } from '@/components/form-field'
import { MoneyInput } from '@/components/money-input' import { MoneyInput } from '@/components/money-input'
import { PageError, PageLoading } from '@/components/page' 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 { SummaryTiles } from '@/features/planning/summary-tiles'
import { ApiError } from '@/lib/api' import { ApiError } from '@/lib/api'
import { formatMoney, formatNumber, parseMoney } from '@/lib/format' import { parseMoney } from '@/lib/format'
import { GROUPS } from '@/lib/groups' import { GROUPS } from '@/lib/groups'
import { toKurus } from '@/lib/money-schema' import { toKurus } from '@/lib/money-schema'
import { useBudget, useSetBudget, useSuggestion, useSummary } from '@/lib/periods' import { useBudget, useSetBudget, useSuggestion, useSummary } from '@/lib/periods'
import { moneyToText } from '@/lib/plan-draft'
import { t } from '@/lib/strings' import { t } from '@/lib/strings'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { GroupAmounts } from '@/types/GroupAmounts' import type { GroupAmounts } from '@/types/GroupAmounts'
import type { Period } from '@/types/Period' import type { Period } from '@/types/Period'
import type { PeriodSummary } from '@/types/PeriodSummary' import type { PeriodSummary } from '@/types/PeriodSummary'
import type { SuggestMethod } from '@/types/SuggestMethod'
import { budgetSchema, type BudgetForm } from './schemas' import { budgetSchema, type BudgetForm } from './schemas'
import { StepActions, StepIntro } from './wizard-ui' import { StepActions, StepIntro } from './wizard-ui'
const s = t.wizard.budget const s = t.wizard.budget
const METHODS: SuggestMethod[] = ['default', 'previous', 'avg3']
function toForm(a: GroupAmounts): BudgetForm { function toForm(a: GroupAmounts): BudgetForm {
const f = (k: number) => (k ? formatNumber(k / 100) : '')
return { return {
needs: f(a.needs), needs: moneyToText(a.needs),
wants: f(a.wants), wants: moneyToText(a.wants),
culture: f(a.culture), culture: moneyToText(a.culture),
unexpected: f(a.unexpected), unexpected: moneyToText(a.unexpected),
} }
} }
@@ -84,9 +81,6 @@ function BudgetStepForm({
onDone: () => void onDone: () => void
}) { }) {
const setBudget = useSetBudget(period.id) const setBudget = useSetBudget(period.id)
const [method, setMethod] = useState<SuggestMethod>('default')
const suggestion = useSuggestion(period.id, method)
const { const {
control, control,
handleSubmit, handleSubmit,
@@ -103,7 +97,6 @@ function BudgetStepForm({
(sum, g) => sum + Math.max(parseMoney(values[g.code] ?? '') ?? 0, 0), (sum, g) => sum + Math.max(parseMoney(values[g.code] ?? '') ?? 0, 0),
0, 0,
) )
const remaining = summary.spendable - distributed
const onSubmit = handleSubmit(async (v) => { const onSubmit = handleSubmit(async (v) => {
try { try {
@@ -124,45 +117,10 @@ function BudgetStepForm({
<StepIntro title={s.title} hint={s.hint} /> <StepIntro title={s.title} hint={s.hint} />
<SummaryTiles summary={summary} /> <SummaryTiles summary={summary} />
{/* Öneri seçimi */} <div className="mt-6">
<fieldset className="mt-6 space-y-3 rounded-xl border p-4"> <SuggestionPicker periodId={period.id} onApply={(a) => reset(toForm(a))} />
<legend className="px-1 text-sm font-medium">{s.method}</legend> </div>
<div className="flex flex-wrap gap-2">
{METHODS.map((m) => (
<Button
key={m}
type="button"
size="sm"
variant={method === m ? 'default' : 'outline'}
aria-pressed={method === m}
onClick={() => setMethod(m)}
>
{s.methods[m]}
</Button>
))}
</div>
{suggestion.isPending ? (
<Loader2
className="size-5 animate-spin text-muted-foreground"
aria-label={t.common.loading}
/>
) : suggestion.data?.available ? (
<Button
type="button"
variant="secondary"
onClick={() => reset(toForm(suggestion.data.amounts))}
>
<Sparkles aria-hidden />
{s.apply}
</Button>
) : (
<p className="text-sm text-muted-foreground">
{suggestion.data?.message ?? suggestion.error?.message}
</p>
)}
</fieldset>
{/* 4 grup */}
<div className="mt-6 grid gap-4 sm:grid-cols-2"> <div className="mt-6 grid gap-4 sm:grid-cols-2">
{GROUPS.map((g) => ( {GROUPS.map((g) => (
<FormField key={g.code} label={`${g.emoji} ${g.label}`} error={errors[g.code]}> <FormField key={g.code} label={`${g.emoji} ${g.label}`} error={errors[g.code]}>
@@ -182,34 +140,8 @@ function BudgetStepForm({
))} ))}
</div> </div>
{/* Dağıtım durumu */} <div className="mt-6">
<div className="mt-6 space-y-2 rounded-xl border p-4" aria-live="polite"> <DistributionBar distributed={distributed} spendable={summary.spendable} />
<div className="flex justify-between text-sm">
<span>{s.distributed}</span>
<span className="money font-medium">
{formatMoney(distributed)} / {formatMoney(summary.spendable)}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted" aria-hidden>
<div
className={cn('h-full rounded-full', remaining < 0 ? 'bg-warning' : 'bg-primary')}
style={{
width: `${summary.spendable > 0 ? Math.min(100, (distributed / summary.spendable) * 100) : 0}%`,
}}
/>
</div>
<p
className={cn(
'text-sm',
remaining < 0 ? 'font-medium text-warning' : 'text-muted-foreground',
)}
>
{remaining < 0
? s.over(formatMoney(-remaining))
: remaining > 0
? s.unallocated(formatMoney(remaining))
: s.balanced}
</p>
</div> </div>
<FormError message={errors.root?.message} /> <FormError message={errors.root?.message} />
@@ -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. */ /** Ö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 periods = usePeriods()
const copy = useCopyPrevious(period.id) const copy = useCopyPrevious(period.id)
const hasPrevious = periods.data?.some((p) => p.start_date < period.start_date) const hasPrevious = periods.data?.some((p) => p.start_date < period.start_date)
@@ -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 (
<div className="space-y-2 rounded-xl border p-4" aria-live="polite">
<div className="flex flex-wrap justify-between gap-x-3 text-sm">
<span>{s.distributed}</span>
<span className="money font-medium">
{formatMoney(distributed)} / {formatMoney(spendable)}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted" aria-hidden>
<div
className={cn('h-full rounded-full', remaining < 0 ? 'bg-warning' : 'bg-primary')}
style={{ width: `${pct}%` }}
/>
</div>
<p
className={cn(
'text-sm',
remaining < 0 ? 'font-medium text-warning' : 'text-muted-foreground',
)}
>
{remaining < 0
? s.over(formatMoney(-remaining))
: remaining > 0
? s.unallocated(formatMoney(remaining))
: s.balanced}
</p>
</div>
)
}
+50 -24
View File
@@ -1,13 +1,14 @@
import { useState } from 'react'
import { Pencil } from 'lucide-react' import { Pencil } from 'lucide-react'
import { Link } from 'react-router' import { PageError, PageHeader, PageLoading } from '@/components/page'
import { paths } from '@/components/layout/nav-items'
import { ComingSoon, PageError, PageHeader, PageLoading } from '@/components/page'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { CopyPrevious } from '@/features/period-wizard/step-items'
import { NoPeriodCard } from '@/features/period-wizard/no-period-card' import { NoPeriodCard } from '@/features/period-wizard/no-period-card'
import { formatDate } from '@/lib/format' import { useBudget, useCurrentPeriod, usePlanItems, useSummary } from '@/lib/periods'
import { useCurrentPeriod, useSummary } from '@/lib/periods'
import { t } from '@/lib/strings' import { t } from '@/lib/strings'
import type { Period } from '@/types/Period' import type { Period } from '@/types/Period'
import { PlanEdit } from './plan-edit'
import { BudgetRead, ItemsList, PeriodHeading } from './plan-read'
import { SummaryTiles } from './summary-tiles' import { SummaryTiles } from './summary-tiles'
export function PlanningPage() { export function PlanningPage() {
@@ -20,7 +21,7 @@ export function PlanningPage() {
) : current.isError ? ( ) : current.isError ? (
<PageError message={current.error.message} onRetry={current.refetch} /> <PageError message={current.error.message} onRetry={current.refetch} />
) : current.data ? ( ) : current.data ? (
<OpenPeriod period={current.data} /> <PlanView period={current.data} />
) : ( ) : (
<NoPeriodCard /> <NoPeriodCard />
)} )}
@@ -28,28 +29,53 @@ export function PlanningPage() {
) )
} }
/** Geçici görünüm: ayrıntılı Plan ekranı Faz 4 – Adım 5'te. */ /** Varsayılan salt okunur; "Düzenle" → düzenleme modu (Kaydet / Vazgeç). */
function OpenPeriod({ period }: { period: Period }) { function PlanView({ period }: { period: Period }) {
const items = usePlanItems(period.id)
const summary = useSummary(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 <PageError message={failed.error.message} onRetry={failed.refetch} />
if (!items.data || !summary.data || !budget.data) return <PageLoading />
if (editing) {
return (
<PlanEdit
period={period}
items={items.data}
budget={budget.data}
summary={summary.data}
onClose={() => {
setEditing(false)
window.scrollTo({ top: 0 })
}}
/>
)
}
const editable = period.status === 'open'
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3"> <PeriodHeading
<div> period={period}
<h2 className="text-lg font-semibold">{period.name}</h2> actions={
<p className="text-sm text-muted-foreground"> editable && (
{formatDate(period.start_date)} – {formatDate(period.last_date)} · {period.days} gün ·{' '} <Button onClick={() => setEditing(true)}>
{period.weeks.length} hafta <Pencil aria-hidden />
</p> {t.plan.edit}
</div> </Button>
<Button asChild variant="outline"> )
<Link to={`${paths.periodWizard}?adim=2`}> }
<Pencil aria-hidden /> />
Sihirbazla düzenle <SummaryTiles summary={summary.data} />
</Link> <div className="grid gap-4 lg:grid-cols-2">
</Button> <ItemsList kind="income" list={items.data.filter((i) => i.kind === 'income')} />
<ItemsList kind="expense" list={items.data.filter((i) => i.kind === 'expense')} />
</div> </div>
{summary.data && <SummaryTiles summary={summary.data} />} {editable && <CopyPrevious period={period} />}
<ComingSoon when="bir sonraki adımda" /> <BudgetRead budget={budget.data} summary={summary.data} />
</div> </div>
) )
} }
@@ -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<PlanForm>({
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 = (
<>
<Button type="button" variant="outline" onClick={onClose} disabled={isSubmitting}>
<X aria-hidden />
{s.cancel}
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? <Loader2 className="animate-spin" aria-hidden /> : <Save aria-hidden />}
{isSubmitting ? s.saving : s.save}
</Button>
</>
)
return (
<form onSubmit={onSubmit} noValidate className="space-y-6">
<div className="rounded-xl border border-primary/40 bg-primary/5 px-4 py-3 text-sm">
<span className="font-medium">✏️ {s.editing}</span>
<span className="text-muted-foreground"> · {s.editingHint}</span>
</div>
<PeriodHeading period={period} actions={actions} />
<SummaryTiles summary={preview} />
<div className="grid gap-4">
<ItemsEdit
kind="income"
period={period}
control={control}
register={register}
errors={errors}
/>
<ItemsEdit
kind="expense"
period={period}
control={control}
register={register}
errors={errors}
/>
</div>
<Section
title={s.savings}
action={
<Button asChild variant="ghost" size="sm">
<Link to={`${paths.periodWizard}?adim=1`}>
<CalendarCog aria-hidden />
{s.changeDates}
</Link>
</Button>
}
>
<FormField label={s.savings} error={errors.savings} className="max-w-xs">
{(f) => (
<Controller
control={control}
name="savings"
render={({ field }) => <MoneyInput {...f} {...field} placeholder="0,00" />}
/>
)}
</FormField>
</Section>
<BudgetEdit
period={period}
control={control}
setValue={setValue}
errors={errors}
values={v}
spendable={spendable}
budgetTotal={budgetTotal}
/>
<div className="flex justify-end gap-2 border-t pt-5">{actions}</div>
</form>
)
}
// ---------- Gelir / gider satırları ----------
function ItemsEdit({
kind,
period,
control,
register,
errors,
}: {
kind: PlanItemKind
period: Period
control: Control<PlanForm>
register: UseFormRegister<PlanForm>
errors: FieldErrors<PlanForm>
}) {
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 (
<Section title={text.title}>
{fields.length === 0 && <p className="mb-3 text-sm text-muted-foreground">{text.empty}</p>}
<ul className="space-y-3">
{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 (
<li
key={field.key}
className="grid grid-cols-2 gap-2 sm:grid-cols-[minmax(0,1fr)_10rem_10.5rem_auto]"
>
{/* Mobilde ad + sil aynı satırda; geniş ekranda `contents` → ızgaraya katılır */}
<div className="col-span-2 flex gap-2 sm:contents">
<Input
className="flex-1"
placeholder={text.placeholder}
aria-label={itemText.name}
aria-invalid={!!e?.name}
maxLength={60}
{...register(`${name}.${i}.name`)}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="sm:order-last"
aria-label={itemText.remove(label)}
onClick={() => remove(i)}
>
<Trash2 className="text-muted-foreground" aria-hidden />
</Button>
</div>
<Controller
control={control}
name={`${name}.${i}.amount`}
render={({ field: f }) => (
<MoneyInput
{...f}
aria-label={itemText.amount}
aria-invalid={!!e?.amount}
placeholder="0,00"
/>
)}
/>
<Input
type="date"
min={period.start_date}
max={period.last_date}
aria-label={itemText.date}
aria-invalid={!!e?.date}
{...register(`${name}.${i}.date`)}
/>
{messages.length > 0 && (
<p className="col-span-2 text-sm text-expense sm:order-last sm:col-span-4">
{messages.join(' · ')}
</p>
)}
</li>
)
})}
</ul>
<Button
type="button"
variant="secondary"
className="mt-3"
onClick={() => append({ name: '', amount: '', date: '' }, { shouldFocus: true })}
>
<Plus aria-hidden />
{s.add[kind]}
</Button>
<div className="mt-3 flex justify-between border-t pt-3 font-medium">
<span>{text.total}</span>
<span className={cn('money', kind === 'income' ? 'text-income' : 'text-expense')}>
{formatMoney(total)}
</span>
</div>
</Section>
)
}
// ---------- Bütçe: aylık + haftalık ----------
function BudgetEdit({
period,
control,
setValue,
errors,
values,
spendable,
budgetTotal,
}: {
period: Period
control: Control<PlanForm>
setValue: UseFormSetValue<PlanForm>
errors: FieldErrors<PlanForm>
values: ReturnType<typeof useWatch<PlanForm>>
spendable: number
budgetTotal: number
}) {
return (
<Section title={s.budget}>
<div className="space-y-4">
<SuggestionPicker
periodId={period.id}
hint={s.suggestionHint}
onApply={(a) =>
GROUP_CODES.forEach((g) =>
setValue(`budget.${g}`, moneyToText(a[g]), {
shouldDirty: true,
shouldValidate: true,
}),
)
}
/>
<DistributionBar distributed={budgetTotal} spendable={spendable} />
<div className="grid items-start gap-4 md:grid-cols-2">
{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 (
<div key={g.code} className="overflow-hidden rounded-xl border">
<div className={cn('space-y-2 px-4 py-3', g.className.soft)}>
<FormField label={`${g.emoji} ${g.label}`} error={errors.budget?.[g.code]}>
{(f) => (
<Controller
control={control}
name={`budget.${g.code}`}
render={({ field }) => (
<MoneyInput
{...f}
{...field}
placeholder="0,00"
className="bg-background"
/>
)}
/>
)}
</FormField>
</div>
<details open={pv.weeks.some((w) => w.isManual)} className="group/weeks">
<summary className="cursor-pointer px-4 py-3 text-sm font-medium select-none">
{s.weekly}
</summary>
<p className="px-4 pb-2 text-xs text-muted-foreground">{s.weekHint}</p>
<ul className="divide-y text-sm">
{period.weeks.map((w, i) => (
<WeekRow
key={w.start_date}
control={control}
setValue={setValue}
groupCode={g.code}
index={i}
label={s.week(w.index)}
range={`${formatDayMonth(w.start_date)}–${formatDayMonth(w.last_date)}`}
days={w.days}
auto={pv.weeks[i]?.amount ?? 0}
manual={pv.weeks[i]?.isManual ?? false}
error={errors.weeks?.[g.code]?.[i]?.message}
/>
))}
</ul>
</details>
{pv.weeklyOver && (
<p role="status" className="border-t px-4 py-2 text-sm text-warning">
{s.weeklyOver}
</p>
)}
</div>
)
})}
</div>
</div>
</Section>
)
}
function WeekRow({
control,
setValue,
groupCode,
index,
label,
range,
days,
auto,
manual,
error,
}: {
control: Control<PlanForm>
setValue: UseFormSetValue<PlanForm>
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 (
<li className="flex flex-wrap items-center gap-2 px-4 py-2">
<span className="min-w-0 flex-1">
<span className="block">{label}</span>
<span className="text-xs text-muted-foreground">
{range} · {s.weekDays(days)}
{manual && ` · ${s.manual}`}
</span>
</span>
<div className="w-36">
<Controller
control={control}
name={fieldName}
render={({ field }) => (
<MoneyInput
{...field}
aria-label={`${group(groupCode).label} · ${label} (${range})`}
aria-invalid={!!error}
// Boşsa otomatik tutar silik görünür; yazınca elle girilmiş olur
placeholder={formatNumber(auto / 100)}
/>
)}
/>
</div>
{manual ? (
<Button
type="button"
variant="ghost"
size="icon"
aria-label={s.resetWeek(index + 1)}
onClick={() => setValue(fieldName, '', { shouldDirty: true, shouldValidate: true })}
>
<RotateCcw className="text-muted-foreground" aria-hidden />
</Button>
) : (
<span className="size-11" aria-hidden />
)}
{error && <p className="w-full text-sm text-expense">{error}</p>}
</li>
)
}
@@ -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 (
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold">{period.name}</h2>
<p className="text-sm text-muted-foreground">
{formatDate(period.start_date)} – {formatDate(period.last_date)} ·{' '}
{s.periodInfo(period.days, period.weeks.length)}
</p>
</div>
{actions && <div className="flex flex-wrap gap-2">{actions}</div>}
</div>
)
}
/** 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 (
<section className={cn('rounded-2xl border bg-card p-4 sm:p-5', className)}>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 className="font-semibold">{title}</h3>
{action}
</div>
{children}
</section>
)
}
/** 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 (
<Section title={text.title}>
{list.length === 0 ? (
<p className="text-sm text-muted-foreground">{text.empty}</p>
) : (
<ul className="divide-y">
{list.map((i) => (
<li key={i.id} className="flex items-center gap-3 py-2">
<div className="min-w-0 flex-1">
<p className="truncate">{i.name}</p>
{i.date && <p className="text-xs text-muted-foreground">{formatDate(i.date)}</p>}
</div>
<span className="money">{formatMoney(i.amount)}</span>
</li>
))}
</ul>
)}
<div className="mt-2 flex justify-between border-t pt-3 font-medium">
<span>{text.total}</span>
<span className={cn('money', kind === 'income' ? 'text-income' : 'text-expense')}>
{formatMoney(total)}
</span>
</div>
</Section>
)
}
/** 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 (
<Section title={s.budget}>
<div className="space-y-4">
<DistributionBar distributed={summary.budget_total} spendable={summary.spendable} />
{summary.budget_total === 0 && (
<p className="text-sm text-muted-foreground">{s.emptyBudget}</p>
)}
<div className="grid gap-4 md:grid-cols-2">
{GROUPS.map((g) => {
const b = budget.find((x) => x.group === g.code)
return b ? <GroupBudgetCard key={g.code} budget={b} /> : null
})}
</div>
</div>
</Section>
)
}
function GroupBudgetCard({ budget }: { budget: GroupBudget }) {
const g = group(budget.group)
return (
<div className="overflow-hidden rounded-xl border">
<div className={cn('flex items-center justify-between gap-2 px-4 py-3', g.className.soft)}>
<span className={cn('font-medium', g.className.text)}>
<span aria-hidden>{g.emoji} </span>
{g.label}
</span>
<span className="money font-semibold">{formatMoney(budget.monthly)}</span>
</div>
<ul className="divide-y text-sm" aria-label={`${g.label} · ${s.weekly}`}>
{budget.weeks.map((w) => (
<li key={w.start_date} className="flex items-center gap-2 px-4 py-2">
<span className="flex-1 text-muted-foreground">
{s.week(w.index)} · {formatDayMonth(w.start_date)}–{formatDayMonth(w.last_date)}
</span>
{w.is_manual && (
<span className="rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
{s.manual}
</span>
)}
<span className="money">{formatMoney(w.amount)}</span>
</li>
))}
</ul>
{budget.weekly_over && (
<p role="status" className="border-t px-4 py-2 text-sm text-warning">
{s.weeklyOver}
</p>
)}
</div>
)
}
+25
View File
@@ -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<ReturnType<typeof planSchema>>
@@ -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<SuggestMethod>('default')
const suggestion = useSuggestion(periodId, method)
return (
<fieldset className="space-y-3 rounded-xl border p-4">
<legend className="px-1 text-sm font-medium">{s.method}</legend>
<div className="flex flex-wrap gap-2">
{METHODS.map((m) => (
<Button
key={m}
type="button"
size="sm"
variant={method === m ? 'default' : 'outline'}
aria-pressed={method === m}
onClick={() => setMethod(m)}
>
{s.methods[m]}
</Button>
))}
</div>
{suggestion.isPending ? (
<Loader2
className="size-5 animate-spin text-muted-foreground"
aria-label={t.common.loading}
/>
) : suggestion.data?.available ? (
<Button type="button" variant="secondary" onClick={() => onApply(suggestion.data.amounts)}>
<Sparkles aria-hidden />
{s.apply}
</Button>
) : (
<p className="text-sm text-muted-foreground">
{suggestion.data?.message ?? suggestion.error?.message}
</p>
)}
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
</fieldset>
)
}
+63
View File
@@ -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)
})
})
+64
View File
@@ -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,
}
}
+6
View File
@@ -115,3 +115,9 @@ export function toIsoDate(date: Date): string {
const dd = String(date.getDate()).padStart(2, '0') const dd = String(date.getDate()).padStart(2, '0')
return `${date.getFullYear()}-${mm}-${dd}` 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
}
+33
View File
@@ -17,6 +17,7 @@ import type { PlanItem } from '@/types/PlanItem'
import type { PlanItemCreateRequest } from '@/types/PlanItemCreateRequest' import type { PlanItemCreateRequest } from '@/types/PlanItemCreateRequest'
import type { SuggestMethod } from '@/types/SuggestMethod' import type { SuggestMethod } from '@/types/SuggestMethod'
import { api } from './api' import { api } from './api'
import type { PlanOps } from './plan-draft'
import { queryKeys } from './query' import { queryKeys } from './query'
function refreshPeriod(qc: QueryClient, id: string) { 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<Period>(`/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),
]),
})
}
+116
View File
@@ -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 }])
})
})
+152
View File
@@ -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<Group, string>
/** Her grup için dönemin haftaları sırasıyla; `""` → otomatik */
weeks: Record<Group, string[]>
}
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 = <T>(f: (g: Group) => T) =>
Object.fromEntries(GROUP_CODES.map((g) => [g, f(g)])) as Record<Group, T>
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<string>()
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
)
}
+27
View File
@@ -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: { summary: {
income: 'Gelir', income: 'Gelir',
fixed: 'Sabit giderler', fixed: 'Sabit giderler',