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