feat(plan): dönem notları — Markdown, yaz/önizle sekmeleri

- NotesSection: ekle/düzenle/sil (onaylı), yazar ve tarih, kapalı dönemde salt okunur
- Markdown gösterimi (react-markdown + remark-gfm): ham HTML yok, güvenli bağlantılar
- Çakışma (409) mesajı formda; Plan sayfasına bağlandı
- Dağıtım çubuğu boş dönemde "tamamı dağıtıldı" demiyor
- Faz 4 tamamlandı

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 08:55:16 +03:00
parent 9004a7cc42
commit eca0bb27a0
16 changed files with 1985 additions and 19 deletions
+121
View File
@@ -0,0 +1,121 @@
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 { 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
/**
* Not formu: başlık + Markdown metin, "Yaz / Önizle" sekmeleri.
* `onSave` hata fırlatırsa mesaj formda gösterilir (ör. 409: başkası değiştirmiş).
*/
export function NoteEditor({
initial,
onSave,
onCancel,
}: {
initial?: NoteForm
onSave: (v: NoteForm) => Promise<void>
onCancel: () => void
}) {
const [tab, setTab] = useState<'write' | 'preview'>('write')
const {
register,
control,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = useForm<NoteForm>({
resolver: zodResolver(noteSchema),
mode: 'onTouched',
defaultValues: initial ?? { title: '', body_md: '' },
})
const body = useWatch({ control, name: 'body_md' })
const onSubmit = handleSubmit(async (v) => {
try {
await onSave(v)
} catch (e) {
setError('root', { message: e instanceof Error ? e.message : String(e) })
}
})
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}>
{(f) => (
<Input
{...f}
autoFocus
maxLength={120}
placeholder={s.titlePlaceholder}
{...register('title')}
/>
)}
</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>
<FormError message={errors.root?.message} />
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
{s.cancel}
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="animate-spin" aria-hidden />}
{isSubmitting ? s.saving : s.save}
</Button>
</div>
</form>
)
}
@@ -0,0 +1,131 @@
import { useState } from 'react'
import { Pencil, Plus, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { Markdown } from '@/components/markdown'
import { PageError, PageLoading } from '@/components/page'
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 type { Note } from '@/types/Note'
import type { NoteContext } from '@/types/NoteContext'
import { NoteEditor } from './note-editor'
const s = t.notes
/**
* Döneme özel notlar (Plan ve ileride Değerlendirme sayfası).
* `readOnly`: kapanmış dönemde ekleme/düzenleme yok.
*/
export function NotesSection({
periodId,
context,
readOnly = false,
}: {
periodId: string
context: NoteContext
readOnly?: boolean
}) {
const notes = useNotes(periodId, context)
const household = useHousehold()
const create = useCreateNote(periodId, context)
const update = useUpdateNote(periodId, context)
const remove = useDeleteNote(periodId, context)
/** `'new'` → yeni not formu; not kimliği → o not düzenleniyor */
const [editing, setEditing] = useState<string | null>(null)
const author = (id: string) => household.data?.members.find((m) => m.id === id)?.name
const onRemove = (note: Note) => {
if (!window.confirm(s.confirmRemove(note.title))) return
remove.mutate(note.id, {
onSuccess: () => toast.success(s.removed),
onError: (e) => toast.error(e.message),
})
}
return (
<section aria-labelledby="notes-title" className="rounded-2xl border bg-card p-4 sm:p-5">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 id="notes-title" className="font-semibold">
🗒️ {s.title}
</h3>
{!readOnly && editing !== 'new' && (
<Button variant="secondary" onClick={() => setEditing('new')}>
<Plus aria-hidden />
{s.add}
</Button>
)}
</div>
<div className="space-y-3">
{editing === 'new' && (
<NoteEditor
onCancel={() => setEditing(null)}
onSave={async (v) => {
await create.mutateAsync(v)
toast.success(s.saved)
setEditing(null)
}}
/>
)}
{notes.isPending ? (
<PageLoading />
) : notes.isError ? (
<PageError message={notes.error.message} onRetry={notes.refetch} />
) : notes.data.length === 0 && editing !== 'new' ? (
<p className="text-sm text-muted-foreground">{s.empty}</p>
) : (
notes.data.map((note) =>
editing === note.id ? (
<NoteEditor
key={note.id}
initial={{ title: note.title, body_md: note.body_md }}
onCancel={() => setEditing(null)}
onSave={async (v) => {
await update.mutateAsync({ id: note.id, version: note.version, ...v })
toast.success(s.saved)
setEditing(null)
}}
/>
) : (
<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">
{s.meta(formatDate(note.updated_at), author(note.created_by))}
</p>
</div>
{!readOnly && (
<div className="-mt-2 -mr-2 flex">
<Button
variant="ghost"
size="icon"
aria-label={s.editNote(note.title)}
onClick={() => setEditing(note.id)}
>
<Pencil className="text-muted-foreground" aria-hidden />
</Button>
<Button
variant="ghost"
size="icon"
aria-label={s.removeNote(note.title)}
disabled={remove.isPending}
onClick={() => onRemove(note)}
>
<Trash2 className="text-muted-foreground" aria-hidden />
</Button>
</div>
)}
</header>
{note.body_md.trim() && <Markdown>{note.body_md}</Markdown>}
</article>
),
)
)}
</div>
</section>
)
}
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { t } from '@/lib/strings'
import { noteSchema } from './schema'
const err = (title: string, body_md = '') => {
const r = noteSchema.safeParse({ title, body_md })
return r.success ? null : r.error.issues[0].message
}
describe('noteSchema', () => {
it('accepts a note with an empty body', () => {
expect(err('Hedefler')).toBeNull()
})
it('rejects empty and too long titles', () => {
expect(err(' ')).toBe(t.notes.titleRequired)
expect(err('x'.repeat(121))).toBe(t.notes.titleTooLong)
})
it('counts characters like the backend (emoji = 1)', () => {
expect(err('🎯'.repeat(120))).toBeNull() // UTF-16'da 240 birim
expect(err('Not', '🎯'.repeat(20_000))).toBeNull()
expect(err('Not', 'a'.repeat(20_001))).toBe(t.notes.bodyTooLong)
})
})
+17
View File
@@ -0,0 +1,17 @@
import { z } from 'zod'
import { t } from '@/lib/strings'
const s = t.notes
/** Karakter sayısı backend gibi Unicode karakteri sayar (emoji = 1), `length` gibi UTF-16 değil */
const chars = (v: string) => [...v].length
export const noteSchema = z.object({
title: z
.string()
.trim()
.min(1, s.titleRequired)
.refine((v) => chars(v) <= 120, s.titleTooLong),
body_md: z.string().refine((v) => chars(v) <= 20_000, s.bodyTooLong),
})
export type NoteForm = z.infer<typeof noteSchema>
@@ -28,18 +28,21 @@ export function DistributionBar({
style={{ width: `${pct}%` }}
/>
</div>
<p
className={cn(
'text-sm',
remaining < 0 ? 'font-medium text-warning' : 'text-muted-foreground',
)}
>
{remaining < 0
? s.over(formatMoney(-remaining))
: remaining > 0
? s.unallocated(formatMoney(remaining))
: s.balanced}
</p>
{/* Hiç dağıtılacak ve dağıtılmış tutar yoksa "tamamı dağıtıldı" yanıltıcı olur */}
{(distributed !== 0 || spendable !== 0) && (
<p
className={cn(
'text-sm',
remaining < 0 ? 'font-medium text-warning' : 'text-muted-foreground',
)}
>
{remaining < 0
? s.over(formatMoney(-remaining))
: remaining > 0
? s.unallocated(formatMoney(remaining))
: s.balanced}
</p>
)}
</div>
)
}
+2
View File
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { Pencil } from 'lucide-react'
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'
@@ -76,6 +77,7 @@ function PlanView({ period }: { period: Period }) {
</div>
{editable && <CopyPrevious period={period} />}
<BudgetRead budget={budget.data} summary={summary.data} />
<NotesSection periodId={period.id} context="planning" readOnly={!editable} />
</div>
)
}