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
+3 -1
View File
@@ -1,7 +1,7 @@
# 🧠 Proje Hafızası
## ▶️ Sıradaki Adım
**Faz 4 – Adım 6:** Notlar (Markdown): dönem notları — planlama ve değerlendirme bağlamı (`NoteContext`), liste + ekle/düzenle/sil, düzenle/önizle sekmeleri, react-markdown + remark-gfm (yeni bağımlılık, gerekçe verilecek). Plan ekranına "Notlar" bölümü. Backend hazır (`/api/periods/{id}/notes`, `/api/notes/{id}`).
**Faz 5 – Adım 1:** Backend harcama kayıtları (`entries`): model (DATA_MODEL'e bak), `services/entry.rs` (create/update/delete soft + restore, list: dönem/hafta/tarih aralığı), doğrulama (tutar > 0, tarih açık dönem içinde, kategori hanenin ve aktif, grup = kategorinin grubu, ödeme türü hanenin), `routes/entry.rs`, TS tipleri, `tests/entries.rs` (CRUD, kapalı dönem, izolasyon, geri al). Ardından `budget::suggest` previous/avg3 testleri gerçek kayıtla.
## 📌 Alınan Kararlar
- **Yerel demo hesapları** (`kakeibo_dev`): `demo@ornek.com` / `demo12345` (owner, "Demo Hanesi") · `es@ornek.com` / `demo12345` (member, aynı hane). `npm run db:reset` silerse API ile yeniden oluştur (ileride `db:seed`).
@@ -95,3 +95,5 @@
- Not: üretim JS paketi ~620 kB (gzip 195 kB) → Faz 10'da sayfa bazlı bölme (lazy route).
- **Adım 5:** Plan ekranı (`features/planning/`): `plan-read.tsx` (salt okunur: kalemler, 4 grup + haftalık döküm, "elle" etiketi), `plan-edit.tsx` (taslak formu, Kaydet/Vazgeç, `useBlocker` + beforeunload ile kaydedilmemiş değişiklik onayı). `lib/plan-draft.ts` (`toDraft`/`diffPlan`: yalnızca değişenler gönderilir), `lib/budget-preview.ts` (backend `split_proportional` + `view` ile birebir; aynı test örnekleri), `useSavePlan` (sıralı istekler; hata olursa yeniden yükler, transaction değil). Ortak: `DistributionBar`, `SuggestionPicker` (sihirbaz da kullanıyor). Frontend 69 test.
- E2E artık ayrı sunucuda: `REGISTRATION=open PORT=8091 STATIC_DIR=frontend/dist MONGODB_URI=…/kakeibo_test_e2e` (kullanıcının dev verisine dokunulmaz; bitince DB silinir). 31 kontrol geçti; ekran görüntüsüyle 3 düzen sorunu bulundu/düzeltildi (dar satırlar, mobil tutar/tarih, hafta etiketi).
- **Adım 6:** Notlar: `features/notes/` (`NotesSection` — Plan'da `context="planning"`, Değerlendirme'de `review` ile yeniden kullanılacak; `NoteEditor` Yaz/Önizle sekmeleri; zod şeması karakteri backend gibi sayar), `lib/notes.ts` (+ `useHousehold` yazar adı için), `components/markdown.tsx` (ham HTML yok, bağlantılar yeni sekme), `components/ui/textarea.tsx`, `index.css` `.markdown` stilleri. Yeni bağımlılık: react-markdown 10, remark-gfm 4. E2E 16 kontrol (XSS, 409 çakışma dahil). `DistributionBar` 0/0 durumunda "tamamı dağıtıldı" demiyor. **Faz 4 tamamlandı.**
- Not: JS paketi 820 kB (gzip 254 kB; markdown ~200 kB) → Faz 10'da route bazlı lazy + Markdown'u ayrı parçaya al. `npm audit` uyarıları shadcn CLI bağımlılıklarından (deepmerge, minimatch), uygulamaya girmiyor; `--force` shadcn'i 1.0'a düşürür, yapılmadı.
+10 -3
View File
@@ -8,8 +8,8 @@
| **Faz 1** | Backend temeli: config, DB bağlantısı, kimlik doğrulama, hane, kategoriler, ödeme türleri, health check | API `.http` dosyasıyla test edilebiliyor | ✅ |
| **Faz 2** | Frontend temeli: tasarım sistemi, tema, logo/ikonlar, layout, navigasyon, giriş/kayıt | Giriş yapılabilen, temalı iskelet uygulama | ✅ |
| **Faz 3** | 🚀 İlk canlıya alma: Dockerfile, Dokploy, canlı MongoDB, domain + HTTPS, yedekleme | Giriş ekranı canlıda çalışıyor | ✅ |
| **Faz 4** | Dönem açma + aylık planlama + notlar | Plan ekranı | 🔄 |
| **Faz 5** | Günlük defter | Harcama girişi | ⏳ |
| **Faz 4** | Dönem açma + aylık planlama + notlar | Plan ekranı | ✅ |
| **Faz 5** | Günlük defter | Harcama girişi | 🔄 |
| **Faz 6** | Ay sonu değerlendirmesi + dönem kapatma/devir sihirbazı | Tam dönem döngüsü | ⏳ |
| **Faz 7** | Dashboard: özet kartları ve grafikler | Ana ekran | ⏳ |
| **Faz 8** | Birikimler/varlıklar, kur entegrasyonu, kâr/zarar | Birikim modülü | ⏳ |
@@ -51,4 +51,11 @@
- ✅ 3. Backend: bütçe (aylık/haftalık, öneri), özet (harcanabilir tutar)
- ✅ 4. Frontend: ilk dönem sihirbazı
- ✅ 5. Frontend: Plan ekranı (görüntüle/düzenle, özet kutucukları, bütçe dağıtımı)
- 🔄 6. Frontend: notlar (Markdown, düzenle/önizle)
- ✅ 6. Frontend: notlar (Markdown, düzenle/önizle)
## Faz 5 Adımları (taslak)
- ⏳ 1. Backend: harcama kayıtları (ekle/düzenle/sil + geri al, hafta/dönem filtresi, kategori–grup tutarlılığı, yalnızca açık dönem), testler
- ⏳ 2. Backend: haftalık/aylık gerçekleşen (grup bazlı bütçe–harcanan–kalan), özet ve önerilere gerçekleşen verisi
- ⏳ 3. Frontend: Defter — hafta seçici + hızlı giriş formu (tutar, 4 grup düğmesi, kategori, ödeme türü, tarih, açıklama; son seçim hatırlanır)
- ⏳ 4. Frontend: haftanın girişleri (gün gün), düzenle/sil + "Geri al", haftalık özet
- ⏳ 5. Canlı yedekleme (mongodump günlük, 14 gün) + geri yükleme denemesi
+1473 -3
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -25,7 +25,9 @@
"react": "^19.3.1",
"react-dom": "^19.3.1",
"react-hook-form": "^7.92.0",
"react-markdown": "^10.1.0",
"react-router": "^8.5.1",
"remark-gfm": "^4.0.1",
"shadcn": "^4.23.0",
"sonner": "^2.0.8",
"tailwindcss": "^4.3.5",
+22
View File
@@ -0,0 +1,22 @@
import ReactMarkdown, { type Components } from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { cn } from '@/lib/utils'
/**
* Not metnini Markdown olarak gösterir (GFM: tablo, ~~üstü çizili~~, `- [ ]` yapılacaklar).
* Güvenlik: ham HTML işlenmez (react-markdown varsayılanı); `javascript:` gibi
* tehlikeli bağlantılar kütüphane tarafından temizlenir. Bağlantılar yeni sekmede açılır.
*/
const components: Components = {
a: ({ node: _node, ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />,
}
export function Markdown({ children, className }: { children: string; className?: string }) {
return (
<div className={cn('markdown', className)}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
{children}
</ReactMarkdown>
</div>
)
}
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "cn"
// shadcn textarea (Input ile aynı görünüm); Kakeibo: mobilde text-base → iOS yakınlaştırmaz
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-40 w-full rounded-lg border border-input bg-transparent px-3 py-2.5 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
+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>
)
}
+58
View File
@@ -207,3 +207,61 @@
scroll-behavior: auto !important;
}
}
/* Not metni (components/markdown.tsx) — typography eklentisi yerine küçük, elle stil */
@layer components {
.markdown {
@apply space-y-3 text-sm leading-relaxed break-words;
}
.markdown h1 {
@apply text-lg font-semibold;
}
.markdown h2 {
@apply text-base font-semibold;
}
.markdown h3,
.markdown h4 {
@apply font-semibold;
}
.markdown ul {
@apply list-disc space-y-1 pl-5;
}
.markdown ol {
@apply list-decimal space-y-1 pl-5;
}
/* - [ ] yapılacaklar: madde işareti yerine kutucuk */
.markdown ul:has(> li > input[type='checkbox']) {
@apply list-none pl-1;
}
.markdown li > input[type='checkbox'] {
@apply mr-2 size-4 translate-y-0.5 accent-primary;
}
.markdown a {
@apply font-medium text-primary underline underline-offset-4;
}
.markdown blockquote {
@apply border-l-4 pl-3 text-muted-foreground;
}
.markdown code {
@apply rounded bg-muted px-1 py-0.5 font-mono text-[0.85em];
}
.markdown pre {
@apply overflow-x-auto rounded-lg bg-muted p-3;
}
.markdown pre code {
@apply bg-transparent p-0;
}
.markdown table {
@apply block w-full overflow-x-auto border-collapse;
}
.markdown th,
.markdown td {
@apply border px-2 py-1 text-left;
}
.markdown th {
@apply bg-muted font-medium;
}
.markdown hr {
@apply border-border;
}
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Dönem notları (planlama / değerlendirme sayfası) ve yazar adı için hane üyeleri.
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { Household } from '@/types/Household'
import type { Note } from '@/types/Note'
import type { NoteContext } from '@/types/NoteContext'
import type { NoteCreateRequest } from '@/types/NoteCreateRequest'
import type { NoteUpdateRequest } from '@/types/NoteUpdateRequest'
import { api } from './api'
import { queryKeys } from './query'
export function useNotes(periodId: string, context: NoteContext) {
return useQuery({
queryKey: queryKeys.notes(periodId, context),
queryFn: () => api.get<Note[]>(`/periods/${periodId}/notes?context=${context}`),
})
}
function useRefreshNotes(periodId: string, context: NoteContext) {
const qc = useQueryClient()
return () => qc.invalidateQueries({ queryKey: queryKeys.notes(periodId, context) })
}
export function useCreateNote(periodId: string, context: NoteContext) {
const refresh = useRefreshNotes(periodId, context)
return useMutation({
mutationFn: (body: Omit<NoteCreateRequest, 'context'>) =>
api.post<Note>(`/periods/${periodId}/notes`, { ...body, context }),
onSuccess: refresh,
})
}
export function useUpdateNote(periodId: string, context: NoteContext) {
const refresh = useRefreshNotes(periodId, context)
return useMutation({
mutationFn: ({ id, ...body }: NoteUpdateRequest & { id: string }) =>
api.patch<Note>(`/notes/${id}`, body),
// 409 (başkası değiştirmiş) dahil her durumda güncel listeyi getir
onSettled: refresh,
})
}
export function useDeleteNote(periodId: string, context: NoteContext) {
const refresh = useRefreshNotes(periodId, context)
return useMutation({
mutationFn: (id: string) => api.delete(`/notes/${id}`),
onSettled: refresh,
})
}
/** Hane bilgisi + üyeler (notun yazarını göstermek için) */
export function useHousehold() {
return useQuery({
queryKey: queryKeys.household,
queryFn: () => api.get<Household>('/household'),
staleTime: 5 * 60_000,
})
}
+1
View File
@@ -21,6 +21,7 @@ export const queryKeys = {
summary: (id: string) => ['period', id, 'summary'] 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,
}
/** 4xx hataları tekrar denemenin anlamı yok (yanlış istek / yetki); ağ ve 5xx için 2 kez dene. */
+28
View File
@@ -212,6 +212,34 @@ export const t = {
emptyBudget: 'Henüz bütçe dağıtılmadı. "Düzenle" ile 4 gruba dağıtabilirsin.',
},
notes: {
title: 'Notlar',
empty: 'Henüz not yok. Bu dönemle ilgili düşüncelerini, kararlarını ya da yapılacakları yaz.',
add: 'Not ekle',
edit: 'Düzenle',
remove: 'Sil',
editNote: (title: string) => `${title} notunu düzenle`,
removeNote: (title: string) => `${title} notunu sil`,
confirmRemove: (title: string) => `"${title}" notu silinsin mi?`,
removed: 'Not silindi',
saved: 'Not kaydedildi',
noteTitle: 'Başlık',
titlePlaceholder: 'ör. Bu ayın hedefleri',
body: 'Metin',
bodyPlaceholder: '- [ ] Elektrik faturasını öde\n- Dışarıda yemek ayda en fazla 2 kez',
write: 'Yaz',
preview: 'Önizle',
previewEmpty: 'Önizlenecek bir şey yok.',
markdownHint: 'Markdown: **kalın**, *italik*, - liste, - [ ] yapılacak, # başlık',
save: 'Kaydet',
cancel: 'Vazgeç',
saving: 'Kaydediliyor…',
meta: (date: string, author?: string) => (author ? `${date} · ${author}` : date),
titleRequired: 'Başlık yazın',
titleTooLong: 'Başlık en fazla 120 karakter olabilir',
bodyTooLong: 'Metin en fazla 20.000 karakter olabilir',
},
summary: {
income: 'Gelir',
fixed: 'Sabit giderler',