feat(değerlendirme): geçmiş dönemler ve düzeltme için yeniden açma
- Defter, Plan ve Değerlendirme'de dönem seçici (?donem=) - Kapalı dönem salt okunur; düzeltmedeki dönem düzenlenebilir ve yeniden kapatılır - Hane sahibi için onaylı "Düzeltme için yeniden aç" - Geçmiş dönemler listesi, dönem durum rozeti Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,7 @@ export function EntryList({
|
||||
categories,
|
||||
methods,
|
||||
editingId,
|
||||
readOnly = false,
|
||||
onEdit,
|
||||
onDeleted,
|
||||
}: {
|
||||
@@ -30,6 +31,8 @@ export function EntryList({
|
||||
categories: CatalogItem[]
|
||||
methods: CatalogItem[]
|
||||
editingId?: string
|
||||
/** Kapalı dönem: düzenle/sil yok */
|
||||
readOnly?: boolean
|
||||
onEdit: (e: Entry) => void
|
||||
onDeleted: (id: string) => void
|
||||
}) {
|
||||
@@ -115,27 +118,29 @@ export function EntryList({
|
||||
</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>
|
||||
{!readOnly && (
|
||||
<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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router'
|
||||
import { Info } from 'lucide-react'
|
||||
import { PageError, PageHeader, PageLoading } from '@/components/page'
|
||||
import { NoPeriodCard } from '@/features/period-wizard/no-period-card'
|
||||
import { PeriodPicker } from '@/features/periods/period-picker'
|
||||
import { SelectedPeriod } from '@/features/periods/selected-period'
|
||||
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 { isWritable } from '@/lib/period-select'
|
||||
import { useBudget } from '@/lib/periods'
|
||||
import { t } from '@/lib/strings'
|
||||
import type { Entry } from '@/types/Entry'
|
||||
import type { Period } from '@/types/Period'
|
||||
@@ -17,19 +20,14 @@ import { WeekPicker } from './week-picker'
|
||||
import { WeekSummary } from './week-summary'
|
||||
|
||||
export function LedgerPage() {
|
||||
const current = useCurrentPeriod()
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={t.pages.ledger.title} description={t.pages.ledger.description} />
|
||||
{current.isPending ? (
|
||||
<PageLoading />
|
||||
) : current.isError ? (
|
||||
<PageError message={current.error.message} onRetry={current.refetch} />
|
||||
) : current.data ? (
|
||||
<LedgerView period={current.data} />
|
||||
) : (
|
||||
<NoPeriodCard />
|
||||
)}
|
||||
<PageHeader
|
||||
title={t.pages.ledger.title}
|
||||
description={t.pages.ledger.description}
|
||||
actions={<PeriodPicker />}
|
||||
/>
|
||||
<SelectedPeriod>{(p) => <LedgerView key={p.id} period={p} />}</SelectedPeriod>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -48,6 +46,7 @@ function LedgerView({ period }: { period: Period }) {
|
||||
const index =
|
||||
parseWeekParam(params.get('hafta'), period.weeks.length) ?? weekIndexFor(period.weeks, today)
|
||||
const week = period.weeks[index - 1]
|
||||
const readOnly = !isWritable(period)
|
||||
|
||||
const budget = useBudget(period.id)
|
||||
const categories = useCategories(true)
|
||||
@@ -56,7 +55,15 @@ function LedgerView({ period }: { period: Period }) {
|
||||
|
||||
const goTo = (n: number) => {
|
||||
setEditing(null)
|
||||
setParams(n === todayIndex ? {} : { hafta: String(n) }, { replace: true })
|
||||
setParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev) // seçili dönem (`donem`) korunur
|
||||
if (n === todayIndex) next.delete('hafta')
|
||||
else next.set('hafta', String(n))
|
||||
return next
|
||||
},
|
||||
{ replace: true },
|
||||
)
|
||||
}
|
||||
|
||||
function edit(e: Entry) {
|
||||
@@ -78,29 +85,37 @@ function LedgerView({ period }: { period: Period }) {
|
||||
onChange={goTo}
|
||||
/>
|
||||
</div>
|
||||
{readOnly && (
|
||||
<p className="flex items-start gap-2 rounded-xl bg-muted px-3 py-2 text-sm text-muted-foreground">
|
||||
<Info className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||||
{t.ledger.readOnly}
|
||||
</p>
|
||||
)}
|
||||
<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">
|
||||
{!readOnly && (
|
||||
<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={readOnly ? 'space-y-4 lg:col-span-2' : 'space-y-4'}>
|
||||
<Section title={t.ledger.summaryTitle}>
|
||||
{budget.isError ? (
|
||||
<PageError message={budget.error.message} onRetry={budget.refetch} />
|
||||
@@ -120,6 +135,7 @@ function LedgerView({ period }: { period: Period }) {
|
||||
categories={catalog.c}
|
||||
methods={catalog.m}
|
||||
editingId={editing?.id}
|
||||
readOnly={readOnly}
|
||||
onEdit={edit}
|
||||
onDeleted={(id) => editing?.id === id && setEditing(null)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useSearchParams } from 'react-router'
|
||||
import { PERIOD_PARAM, useSelectedPeriod } from '@/lib/period-select'
|
||||
import { t } from '@/lib/strings'
|
||||
|
||||
const s = t.periods
|
||||
|
||||
/**
|
||||
* Sayfa başlığında dönem seçimi (birden fazla dönem varsa). Seçim adrese yazılır
|
||||
* (`?donem=`); açık dönem varsayılan olduğundan onda parametre kalkar. Hafta vb. sıfırlanır.
|
||||
*/
|
||||
export function PeriodPicker() {
|
||||
const { period, periods } = useSelectedPeriod()
|
||||
const [, setParams] = useSearchParams()
|
||||
if (periods.length < 2) return null
|
||||
|
||||
return (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">{s.picker}</span>
|
||||
<select
|
||||
name="period"
|
||||
value={period?.id ?? ''}
|
||||
onChange={(e) => {
|
||||
const p = periods.find((x) => x.id === e.target.value)
|
||||
if (p) setParams(p.status === 'open' ? {} : { [PERIOD_PARAM]: p.id })
|
||||
}}
|
||||
className="h-11 max-w-72 rounded-lg border bg-background px-3 text-sm"
|
||||
>
|
||||
{!period && <option value="">—</option>}
|
||||
{periods.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
{p.status !== 'open' ? ` · ${s.status[p.status]}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { PageError, PageLoading } from '@/components/page'
|
||||
import { NoPeriodCard } from '@/features/period-wizard/no-period-card'
|
||||
import { useSelectedPeriod } from '@/lib/period-select'
|
||||
import { t } from '@/lib/strings'
|
||||
import type { Period } from '@/types/Period'
|
||||
|
||||
/**
|
||||
* Seçili dönemi (`?donem=` ya da açık dönem) yükler; yükleniyor / hata / bulunamadı /
|
||||
* dönem yok durumlarını tek yerde gösterir. Dönem varsa `children(dönem, tüm dönemler)`.
|
||||
*/
|
||||
export function SelectedPeriod({
|
||||
empty = <NoPeriodCard />,
|
||||
children,
|
||||
}: {
|
||||
empty?: ReactNode
|
||||
children: (period: Period, periods: Period[]) => ReactNode
|
||||
}) {
|
||||
const sel = useSelectedPeriod()
|
||||
if (sel.error) return <PageError message={sel.error.message} onRetry={sel.refetch} />
|
||||
if (sel.isPending) return <PageLoading />
|
||||
if (sel.period === undefined)
|
||||
return (
|
||||
<p role="alert" className="rounded-xl bg-muted p-4 text-muted-foreground">
|
||||
{t.periods.notFound}
|
||||
</p>
|
||||
)
|
||||
if (sel.period === null) return empty
|
||||
return children(sel.period, sel.periods)
|
||||
}
|
||||
@@ -4,8 +4,10 @@ import { PageError, PageHeader, PageLoading } from '@/components/page'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { NotesSection } from '@/features/notes/notes-section'
|
||||
import { CopyPrevious } from '@/features/period-wizard/step-items'
|
||||
import { NoPeriodCard } from '@/features/period-wizard/no-period-card'
|
||||
import { useBudget, useCurrentPeriod, usePlanItems, useSummary } from '@/lib/periods'
|
||||
import { PeriodPicker } from '@/features/periods/period-picker'
|
||||
import { SelectedPeriod } from '@/features/periods/selected-period'
|
||||
import { isWritable } from '@/lib/period-select'
|
||||
import { useBudget, usePlanItems, useSummary } from '@/lib/periods'
|
||||
import { t } from '@/lib/strings'
|
||||
import type { Period } from '@/types/Period'
|
||||
import { PlanEdit } from './plan-edit'
|
||||
@@ -13,19 +15,14 @@ import { BudgetRead, ItemsList, PeriodHeading } from './plan-read'
|
||||
import { SummaryTiles } from './summary-tiles'
|
||||
|
||||
export function PlanningPage() {
|
||||
const current = useCurrentPeriod()
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={t.pages.planning.title} description={t.pages.planning.description} />
|
||||
{current.isPending ? (
|
||||
<PageLoading />
|
||||
) : current.isError ? (
|
||||
<PageError message={current.error.message} onRetry={current.refetch} />
|
||||
) : current.data ? (
|
||||
<PlanView period={current.data} />
|
||||
) : (
|
||||
<NoPeriodCard />
|
||||
)}
|
||||
<PageHeader
|
||||
title={t.pages.planning.title}
|
||||
description={t.pages.planning.description}
|
||||
actions={<PeriodPicker />}
|
||||
/>
|
||||
<SelectedPeriod>{(p) => <PlanView key={p.id} period={p} />}</SelectedPeriod>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -56,7 +53,7 @@ function PlanView({ period }: { period: Period }) {
|
||||
)
|
||||
}
|
||||
|
||||
const editable = period.status === 'open'
|
||||
const editable = isWritable(period) // açık ya da düzeltmede
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PeriodHeading
|
||||
@@ -75,7 +72,7 @@ function PlanView({ period }: { period: Period }) {
|
||||
<ItemsList kind="income" list={items.data.filter((i) => i.kind === 'income')} />
|
||||
<ItemsList kind="expense" list={items.data.filter((i) => i.kind === 'expense')} />
|
||||
</div>
|
||||
{editable && <CopyPrevious period={period} />}
|
||||
{period.status === 'open' && <CopyPrevious period={period} />}
|
||||
<BudgetRead budget={budget.data} summary={summary.data} />
|
||||
<NotesSection periodId={period.id} context="planning" readOnly={!editable} />
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,19 @@ export function PeriodHeading({ period, actions }: { period: Period; actions?: R
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{period.name}</h2>
|
||||
<h2 className="flex flex-wrap items-center gap-2 text-lg font-semibold">
|
||||
{period.name}
|
||||
{period.status !== 'open' && (
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full px-2.5 py-0.5 text-xs font-medium',
|
||||
period.status === 'closed' ? 'bg-muted text-muted-foreground' : 'bg-warning/15',
|
||||
)}
|
||||
>
|
||||
{t.periods.status[period.status]}
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatDate(period.start_date)} – {formatDate(period.last_date)} ·{' '}
|
||||
{s.periodInfo(period.days, period.weeks.length)}
|
||||
|
||||
@@ -9,10 +9,12 @@ import { MoneyInput } from '@/components/money-input'
|
||||
import { PageError, PageHeader, PageLoading } from '@/components/page'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { NoPeriodCard } from '@/features/period-wizard/no-period-card'
|
||||
import { SelectedPeriod } from '@/features/periods/selected-period'
|
||||
import { StepActions, StepIntro, Stepper } from '@/features/period-wizard/wizard-ui'
|
||||
import { PeriodHeading } from '@/features/planning/plan-read'
|
||||
import { formatDate, formatMoney, formatNumber, parseMoney, toIsoDate } from '@/lib/format'
|
||||
import { useClosePeriod, useCurrentPeriod, useReview } from '@/lib/periods'
|
||||
import { periodPath } from '@/lib/period-select'
|
||||
import { useClosePeriod, useReview } from '@/lib/periods'
|
||||
import { settlePreview } from '@/lib/review'
|
||||
import { t } from '@/lib/strings'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -27,32 +29,36 @@ const s = t.closing
|
||||
/**
|
||||
* Dönem kapatma sihirbazı: Özet → Birikim → Onay → Sonuç.
|
||||
*
|
||||
* Kapanınca açık dönem kalmaz (`current` → `null`); sonuç bu yüzden sayfanın kendi
|
||||
* durumunda tutulur. Adımlar adreste değil: yarıda yenilenirse baştan başlar (hiçbir şey
|
||||
* Kapanınca dönem artık seçilemez (açık dönem kalmaz); sonuç bu yüzden sayfanın kendi
|
||||
* durumunda tutulur. `?donem=` ile düzeltmedeki dönem yeniden kapatılır. Adımlar adreste değil: yarıda yenilenirse baştan başlar (hiçbir şey
|
||||
* kaydedilmemiştir, kapatma yalnızca son adımda tek istekle yapılır).
|
||||
*/
|
||||
export function ClosePeriodPage() {
|
||||
const current = useCurrentPeriod()
|
||||
const [result, setResult] = useState<PeriodCloseResult | null>(null)
|
||||
|
||||
let body: ReactNode
|
||||
if (result) body = <Result result={result} />
|
||||
else if (current.isPending) body = <PageLoading />
|
||||
else if (current.isError)
|
||||
body = <PageError message={current.error.message} onRetry={current.refetch} />
|
||||
else if (!current.data)
|
||||
body = (
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted-foreground">{s.noOpen}</p>
|
||||
<NoPeriodCard />
|
||||
</div>
|
||||
)
|
||||
else body = <Wizard period={current.data} onClosed={setResult} />
|
||||
const empty = (
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted-foreground">{s.noOpen}</p>
|
||||
<NoPeriodCard />
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<PageHeader title={s.title} description={s.description} />
|
||||
{body}
|
||||
{result ? (
|
||||
<Result result={result} />
|
||||
) : (
|
||||
// Açık dönem ya da `?donem=` ile düzeltmedeki dönem (yeniden kapatma)
|
||||
<SelectedPeriod empty={empty}>
|
||||
{(p) =>
|
||||
p.status === 'closed' ? (
|
||||
<p className="text-muted-foreground">{s.alreadyClosed}</p>
|
||||
) : (
|
||||
<Wizard key={p.id} period={p} onClosed={setResult} />
|
||||
)
|
||||
}
|
||||
</SelectedPeriod>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -128,7 +134,10 @@ function StepSummary({
|
||||
{!period.reflection_md && (
|
||||
<Notice>
|
||||
{s.noReflection}{' '}
|
||||
<Link to={paths.review} className="font-medium text-foreground underline">
|
||||
<Link
|
||||
to={periodPath(paths.review, period)}
|
||||
className="font-medium text-foreground underline"
|
||||
>
|
||||
{s.writeReflection}
|
||||
</Link>
|
||||
</Notice>
|
||||
|
||||
@@ -1,44 +1,40 @@
|
||||
import { useState } from 'react'
|
||||
import { Info } from 'lucide-react'
|
||||
import { Link } from 'react-router'
|
||||
import { paths } from '@/components/layout/nav-items'
|
||||
import { PageError, PageHeader, PageLoading } from '@/components/page'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { NotesSection } from '@/features/notes/notes-section'
|
||||
import { NoPeriodCard } from '@/features/period-wizard/no-period-card'
|
||||
import { PeriodPicker } from '@/features/periods/period-picker'
|
||||
import { SelectedPeriod } from '@/features/periods/selected-period'
|
||||
import { PeriodHeading } from '@/features/planning/plan-read'
|
||||
import { useCategories } from '@/lib/catalog'
|
||||
import { formatDate, toIsoDate } from '@/lib/format'
|
||||
import { useCurrentPeriod, useReview } from '@/lib/periods'
|
||||
import { useReview } from '@/lib/periods'
|
||||
import { t } from '@/lib/strings'
|
||||
import type { Period } from '@/types/Period'
|
||||
import { GroupComparison, TopCategories, WeeklyDistribution } from './breakdowns'
|
||||
import { ClosingSection, PeriodHistory } from './period-status'
|
||||
import { Questions } from './questions'
|
||||
import { Reflection } from './reflection'
|
||||
|
||||
export function ReviewPage() {
|
||||
const current = useCurrentPeriod()
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={t.pages.review.title} description={t.pages.review.description} />
|
||||
{current.isPending ? (
|
||||
<PageLoading />
|
||||
) : current.isError ? (
|
||||
<PageError message={current.error.message} onRetry={current.refetch} />
|
||||
) : current.data ? (
|
||||
<ReviewView period={current.data} />
|
||||
) : (
|
||||
<NoPeriodCard />
|
||||
)}
|
||||
<PageHeader
|
||||
title={t.pages.review.title}
|
||||
description={t.pages.review.description}
|
||||
actions={<PeriodPicker />}
|
||||
/>
|
||||
<SelectedPeriod>
|
||||
{(p, all) => <ReviewView key={p.id} period={p} periods={all} />}
|
||||
</SelectedPeriod>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hesaplar sorgu anında backend'de yapılır (`GET /periods/{id}/review`); defterde
|
||||
* bir kayıt değişince bu sayfa da yenilenir. Kapalı dönem: salt okunur (Adım 5'te seçilebilir).
|
||||
* bir kayıt değişince bu sayfa da yenilenir. Kapalı dönem salt okunur; `?donem=` ile seçilir.
|
||||
*/
|
||||
function ReviewView({ period }: { period: Period }) {
|
||||
function ReviewView({ period, periods }: { period: Period; periods: Period[] }) {
|
||||
const review = useReview(period.id)
|
||||
const categories = useCategories(true)
|
||||
const [today] = useState(() => toIsoDate(new Date()))
|
||||
@@ -50,7 +46,7 @@ function ReviewView({ period }: { period: Period }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PeriodHeading period={period} />
|
||||
{!readOnly && today <= period.last_date && (
|
||||
{period.status === 'open' && today <= period.last_date && (
|
||||
<p className="flex items-start gap-2 rounded-xl bg-muted px-3 py-2 text-sm text-muted-foreground">
|
||||
<Info className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||||
{t.review.ongoing(formatDate(period.last_date))}
|
||||
@@ -64,17 +60,8 @@ function ReviewView({ period }: { period: Period }) {
|
||||
<WeeklyDistribution review={review.data} />
|
||||
<Reflection period={period} readOnly={readOnly} />
|
||||
<NotesSection periodId={period.id} context="review" readOnly={readOnly} collapsed />
|
||||
{!readOnly && (
|
||||
<section className="flex flex-col gap-4 rounded-2xl border bg-card p-5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<h2 className="font-semibold">{t.review.closeTitle}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t.review.closeHint}</p>
|
||||
</div>
|
||||
<Button asChild size="lg" className="shrink-0">
|
||||
<Link to={paths.closePeriod}>{t.review.closeAction}</Link>
|
||||
</Button>
|
||||
</section>
|
||||
)}
|
||||
<ClosingSection period={period} periods={periods} />
|
||||
<PeriodHistory periods={periods} selected={period.id} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useState } from 'react'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { Link } from 'react-router'
|
||||
import { toast } from 'sonner'
|
||||
import { paths } from '@/components/layout/nav-items'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useMe } from '@/lib/auth'
|
||||
import { formatDate, formatMoney, toIsoDate } from '@/lib/format'
|
||||
import { canReopen, periodPath } from '@/lib/period-select'
|
||||
import { useReopenPeriod } from '@/lib/periods'
|
||||
import { t } from '@/lib/strings'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Period } from '@/types/Period'
|
||||
|
||||
const s = t.review
|
||||
|
||||
/**
|
||||
* Sayfa sonundaki kapanış bölümü, dönemin durumuna göre:
|
||||
* açık → "Dönemi Kapat" · düzeltmede → "Yeniden Kapat" · kapalı → bilgi + (sahip) yeniden aç.
|
||||
*/
|
||||
export function ClosingSection({ period, periods }: { period: Period; periods: Period[] }) {
|
||||
if (period.status === 'closed') return <ClosedInfo period={period} periods={periods} />
|
||||
const correcting = period.status === 'correcting'
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
'flex flex-col gap-4 rounded-2xl border bg-card p-5 lg:flex-row lg:items-center lg:justify-between',
|
||||
correcting && 'border-warning/50',
|
||||
)}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<h2 className="font-semibold">{correcting ? s.correctingTitle : s.closeTitle}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{correcting ? s.correctingHint : s.closeHint}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col gap-2 sm:flex-row">
|
||||
{correcting && (
|
||||
<>
|
||||
<Button asChild size="lg" variant="outline">
|
||||
<Link to={periodPath(paths.ledger, period)}>{s.fixInLedger}</Link>
|
||||
</Button>
|
||||
<Button asChild size="lg" variant="outline">
|
||||
<Link to={periodPath(paths.planning, period)}>{s.fixInPlan}</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button asChild size="lg">
|
||||
<Link to={periodPath(paths.closePeriod, period)}>
|
||||
{correcting ? s.recloseAction : s.closeAction}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ClosedInfo({ period, periods }: { period: Period; periods: Period[] }) {
|
||||
const me = useMe()
|
||||
const reopen = useReopenPeriod()
|
||||
const [asking, setAsking] = useState(false)
|
||||
const owner = me.data?.user.role === 'owner'
|
||||
const allowed = canReopen(periods, period)
|
||||
const closedAt = period.closed_at ? formatDate(toIsoDate(new Date(period.closed_at))) : '—'
|
||||
|
||||
// `mutateAsync`: başarıda dönem "düzeltmede" olur ve bu bileşen kalkar; `mutate`'in
|
||||
// bileşene bağlı geri çağrıları o zaman çalışmazdı (bildirim görünmezdi)
|
||||
async function confirm() {
|
||||
try {
|
||||
await reopen.mutateAsync(period.id)
|
||||
toast.success(s.reopened)
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-3 rounded-2xl border bg-card p-5">
|
||||
<p>{s.closedInfo(closedAt, formatMoney(period.to_savings ?? 0))}</p>
|
||||
{owner &&
|
||||
(!allowed ? (
|
||||
<p className="text-sm text-muted-foreground">{s.reopenBlocked}</p>
|
||||
) : asking ? (
|
||||
<div className="space-y-3 rounded-xl bg-warning/10 p-3">
|
||||
<p className="text-sm">{s.reopenConfirm}</p>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button onClick={() => void confirm()} disabled={reopen.isPending}>
|
||||
{s.reopenYes}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setAsking(false)}>
|
||||
{s.reopenNo}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="outline" onClick={() => setAsking(true)}>
|
||||
{s.reopen}
|
||||
</Button>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** Kapalı ve düzeltmedeki dönemler; tıklayınca o dönemin değerlendirmesi */
|
||||
export function PeriodHistory({ periods, selected }: { periods: Period[]; selected: string }) {
|
||||
const past = periods.filter((p) => p.status !== 'open')
|
||||
if (past.length === 0) return null
|
||||
return (
|
||||
<section className="rounded-2xl border bg-card p-4 sm:p-5">
|
||||
<h3 className="mb-2 font-semibold">{t.periods.history}</h3>
|
||||
<ul className="divide-y">
|
||||
{past.map((p) => (
|
||||
<li key={p.id}>
|
||||
<Link
|
||||
to={periodPath(paths.review, p)}
|
||||
aria-current={p.id === selected ? 'page' : undefined}
|
||||
className={cn(
|
||||
'-mx-2 flex min-h-11 items-center gap-3 rounded-lg px-2 py-2 hover:bg-muted',
|
||||
p.id === selected && 'bg-muted',
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium">{p.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDate(p.start_date)} – {formatDate(p.last_date)} ·{' '}
|
||||
{t.periods.status[p.status]}
|
||||
</p>
|
||||
</div>
|
||||
{p.to_savings !== null && (
|
||||
<span className="money shrink-0 text-sm text-income">
|
||||
{t.periods.toSavings(formatMoney(p.to_savings))}
|
||||
</span>
|
||||
)}
|
||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -39,7 +39,9 @@ export function Reflection({ period, readOnly }: { period: Period; readOnly: boo
|
||||
) : text ? (
|
||||
<Markdown>{text}</Markdown>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{s.reflectionEmpty}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{readOnly ? s.reflectionNone : s.reflectionEmpty}
|
||||
</p>
|
||||
)}
|
||||
</Section>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Period } from '@/types/Period'
|
||||
import { canReopen, periodPath, pickPeriod } from './period-select'
|
||||
|
||||
const p = (id: string, start: string, status: Period['status']) =>
|
||||
({ id, start_date: start, status }) as Period
|
||||
|
||||
describe('period select', () => {
|
||||
const sep = p('sep', '2026-09-01', 'closed')
|
||||
const oct = p('oct', '2026-10-01', 'closed')
|
||||
const nov = p('nov', '2026-11-01', 'open')
|
||||
const all = [nov, oct, sep] // en yeni başta
|
||||
|
||||
it('picks the requested, else open, else correcting', () => {
|
||||
expect(pickPeriod(all, nov, 'sep')).toBe(sep)
|
||||
expect(pickPeriod(all, nov, 'yok')).toBeUndefined()
|
||||
expect(pickPeriod(all, nov, null)).toBe(nov)
|
||||
const fixing = p('oct', '2026-10-01', 'correcting')
|
||||
expect(pickPeriod([fixing, sep], null, null)).toBe(fixing)
|
||||
expect(pickPeriod([sep], null, null)).toBeNull()
|
||||
})
|
||||
|
||||
it('reopens only the last closed period', () => {
|
||||
expect(canReopen(all, oct)).toBe(true) // sonrası yalnızca açık Kasım
|
||||
expect(canReopen(all, sep)).toBe(false) // Ekim kapalı
|
||||
expect(canReopen(all, nov)).toBe(false) // kapalı değil
|
||||
const fixing = p('oct', '2026-10-01', 'correcting')
|
||||
expect(canReopen([nov, fixing, sep], sep)).toBe(false) // Ekim düzeltmede
|
||||
})
|
||||
|
||||
it('links keep the period unless it is the open one', () => {
|
||||
expect(periodPath('/defter', nov)).toBe('/defter')
|
||||
expect(periodPath('/defter', oct)).toBe('/defter?donem=oct')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Hangi dönem gösteriliyor? Defter, Plan, Değerlendirme ve kapatma sihirbazı ortak kullanır.
|
||||
*
|
||||
* Adreste `?donem=<id>` varsa o dönem (kapalı ya da düzeltmede olabilir); yoksa açık dönem,
|
||||
* o da yoksa düzeltmedeki dönem. Dönem verisi listeden gelir (liste tüm alanları içerir).
|
||||
*/
|
||||
import { useSearchParams } from 'react-router'
|
||||
import type { Period } from '@/types/Period'
|
||||
import { useCurrentPeriod, usePeriods } from './periods'
|
||||
|
||||
export const PERIOD_PARAM = 'donem'
|
||||
|
||||
/** `undefined` → adresteki dönem bulunamadı; `null` → gösterilecek dönem yok */
|
||||
export function pickPeriod(
|
||||
periods: Period[],
|
||||
current: Period | null,
|
||||
id: string | null,
|
||||
): Period | null | undefined {
|
||||
if (id) return periods.find((p) => p.id === id)
|
||||
return current ?? periods.find((p) => p.status === 'correcting') ?? null
|
||||
}
|
||||
|
||||
/** Kayıt/plan değiştirilebilir mi? (backend `PeriodStatus::is_writable`) */
|
||||
export const isWritable = (p: Period) => p.status !== 'closed'
|
||||
|
||||
/**
|
||||
* Yeniden açılabilir mi? Yalnızca kapalı dönem ve ondan sonra kapalı/düzeltmede dönem yoksa
|
||||
* (backend `closing::reopen` ile aynı kural; aksi hâlde sonraki devirler zincirleme bozulurdu).
|
||||
*/
|
||||
export function canReopen(periods: Period[], p: Period): boolean {
|
||||
return (
|
||||
p.status === 'closed' &&
|
||||
!periods.some((q) => q.start_date > p.start_date && q.status !== 'open')
|
||||
)
|
||||
}
|
||||
|
||||
/** Sayfa adresi + dönem: açık dönem varsayılan olduğundan parametresiz */
|
||||
export function periodPath(path: string, p: Pick<Period, 'id' | 'status'>): string {
|
||||
return p.status === 'open' ? path : `${path}?${PERIOD_PARAM}=${p.id}`
|
||||
}
|
||||
|
||||
export function useSelectedPeriod() {
|
||||
const [params] = useSearchParams()
|
||||
const id = params.get(PERIOD_PARAM)
|
||||
const current = useCurrentPeriod()
|
||||
const periods = usePeriods()
|
||||
const failed = [current, periods].find((q) => q.isError)
|
||||
const ready = current.isSuccess && periods.isSuccess
|
||||
return {
|
||||
isPending: !failed && !ready,
|
||||
error: failed?.error ?? null,
|
||||
refetch: () => Promise.all([current.refetch(), periods.refetch()]),
|
||||
period: ready ? pickPeriod(periods.data, current.data, id) : undefined,
|
||||
periods: periods.data ?? [],
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,10 @@ export function useUpdatePeriod() {
|
||||
api.patch<Period>(`/periods/${id}`, body),
|
||||
onSuccess: (p) => {
|
||||
if (p.status === 'open') qc.setQueryData(queryKeys.currentPeriod, p)
|
||||
// Seçili dönem listeden okunur → yeni sürüm hemen görünsün (art arda kayıtta 409 olmasın)
|
||||
qc.setQueryData<Period[]>(queryKeys.periods, (list) =>
|
||||
list?.map((x) => (x.id === p.id ? p : x)),
|
||||
)
|
||||
void qc.invalidateQueries({ queryKey: queryKeys.periods, exact: true })
|
||||
void refreshPeriod(qc, p.id)
|
||||
},
|
||||
@@ -86,6 +90,19 @@ export function useClosePeriod() {
|
||||
})
|
||||
}
|
||||
|
||||
/** **Sahip:** kapalı dönemi düzeltme için yeniden aç (`correcting`) */
|
||||
export function useReopenPeriod() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.post<Period>(`/periods/${id}/reopen`),
|
||||
onSettled: () =>
|
||||
Promise.all([
|
||||
qc.invalidateQueries({ queryKey: queryKeys.periods }),
|
||||
qc.invalidateQueries({ queryKey: ['period'] }),
|
||||
]),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- Plan kalemleri ----------
|
||||
|
||||
export function usePlanItems(periodId: string) {
|
||||
|
||||
@@ -250,6 +250,8 @@ export const t = {
|
||||
},
|
||||
|
||||
ledger: {
|
||||
readOnly:
|
||||
'Bu dönem kapalı; kayıtlar yalnızca görüntülenebilir. Düzeltme için hane sahibi Değerlendirme’den yeniden açabilir.',
|
||||
week: (n: number, count: number) => `${n}. hafta / ${count}`,
|
||||
prevWeek: 'Önceki hafta',
|
||||
nextWeek: 'Sonraki hafta',
|
||||
@@ -322,6 +324,7 @@ export const t = {
|
||||
week: (n: number) => `${n}. hafta`,
|
||||
reflectionTitle: 'Nasıl daha iyi yapabilirim?',
|
||||
reflectionEmpty: 'Bu ay neler iyi gitti, neyi değiştirmek istersin? Kısa notlar yeterli.',
|
||||
reflectionNone: 'Bu dönem için yazılmamış.',
|
||||
reflectionPlaceholder:
|
||||
'- Dışarıda yemeği haftada bire indir\n- Market alışverişini listeyle yap',
|
||||
reflectionSaved: 'Kaydedildi',
|
||||
@@ -332,6 +335,29 @@ export const t = {
|
||||
closeHint:
|
||||
'Değerlendirme bittiyse dönemi kapat: birikimin ayrılır, kalan artı ya da açık sonraki döneme devreder.',
|
||||
closeAction: '🔒 Dönemi Kapat',
|
||||
closedInfo: (date: string, saved: string) =>
|
||||
`Bu dönem ${date} tarihinde kapandı. Birikime aktarılan: ${saved}.`,
|
||||
reopen: 'Düzeltme için yeniden aç',
|
||||
reopenConfirm:
|
||||
'Dönem düzeltmeye açılır: kayıt ve plan değiştirilebilir. Bitince yeniden kapatman gerekir; devir o zaman yeniden hesaplanır.',
|
||||
reopenYes: 'Evet, yeniden aç',
|
||||
reopenNo: 'Vazgeç',
|
||||
reopened: 'Dönem düzeltmeye açıldı',
|
||||
reopenBlocked: 'Sonraki dönem kapalı olduğu için bu dönem yeniden açılamaz.',
|
||||
correctingTitle: 'Bu dönem düzeltmede',
|
||||
correctingHint:
|
||||
'Kayıtları ve planı düzelt, bitince yeniden kapat; sonraki döneme devir güncellenir.',
|
||||
recloseAction: '🔒 Yeniden Kapat',
|
||||
fixInLedger: 'Defter’de düzelt',
|
||||
fixInPlan: 'Plan’da düzelt',
|
||||
},
|
||||
|
||||
periods: {
|
||||
picker: 'Dönem',
|
||||
status: { open: 'Açık', closed: '🔒 Kapalı', correcting: '✏️ Düzeltmede' },
|
||||
notFound: 'Dönem bulunamadı. Listeden başka bir dönem seç.',
|
||||
history: 'Geçmiş dönemler',
|
||||
toSavings: (x: string) => `Birikime ${x}`,
|
||||
},
|
||||
|
||||
closing: {
|
||||
@@ -371,6 +397,7 @@ export const t = {
|
||||
toPlan: 'Plana git',
|
||||
toDashboard: 'Panele dön',
|
||||
noOpen: 'Kapatılacak açık dönem yok.',
|
||||
alreadyClosed: 'Bu dönem zaten kapalı.',
|
||||
},
|
||||
|
||||
common: {
|
||||
|
||||
Reference in New Issue
Block a user