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:
@@ -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.
|
||||
|
||||
+3
-1
@@ -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).
|
||||
|
||||
+2
-2
@@ -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)
|
||||
|
||||
@@ -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<SuggestMethod>('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({
|
||||
<StepIntro title={s.title} hint={s.hint} />
|
||||
<SummaryTiles summary={summary} />
|
||||
|
||||
{/* Öneri seçimi */}
|
||||
<fieldset className="mt-6 space-y-3 rounded-xl border p-4">
|
||||
<legend className="px-1 text-sm font-medium">{s.method}</legend>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{METHODS.map((m) => (
|
||||
<Button
|
||||
key={m}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={method === m ? 'default' : 'outline'}
|
||||
aria-pressed={method === m}
|
||||
onClick={() => setMethod(m)}
|
||||
>
|
||||
{s.methods[m]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{suggestion.isPending ? (
|
||||
<Loader2
|
||||
className="size-5 animate-spin text-muted-foreground"
|
||||
aria-label={t.common.loading}
|
||||
/>
|
||||
) : suggestion.data?.available ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => reset(toForm(suggestion.data.amounts))}
|
||||
>
|
||||
<Sparkles aria-hidden />
|
||||
{s.apply}
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{suggestion.data?.message ?? suggestion.error?.message}
|
||||
</p>
|
||||
)}
|
||||
</fieldset>
|
||||
<div className="mt-6">
|
||||
<SuggestionPicker periodId={period.id} onApply={(a) => reset(toForm(a))} />
|
||||
</div>
|
||||
|
||||
{/* 4 grup */}
|
||||
<div className="mt-6 grid gap-4 sm:grid-cols-2">
|
||||
{GROUPS.map((g) => (
|
||||
<FormField key={g.code} label={`${g.emoji} ${g.label}`} error={errors[g.code]}>
|
||||
@@ -182,34 +140,8 @@ function BudgetStepForm({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Dağıtım durumu */}
|
||||
<div className="mt-6 space-y-2 rounded-xl border p-4" aria-live="polite">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>{s.distributed}</span>
|
||||
<span className="money font-medium">
|
||||
{formatMoney(distributed)} / {formatMoney(summary.spendable)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted" aria-hidden>
|
||||
<div
|
||||
className={cn('h-full rounded-full', remaining < 0 ? 'bg-warning' : 'bg-primary')}
|
||||
style={{
|
||||
width: `${summary.spendable > 0 ? Math.min(100, (distributed / summary.spendable) * 100) : 0}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
'text-sm',
|
||||
remaining < 0 ? 'font-medium text-warning' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{remaining < 0
|
||||
? s.over(formatMoney(-remaining))
|
||||
: remaining > 0
|
||||
? s.unallocated(formatMoney(remaining))
|
||||
: s.balanced}
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<DistributionBar distributed={distributed} spendable={summary.spendable} />
|
||||
</div>
|
||||
|
||||
<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. */
|
||||
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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 ? (
|
||||
<PageError message={current.error.message} onRetry={current.refetch} />
|
||||
) : current.data ? (
|
||||
<OpenPeriod period={current.data} />
|
||||
<PlanView period={current.data} />
|
||||
) : (
|
||||
<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. */
|
||||
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 <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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{period.name}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatDate(period.start_date)} – {formatDate(period.last_date)} · {period.days} gün ·{' '}
|
||||
{period.weeks.length} hafta
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<Link to={`${paths.periodWizard}?adim=2`}>
|
||||
<Pencil aria-hidden />
|
||||
Sihirbazla düzenle
|
||||
</Link>
|
||||
</Button>
|
||||
<PeriodHeading
|
||||
period={period}
|
||||
actions={
|
||||
editable && (
|
||||
<Button onClick={() => setEditing(true)}>
|
||||
<Pencil aria-hidden />
|
||||
{t.plan.edit}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<SummaryTiles summary={summary.data} />
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<ItemsList kind="income" list={items.data.filter((i) => i.kind === 'income')} />
|
||||
<ItemsList kind="expense" list={items.data.filter((i) => i.kind === 'expense')} />
|
||||
</div>
|
||||
{summary.data && <SummaryTiles summary={summary.data} />}
|
||||
<ComingSoon when="bir sonraki adımda" />
|
||||
{editable && <CopyPrevious period={period} />}
|
||||
<BudgetRead budget={budget.data} summary={summary.data} />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<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),
|
||||
]),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 }])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user