feat(defter): haftanın girişleri, düzenle/sil + geri al, haftalık özet
- Girişler gün gün, gün toplamıyla; satırda kategori, grup, ödeme türü, açıklama - Düzenleme aynı formda (sürüm kontrolü, pasif kategori korunur) - Silmede "Geri al" bildirimi; grup bazlı bütçe / harcanan / kalan özeti Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,26 +7,43 @@ import { FormError, FormField } from '@/components/form-field'
|
||||
import { MoneyInput } from '@/components/money-input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useCreateEntry } from '@/lib/entries'
|
||||
import { useCreateEntry, useUpdateEntry } from '@/lib/entries'
|
||||
import { formatMoney } from '@/lib/format'
|
||||
import { GROUPS, type GroupCode } from '@/lib/groups'
|
||||
import { defaultEntryDate, loadLastChoice, saveLastChoice } from '@/lib/ledger'
|
||||
import { toKurus } from '@/lib/money-schema'
|
||||
import { moneyToText } from '@/lib/plan-draft'
|
||||
import { t } from '@/lib/strings'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { CatalogItem } from '@/types/CatalogItem'
|
||||
import type { Entry } from '@/types/Entry'
|
||||
import type { Period } from '@/types/Period'
|
||||
import type { PeriodWeek } from '@/types/PeriodWeek'
|
||||
import { entrySchema, fieldForError, MAX_DESCRIPTION, type EntryForm } from './schema'
|
||||
|
||||
const s = t.ledger
|
||||
|
||||
/** Formun ilk değerleri: son seçimler hâlâ aktifse onlar (bu cihazda hatırlanır) */
|
||||
function initialValues(categories: CatalogItem[], methods: CatalogItem[], date: string): EntryForm {
|
||||
/** Formun ilk değerleri: düzenlemede kaydın kendisi, yoksa son seçimler (hâlâ aktifse) */
|
||||
function initialValues(
|
||||
categories: CatalogItem[],
|
||||
methods: CatalogItem[],
|
||||
date: string,
|
||||
entry?: Entry,
|
||||
): EntryForm {
|
||||
if (entry) {
|
||||
return {
|
||||
amount: moneyToText(entry.amount),
|
||||
group: entry.group,
|
||||
category_id: entry.category_id,
|
||||
payment_method_id: entry.payment_method_id ?? '',
|
||||
date: entry.date,
|
||||
description: entry.description ?? '',
|
||||
}
|
||||
}
|
||||
const last = loadLastChoice()
|
||||
const group = last.group
|
||||
const cat = group && categories.find((c) => c.id === last.categoryByGroup[group])
|
||||
const pm = methods.find((m) => m.id === last.paymentMethodId)
|
||||
const cat = group && categories.find((c) => c.is_active && c.id === last.categoryByGroup[group])
|
||||
const pm = methods.find((m) => m.is_active && m.id === last.paymentMethodId)
|
||||
return {
|
||||
amount: '',
|
||||
group: (cat ? group : '') as GroupCode,
|
||||
@@ -37,9 +54,15 @@ function initialValues(categories: CatalogItem[], methods: CatalogItem[], date:
|
||||
}
|
||||
}
|
||||
|
||||
/** Seçenek olarak sunulabilir mi: aktif ya da kaydın zaten kullandığı (pasifleşmiş olabilir) */
|
||||
const usable = (c: CatalogItem, current?: string | null) => c.is_active || c.id === current
|
||||
|
||||
/**
|
||||
* Hızlı giriş: tutar → grup → kategori → (ödeme türü, tarih, açıklama) → Kaydet.
|
||||
* Kayıttan sonra tutar ve açıklama temizlenir, diğer seçimler kalır (art arda giriş için).
|
||||
*
|
||||
* `entry` verilirse düzenleme modu (Güncelle / Vazgeç). Başka bir kaydı düzenlemek için
|
||||
* bileşen `key` ile yeniden oluşturulur.
|
||||
*/
|
||||
export function EntryForm({
|
||||
period,
|
||||
@@ -47,18 +70,24 @@ export function EntryForm({
|
||||
today,
|
||||
categories,
|
||||
methods,
|
||||
entry,
|
||||
onSaved,
|
||||
onCancel,
|
||||
}: {
|
||||
period: Period
|
||||
week: PeriodWeek
|
||||
today: string
|
||||
/** Yalnızca aktifler */
|
||||
/** Pasifler dahil (eski kaydın değeri için); yeni seçimde yalnızca aktifler sunulur */
|
||||
categories: CatalogItem[]
|
||||
methods: CatalogItem[]
|
||||
/** Düzenlenen kayıt */
|
||||
entry?: Entry
|
||||
/** Kaydedilen kaydın tarihi (o haftaya geçmek için) */
|
||||
onSaved: (date: string) => void
|
||||
onCancel?: () => void
|
||||
}) {
|
||||
const create = useCreateEntry(period.id)
|
||||
const update = useUpdateEntry(period.id)
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
@@ -72,22 +101,31 @@ export function EntryForm({
|
||||
} = useForm<EntryForm>({
|
||||
resolver: zodResolver(entrySchema(period.start_date, period.last_date)),
|
||||
mode: 'onTouched',
|
||||
defaultValues: initialValues(categories, methods, defaultEntryDate(week, today)),
|
||||
defaultValues: initialValues(categories, methods, defaultEntryDate(week, today), entry),
|
||||
})
|
||||
const group = useWatch({ control, name: 'group' })
|
||||
const groupCategories = categories.filter((c) => c.group === group)
|
||||
const groupCategories = categories.filter(
|
||||
(c) => c.group === group && usable(c, entry?.category_id),
|
||||
)
|
||||
const methodOptions = methods.filter((m) => usable(m, entry?.payment_method_id))
|
||||
|
||||
// Başka haftaya geçilince tarih o haftaya çekilir (seçili tarih zaten oradaysa dokunulmaz)
|
||||
useEffect(() => {
|
||||
if (entry) return
|
||||
const d = getValues('date')
|
||||
if (!(d >= week.start_date && d <= week.last_date)) {
|
||||
setValue('date', defaultEntryDate(week, today))
|
||||
}
|
||||
}, [week, today, getValues, setValue])
|
||||
}, [entry, week, today, getValues, setValue])
|
||||
|
||||
// Düzenlemeye geçince form görünür olsun ve tutara odaklansın
|
||||
useEffect(() => {
|
||||
if (entry) setFocus('amount')
|
||||
}, [entry, setFocus])
|
||||
|
||||
function pickGroup(code: GroupCode) {
|
||||
if (code === getValues('group')) return
|
||||
const inGroup = categories.filter((c) => c.group === code)
|
||||
const inGroup = categories.filter((c) => c.group === code && usable(c, entry?.category_id))
|
||||
const remembered = inGroup.find((c) => c.id === loadLastChoice().categoryByGroup[code])
|
||||
setValue('group', code, { shouldValidate: true })
|
||||
setValue('category_id', remembered?.id ?? (inGroup.length === 1 ? inGroup[0].id : ''), {
|
||||
@@ -97,6 +135,20 @@ export function EntryForm({
|
||||
|
||||
const onSubmit = handleSubmit(async (v) => {
|
||||
try {
|
||||
if (entry) {
|
||||
const e = await update.mutateAsync({
|
||||
id: entry.id,
|
||||
version: entry.version,
|
||||
date: v.date,
|
||||
amount: toKurus(v.amount),
|
||||
category_id: v.category_id,
|
||||
payment_method_id: v.payment_method_id, // '' → kaldır
|
||||
description: v.description.trim(), // '' → kaldır
|
||||
})
|
||||
toast.success(s.updated)
|
||||
onSaved(e.date)
|
||||
return
|
||||
}
|
||||
const e = await create.mutateAsync({
|
||||
date: v.date,
|
||||
amount: toKurus(v.amount),
|
||||
@@ -122,7 +174,7 @@ export function EntryForm({
|
||||
})
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} noValidate className="space-y-5">
|
||||
<form onSubmit={onSubmit} noValidate className="@container space-y-5">
|
||||
<Controller
|
||||
control={control}
|
||||
name="amount"
|
||||
@@ -146,7 +198,7 @@ export function EntryForm({
|
||||
|
||||
<fieldset>
|
||||
<legend className="mb-2 text-sm font-medium">{s.group}</legend>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<div className="grid grid-cols-2 gap-2 @lg:grid-cols-4">
|
||||
{GROUPS.map((g) => {
|
||||
const selected = group === g.code
|
||||
return (
|
||||
@@ -156,13 +208,13 @@ export function EntryForm({
|
||||
aria-pressed={selected}
|
||||
onClick={() => pickGroup(g.code)}
|
||||
className={cn(
|
||||
'flex min-h-14 min-w-0 items-center gap-1.5 rounded-xl border-2 px-2.5 py-2 text-left text-sm font-medium transition-colors sm:gap-2 sm:px-3 sm:text-base',
|
||||
'flex min-h-14 min-w-0 items-center gap-1.5 rounded-xl border-2 px-2.5 py-2 text-left text-sm font-medium transition-colors @lg:gap-2 @lg:px-3 @lg:text-base',
|
||||
selected
|
||||
? cn(g.className.soft, g.className.text, 'border-current')
|
||||
: 'border-border hover:bg-muted',
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className="text-lg sm:text-xl">
|
||||
<span aria-hidden className="text-lg @lg:text-xl">
|
||||
{g.emoji}
|
||||
</span>
|
||||
{g.label}
|
||||
@@ -190,7 +242,7 @@ export function EntryForm({
|
||||
/>
|
||||
)}
|
||||
|
||||
{methods.length > 0 && (
|
||||
{methodOptions.length > 0 && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="payment_method_id"
|
||||
@@ -201,7 +253,7 @@ export function EntryForm({
|
||||
onChange={field.onChange}
|
||||
options={[
|
||||
{ value: '', label: s.noPaymentMethod },
|
||||
...methods.map((m) => ({ value: m.id, label: label(m) })),
|
||||
...methodOptions.map((m) => ({ value: m.id, label: label(m) })),
|
||||
]}
|
||||
error={errors.payment_method_id?.message}
|
||||
/>
|
||||
@@ -209,7 +261,7 @@ export function EntryForm({
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-[12rem_1fr]">
|
||||
<div className="grid gap-4 @md:grid-cols-[12rem_1fr]">
|
||||
<FormField label={s.date} error={errors.date}>
|
||||
{(f) => (
|
||||
<Input
|
||||
@@ -227,10 +279,17 @@ export function EntryForm({
|
||||
</div>
|
||||
|
||||
<FormError message={errors.root?.message} />
|
||||
<Button type="submit" size="lg" className="w-full sm:w-auto" disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="animate-spin" aria-hidden />}
|
||||
{isSubmitting ? s.saving : s.save}
|
||||
</Button>
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row">
|
||||
{entry && (
|
||||
<Button type="button" variant="outline" size="lg" onClick={onCancel}>
|
||||
{s.cancel}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" size="lg" className="w-full sm:w-auto" disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="animate-spin" aria-hidden />}
|
||||
{isSubmitting ? s.saving : entry ? s.update : s.save}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { Pencil, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useDeleteEntry, useRestoreEntry } from '@/lib/entries'
|
||||
import { formatDate, formatMoney, formatWeekday } from '@/lib/format'
|
||||
import { group } from '@/lib/groups'
|
||||
import { groupByDay } from '@/lib/ledger'
|
||||
import { t } from '@/lib/strings'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { CatalogItem } from '@/types/CatalogItem'
|
||||
import type { Entry } from '@/types/Entry'
|
||||
|
||||
const s = t.ledger
|
||||
|
||||
/**
|
||||
* Haftanın girişleri, gün gün. Sil → bildirimde "Geri al" (sunucuda soft delete + restore).
|
||||
* Kategori ve ödeme türü adları pasifler dahil listeden bulunur (eski kayıtlar da adıyla görünür).
|
||||
*/
|
||||
export function EntryList({
|
||||
periodId,
|
||||
entries,
|
||||
categories,
|
||||
methods,
|
||||
editingId,
|
||||
onEdit,
|
||||
onDeleted,
|
||||
}: {
|
||||
periodId: string
|
||||
entries: Entry[]
|
||||
categories: CatalogItem[]
|
||||
methods: CatalogItem[]
|
||||
editingId?: string
|
||||
onEdit: (e: Entry) => void
|
||||
onDeleted: (id: string) => void
|
||||
}) {
|
||||
const del = useDeleteEntry(periodId)
|
||||
const restore = useRestoreEntry(periodId)
|
||||
const catName = (id: string) => categories.find((c) => c.id === id)
|
||||
const pmName = (id: string | null) => (id ? methods.find((m) => m.id === id)?.name : undefined)
|
||||
|
||||
function remove(e: Entry, name: string) {
|
||||
del.mutate(e.id, {
|
||||
onSuccess: () => {
|
||||
onDeleted(e.id)
|
||||
toast(s.deleted, {
|
||||
description: `${formatMoney(e.amount)} · ${name}`,
|
||||
duration: 8000,
|
||||
action: {
|
||||
label: s.undo,
|
||||
onClick: () =>
|
||||
restore.mutate(e.id, {
|
||||
onSuccess: () => toast.success(s.restored),
|
||||
onError: (err) => toast.error(err.message),
|
||||
}),
|
||||
},
|
||||
})
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">{s.empty}</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{groupByDay(entries).map((day) => (
|
||||
<section key={day.date} aria-label={`${formatWeekday(day.date)} ${formatDate(day.date)}`}>
|
||||
<div className="mb-1 flex items-baseline justify-between gap-2 border-b pb-1 text-sm">
|
||||
<h4 className="font-medium">
|
||||
{formatWeekday(day.date)}{' '}
|
||||
<span className="text-muted-foreground">{formatDate(day.date)}</span>
|
||||
</h4>
|
||||
<span className="money text-muted-foreground" title={s.dayTotal}>
|
||||
{formatMoney(day.total)}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="divide-y">
|
||||
{day.entries.map((e) => {
|
||||
const g = group(e.group)
|
||||
const cat = catName(e.category_id)
|
||||
const name = cat?.name ?? '—'
|
||||
const editing = e.id === editingId
|
||||
const details = [g.label, pmName(e.payment_method_id), e.description].filter(Boolean)
|
||||
return (
|
||||
<li
|
||||
key={e.id}
|
||||
className={cn(
|
||||
'flex items-center gap-2 py-2',
|
||||
editing && '-mx-2 rounded-lg bg-muted px-2',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'flex size-9 shrink-0 items-center justify-center rounded-full text-lg',
|
||||
g.className.soft,
|
||||
)}
|
||||
>
|
||||
{cat?.emoji ?? g.emoji}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium">
|
||||
{name}
|
||||
{editing && (
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||
· {s.editing}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
<span className={g.className.text}>{details[0]}</span>
|
||||
{details.slice(1).map((d) => ` · ${d}`)}
|
||||
</p>
|
||||
</div>
|
||||
<span className="money shrink-0 font-medium">{formatMoney(e.amount)}</span>
|
||||
<div className="flex shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={s.edit(name)}
|
||||
aria-pressed={editing}
|
||||
onClick={() => onEdit(e)}
|
||||
>
|
||||
<Pencil aria-hidden />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={s.remove(name)}
|
||||
className="text-muted-foreground hover:text-expense"
|
||||
disabled={del.isPending && del.variables === e.id}
|
||||
onClick={() => remove(e, name)}
|
||||
>
|
||||
<Trash2 aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,20 @@
|
||||
import { useState } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router'
|
||||
import { PageError, PageHeader, PageLoading } from '@/components/page'
|
||||
import { NoPeriodCard } from '@/features/period-wizard/no-period-card'
|
||||
import { Section } from '@/features/planning/plan-read'
|
||||
import { useCategories, usePaymentMethods } from '@/lib/catalog'
|
||||
import { useEntries } from '@/lib/entries'
|
||||
import { toIsoDate } from '@/lib/format'
|
||||
import { parseWeekParam, weekIndexFor } from '@/lib/ledger'
|
||||
import { useBudget, useCurrentPeriod } from '@/lib/periods'
|
||||
import { t } from '@/lib/strings'
|
||||
import type { Entry } from '@/types/Entry'
|
||||
import type { Period } from '@/types/Period'
|
||||
import { EntryForm } from './entry-form'
|
||||
import { EntryList } from './entry-list'
|
||||
import { WeekPicker } from './week-picker'
|
||||
import { WeekSummary } from './week-summary'
|
||||
|
||||
export function LedgerPage() {
|
||||
const current = useCurrentPeriod()
|
||||
@@ -30,10 +34,15 @@ export function LedgerPage() {
|
||||
)
|
||||
}
|
||||
|
||||
/** Hafta adreste tutulur (`?hafta=2`): yenileyince / geri gelince aynı hafta açılır. */
|
||||
/**
|
||||
* Hafta adreste tutulur (`?hafta=2`): yenileyince / geri gelince aynı hafta açılır.
|
||||
* Düzen: hafta seçici → form → haftalık özet → girişler (masaüstünde form solda, diğerleri sağda).
|
||||
*/
|
||||
function LedgerView({ period }: { period: Period }) {
|
||||
const [params, setParams] = useSearchParams()
|
||||
const [today] = useState(() => toIsoDate(new Date()))
|
||||
const [editing, setEditing] = useState<Entry | null>(null)
|
||||
const formRef = useRef<HTMLDivElement>(null)
|
||||
const inPeriod = today >= period.start_date && today <= period.last_date
|
||||
const todayIndex = inPeriod ? weekIndexFor(period.weeks, today) : null
|
||||
const index =
|
||||
@@ -41,38 +50,85 @@ function LedgerView({ period }: { period: Period }) {
|
||||
const week = period.weeks[index - 1]
|
||||
|
||||
const budget = useBudget(period.id)
|
||||
const categories = useCategories()
|
||||
const methods = usePaymentMethods()
|
||||
const categories = useCategories(true)
|
||||
const methods = usePaymentMethods(true)
|
||||
const entries = useEntries(period.id, { from: week.start_date, to: week.last_date })
|
||||
|
||||
const goTo = (n: number) =>
|
||||
const goTo = (n: number) => {
|
||||
setEditing(null)
|
||||
setParams(n === todayIndex ? {} : { hafta: String(n) }, { replace: true })
|
||||
}
|
||||
|
||||
function edit(e: Entry) {
|
||||
setEditing(e)
|
||||
formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
|
||||
const catalogFailed = [categories, methods].find((q) => q.isError)
|
||||
const catalog = categories.data && methods.data ? { c: categories.data, m: methods.data } : null
|
||||
|
||||
const failed = [categories, methods].find((q) => q.isError)
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-4">
|
||||
<WeekPicker
|
||||
weeks={period.weeks}
|
||||
index={index}
|
||||
todayIndex={todayIndex}
|
||||
budget={budget.data}
|
||||
onChange={goTo}
|
||||
/>
|
||||
<Section title={t.ledger.formTitle}>
|
||||
{failed?.error ? (
|
||||
<PageError message={failed.error.message} onRetry={failed.refetch} />
|
||||
) : !categories.data || !methods.data ? (
|
||||
<PageLoading />
|
||||
) : (
|
||||
<EntryForm
|
||||
period={period}
|
||||
week={week}
|
||||
today={today}
|
||||
categories={categories.data}
|
||||
methods={methods.data}
|
||||
onSaved={(date) => goTo(weekIndexFor(period.weeks, date))}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
<div className="space-y-4">
|
||||
<div className="mx-auto max-w-3xl lg:max-w-none">
|
||||
<WeekPicker
|
||||
weeks={period.weeks}
|
||||
index={index}
|
||||
todayIndex={todayIndex}
|
||||
budget={budget.data}
|
||||
onChange={goTo}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid items-start gap-4 lg:grid-cols-2">
|
||||
<div ref={formRef} className="scroll-mt-4 lg:sticky lg:top-4">
|
||||
<Section title={editing ? t.ledger.editTitle : t.ledger.formTitle}>
|
||||
{catalogFailed?.error ? (
|
||||
<PageError message={catalogFailed.error.message} onRetry={catalogFailed.refetch} />
|
||||
) : !catalog ? (
|
||||
<PageLoading />
|
||||
) : (
|
||||
<EntryForm
|
||||
key={editing?.id ?? 'new'}
|
||||
period={period}
|
||||
week={week}
|
||||
today={today}
|
||||
categories={catalog.c}
|
||||
methods={catalog.m}
|
||||
entry={editing ?? undefined}
|
||||
onSaved={(date) => goTo(weekIndexFor(period.weeks, date))}
|
||||
onCancel={() => setEditing(null)}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<Section title={t.ledger.summaryTitle}>
|
||||
{budget.isError ? (
|
||||
<PageError message={budget.error.message} onRetry={budget.refetch} />
|
||||
) : budget.data ? (
|
||||
<WeekSummary budget={budget.data} index={index} />
|
||||
) : (
|
||||
<PageLoading />
|
||||
)}
|
||||
</Section>
|
||||
<Section title={t.ledger.listTitle}>
|
||||
{entries.isError ? (
|
||||
<PageError message={entries.error.message} onRetry={entries.refetch} />
|
||||
) : entries.data && catalog ? (
|
||||
<EntryList
|
||||
periodId={period.id}
|
||||
entries={entries.data}
|
||||
categories={catalog.c}
|
||||
methods={catalog.m}
|
||||
editingId={editing?.id}
|
||||
onEdit={edit}
|
||||
onDeleted={(id) => editing?.id === id && setEditing(null)}
|
||||
/>
|
||||
) : (
|
||||
<PageLoading />
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { formatMoney } from '@/lib/format'
|
||||
import { group } from '@/lib/groups'
|
||||
import { t } from '@/lib/strings'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { GroupBudget } from '@/types/GroupBudget'
|
||||
|
||||
const s = t.ledger
|
||||
|
||||
/** Seçili haftada grup bazlı bütçe / harcanan / kalan (+ ilerleme çubuğu) */
|
||||
export function WeekSummary({ budget, index }: { budget: GroupBudget[]; index: number }) {
|
||||
return (
|
||||
<div className="@container">
|
||||
<ul className="grid gap-3 @lg:grid-cols-2">
|
||||
{budget.map((b) => {
|
||||
const g = group(b.group)
|
||||
const w = b.weeks[index - 1]
|
||||
const planned = w?.amount ?? 0
|
||||
const spent = w?.spent ?? 0
|
||||
const left = planned - spent
|
||||
const over = left < 0
|
||||
const ratio = planned > 0 ? Math.min(spent / planned, 1) : spent > 0 ? 1 : 0
|
||||
return (
|
||||
<li key={b.group} className="rounded-xl border p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-2">
|
||||
<span className={cn('font-medium whitespace-nowrap', g.className.text)}>
|
||||
<span aria-hidden>{g.emoji}</span> {g.label}
|
||||
</span>
|
||||
<span className={cn('money text-sm font-semibold', over && 'text-expense')}>
|
||||
{over ? `${s.over} ${formatMoney(-left)}` : `${s.left} ${formatMoney(left)}`}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="my-2 h-2 overflow-hidden rounded-full bg-muted"
|
||||
role="progressbar"
|
||||
aria-label={`${g.label}: ${s.spent}`}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(ratio * 100)}
|
||||
>
|
||||
<div
|
||||
className={cn('h-full rounded-full', over ? 'bg-expense' : g.className.fill)}
|
||||
style={{ width: `${ratio * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{s.spent} <span className="money">{formatMoney(spent)}</span> / {s.budget}{' '}
|
||||
<span className="money">{formatMoney(planned)}</span>
|
||||
</p>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Kategoriler ve ödeme türleri (sunucu verisi). Yönetim ekranı Ayarlar'da gelecek;
|
||||
* şimdilik Defter formu yalnızca **aktif** olanları okur.
|
||||
* Defter pasifler dahil okur: eski kayıtların adı gösterilir, yeni seçimde yalnızca aktifler sunulur.
|
||||
*/
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { CatalogItem } from '@/types/CatalogItem'
|
||||
@@ -10,18 +10,18 @@ import { queryKeys } from './query'
|
||||
/** Kategori ve ödeme türleri seyrek değişir → 5 dk taze sayılır */
|
||||
const STALE = 5 * 60_000
|
||||
|
||||
export function useCategories() {
|
||||
export function useCategories(includeInactive = false) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.categories(),
|
||||
queryFn: () => api.get<CatalogItem[]>('/categories'),
|
||||
queryKey: queryKeys.categories(includeInactive),
|
||||
queryFn: () => api.get<CatalogItem[]>(`/categories?include_inactive=${includeInactive}`),
|
||||
staleTime: STALE,
|
||||
})
|
||||
}
|
||||
|
||||
export function usePaymentMethods() {
|
||||
export function usePaymentMethods(includeInactive = false) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.paymentMethods(),
|
||||
queryFn: () => api.get<CatalogItem[]>('/payment-methods'),
|
||||
queryKey: queryKeys.paymentMethods(includeInactive),
|
||||
queryFn: () => api.get<CatalogItem[]>(`/payment-methods?include_inactive=${includeInactive}`),
|
||||
staleTime: STALE,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import type { Entry } from '@/types/Entry'
|
||||
import type { EntryCreateRequest } from '@/types/EntryCreateRequest'
|
||||
import type { EntryUpdateRequest } from '@/types/EntryUpdateRequest'
|
||||
import { api } from './api'
|
||||
import { queryKeys } from './query'
|
||||
|
||||
@@ -28,3 +29,30 @@ export function useCreateEntry(periodId: string) {
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.period(periodId) }),
|
||||
})
|
||||
}
|
||||
|
||||
/** Düzenle (`version` ile: başkası değiştirdiyse 409). Hata da olsa liste yenilenir. */
|
||||
export function useUpdateEntry(periodId: string) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...body }: EntryUpdateRequest & { id: string }) =>
|
||||
api.patch<Entry>(`/entries/${id}`, body),
|
||||
onSettled: () => qc.invalidateQueries({ queryKey: queryKeys.period(periodId) }),
|
||||
})
|
||||
}
|
||||
|
||||
/** Sil (soft) → `useRestoreEntry` ile geri alınabilir */
|
||||
export function useDeleteEntry(periodId: string) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/entries/${id}`),
|
||||
onSettled: () => qc.invalidateQueries({ queryKey: queryKeys.period(periodId) }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useRestoreEntry(periodId: string) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.post<Entry>(`/entries/${id}/restore`),
|
||||
onSettled: () => qc.invalidateQueries({ queryKey: queryKeys.period(periodId) }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
formatWeekday,
|
||||
formatDate,
|
||||
formatMoney,
|
||||
formatNumber,
|
||||
@@ -67,3 +68,11 @@ describe('parseMoney', () => {
|
||||
expect(parseMoney('19,99')).toBe(1999)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatWeekday', () => {
|
||||
it('Türkçe gün adı, saat diliminden bağımsız', () => {
|
||||
expect(formatWeekday('2026-11-12')).toBe('Perşembe')
|
||||
expect(formatWeekday('2026-11-01')).toBe('Pazar')
|
||||
expect(formatWeekday('2026-11-02')).toBe('Pazartesi')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -121,3 +121,11 @@ export function formatDayMonth(iso: string): string {
|
||||
const m = /^\d{4}-(\d{2})-(\d{2})$/.exec(iso)
|
||||
return m ? `${m[2]}.${m[1]}` : iso
|
||||
}
|
||||
|
||||
const weekdayFormat = new Intl.DateTimeFormat('tr-TR', { weekday: 'long', timeZone: 'UTC' })
|
||||
|
||||
/** `"2026-11-12"` → `Perşembe` (UTC üzerinden: saat dilimi günü kaydırmaz) */
|
||||
export function formatWeekday(iso: string): string {
|
||||
const [y, m, d] = iso.split('-').map(Number)
|
||||
return weekdayFormat.format(new Date(Date.UTC(y, m - 1, d)))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { PeriodWeek } from '@/types/PeriodWeek'
|
||||
import { defaultEntryDate, parseWeekParam, weekIndexFor } from './ledger'
|
||||
import type { Entry } from '@/types/Entry'
|
||||
import { defaultEntryDate, groupByDay, parseWeekParam, weekIndexFor } from './ledger'
|
||||
|
||||
// Aralık 2026: 1–6 · 7–13 · 14–20 · 21–27 · 28–31 (backend testleriyle aynı)
|
||||
const weeks: PeriodWeek[] = [
|
||||
@@ -33,4 +34,17 @@ describe('ledger', () => {
|
||||
expect(parseWeekParam('2a', 5)).toBeNull()
|
||||
expect(parseWeekParam(null, 5)).toBeNull()
|
||||
})
|
||||
|
||||
it('groups entries by day, newest day first', () => {
|
||||
const e = (id: string, date: string, amount: number) => ({ id, date, amount }) as Entry
|
||||
const days = groupByDay([
|
||||
e('a', '2026-12-09', 100),
|
||||
e('b', '2026-12-07', 50),
|
||||
e('c', '2026-12-09', 25),
|
||||
])
|
||||
expect(days.map((d) => [d.date, d.total, d.entries.map((x) => x.id).join('')])).toEqual([
|
||||
['2026-12-09', 125, 'ac'],
|
||||
['2026-12-07', 50, 'b'],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* Tarihler `"YYYY-MM-DD"` metni → metin karşılaştırması tarih sırasıyla aynıdır.
|
||||
*/
|
||||
import type { GroupCode } from './groups'
|
||||
import type { Entry } from '@/types/Entry'
|
||||
import type { PeriodWeek } from '@/types/PeriodWeek'
|
||||
|
||||
/** Tarihin düştüğü hafta (1'den başlar); dönem dışındaysa en yakın uç hafta */
|
||||
@@ -27,6 +28,20 @@ export function parseWeekParam(value: string | null, count: number): number | nu
|
||||
return n >= 1 && n <= count ? n : null
|
||||
}
|
||||
|
||||
export type EntryDay = { date: string; total: number; entries: Entry[] }
|
||||
|
||||
/** Kayıtları gün gün toplar; en yeni gün başta, gün içinde sunucu sırası korunur */
|
||||
export function groupByDay(entries: readonly Entry[]): EntryDay[] {
|
||||
const days = new Map<string, EntryDay>()
|
||||
for (const e of entries) {
|
||||
let day = days.get(e.date)
|
||||
if (!day) days.set(e.date, (day = { date: e.date, total: 0, entries: [] }))
|
||||
day.total += e.amount
|
||||
day.entries.push(e)
|
||||
}
|
||||
return [...days.values()].sort((a, b) => b.date.localeCompare(a.date))
|
||||
}
|
||||
|
||||
// ---------- Son seçimler (bu cihazda hatırlanır) ----------
|
||||
|
||||
export type LastChoice = {
|
||||
|
||||
@@ -274,6 +274,24 @@ export const t = {
|
||||
dateInvalid: 'Tarih geçersiz',
|
||||
dateOutside: 'Tarih dönem içinde olmalı',
|
||||
descriptionTooLong: 'Açıklama en fazla 200 karakter olabilir',
|
||||
editTitle: 'Harcamayı düzenle',
|
||||
update: 'Güncelle',
|
||||
cancel: 'Vazgeç',
|
||||
updated: 'Harcama güncellendi',
|
||||
listTitle: 'Haftanın girişleri',
|
||||
empty: 'Bu hafta henüz harcama yok.',
|
||||
dayTotal: 'Gün toplamı',
|
||||
edit: (what: string) => `${what} harcamasını düzenle`,
|
||||
remove: (what: string) => `${what} harcamasını sil`,
|
||||
deleted: 'Harcama silindi',
|
||||
undo: '↩️ Geri al',
|
||||
restored: 'Harcama geri alındı',
|
||||
editing: 'Düzenleniyor',
|
||||
summaryTitle: 'Haftalık özet',
|
||||
budget: 'Bütçe',
|
||||
spent: 'Harcanan',
|
||||
left: 'Kalan',
|
||||
over: 'Aşıldı',
|
||||
},
|
||||
|
||||
common: {
|
||||
|
||||
Reference in New Issue
Block a user