diff --git a/CLAUDE.md b/CLAUDE.md index 7659052..f2ed962 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,6 +84,7 @@ npm run db:restore -- [--force] - JSON yedeği: `services/backup.rs` (`collect` → `to_json`; `parse` doğrular; `restore` = geri dönüş noktası + tam değiştirme tek transaction; `retarget` kimlik çevirir). Yeni hane koleksiyonu eklenirse `backup::KINDS`'a da ekle. Biçim: DATA_MODEL §3.14b. Arayüz: `features/settings/backup-section.tsx`, `lib/backup.ts` (geri yükleme/geri alma sonrası tüm sorgular yenilenir). - Aylık rapor: `services/monthly_report.rs` (`gather` → `Facts` → saf `analyze` → `render`), şablon `templates/monthly_report.md` (`include_str!`; Dockerfile `templates/`'u kopyalar). Filtreler `tl`/`pct`/`cell`. Şablonda ham HTML yok (arayüz göstermez). Arayüz `features/review/report-page.tsx` (`/degerlendirme/rapor?donem=`), `lib/monthly-report.ts`; `api.getText`; `Markdown` tabloları `.table-wrap` içinde kayar; yazdırma `@page A4` + `PageHeader printHidden`. Cümlelerde sayıdan sonra ek kullanma ("%429'u" gibi ünlü uyumu gerekir) → "X / Y", "kullanımı %N". - Ayarlar: `/ayarlar` menü (+ tema) → alt sayfalar `SettingsSubpage` ("‹ Ayarlar"): `/ayarlar/kategoriler`, `/odeme-turleri`, `/butce` (varsayılan yüzdeler + öneri yöntemi), `/hane` (ad, üyeler, davet bağlantısı `/kayit?davet=`), `/hesap` (ad, şifre), `/veri` (yedek, CSV, ekstre). Kancalar `lib/household.ts`. Şifre değişince `users.sessions_valid_from` → eski jetonlar 401 (`AuthUser`). Katalog yönetimi `features/settings/catalog-editor.tsx` + `lib/catalog.ts` (`useCreateCatalog`/`useUpdateCatalog`/`useReorderCatalog`, `moveId`); sıralama isteği grubun **tüm** kimlikleri (pasifler dahil); grup sonradan değişmez. +- Erişilebilirlik kuralları: `--muted-foreground` açık temada 0.52 (gri zeminde AA); kart bileşenleri (`Section`, rapor bölümü) `min-w-0` — ızgara öğesi içeriğine uzayıp telefonda taşmasın; `Button` `sm`/`icon-sm` dokunmatikte (`pointer-coarse`) 44 px; tutarlar `formatMoney` ile **bölünmez boşluklu** (`1.250,00\u00a0₺`; testlerde `\u00a0`); bütçe çubuğu `budgetLevel` (≥ %80 turuncu + "Bütçe kullanımı %N", aşım kırmızı); `Markdown shiftHeadings` (sayfada tek h1). Cümlelerde sayıdan sonra ek yok. - Hane izolasyonu: hane kimliği yalnızca `AuthUser.household_id`'den; sorgular `scope(hid)` ile başlar; başka hane → 404. ## Ortam diff --git a/docs/MEMORY.md b/docs/MEMORY.md index fa383a2..c7beedf 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -1,7 +1,7 @@ # 🧠 Proje Hafızası ## ▶️ Sıradaki Adım -**Faz 10 – Adım 3:** Arayüz cilası ve erişilebilirlik — tüm sayfaları (Panel, Defter, Plan, sihirbaz, Değerlendirme, Rapor, Birikim, Raporlar, Ayarlar, giriş/kayıt, 404) telefon 390 / masaüstü 1280 / koyu temada gez, ekran görüntüsüyle bul-düzelt; boş durumlar + eylem, skeleton, %80 bütçe uyarısı (KAKEIBO_PROMPT §9), odak halkası/klavye, kontrast, `prefers-reduced-motion`, riskli işlemlerde onay. Önce tarama listesini çıkar, kullanıcıya özet sun. +**Faz 10 – Adım 4:** Performans — `npm run build` çıktısında paket boyutlarını ölç (ana JS, Recharts/SheetJS zaten lazy), sayfa bazlı kod bölme (`React.lazy` route), Axum statik dosyalarda `/assets/*` (hash'li) için `Cache-Control: public, max-age=31536000, immutable`, `index.html` için `no-cache`; sıkıştırma zaten var. Önce/sonra ölçümü raporla. **Bekleyen (kullanıcıda):** Faz 5 – Adım 5 canlı yedek kurulumu (`docs/DEPLOY.md` §6.1–6.3) → çıktılar gelince Faz 5 ✅. ## 📌 Alınan Kararlar @@ -150,3 +150,4 @@ - **Faz 9 – Adım 6b (2026-11-13) → Faz 9 ✅:** `features/review/report-page.tsx` (`paths.monthlyReport = /degerlendirme/rapor`, dönem seçici, Yazdır/PDF `printSection()`, "Markdown indir" `downloadFile` (csv.ts'e genel yardımcı), `reportFileName` = backend slug). Bağlantılar: Değerlendirme başlığı, kapatma sonucu, dönem geçmişinde ayrı simge bağlantı (iç içe `` olmasın). Bulunan/düzeltilen: (1) şablondaki `` dipnot react-markdown'da hiç görünmüyordu → düz `_…_`; (2) telefonda tablolar sayfayı 541 px'e taşırıyordu (mobil emülasyonda `innerWidth` da büyür → kontrol 390 ile) → `Markdown` tablo sarmalayıcısı `.table-wrap`; (3) PDF ilk sayfada sayfa başlığı/dönem seçici → `PageHeader printHidden`; (4) ara raporda başlamamış haftalar "✅ %0" → "—"; (5) hedef kutuları sayfa sonunda bölünüyordu. PDF: Chrome `printToPDF` → 4 sayfa A4, `pdftoppm` ile gözle kontrol. Frontend 135, backend 196 test; e2e 22 kontrol (DB silindi). - **Faz 10 – Adım 1 (2026-11-13):** Ayarlar menüsü (`features/settings/page.tsx`: Kategoriler, Ödeme türleri, Veri ve yedek + tema) ve alt sayfalar (`subpage.tsx`, `catalog-pages.tsx`, `data-page.tsx`); ekstre sayfası "‹ Veri ve yedek"e döner. `catalog-editor.tsx`: satırda ↑ ↓ ✎ (44 px, `aria-label` adlı), düzenleme formu ad + emoji (hızlı seçim paleti, `aria-pressed`), Pasifleştir/Yeniden etkinleştir + toast "Geri al" (sürümsüz PATCH), aynı ad hatası backend'den. `lib/catalog.ts` mutasyonları `['categories'|'payment-methods']` + rapor/dönem önbelleğini yeniler. Telefonda grup başlığı bölünüyordu → açıklama alta. Frontend 136 test; e2e 25 kontrol (DB silindi). - **Faz 10 – Adım 2 (2026-11-13):** Backend: `PATCH /auth/me` (ad), `POST /auth/me/password` (mevcut şifre + limiter `password:`, `sessions_valid_from` → diğer oturumlar 401, bu cihaza yeni çerez, "beni hatırla" eski çerezin `exp-iat`'ından), `PATCH /household` (sahip: ad, `budget_split_pct` toplam 100, `budget_suggestion`), `DELETE /household/members/{id}` (sahip; kendini/sahibi çıkaramaz; e-posta `#removed-` → yeniden davet edilebilir). `GET /household` artık `settings` döner. `services/account.rs`. Testler `tests/account.rs` (4) + 2 bilinçli bozma yakalandı. Frontend: `/ayarlar/hane` (ad, üyeler + çıkar onaylı, davet oluştur → panoya kopyala / paylaş / iptal), `/ayarlar/hesap` (ad, e-posta salt okunur, şifre formu: zod tekrar + farklı, sunucu hatası alana), `/ayarlar/butce` (4 yüzde canlı toplam, 50/30/10/10 düğmesi, yöntem radyo; üye salt okunur). Öneri seçici ve sihirbaz hanenin yöntemiyle açılır (veri yoksa yüzdelere düşer). E2E 28 kontrol: **davet bağlantısıyla gerçek kayıt** dahil (`REGISTRATION=invite_only`). Not: başsız Chrome'da pano için `Emulation.setFocusEmulationEnabled`. Backend 205, frontend 140 test. +- **Faz 10 – Adım 3 (2026-11-13):** Tarama betiği (scratchpad `e2e/scan.mjs`): 21 sayfa × telefon açık/koyu + masaüstü (+ boş hane, giriş/kayıt); sayfa içi denetim (adsız denetim, <44 px hedef, canvas ile oklch→sRGB kontrast, tek h1, alt, lang, taşma) + Tab ile odak halkası. İlk sonuç 89 kontrast / 75 küçük hedef / 27 adsız / 3 h1 → benzersiz: muted yazı 4,35 (→ token 0.52), yeşil 4,37 (→ #166534), `sm` düğmeler 36 px (→ pointer-coarse 44), GFM görev kutusu adsız (→ "Tamamlandı/Yapılacak"), rapor 2 h1 (→ `shiftHeadings`). **Ekran görüntüsünden:** telefonda Defter formu taşıyordu (ızgara öğesi `min-width:auto` 425 px) — otomatik kontrol kaçırdı çünkü mobil öykünmede `innerWidth` da büyür → kontrol `screen.width`/390 ile; Raporlar grafik kartları da taşıyordu → `Section` + rapor bölümüne `min-w-0`. Eklenen: %80 bütçe uyarısı (`lib/budget-level.ts`), Raporlar boş durumu (dönem yok → Dönemi başlat / Ekstreden aktar; filtre boş → Filtreleri temizle), tutarlarda bölünmez boşluk. Sonra: kontrast/adsız/h1/telefon hedef 0. Frontend 142 test. diff --git a/docs/PHASES.md b/docs/PHASES.md index 60d1fa8..60de982 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -89,6 +89,6 @@ ## Faz 10 Adımları (web uygulamasının eksikleri + cila) - ✅ 1. Ayarlar yeniden düzeni (`/ayarlar` menü + tema) · **Kategori yönetimi** (grup grup ekle, ad/emoji düzenle, sırala, pasifleştir) · **Ödeme türü yönetimi** · Veri sayfası `/ayarlar/veri` - ✅ 2. Hane ve hesap: hane adı, üyeler, **davet bağlantısı** (oluştur/kopyala/iptal), üye çıkarma; profil adı, **şifre değiştirme**; varsayılan bütçe yüzdeleri + öneri yöntemi -- 🔄 3. Arayüz cilası ve erişilebilirlik: tüm sayfaların telefon/masaüstü/koyu tema taraması, boş durumlar, skeleton, %80 bütçe uyarısı, odak/klavye, kontrast, onay diyalogları -- ⏳ 4. Performans: sayfa bazlı kod bölme (lazy route), statik dosyalarda uzun önbellek, paket boyutu +- ✅ 3. Arayüz cilası ve erişilebilirlik: tüm sayfaların telefon/masaüstü/koyu tema taraması, boş durumlar, skeleton, %80 bütçe uyarısı, odak/klavye, kontrast, onay diyalogları +- 🔄 4. Performans: sayfa bazlı kod bölme (lazy route), statik dosyalarda uzun önbellek, paket boyutu - ⏳ 5. PWA: manifest + service worker (uygulama kabuğu; `/api` asla önbelleğe alınmaz), kurulum, güncelleme bildirimi diff --git a/frontend/src/components/markdown.tsx b/frontend/src/components/markdown.tsx index 5b358e7..b65ef87 100644 --- a/frontend/src/components/markdown.tsx +++ b/frontend/src/components/markdown.tsx @@ -15,12 +15,39 @@ const components: Components = { ), + // `- [ ]` kutucuğu salt okunur; ekran okuyucu ne olduğunu söylesin + input: ({ node: _node, ...props }) => + props.type === 'checkbox' ? ( + + ) : ( + + ), } -export function Markdown({ children, className }: { children: string; className?: string }) { +/** + * Sayfanın kendi `h1`'i varken belge başlıkları bir düzey aşağı (h1 → h2 …); görünüm + * `md-h1`… sınıflarıyla korunur (sayfada tek `h1`, ekran okuyucuda doğru başlık sırası). + */ +const shifted: Components = { + ...components, + h1: ({ node: _node, ...p }) =>

