fix(arayüz): erişilebilirlik ve telefon düzeni taraması (kontrast, dokunma alanı, taşma, %80 bütçe uyarısı, boş durumlar)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -84,6 +84,7 @@ npm run db:restore -- <dosya|latest> <hedef_db> [--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).
|
- 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".
|
- 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.
|
- 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.
|
- Hane izolasyonu: hane kimliği yalnızca `AuthUser.household_id`'den; sorgular `scope(hid)` ile başlar; başka hane → 404.
|
||||||
|
|
||||||
## Ortam
|
## Ortam
|
||||||
|
|||||||
+2
-1
@@ -1,7 +1,7 @@
|
|||||||
# 🧠 Proje Hafızası
|
# 🧠 Proje Hafızası
|
||||||
|
|
||||||
## ▶️ Sıradaki Adım
|
## ▶️ 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 ✅.
|
**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
|
## 📌 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 `<a>` olmasın). Bulunan/düzeltilen: (1) şablondaki `<small>` 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 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 `<a>` olmasın). Bulunan/düzeltilen: (1) şablondaki `<small>` 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 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:<uid>`, `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-<id>` → 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 2 (2026-11-13):** Backend: `PATCH /auth/me` (ad), `POST /auth/me/password` (mevcut şifre + limiter `password:<uid>`, `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-<id>` → 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.
|
||||||
|
|||||||
+2
-2
@@ -89,6 +89,6 @@
|
|||||||
## Faz 10 Adımları (web uygulamasının eksikleri + cila)
|
## 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`
|
- ✅ 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
|
- ✅ 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ı
|
- ✅ 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
|
- 🔄 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
|
- ⏳ 5. PWA: manifest + service worker (uygulama kabuğu; `/api` asla önbelleğe alınmaz), kurulum, güncelleme bildirimi
|
||||||
|
|||||||
@@ -15,12 +15,39 @@ const components: Components = {
|
|||||||
<table {...props} />
|
<table {...props} />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
// `- [ ]` kutucuğu salt okunur; ekran okuyucu ne olduğunu söylesin
|
||||||
|
input: ({ node: _node, ...props }) =>
|
||||||
|
props.type === 'checkbox' ? (
|
||||||
|
<input {...props} aria-label={props.checked ? 'Tamamlandı' : 'Yapılacak'} />
|
||||||
|
) : (
|
||||||
|
<input {...props} />
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {...p} className="md-h1" />,
|
||||||
|
h2: ({ node: _node, ...p }) => <h3 {...p} className="md-h2" />,
|
||||||
|
h3: ({ node: _node, ...p }) => <h4 {...p} className="md-h3" />,
|
||||||
|
h4: ({ node: _node, ...p }) => <h5 {...p} className="md-h4" />,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Markdown({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
shiftHeadings = false,
|
||||||
|
}: {
|
||||||
|
children: string
|
||||||
|
className?: string
|
||||||
|
shiftHeadings?: boolean
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('markdown', className)}>
|
<div className={cn('markdown', className)}>
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
|
<ReactMarkdown remarkPlugins={[remarkGfm]} components={shiftHeadings ? shifted : components}>
|
||||||
{children}
|
{children}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,17 +20,18 @@ const buttonVariants = cva(
|
|||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
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:
|
default:
|
||||||
"h-11 gap-2 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
"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",
|
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",
|
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: "size-11",
|
||||||
"icon-xs":
|
"icon-xs":
|
||||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||||
"icon-sm":
|
"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",
|
"icon-lg": "size-12",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ function LedgerView({ period }: { period: Period }) {
|
|||||||
)}
|
)}
|
||||||
<div className="grid items-start gap-4 lg:grid-cols-2">
|
<div className="grid items-start gap-4 lg:grid-cols-2">
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<div ref={formRef} className="scroll-mt-4 lg:sticky lg:top-4">
|
<div ref={formRef} className="min-w-0 scroll-mt-4 lg:sticky lg:top-4">
|
||||||
<Section title={editing ? t.ledger.editTitle : t.ledger.formTitle}>
|
<Section title={editing ? t.ledger.editTitle : t.ledger.formTitle}>
|
||||||
{catalogFailed?.error ? (
|
{catalogFailed?.error ? (
|
||||||
<PageError message={catalogFailed.error.message} onRetry={catalogFailed.refetch} />
|
<PageError message={catalogFailed.error.message} onRetry={catalogFailed.refetch} />
|
||||||
@@ -110,7 +110,8 @@ function LedgerView({ period }: { period: Period }) {
|
|||||||
</Section>
|
</Section>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={readOnly ? 'space-y-4 lg:col-span-2' : 'space-y-4'}>
|
{/* min-w-0: ızgara öğesi içeriğinin doğal genişliğine uzayıp telefonda taşmasın */}
|
||||||
|
<div className={readOnly ? 'min-w-0 space-y-4 lg:col-span-2' : 'min-w-0 space-y-4'}>
|
||||||
<Section title={t.ledger.summaryTitle}>
|
<Section title={t.ledger.summaryTitle}>
|
||||||
{budget.isError ? (
|
{budget.isError ? (
|
||||||
<PageError message={budget.error.message} onRetry={budget.refetch} />
|
<PageError message={budget.error.message} onRetry={budget.refetch} />
|
||||||
|
|||||||
@@ -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 { group } from '@/lib/groups'
|
||||||
import { t } from '@/lib/strings'
|
import { t } from '@/lib/strings'
|
||||||
import { cn } from '@/lib/utils'
|
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 planned = index === 'month' ? b.monthly : (w?.amount ?? 0)
|
||||||
const spent = index === 'month' ? b.spent : (w?.spent ?? 0)
|
const spent = index === 'month' ? b.spent : (w?.spent ?? 0)
|
||||||
const left = planned - spent
|
const left = planned - spent
|
||||||
|
const level = budgetLevel(spent, planned)
|
||||||
const over = left < 0
|
const over = left < 0
|
||||||
const ratio = planned > 0 ? Math.min(spent / planned, 1) : spent > 0 ? 1 : 0
|
const ratio = planned > 0 ? Math.min(spent / planned, 1) : spent > 0 ? 1 : 0
|
||||||
return (
|
return (
|
||||||
@@ -41,10 +44,19 @@ export function WeekSummary({ budget, index }: { budget: GroupBudget[]; index: n
|
|||||||
aria-valuenow={Math.round(ratio * 100)}
|
aria-valuenow={Math.round(ratio * 100)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn('h-full rounded-full', over ? 'bg-expense' : g.className.fill)}
|
className={cn(
|
||||||
|
'h-full rounded-full',
|
||||||
|
over ? 'bg-expense' : level === 'near' ? 'bg-warning' : g.className.fill,
|
||||||
|
)}
|
||||||
style={{ width: `${ratio * 100}%` }}
|
style={{ width: `${ratio * 100}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{level === 'near' && (
|
||||||
|
<p className="mb-1 flex items-center gap-1 text-xs font-medium text-warning">
|
||||||
|
<TriangleAlert className="size-3.5 shrink-0" aria-hidden />
|
||||||
|
{s.near(formatPercent(spent / planned, 0))}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{s.spent} <span className="money">{formatMoney(spent)}</span> / {s.budget}{' '}
|
{s.spent} <span className="money">{formatMoney(spent)}</span> / {s.budget}{' '}
|
||||||
<span className="money">{formatMoney(planned)}</span>
|
<span className="money">{formatMoney(planned)}</span>
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export function Section({
|
|||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className={cn('rounded-2xl border bg-card p-4 sm:p-5', className)}>
|
<section className={cn('min-w-0 rounded-2xl border bg-card p-4 sm:p-5', className)}>
|
||||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
<h3 className="font-semibold">{title}</h3>
|
<h3 className="font-semibold">{title}</h3>
|
||||||
{action}
|
{action}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Printer } from 'lucide-react'
|
import { ChartLine, Printer } from 'lucide-react'
|
||||||
import { useSearchParams } from 'react-router'
|
import { Link, useSearchParams } from 'react-router'
|
||||||
|
import { paths } from '@/components/layout/nav-items'
|
||||||
import { PageError, PageHeader, PageLoading } from '@/components/page'
|
import { PageError, PageHeader, PageLoading } from '@/components/page'
|
||||||
import { Skeleton } from '@/components/skeleton'
|
import { Skeleton } from '@/components/skeleton'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -66,6 +67,8 @@ export function ReportsPage() {
|
|||||||
<PageError message={failed.error.message} onRetry={failed.refetch} />
|
<PageError message={failed.error.message} onRetry={failed.refetch} />
|
||||||
) : !periods.data || !categories.data || !methods.data ? (
|
) : !periods.data || !categories.data || !methods.data ? (
|
||||||
<PageLoading />
|
<PageLoading />
|
||||||
|
) : periods.data.length === 0 ? (
|
||||||
|
<EmptyReports />
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Yalnız yazdırmada: kağıtta hangi kapsamın basıldığı belli olsun */}
|
{/* Yalnız yazdırmada: kağıtta hangi kapsamın basıldığı belli olsun */}
|
||||||
@@ -87,6 +90,13 @@ export function ReportsPage() {
|
|||||||
<PageError message={breakdown.error.message} onRetry={breakdown.refetch} />
|
<PageError message={breakdown.error.message} onRetry={breakdown.refetch} />
|
||||||
) : !breakdown.data ? (
|
) : !breakdown.data ? (
|
||||||
<Skeleton className="h-64" />
|
<Skeleton className="h-64" />
|
||||||
|
) : breakdown.data.count === 0 ? (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border bg-card p-4">
|
||||||
|
<p className="text-muted-foreground">{s.noMatch}</p>
|
||||||
|
<Button variant="outline" onClick={() => setFilter({})}>
|
||||||
|
{s.clearFilters}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid items-start gap-4 lg:grid-cols-2 print:block print:space-y-4">
|
<div className="grid items-start gap-4 lg:grid-cols-2 print:block print:space-y-4">
|
||||||
<GroupsReport b={breakdown.data} />
|
<GroupsReport b={breakdown.data} />
|
||||||
@@ -112,3 +122,26 @@ export function ReportsPage() {
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Hiç dönem yokken: rapora veri getirecek iki yol */
|
||||||
|
function EmptyReports() {
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col items-center gap-4 rounded-2xl border bg-card p-8 text-center">
|
||||||
|
<div className="grid size-14 place-items-center rounded-full bg-primary/10 text-primary">
|
||||||
|
<ChartLine className="size-7" aria-hidden />
|
||||||
|
</div>
|
||||||
|
<div className="max-w-md space-y-2">
|
||||||
|
<h2 className="text-lg font-semibold">{s.emptyTitle}</h2>
|
||||||
|
<p className="text-muted-foreground">{s.emptyText}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap justify-center gap-2">
|
||||||
|
<Button asChild size="lg">
|
||||||
|
<Link to={paths.periodWizard}>{s.emptyStart}</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild size="lg" variant="outline">
|
||||||
|
<Link to={paths.statement}>{s.emptyImport}</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function ReportSection({
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
data-report
|
data-report
|
||||||
aria-labelledby={`${id}-title`}
|
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"
|
||||||
>
|
>
|
||||||
<div className="mb-3 flex flex-wrap items-start justify-between gap-2">
|
<div className="mb-3 flex flex-wrap items-start justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
|||||||
@@ -15,6 +15,6 @@ describe('close schema', () => {
|
|||||||
expect(schema.safeParse({ to_savings: '-5' }).success).toBe(false)
|
expect(schema.safeParse({ to_savings: '-5' }).success).toBe(false)
|
||||||
const r = schema.safeParse({ to_savings: '10.000,01' })
|
const r = schema.safeParse({ to_savings: '10.000,01' })
|
||||||
expect(r.success).toBe(false)
|
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')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -65,7 +65,9 @@ function ReportView({ period }: { period: Period }) {
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground print:hidden">{s.printHint}</p>
|
<p className="text-xs text-muted-foreground print:hidden">{s.printHint}</p>
|
||||||
<article className="rounded-2xl border bg-card p-4 sm:p-8 print:rounded-none print:border-0 print:p-0">
|
<article className="rounded-2xl border bg-card p-4 sm:p-8 print:rounded-none print:border-0 print:p-0">
|
||||||
<Markdown className="report">{report.data}</Markdown>
|
<Markdown className="report" shiftHeadings>
|
||||||
|
{report.data}
|
||||||
|
</Markdown>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
+13
-10
@@ -77,7 +77,8 @@
|
|||||||
--secondary: oklch(0.97 0 0);
|
--secondary: oklch(0.97 0 0);
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
--muted: oklch(0.97 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: oklch(0.97 0 0);
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
@@ -115,7 +116,8 @@
|
|||||||
--unexpected-soft: #fbefe5;
|
--unexpected-soft: #fbefe5;
|
||||||
|
|
||||||
/* Durum: gelir/olumlu · gider/aşım · uyarı (yazı olarak AA uyumlu) */
|
/* 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;
|
--expense: #c62828;
|
||||||
--warning: #b45309;
|
--warning: #b45309;
|
||||||
}
|
}
|
||||||
@@ -220,7 +222,8 @@
|
|||||||
@apply text-base font-semibold;
|
@apply text-base font-semibold;
|
||||||
}
|
}
|
||||||
.markdown h3,
|
.markdown h3,
|
||||||
.markdown h4 {
|
.markdown h4,
|
||||||
|
.markdown h5 {
|
||||||
@apply font-semibold;
|
@apply font-semibold;
|
||||||
}
|
}
|
||||||
.markdown ul {
|
.markdown ul {
|
||||||
@@ -272,16 +275,16 @@
|
|||||||
.markdown.report {
|
.markdown.report {
|
||||||
@apply space-y-4 text-[0.95rem];
|
@apply space-y-4 text-[0.95rem];
|
||||||
}
|
}
|
||||||
.markdown.report h1 {
|
.markdown.report .md-h1 {
|
||||||
@apply text-2xl font-bold tracking-tight;
|
@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;
|
@apply mt-8 border-t pt-6 text-xl font-semibold;
|
||||||
}
|
}
|
||||||
.markdown.report h3 {
|
.markdown.report .md-h3 {
|
||||||
@apply mt-5 text-base;
|
@apply mt-5 text-base;
|
||||||
}
|
}
|
||||||
.markdown.report h1 + p {
|
.markdown.report .md-h1 + p {
|
||||||
@apply text-muted-foreground;
|
@apply text-muted-foreground;
|
||||||
}
|
}
|
||||||
.markdown.report blockquote {
|
.markdown.report blockquote {
|
||||||
@@ -314,9 +317,9 @@
|
|||||||
.markdown.report ul:has(> li > input[type='checkbox']) {
|
.markdown.report ul:has(> li > input[type='checkbox']) {
|
||||||
break-inside: avoid;
|
break-inside: avoid;
|
||||||
}
|
}
|
||||||
.markdown.report h2,
|
.markdown.report .md-h2,
|
||||||
.markdown.report h3,
|
.markdown.report .md-h3,
|
||||||
.markdown.report h4 {
|
.markdown.report .md-h4 {
|
||||||
break-after: avoid;
|
break-after: avoid;
|
||||||
}
|
}
|
||||||
html[data-print-one] main [data-report]:not([data-print-target]) {
|
html[data-print-one] main [data-report]:not([data-print-target]) {
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
@@ -14,25 +14,25 @@ import {
|
|||||||
|
|
||||||
describe('formatMoney', () => {
|
describe('formatMoney', () => {
|
||||||
it('kuruşu Türkçe biçimde, para birimi sağda ve boşluklu yazar', () => {
|
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(125000)).toBe('1.250,00\u00a0₺')
|
||||||
expect(formatMoney(30000, 'EUR')).toBe('300,00 €')
|
expect(formatMoney(30000, 'EUR')).toBe('300,00\u00a0€')
|
||||||
expect(formatMoney(15000, 'USD')).toBe('150,00 $')
|
expect(formatMoney(15000, 'USD')).toBe('150,00\u00a0$')
|
||||||
expect(formatMoney(0)).toBe('0,00 ₺')
|
expect(formatMoney(0)).toBe('0,00\u00a0₺')
|
||||||
expect(formatMoney(5)).toBe('0,05 ₺')
|
expect(formatMoney(5)).toBe('0,05\u00a0₺')
|
||||||
expect(formatMoney(123456789)).toBe('1.234.567,89 ₺')
|
expect(formatMoney(123456789)).toBe('1.234.567,89\u00a0₺')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('negatif ve işaretli', () => {
|
it('negatif ve işaretli', () => {
|
||||||
expect(formatMoney(-125000)).toBe('-1.250,00 ₺')
|
expect(formatMoney(-125000)).toBe('-1.250,00\u00a0₺')
|
||||||
expect(formatMoney(125000, 'TRY', { signed: true })).toBe('+1.250,00 ₺')
|
expect(formatMoney(125000, 'TRY', { signed: true })).toBe('+1.250,00\u00a0₺')
|
||||||
expect(formatMoney(-500, 'TRY', { signed: true })).toBe('-5,00 ₺')
|
expect(formatMoney(-500, 'TRY', { signed: true })).toBe('-5,00\u00a0₺')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('diğer biçimler', () => {
|
describe('diğer biçimler', () => {
|
||||||
it('miktar, sayı, yüzde', () => {
|
it('miktar, sayı, yüzde', () => {
|
||||||
expect(formatQuantity(12_500_000, 'gr')).toBe('12,50 gr')
|
expect(formatQuantity(12_500_000, 'gr')).toBe('12,50\u00a0gr')
|
||||||
expect(formatQuantity(1_234_567, 'gr', 3)).toBe('1,235 gr')
|
expect(formatQuantity(1_234_567, 'gr', 3)).toBe('1,235\u00a0gr')
|
||||||
expect(formatNumber(1250.5)).toBe('1.250,50')
|
expect(formatNumber(1250.5)).toBe('1.250,50')
|
||||||
expect(formatPercent(0.123)).toBe('%12,3')
|
expect(formatPercent(0.123)).toBe('%12,3')
|
||||||
})
|
})
|
||||||
@@ -60,6 +60,7 @@ describe('parseMoney', () => {
|
|||||||
['1250.50', 125050], // telefon klavyesindeki nokta
|
['1250.50', 125050], // telefon klavyesindeki nokta
|
||||||
['1250.5', 125050],
|
['1250.5', 125050],
|
||||||
[' 1.250,00 ₺ ', 125000],
|
[' 1.250,00 ₺ ', 125000],
|
||||||
|
['1.250,00\u00a0₺', 125000], // ekrandan kopyalanan (bölünmez boşluk)
|
||||||
[',99', 99],
|
[',99', 99],
|
||||||
['0,1', 10],
|
['0,1', 10],
|
||||||
['1.234.567,89', 123456789],
|
['1.234.567,89', 123456789],
|
||||||
|
|||||||
@@ -47,13 +47,14 @@ export function formatMoney(
|
|||||||
currency: CurrencyCode = 'TRY',
|
currency: CurrencyCode = 'TRY',
|
||||||
options: { signed?: boolean } = {},
|
options: { signed?: boolean } = {},
|
||||||
): string {
|
): 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
|
return options.signed && kurus > 0 ? `+${text}` : text
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Milyonda bir birim → `12,50 gr` (altın, döviz miktarı) */
|
/** Milyonda bir birim → `12,50 gr` (altın, döviz miktarı) */
|
||||||
export function formatQuantity(e6: number, unit: string, decimals = 2): string {
|
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) */
|
/** Yüzde: `0.123` → `%12,3` (Türkçede % işareti başta) */
|
||||||
|
|||||||
@@ -296,6 +296,7 @@ export const t = {
|
|||||||
spent: 'Harcanan',
|
spent: 'Harcanan',
|
||||||
left: 'Kalan',
|
left: 'Kalan',
|
||||||
over: 'Aşıldı',
|
over: 'Aşıldı',
|
||||||
|
near: (pct: string) => `Bütçe kullanımı ${pct}`,
|
||||||
},
|
},
|
||||||
|
|
||||||
settings: {
|
settings: {
|
||||||
@@ -638,6 +639,13 @@ export const t = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
reports: {
|
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',
|
printAll: 'Tümünü yazdır',
|
||||||
print: 'Yazdır',
|
print: 'Yazdır',
|
||||||
csv: 'CSV indir',
|
csv: 'CSV indir',
|
||||||
|
|||||||
Reference in New Issue
Block a user