feat(değerlendirme): ay sonu değerlendirmesi sayfası
- Kakeibo'nun 4 sorusu, nasıl hesaplandığıyla birlikte - Grup bazında planlanan/gerçekleşen, en çok harcanan kategoriler, haftalık dağılım - "Nasıl daha iyi yapabilirim?" (Markdown) ve yalnızca başlıkları listelenen notlar Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import { useId, useState, type ReactNode } from 'react'
|
||||
import { Markdown } from '@/components/markdown'
|
||||
import { t } from '@/lib/strings'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Markdown alanı için "Yaz / Önizle" sekmeleri. `children` = yazma paneli (genelde `Textarea`).
|
||||
* Kimlikler `useId` ile üretilir → aynı sayfada birden çok editör olabilir.
|
||||
*/
|
||||
export function MarkdownTabs({
|
||||
label,
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
/** Sekme listesinin erişilebilir adı */
|
||||
label: string
|
||||
/** Önizlenecek metin */
|
||||
value: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
const id = useId()
|
||||
const [tab, setTab] = useState<'write' | 'preview'>('write')
|
||||
|
||||
const tabButton = (v: typeof tab, text: string) => (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`${id}-tab-${v}`}
|
||||
aria-selected={tab === v}
|
||||
aria-controls={`${id}-panel-${v}`}
|
||||
onClick={() => setTab(v)}
|
||||
className={cn(
|
||||
'h-11 rounded-md px-4 text-sm font-medium transition-colors',
|
||||
tab === v ? 'bg-background shadow-sm' : 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div role="tablist" aria-label={label} className="inline-flex rounded-lg bg-muted p-1">
|
||||
{tabButton('write', t.notes.write)}
|
||||
{tabButton('preview', t.notes.preview)}
|
||||
</div>
|
||||
{tab === 'write' ? (
|
||||
<div id={`${id}-panel-write`} role="tabpanel" aria-labelledby={`${id}-tab-write`}>
|
||||
{children}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
id={`${id}-panel-preview`}
|
||||
role="tabpanel"
|
||||
aria-labelledby={`${id}-tab-preview`}
|
||||
className="min-h-40 rounded-lg border p-3"
|
||||
>
|
||||
{value.trim() ? (
|
||||
<Markdown>{value}</Markdown>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{t.notes.previewEmpty}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
import { useState } from 'react'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useForm, useWatch } from 'react-hook-form'
|
||||
import { FormError, FormField } from '@/components/form-field'
|
||||
import { Markdown } from '@/components/markdown'
|
||||
import { MarkdownTabs } from '@/components/markdown-tabs'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { t } from '@/lib/strings'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { noteSchema, type NoteForm } from './schema'
|
||||
|
||||
const s = t.notes
|
||||
@@ -26,7 +24,6 @@ export function NoteEditor({
|
||||
onSave: (v: NoteForm) => Promise<void>
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const [tab, setTab] = useState<'write' | 'preview'>('write')
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
@@ -48,23 +45,6 @@ export function NoteEditor({
|
||||
}
|
||||
})
|
||||
|
||||
const tabButton = (value: typeof tab, label: string) => (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`note-tab-${value}`}
|
||||
aria-selected={tab === value}
|
||||
aria-controls={`note-panel-${value}`}
|
||||
onClick={() => setTab(value)}
|
||||
className={cn(
|
||||
'h-11 rounded-md px-4 text-sm font-medium transition-colors',
|
||||
tab === value ? 'bg-background shadow-sm' : 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} noValidate className="space-y-4 rounded-xl border bg-card p-4">
|
||||
<FormField label={s.noteTitle} error={errors.title}>
|
||||
@@ -79,32 +59,11 @@ export function NoteEditor({
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div role="tablist" aria-label={s.body} className="inline-flex rounded-lg bg-muted p-1">
|
||||
{tabButton('write', s.write)}
|
||||
{tabButton('preview', s.preview)}
|
||||
</div>
|
||||
{tab === 'write' ? (
|
||||
<div id="note-panel-write" role="tabpanel" aria-labelledby="note-tab-write">
|
||||
<FormField label={s.body} error={errors.body_md} hint={s.markdownHint}>
|
||||
{(f) => <Textarea {...f} placeholder={s.bodyPlaceholder} {...register('body_md')} />}
|
||||
</FormField>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
id="note-panel-preview"
|
||||
role="tabpanel"
|
||||
aria-labelledby="note-tab-preview"
|
||||
className="min-h-40 rounded-lg border p-3"
|
||||
>
|
||||
{body.trim() ? (
|
||||
<Markdown>{body}</Markdown>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{s.previewEmpty}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<MarkdownTabs label={s.body} value={body}>
|
||||
<FormField label={s.body} error={errors.body_md} hint={s.markdownHint}>
|
||||
{(f) => <Textarea {...f} placeholder={s.bodyPlaceholder} {...register('body_md')} />}
|
||||
</FormField>
|
||||
</MarkdownTabs>
|
||||
|
||||
<FormError message={errors.root?.message} />
|
||||
<div className="flex justify-end gap-2">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { ChevronDown, Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Markdown } from '@/components/markdown'
|
||||
import { PageError, PageLoading } from '@/components/page'
|
||||
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { formatDate } from '@/lib/format'
|
||||
import { useCreateNote, useDeleteNote, useHousehold, useNotes, useUpdateNote } from '@/lib/notes'
|
||||
import { t } from '@/lib/strings'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Note } from '@/types/Note'
|
||||
import type { NoteContext } from '@/types/NoteContext'
|
||||
import { NoteEditor } from './note-editor'
|
||||
@@ -14,17 +15,20 @@ import { NoteEditor } from './note-editor'
|
||||
const s = t.notes
|
||||
|
||||
/**
|
||||
* Döneme özel notlar (Plan ve ileride Değerlendirme sayfası).
|
||||
* Döneme özel notlar (Plan ve Değerlendirme sayfası).
|
||||
* `readOnly`: kapanmış dönemde ekleme/düzenleme yok.
|
||||
* `collapsed`: yalnızca başlıklar listelenir, tıklayınca metin açılır (Değerlendirme, §8.5).
|
||||
*/
|
||||
export function NotesSection({
|
||||
periodId,
|
||||
context,
|
||||
readOnly = false,
|
||||
collapsed = false,
|
||||
}: {
|
||||
periodId: string
|
||||
context: NoteContext
|
||||
readOnly?: boolean
|
||||
collapsed?: boolean
|
||||
}) {
|
||||
const notes = useNotes(periodId, context)
|
||||
const household = useHousehold()
|
||||
@@ -33,6 +37,15 @@ export function NotesSection({
|
||||
const remove = useDeleteNote(periodId, context)
|
||||
/** `'new'` → yeni not formu; not kimliği → o not düzenleniyor */
|
||||
const [editing, setEditing] = useState<string | null>(null)
|
||||
/** `collapsed` modunda açık olan notlar */
|
||||
const [expanded, setExpanded] = useState<ReadonlySet<string>>(new Set())
|
||||
const toggle = (id: string) =>
|
||||
setExpanded((cur) => {
|
||||
const next = new Set(cur)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
|
||||
const author = (id: string) => household.data?.members.find((m) => m.id === id)?.name
|
||||
|
||||
@@ -93,8 +106,29 @@ export function NotesSection({
|
||||
<article key={note.id} className="rounded-xl border p-4">
|
||||
<header className="mb-2 flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="font-medium break-words">{note.title}</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<h4 className="font-medium break-words">
|
||||
{collapsed ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded.has(note.id)}
|
||||
aria-controls={`note-body-${note.id}`}
|
||||
onClick={() => toggle(note.id)}
|
||||
className="-my-2 flex min-h-11 w-full items-center gap-2 text-left hover:underline"
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-4 shrink-0 text-muted-foreground transition-transform',
|
||||
!expanded.has(note.id) && '-rotate-90',
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
{note.title}
|
||||
</button>
|
||||
) : (
|
||||
note.title
|
||||
)}
|
||||
</h4>
|
||||
<p className={cn('text-xs text-muted-foreground', collapsed && 'pl-6')}>
|
||||
{s.meta(formatDate(note.updated_at), author(note.created_by))}
|
||||
</p>
|
||||
</div>
|
||||
@@ -120,7 +154,11 @@ export function NotesSection({
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
{note.body_md.trim() && <Markdown>{note.body_md}</Markdown>}
|
||||
{note.body_md.trim() && (!collapsed || expanded.has(note.id)) && (
|
||||
<div id={`note-body-${note.id}`} className={cn(collapsed && 'pl-6')}>
|
||||
<Markdown>{note.body_md}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Section } from '@/features/planning/plan-read'
|
||||
import { formatDayMonth, formatMoney, formatPercent } from '@/lib/format'
|
||||
import { group } from '@/lib/groups'
|
||||
import { ratio } from '@/lib/review'
|
||||
import { t } from '@/lib/strings'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { CatalogItem } from '@/types/CatalogItem'
|
||||
import type { PeriodReview } from '@/types/PeriodReview'
|
||||
|
||||
const s = t.review
|
||||
|
||||
/** Yatay çubuk: gerçekleşen / planlanan; aşımda kırmızı */
|
||||
function Bar({ value, max, className }: { value: number; max: number; className: string }) {
|
||||
const over = max > 0 && value > max
|
||||
return (
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted" aria-hidden>
|
||||
<div
|
||||
className={cn('h-full rounded-full', over ? 'bg-expense' : className)}
|
||||
style={{ width: `${ratio(value, max) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 4 grup: bütçe ve gerçekleşen */
|
||||
export function GroupComparison({ review }: { review: PeriodReview }) {
|
||||
return (
|
||||
<Section title={s.groupsTitle}>
|
||||
<ul className="space-y-4">
|
||||
{review.groups.map((g) => {
|
||||
const meta = group(g.group)
|
||||
const left = g.planned - g.spent
|
||||
return (
|
||||
<li key={g.group}>
|
||||
<div className="mb-1 flex flex-wrap items-baseline justify-between gap-x-2 text-sm">
|
||||
<span className={cn('font-medium', meta.className.text)}>
|
||||
<span aria-hidden>{meta.emoji}</span> {meta.label}
|
||||
</span>
|
||||
<span className="money">
|
||||
{s.groupLine(formatMoney(g.spent), formatMoney(g.planned))}
|
||||
</span>
|
||||
</div>
|
||||
<Bar value={g.spent} max={g.planned} className={meta.className.fill} />
|
||||
<p
|
||||
className={cn('mt-1 text-xs', left < 0 ? 'text-expense' : 'text-muted-foreground')}
|
||||
>
|
||||
{left < 0 ? s.over(formatMoney(-left)) : s.left(formatMoney(left))}
|
||||
{g.planned > 0 && ` · ${formatPercent(ratio(g.spent, g.planned, false), 0)}`}
|
||||
</p>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
const TOP = 5
|
||||
|
||||
/** Harcama olan kategoriler, çoktan aza; ilk 5 + "Tümünü göster" */
|
||||
export function TopCategories({
|
||||
review,
|
||||
categories,
|
||||
}: {
|
||||
review: PeriodReview
|
||||
categories: CatalogItem[]
|
||||
}) {
|
||||
const [all, setAll] = useState(false)
|
||||
const list = all ? review.categories : review.categories.slice(0, TOP)
|
||||
const max = review.categories[0]?.spent ?? 0
|
||||
|
||||
return (
|
||||
<Section title={s.categoriesTitle}>
|
||||
{review.categories.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{s.categoriesEmpty}</p>
|
||||
) : (
|
||||
<>
|
||||
<ol className="space-y-3">
|
||||
{list.map((c, i) => {
|
||||
const meta = group(c.group)
|
||||
const cat = categories.find((x) => x.id === c.category_id)
|
||||
return (
|
||||
<li key={c.category_id}>
|
||||
<div className="mb-1 flex items-baseline gap-2 text-sm">
|
||||
<span className="w-5 shrink-0 text-muted-foreground">{i + 1}.</span>
|
||||
<span className="min-w-0 flex-1 truncate font-medium">
|
||||
{cat?.emoji && <span aria-hidden>{cat.emoji} </span>}
|
||||
{cat?.name ?? '—'}
|
||||
<span className={cn('ml-2 text-xs font-normal', meta.className.text)}>
|
||||
{meta.label}
|
||||
</span>
|
||||
</span>
|
||||
<span className="money shrink-0">{formatMoney(c.spent)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pl-7">
|
||||
<div className="flex-1">
|
||||
<Bar value={c.spent} max={max} className={meta.className.fill} />
|
||||
</div>
|
||||
<span className="w-24 shrink-0 text-right text-xs text-muted-foreground">
|
||||
{s.count(c.count)} · {formatPercent(ratio(c.spent, review.spent_total), 0)}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
{review.categories.length > TOP && (
|
||||
<Button variant="ghost" className="mt-2" onClick={() => setAll((v) => !v)}>
|
||||
{all ? s.showLess : s.showAll(review.categories.length)}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
/** Haftalar: planlanan (4 grup toplamı) ve gerçekleşen */
|
||||
export function WeeklyDistribution({ review }: { review: PeriodReview }) {
|
||||
return (
|
||||
<Section title={s.weeksTitle}>
|
||||
<ul className="space-y-3">
|
||||
{review.weeks.map((w) => (
|
||||
<li
|
||||
key={w.index}
|
||||
className="grid grid-cols-[1fr_auto] items-center gap-x-3 gap-y-1 sm:grid-cols-[9rem_1fr_auto]"
|
||||
>
|
||||
<span className="text-sm">
|
||||
{s.week(w.index)}{' '}
|
||||
<span className="text-muted-foreground">
|
||||
{formatDayMonth(w.start_date)}–{formatDayMonth(w.last_date)}
|
||||
</span>
|
||||
</span>
|
||||
{/* Telefonda: ad + tutar üst satırda, çubuk altta; masaüstünde tek satır */}
|
||||
<div className="order-last col-span-2 sm:order-none sm:col-span-1">
|
||||
<Bar value={w.spent} max={w.planned} className="bg-primary" />
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'money text-right text-sm',
|
||||
w.spent > w.planned && w.planned > 0 && 'text-expense',
|
||||
)}
|
||||
>
|
||||
{s.groupLine(formatMoney(w.spent), formatMoney(w.planned))}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,66 @@
|
||||
import { ComingSoon, PageHeader } from '@/components/page'
|
||||
import { useState } from 'react'
|
||||
import { Info } from 'lucide-react'
|
||||
import { PageError, PageHeader, PageLoading } from '@/components/page'
|
||||
import { NotesSection } from '@/features/notes/notes-section'
|
||||
import { NoPeriodCard } from '@/features/period-wizard/no-period-card'
|
||||
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 { t } from '@/lib/strings'
|
||||
import type { Period } from '@/types/Period'
|
||||
import { GroupComparison, TopCategories, WeeklyDistribution } from './breakdowns'
|
||||
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} />
|
||||
<ComingSoon when="Faz 6'da" />
|
||||
{current.isPending ? (
|
||||
<PageLoading />
|
||||
) : current.isError ? (
|
||||
<PageError message={current.error.message} onRetry={current.refetch} />
|
||||
) : current.data ? (
|
||||
<ReviewView period={current.data} />
|
||||
) : (
|
||||
<NoPeriodCard />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
function ReviewView({ period }: { period: Period }) {
|
||||
const review = useReview(period.id)
|
||||
const categories = useCategories(true)
|
||||
const [today] = useState(() => toIsoDate(new Date()))
|
||||
const readOnly = period.status === 'closed'
|
||||
|
||||
if (review.isError) return <PageError message={review.error.message} onRetry={review.refetch} />
|
||||
if (!review.data) return <PageLoading />
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PeriodHeading period={period} />
|
||||
{!readOnly && 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))}
|
||||
</p>
|
||||
)}
|
||||
<Questions review={review.data} />
|
||||
<div className="grid items-start gap-4 lg:grid-cols-2">
|
||||
<GroupComparison review={review.data} />
|
||||
<TopCategories review={review.data} categories={categories.data ?? []} />
|
||||
</div>
|
||||
<WeeklyDistribution review={review.data} />
|
||||
<Reflection period={period} readOnly={readOnly} />
|
||||
<NotesSection periodId={period.id} context="review" readOnly={readOnly} collapsed />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { formatMoney, formatPercent } from '@/lib/format'
|
||||
import { ratio, savingsStatus } from '@/lib/review'
|
||||
import { t } from '@/lib/strings'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { PeriodReview } from '@/types/PeriodReview'
|
||||
|
||||
const s = t.review
|
||||
|
||||
/** Kakeibo'nun dört sorusu: tutar + nasıl hesaplandığı */
|
||||
export function Questions({ review: r }: { review: PeriodReview }) {
|
||||
const budget = r.groups.reduce((a, g) => a + g.planned, 0)
|
||||
const status = savingsStatus(r)
|
||||
const q1 =
|
||||
s.q1Detail(formatMoney(r.income_total), formatMoney(r.fixed_expense_total)) +
|
||||
(r.carryover_debt ? s.q1Debt(formatMoney(r.carryover_debt)) : '') +
|
||||
(r.carryover_surplus ? s.q1Surplus(formatMoney(r.carryover_surplus)) : '')
|
||||
|
||||
const cards = [
|
||||
{ n: 1, emoji: '💰', q: s.q1, amount: r.available, detail: q1 },
|
||||
{ n: 2, emoji: '🎯', q: s.q2, amount: r.savings_goal, detail: s.q2Detail },
|
||||
{
|
||||
n: 3,
|
||||
emoji: '🛒',
|
||||
q: s.q3,
|
||||
amount: r.spent_total,
|
||||
detail:
|
||||
budget > 0
|
||||
? s.q3Detail(formatMoney(budget), formatPercent(ratio(r.spent_total, budget, false), 0))
|
||||
: s.q3NoBudget,
|
||||
},
|
||||
{
|
||||
n: 4,
|
||||
emoji: '🐷',
|
||||
q: s.q4,
|
||||
amount: r.realized_savings,
|
||||
detail: s[status.kind](formatMoney(status.amount)),
|
||||
tone:
|
||||
status.kind === 'above'
|
||||
? 'text-income'
|
||||
: status.kind === 'below'
|
||||
? 'text-warning'
|
||||
: 'text-expense',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<ol className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{cards.map((c) => (
|
||||
<li
|
||||
key={c.n}
|
||||
className={cn(
|
||||
'rounded-2xl border bg-card p-4',
|
||||
c.n === 4 && 'border-2 border-primary/40',
|
||||
)}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span aria-hidden>{c.emoji}</span> {c.n}. {c.q}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
'money mt-1 text-2xl font-bold',
|
||||
c.n === 4 && c.amount < 0 && 'text-expense',
|
||||
)}
|
||||
>
|
||||
{formatMoney(c.amount)}
|
||||
</p>
|
||||
<p className={cn('mt-1 text-sm', c.tone ?? 'text-muted-foreground')}>{c.detail}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useState } from 'react'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Loader2, Pencil } from 'lucide-react'
|
||||
import { useForm, useWatch } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
import { FormError, FormField } from '@/components/form-field'
|
||||
import { Markdown } from '@/components/markdown'
|
||||
import { MarkdownTabs } from '@/components/markdown-tabs'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Section } from '@/features/planning/plan-read'
|
||||
import { useUpdatePeriod } from '@/lib/periods'
|
||||
import { t } from '@/lib/strings'
|
||||
import type { Period } from '@/types/Period'
|
||||
import { reflectionSchema, type ReflectionForm as Form } from './schema'
|
||||
|
||||
const s = t.review
|
||||
|
||||
/** "Nasıl daha iyi yapabilirim?" — görüntüle / düzenle (Markdown) */
|
||||
export function Reflection({ period, readOnly }: { period: Period; readOnly: boolean }) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const text = period.reflection_md ?? ''
|
||||
|
||||
return (
|
||||
<Section
|
||||
title={`💡 ${s.reflectionTitle}`}
|
||||
action={
|
||||
!readOnly &&
|
||||
!editing && (
|
||||
<Button variant="secondary" onClick={() => setEditing(true)}>
|
||||
<Pencil aria-hidden />
|
||||
{s.edit}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{editing ? (
|
||||
<ReflectionForm period={period} onDone={() => setEditing(false)} />
|
||||
) : text ? (
|
||||
<Markdown>{text}</Markdown>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{s.reflectionEmpty}</p>
|
||||
)}
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
function ReflectionForm({ period, onDone }: { period: Period; onDone: () => void }) {
|
||||
const update = useUpdatePeriod()
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<Form>({
|
||||
resolver: zodResolver(reflectionSchema),
|
||||
mode: 'onTouched',
|
||||
defaultValues: { text: period.reflection_md ?? '' },
|
||||
})
|
||||
const text = useWatch({ control, name: 'text' })
|
||||
|
||||
const onSubmit = handleSubmit(async (v) => {
|
||||
try {
|
||||
await update.mutateAsync({ id: period.id, reflection_md: v.text, version: period.version })
|
||||
toast.success(s.reflectionSaved)
|
||||
onDone()
|
||||
} catch (e) {
|
||||
setError('root', { message: e instanceof Error ? e.message : String(e) })
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} noValidate className="space-y-3">
|
||||
<MarkdownTabs label={s.reflectionTitle} value={text}>
|
||||
<FormField label={s.reflectionTitle} error={errors.text} hint={t.notes.markdownHint}>
|
||||
{(f) => (
|
||||
<Textarea
|
||||
{...f}
|
||||
autoFocus
|
||||
placeholder={s.reflectionPlaceholder}
|
||||
{...register('text')}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
</MarkdownTabs>
|
||||
<FormError message={errors.root?.message} />
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={onDone} disabled={isSubmitting}>
|
||||
{t.notes.cancel}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="animate-spin" aria-hidden />}
|
||||
{isSubmitting ? t.notes.saving : t.notes.save}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { reflectionSchema } from './schema'
|
||||
|
||||
describe('reflection', () => {
|
||||
it('counts characters like the backend and allows empty', () => {
|
||||
expect(reflectionSchema.safeParse({ text: '' }).success).toBe(true)
|
||||
expect(reflectionSchema.safeParse({ text: 'ş'.repeat(20_000) }).success).toBe(true)
|
||||
expect(reflectionSchema.safeParse({ text: ` ${'a'.repeat(20_000)} ` }).success).toBe(true)
|
||||
const r = reflectionSchema.safeParse({ text: 'a'.repeat(20_001) })
|
||||
expect(r.error?.issues[0].message).toBe('Metin en fazla 20.000 karakter olabilir')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { z } from 'zod'
|
||||
import { t } from '@/lib/strings'
|
||||
|
||||
/** Backend gibi Unicode karakter sayar (`services/period.rs` → `MAX_REFLECTION`) */
|
||||
export const MAX_REFLECTION = 20_000
|
||||
|
||||
export const reflectionSchema = z.object({
|
||||
text: z
|
||||
.string()
|
||||
.refine((v) => [...v.trim()].length <= MAX_REFLECTION, t.review.reflectionTooLong),
|
||||
})
|
||||
export type ReflectionForm = z.infer<typeof reflectionSchema>
|
||||
@@ -11,6 +11,7 @@ import type { GroupAmounts } from '@/types/GroupAmounts'
|
||||
import type { GroupBudget } from '@/types/GroupBudget'
|
||||
import type { Period } from '@/types/Period'
|
||||
import type { PeriodCreateRequest } from '@/types/PeriodCreateRequest'
|
||||
import type { PeriodReview } from '@/types/PeriodReview'
|
||||
import type { PeriodSummary } from '@/types/PeriodSummary'
|
||||
import type { PeriodUpdateRequest } from '@/types/PeriodUpdateRequest'
|
||||
import type { PlanItem } from '@/types/PlanItem'
|
||||
@@ -108,6 +109,14 @@ export function useSummary(periodId: string) {
|
||||
})
|
||||
}
|
||||
|
||||
/** Ay sonu değerlendirmesi: 4 soru + grup/kategori/hafta dökümü (kayıt değişince yenilenir) */
|
||||
export function useReview(periodId: string) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.review(periodId),
|
||||
queryFn: () => api.get<PeriodReview>(`/periods/${periodId}/review`),
|
||||
})
|
||||
}
|
||||
|
||||
export function useBudget(periodId: string) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.budget(periodId),
|
||||
|
||||
@@ -19,6 +19,7 @@ export const queryKeys = {
|
||||
period: (id: string) => ['period', id] as const,
|
||||
planItems: (id: string) => ['period', id, 'plan-items'] as const,
|
||||
summary: (id: string) => ['period', id, 'summary'] as const,
|
||||
review: (id: string) => ['period', id, 'review'] as const,
|
||||
budget: (id: string) => ['period', id, 'budget'] as const,
|
||||
suggestion: (id: string, method: string) => ['period', id, 'suggestion', method] as const,
|
||||
notes: (id: string, context: string) => ['period', id, 'notes', context] as const,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ratio, savingsStatus } from './review'
|
||||
|
||||
describe('review', () => {
|
||||
it('classifies realized savings against the goal', () => {
|
||||
expect(savingsStatus({ realized_savings: 700, savings_gap: 200 })).toEqual({
|
||||
kind: 'above',
|
||||
amount: 200,
|
||||
})
|
||||
expect(savingsStatus({ realized_savings: 500, savings_gap: 0 })).toEqual({
|
||||
kind: 'above',
|
||||
amount: 0,
|
||||
})
|
||||
expect(savingsStatus({ realized_savings: 300, savings_gap: -200 })).toEqual({
|
||||
kind: 'below',
|
||||
amount: 200,
|
||||
})
|
||||
expect(savingsStatus({ realized_savings: -50, savings_gap: -550 })).toEqual({
|
||||
kind: 'deficit',
|
||||
amount: 50,
|
||||
})
|
||||
})
|
||||
|
||||
it('computes bar ratios safely', () => {
|
||||
expect(ratio(50, 200)).toBe(0.25)
|
||||
expect(ratio(300, 200)).toBe(1)
|
||||
expect(ratio(300, 200, false)).toBe(1.5)
|
||||
expect(ratio(10, 0)).toBe(1)
|
||||
expect(ratio(0, 0)).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Değerlendirme sayfasının saf hesapları. Tutarlar backend'den gelir (`PeriodReview`);
|
||||
* burada yalnızca yorum (durum, oran) yapılır.
|
||||
*/
|
||||
import type { PeriodReview } from '@/types/PeriodReview'
|
||||
|
||||
/** 4. soru: birikim hedefe göre nerede? */
|
||||
export type SavingsStatus =
|
||||
| { kind: 'above'; amount: number } // hedefe ulaşıldı/aşıldı (amount ≥ 0)
|
||||
| { kind: 'below'; amount: number } // birikim var ama hedefin altında
|
||||
| { kind: 'deficit'; amount: number } // açık var → borç olarak devreder
|
||||
|
||||
export function savingsStatus(
|
||||
r: Pick<PeriodReview, 'realized_savings' | 'savings_gap'>,
|
||||
): SavingsStatus {
|
||||
if (r.realized_savings < 0) return { kind: 'deficit', amount: -r.realized_savings }
|
||||
if (r.savings_gap < 0) return { kind: 'below', amount: -r.savings_gap }
|
||||
return { kind: 'above', amount: r.savings_gap }
|
||||
}
|
||||
|
||||
/** Oran (0–1); payda 0 ise pay varsa 1, yoksa 0. Çubuk genişliği için 1'de kesilir. */
|
||||
export function ratio(part: number, total: number, cap = true): number {
|
||||
if (total <= 0) return part > 0 ? 1 : 0
|
||||
const r = part / total
|
||||
return cap ? Math.min(r, 1) : r
|
||||
}
|
||||
@@ -294,6 +294,42 @@ export const t = {
|
||||
over: 'Aşıldı',
|
||||
},
|
||||
|
||||
review: {
|
||||
ongoing: (last: string) => `Dönem ${last} tarihinde bitiyor; değerler kayıt girdikçe değişir.`,
|
||||
q1: 'Ne kadar param vardı?',
|
||||
q1Detail: (income: string, fixed: string) => `Gelir ${income} − sabit giderler ${fixed}`,
|
||||
q1Debt: (debt: string) => ` − devreden borç ${debt}`,
|
||||
q1Surplus: (surplus: string) => ` + devreden artı ${surplus}`,
|
||||
q2: 'Ne kadar biriktirmek istedim?',
|
||||
q2Detail: 'Dönem başında belirlenen birikim hedefi',
|
||||
q3: 'Ne kadar harcadım?',
|
||||
q3Detail: (budget: string, pct: string) => `Bütçenin ${pct} kadarı (${budget})`,
|
||||
q3NoBudget: 'Bütçe dağıtılmamış',
|
||||
q4: 'Ne kadar biriktirdim?',
|
||||
above: (x: string) => (x === '0,00 ₺' ? 'Hedefe tam ulaşıldı' : `Hedefin ${x} üstünde 🎉`),
|
||||
below: (x: string) => `Hedefin ${x} altında`,
|
||||
deficit: (x: string) => `${x} açık; borç olarak sonraki döneme devreder`,
|
||||
groupsTitle: 'Planlanan ve gerçekleşen',
|
||||
groupLine: (spent: string, planned: string) => `${spent} / ${planned}`,
|
||||
left: (x: string) => `${x} kaldı`,
|
||||
over: (x: string) => `${x} aşıldı`,
|
||||
categoriesTitle: 'En çok harcanan kategoriler',
|
||||
categoriesEmpty: 'Bu dönemde henüz harcama yok.',
|
||||
count: (n: number) => `${n} kayıt`,
|
||||
showAll: (n: number) => `Tümünü göster (${n})`,
|
||||
showLess: 'Daha az göster',
|
||||
weeksTitle: 'Haftalık dağılım',
|
||||
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.',
|
||||
reflectionPlaceholder:
|
||||
'- Dışarıda yemeği haftada bire indir\n- Market alışverişini listeyle yap',
|
||||
reflectionSaved: 'Kaydedildi',
|
||||
reflectionTooLong: 'Metin en fazla 20.000 karakter olabilir',
|
||||
edit: 'Düzenle',
|
||||
write: 'Yaz',
|
||||
},
|
||||
|
||||
common: {
|
||||
loading: 'Yükleniyor…',
|
||||
retry: 'Tekrar dene',
|
||||
|
||||
Reference in New Issue
Block a user