, + h2: ({ node: _node, ...p }) =>

, + h3: ({ node: _node, ...p }) =>

, + h4: ({ node: _node, ...p }) =>

, +} + +export function Markdown({ + children, + className, + shiftHeadings = false, +}: { + children: string + className?: string + shiftHeadings?: boolean +}) { return (
- + {children}
diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx index 4ad10bf..18c1357 100644 --- a/frontend/src/components/ui/button.tsx +++ b/frontend/src/components/ui/button.tsx @@ -20,17 +20,18 @@ const buttonVariants = cva( link: "text-primary underline-offset-4 hover:underline", }, size: { - // Kakeibo: mobile-first — dokunma alanı en az 44×44 px (KAKEIBO_PROMPT §9) + // Kakeibo: mobile-first — dokunma alanı en az 44×44 px (KAKEIBO_PROMPT §9); + // sm / icon-sm yalnız fare ile kompakt, dokunmatikte (pointer: coarse) 44 px default: "h-11 gap-2 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3", xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", - sm: "h-9 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + sm: "h-9 pointer-coarse:h-11 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", lg: "h-12 gap-2 px-5 text-base has-data-[icon=inline-end]:pr-4 has-data-[icon=inline-start]:pl-4", icon: "size-11", "icon-xs": "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", "icon-sm": - "size-9 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", + "size-9 pointer-coarse:size-11 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", "icon-lg": "size-12", }, }, diff --git a/frontend/src/features/ledger/page.tsx b/frontend/src/features/ledger/page.tsx index 530f1ed..2231c5e 100644 --- a/frontend/src/features/ledger/page.tsx +++ b/frontend/src/features/ledger/page.tsx @@ -87,7 +87,7 @@ function LedgerView({ period }: { period: Period }) { )}
{!readOnly && ( -
+
{catalogFailed?.error ? ( @@ -110,7 +110,8 @@ function LedgerView({ period }: { period: Period }) {
)} -
+ {/* min-w-0: ızgara öğesi içeriğinin doğal genişliğine uzayıp telefonda taşmasın */} +
{budget.isError ? ( diff --git a/frontend/src/features/ledger/week-summary.tsx b/frontend/src/features/ledger/week-summary.tsx index 98e1b95..e0888eb 100644 --- a/frontend/src/features/ledger/week-summary.tsx +++ b/frontend/src/features/ledger/week-summary.tsx @@ -1,4 +1,6 @@ -import { formatMoney } from '@/lib/format' +import { TriangleAlert } from 'lucide-react' +import { budgetLevel } from '@/lib/budget-level' +import { formatMoney, formatPercent } from '@/lib/format' import { group } from '@/lib/groups' import { t } from '@/lib/strings' import { cn } from '@/lib/utils' @@ -20,6 +22,7 @@ export function WeekSummary({ budget, index }: { budget: GroupBudget[]; index: n const planned = index === 'month' ? b.monthly : (w?.amount ?? 0) const spent = index === 'month' ? b.spent : (w?.spent ?? 0) const left = planned - spent + const level = budgetLevel(spent, planned) const over = left < 0 const ratio = planned > 0 ? Math.min(spent / planned, 1) : spent > 0 ? 1 : 0 return ( @@ -41,10 +44,19 @@ export function WeekSummary({ budget, index }: { budget: GroupBudget[]; index: n aria-valuenow={Math.round(ratio * 100)} >
+ {level === 'near' && ( +

+ + {s.near(formatPercent(spent / planned, 0))} +

+ )}

{s.spent} {formatMoney(spent)} / {s.budget}{' '} {formatMoney(planned)} diff --git a/frontend/src/features/planning/plan-read.tsx b/frontend/src/features/planning/plan-read.tsx index 6ca9cbf..54f4d2c 100644 --- a/frontend/src/features/planning/plan-read.tsx +++ b/frontend/src/features/planning/plan-read.tsx @@ -54,7 +54,7 @@ export function Section({ className?: string }) { return ( -

+

{title}

{action} diff --git a/frontend/src/features/reports/page.tsx b/frontend/src/features/reports/page.tsx index 09e40e3..b9a413a 100644 --- a/frontend/src/features/reports/page.tsx +++ b/frontend/src/features/reports/page.tsx @@ -1,5 +1,6 @@ -import { Printer } from 'lucide-react' -import { useSearchParams } from 'react-router' +import { ChartLine, Printer } from 'lucide-react' +import { Link, useSearchParams } from 'react-router' +import { paths } from '@/components/layout/nav-items' import { PageError, PageHeader, PageLoading } from '@/components/page' import { Skeleton } from '@/components/skeleton' import { Button } from '@/components/ui/button' @@ -66,6 +67,8 @@ export function ReportsPage() { ) : !periods.data || !categories.data || !methods.data ? ( + ) : periods.data.length === 0 ? ( + ) : (
{/* Yalnız yazdırmada: kağıtta hangi kapsamın basıldığı belli olsun */} @@ -87,6 +90,13 @@ export function ReportsPage() { ) : !breakdown.data ? ( + ) : breakdown.data.count === 0 ? ( +
+

{s.noMatch}

+ +
) : (
@@ -112,3 +122,26 @@ export function ReportsPage() { ) } + +/** Hiç dönem yokken: rapora veri getirecek iki yol */ +function EmptyReports() { + return ( +
+
+ +
+
+

{s.emptyTitle}

+

{s.emptyText}

+
+
+ + +
+
+ ) +} diff --git a/frontend/src/features/reports/section.tsx b/frontend/src/features/reports/section.tsx index 0ac45e5..03f0289 100644 --- a/frontend/src/features/reports/section.tsx +++ b/frontend/src/features/reports/section.tsx @@ -37,7 +37,7 @@ export function ReportSection({ ref={ref} data-report aria-labelledby={`${id}-title`} - className="rounded-2xl border bg-card p-4 sm:p-5 print:rounded-none print:border-0 print:p-0 print:pb-6" + className="min-w-0 rounded-2xl border bg-card p-4 sm:p-5 print:rounded-none print:border-0 print:p-0 print:pb-6" >
diff --git a/frontend/src/features/review/close-schema.test.ts b/frontend/src/features/review/close-schema.test.ts index 668283c..493090c 100644 --- a/frontend/src/features/review/close-schema.test.ts +++ b/frontend/src/features/review/close-schema.test.ts @@ -15,6 +15,6 @@ describe('close schema', () => { expect(schema.safeParse({ to_savings: '-5' }).success).toBe(false) const r = schema.safeParse({ to_savings: '10.000,01' }) expect(r.success).toBe(false) - expect(r.error?.issues[0].message).toBe('En fazla 10.000,00 ₺ aktarabilirsin') + expect(r.error?.issues[0].message).toBe('En fazla 10.000,00\u00a0₺ aktarabilirsin') }) }) diff --git a/frontend/src/features/review/report-page.tsx b/frontend/src/features/review/report-page.tsx index c95b4ef..a61719f 100644 --- a/frontend/src/features/review/report-page.tsx +++ b/frontend/src/features/review/report-page.tsx @@ -65,7 +65,9 @@ function ReportView({ period }: { period: Period }) {

{s.printHint}

- {report.data} + + {report.data} +
) diff --git a/frontend/src/index.css b/frontend/src/index.css index 90377d1..d2d2210 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -77,7 +77,8 @@ --secondary: oklch(0.97 0 0); --secondary-foreground: oklch(0.205 0 0); --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); + /* 0.52: gri (muted) zeminde de WCAG AA ≥ 4.5:1 (0.556 → 4.35 idi) */ + --muted-foreground: oklch(0.52 0 0); --accent: oklch(0.97 0 0); --accent-foreground: oklch(0.205 0 0); --destructive: oklch(0.577 0.245 27.325); @@ -115,7 +116,8 @@ --unexpected-soft: #fbefe5; /* Durum: gelir/olumlu · gider/aşım · uyarı (yazı olarak AA uyumlu) */ - --income: #15803d; + /* yumuşak yeşil zeminde de AA (#15803d → 4.37 idi) */ + --income: #166534; --expense: #c62828; --warning: #b45309; } @@ -220,7 +222,8 @@ @apply text-base font-semibold; } .markdown h3, - .markdown h4 { + .markdown h4, + .markdown h5 { @apply font-semibold; } .markdown ul { @@ -272,16 +275,16 @@ .markdown.report { @apply space-y-4 text-[0.95rem]; } - .markdown.report h1 { + .markdown.report .md-h1 { @apply text-2xl font-bold tracking-tight; } - .markdown.report h2 { + .markdown.report .md-h2 { @apply mt-8 border-t pt-6 text-xl font-semibold; } - .markdown.report h3 { + .markdown.report .md-h3 { @apply mt-5 text-base; } - .markdown.report h1 + p { + .markdown.report .md-h1 + p { @apply text-muted-foreground; } .markdown.report blockquote { @@ -314,9 +317,9 @@ .markdown.report ul:has(> li > input[type='checkbox']) { break-inside: avoid; } - .markdown.report h2, - .markdown.report h3, - .markdown.report h4 { + .markdown.report .md-h2, + .markdown.report .md-h3, + .markdown.report .md-h4 { break-after: avoid; } html[data-print-one] main [data-report]:not([data-print-target]) { diff --git a/frontend/src/lib/budget-level.test.ts b/frontend/src/lib/budget-level.test.ts new file mode 100644 index 0000000..433731d --- /dev/null +++ b/frontend/src/lib/budget-level.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { budgetLevel } from './budget-level' + +describe('budget level', () => { + it('warns from 80% and flags overspending', () => { + expect(budgetLevel(0, 0)).toBe('none') + expect(budgetLevel(500, 0)).toBe('over') + expect(budgetLevel(79_99, 100_00)).toBe('ok') + expect(budgetLevel(80_00, 100_00)).toBe('near') + expect(budgetLevel(100_00, 100_00)).toBe('near') + expect(budgetLevel(100_01, 100_00)).toBe('over') + }) +}) diff --git a/frontend/src/lib/budget-level.ts b/frontend/src/lib/budget-level.ts new file mode 100644 index 0000000..24c0a14 --- /dev/null +++ b/frontend/src/lib/budget-level.ts @@ -0,0 +1,11 @@ +/** Bütçe kullanım düzeyi (KAKEIBO_PROMPT §9: %80'e yaklaşınca ve aşınca görsel uyarı) */ +export type BudgetLevel = 'none' | 'ok' | 'near' | 'over' + +/** Uyarı eşiği: bütçenin %80'i */ +export const NEAR_RATIO = 0.8 + +export function budgetLevel(spent: number, planned: number): BudgetLevel { + if (planned <= 0) return spent > 0 ? 'over' : 'none' + if (spent > planned) return 'over' + return spent / planned >= NEAR_RATIO ? 'near' : 'ok' +} diff --git a/frontend/src/lib/format.test.ts b/frontend/src/lib/format.test.ts index 5988ab3..e35c3d1 100644 --- a/frontend/src/lib/format.test.ts +++ b/frontend/src/lib/format.test.ts @@ -14,25 +14,25 @@ import { describe('formatMoney', () => { it('kuruşu Türkçe biçimde, para birimi sağda ve boşluklu yazar', () => { - expect(formatMoney(125000)).toBe('1.250,00 ₺') - expect(formatMoney(30000, 'EUR')).toBe('300,00 €') - expect(formatMoney(15000, 'USD')).toBe('150,00 $') - expect(formatMoney(0)).toBe('0,00 ₺') - expect(formatMoney(5)).toBe('0,05 ₺') - expect(formatMoney(123456789)).toBe('1.234.567,89 ₺') + expect(formatMoney(125000)).toBe('1.250,00\u00a0₺') + expect(formatMoney(30000, 'EUR')).toBe('300,00\u00a0€') + expect(formatMoney(15000, 'USD')).toBe('150,00\u00a0$') + expect(formatMoney(0)).toBe('0,00\u00a0₺') + expect(formatMoney(5)).toBe('0,05\u00a0₺') + expect(formatMoney(123456789)).toBe('1.234.567,89\u00a0₺') }) it('negatif ve işaretli', () => { - expect(formatMoney(-125000)).toBe('-1.250,00 ₺') - expect(formatMoney(125000, 'TRY', { signed: true })).toBe('+1.250,00 ₺') - expect(formatMoney(-500, 'TRY', { signed: true })).toBe('-5,00 ₺') + expect(formatMoney(-125000)).toBe('-1.250,00\u00a0₺') + expect(formatMoney(125000, 'TRY', { signed: true })).toBe('+1.250,00\u00a0₺') + expect(formatMoney(-500, 'TRY', { signed: true })).toBe('-5,00\u00a0₺') }) }) describe('diğer biçimler', () => { it('miktar, sayı, yüzde', () => { - expect(formatQuantity(12_500_000, 'gr')).toBe('12,50 gr') - expect(formatQuantity(1_234_567, 'gr', 3)).toBe('1,235 gr') + expect(formatQuantity(12_500_000, 'gr')).toBe('12,50\u00a0gr') + expect(formatQuantity(1_234_567, 'gr', 3)).toBe('1,235\u00a0gr') expect(formatNumber(1250.5)).toBe('1.250,50') expect(formatPercent(0.123)).toBe('%12,3') }) @@ -60,6 +60,7 @@ describe('parseMoney', () => { ['1250.50', 125050], // telefon klavyesindeki nokta ['1250.5', 125050], [' 1.250,00 ₺ ', 125000], + ['1.250,00\u00a0₺', 125000], // ekrandan kopyalanan (bölünmez boşluk) [',99', 99], ['0,1', 10], ['1.234.567,89', 123456789], diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index 9dc2a8c..3e8332e 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -47,13 +47,14 @@ export function formatMoney( currency: CurrencyCode = 'TRY', options: { signed?: boolean } = {}, ): string { - const text = `${formatNumber(kurus / 100)} ${CURRENCY_SUFFIX[currency]}` + // Bölünmez boşluk: satır sonunda "₺" tek başına alt satıra düşmesin + const text = `${formatNumber(kurus / 100)}\u00a0${CURRENCY_SUFFIX[currency]}` return options.signed && kurus > 0 ? `+${text}` : text } /** Milyonda bir birim → `12,50 gr` (altın, döviz miktarı) */ export function formatQuantity(e6: number, unit: string, decimals = 2): string { - return `${formatNumber(e6 / 1_000_000, decimals)} ${unit}` + return `${formatNumber(e6 / 1_000_000, decimals)}\u00a0${unit}` } /** Yüzde: `0.123` → `%12,3` (Türkçede % işareti başta) */ diff --git a/frontend/src/lib/strings.ts b/frontend/src/lib/strings.ts index ca12a60..3f101b7 100644 --- a/frontend/src/lib/strings.ts +++ b/frontend/src/lib/strings.ts @@ -296,6 +296,7 @@ export const t = { spent: 'Harcanan', left: 'Kalan', over: 'Aşıldı', + near: (pct: string) => `Bütçe kullanımı ${pct}`, }, settings: { @@ -638,6 +639,13 @@ export const t = { }, reports: { + emptyTitle: 'Henüz rapora girecek veri yok', + emptyText: + 'Raporlar harcamalardan oluşur. Bu ayı başlatıp Defter’e harcama girebilir ya da yıl başından beri olan ayları banka / kart ekstresinden aktarabilirsin.', + emptyStart: 'Dönemi başlat', + emptyImport: 'Ekstreden geçmiş ayları aktar', + noMatch: 'Bu filtrelerle harcama yok.', + clearFilters: 'Filtreleri temizle', printAll: 'Tümünü yazdır', print: 'Yazdır', csv: 'CSV indir',