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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user