feat(panel): özet kartları, anlık durum ve hızlı harcama ekleme
- Gelir, sabit gider, toplam birikim, devreden borç kartları - Anlık durum: kalan harcanabilir ve günlük harcama önerisi - Grup planlanan/gerçekleşen (Haftalık/Aylık) - "Harcama Ekle": masaüstünde başlıkta, mobilde yüzen düğme - Yükleniyor iskeleti, boş durumlar, açık dönem yokken son dönem özeti Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+4
-1
@@ -1,10 +1,12 @@
|
|||||||
# 🧠 Proje Hafızası
|
# 🧠 Proje Hafızası
|
||||||
|
|
||||||
## ▶️ Sıradaki Adım
|
## ▶️ Sıradaki Adım
|
||||||
**Faz 7 – Adım 2:** Panel özet kartları (`features/dashboard/`): gelir, sabit gider, toplam birikim (`/stats/periods` → `total_saved`; "varlık değerleri Faz 8'de" notu), devreden borç (varsa), grup planlanan/gerçekleşen (Haftalık/Aylık geçişi, `useBudget`), **anlık durum** = kalan harcanabilir (en belirgin). Mobilde yüzen "➕ Harcama Ekle" → Defter (tutara odak). Skeleton + boş durumlar. Açık dönem yoksa son dönem salt okunur + "Yeni dönemi aç". **Ardından sorulacak:** Ayarlar'da kategori/ödeme türü yönetimi ekranı hiçbir fazda planlı değil (kullanıcı Defter'de kategorileri kısıtlı buldu; 3. kat alt kategori istiyor mu?).
|
**Faz 7 – Adım 3:** Grafikler (Recharts — yeni bağımlılık, gerekçe ver): 1) harcanabilir (günlere eşit dağıtılmış, kümülatif) vs gerçekleşen (kümülatif), 2) 4 grup kümülatif + harcanabilir çizgisi; sekmeler Günlük (`/periods/{id}/daily`) / Haftalık (aynı veriden `period.weeks` ile) / Aylık (`useStats` → son 12 dönem, harcanabilir vs harcanan). Saf seri hesapları `lib/` + test; koyu tema renkleri CSS token'larından; grafik altında kısa metin özeti (erişilebilirlik). **Bekleyen soru:** kategori yönetimi ekranı Faz 7'den hemen sonra mı (önerilen), Faz 10'da mı?
|
||||||
**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
|
||||||
|
- **Kategoriler (kullanıcı, 2026-11-12):** 3. kat alt kategori **yok** (grup → kategori). Şartı: raporlarda **tüm** kategorilerin dağılımı görünmeli (yalnızca ilk 5 değil). Amaç "market ne için?" gibi kırılım → kategoriyi bölerek (ör. Market · Gıda / Temizlik); bunun için Ayarlar'da kategori yönetimi ekranı gerekli.
|
||||||
|
- **Canlı sürüm eski (2026-11-12 kontrol):** push'lar Gitea'da ama Dokploy yeniden derlemiyor (Faz 4+ yok, `/api/stats/periods` 404). Auto Deploy için Gitea webhook'u gerekiyor → kullanıcıya adım adım anlatıldı.
|
||||||
- **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`).
|
- **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`).
|
||||||
- Sürüm kontrolü: Gitea (`https://gitea.ta1sph.info/suphicakir/kakeibo.git`), dal `main`.
|
- Sürüm kontrolü: Gitea (`https://gitea.ta1sph.info/suphicakir/kakeibo.git`), dal `main`.
|
||||||
- Her adım sonunda Claude commit atar ve haber verir; push'u kullanıcı kendi uygulamasıyla yapar (Claude `git push` çalıştırmaz).
|
- Her adım sonunda Claude commit atar ve haber verir; push'u kullanıcı kendi uygulamasıyla yapar (Claude `git push` çalıştırmaz).
|
||||||
@@ -116,3 +118,4 @@
|
|||||||
### 2026-11-12 — Faz 7
|
### 2026-11-12 — Faz 7
|
||||||
- Kararlar (kullanıcı onayı): toplam birikim Faz 8'e kadar = kapanışlarda birikime aktarılanların toplamı; sekmeler Günlük/Haftalık = bu dönem (kümülatif, harcanabilir günlere eşit dağıtılır), Aylık = son 12 dönem; açık dönem yoksa son dönem salt okunur.
|
- Kararlar (kullanıcı onayı): toplam birikim Faz 8'e kadar = kapanışlarda birikime aktarılanların toplamı; sekmeler Günlük/Haftalık = bu dönem (kümülatif, harcanabilir günlere eşit dağıtılır), Aylık = son 12 dönem; açık dönem yoksa son dönem salt okunur.
|
||||||
- **Adım 1:** `services/stats.rs` + `routes/stats.rs`: `GET /periods/{id}/daily` (`DaySpent[]`: dönemin her günü, `spent` = `GroupAmounts`, harcama yoksa 0; `budget::spent_by_group_and_date` artık `pub(crate)`), `GET /stats/periods?limit=12` (`PeriodStats`: `total_saved` tüm dönemlerin `to_savings` toplamı — limitten bağımsız, yeniden açılan dönemin eski tutarı sayılır; `periods` eskiden yeniye, her biri `budget::summary` ile → dönem başına ~5 sorgu, en fazla 24). `tests/stats.rs` (2). Mutasyon 2/2 yakalandı. Backend 118 test.
|
- **Adım 1:** `services/stats.rs` + `routes/stats.rs`: `GET /periods/{id}/daily` (`DaySpent[]`: dönemin her günü, `spent` = `GroupAmounts`, harcama yoksa 0; `budget::spent_by_group_and_date` artık `pub(crate)`), `GET /stats/periods?limit=12` (`PeriodStats`: `total_saved` tüm dönemlerin `to_savings` toplamı — limitten bağımsız, yeniden açılan dönemin eski tutarı sayılır; `periods` eskiden yeniye, her biri `budget::summary` ile → dönem başına ~5 sorgu, en fazla 24). `tests/stats.rs` (2). Mutasyon 2/2 yakalandı. Backend 118 test.
|
||||||
|
- **Adım 2:** Panel (`features/dashboard/`): `page.tsx` (açık dönem; yoksa `NoPeriodCard` + son dönem salt okunur, `lib/dashboard.ts` → `dashboardPeriod`), `overview.tsx` (kartlar: gelir, sabit, toplam birikim `useStats().total_saved`, borç yalnızca varsa — 3 kartta ızgara 3 sütun; **Anlık durum** DOM'da önce/mobilde üstte, masaüstünde sağda: kalan harcanabilir + `dailyAllowance` = kalan ÷ kalan gün (bugün dahil, kuruş aşağı); gruplar `WeekSummary` artık `index: number | 'month'`, Haftalık/Aylık geçişi; boş durumlar: bütçe dağıtılmamış → Plan, harcama yok → Defter). "Harcama Ekle": masaüstünde başlıkta, mobilde yüzen (alt menünün üstü) → `/defter` `state.focus` → `EntryForm autoFocus`. `components/skeleton.tsx`. `queryKeys.stats = ['periods','stats']` (kapatma/plan ile yenilenir), kayıt mutasyonları da stats'ı yeniler. Frontend 95 test; e2e 22 kontrol (DB silindi). Canlı yayın 2026-11-12 güncellendi (kullanıcı Deploy yaptı; `/api/stats/periods` 401).
|
||||||
|
|||||||
+1
-1
@@ -69,5 +69,5 @@
|
|||||||
|
|
||||||
## Faz 7 Adımları
|
## Faz 7 Adımları
|
||||||
- ✅ 1. Backend: `GET /periods/{id}/daily` (gün × grup harcama; günlük/haftalık grafik verisi) + `GET /stats/periods` (son 12 dönem: harcanabilir, harcanan, birikime aktarılan; aylık grafik ve toplam birikim)
|
- ✅ 1. Backend: `GET /periods/{id}/daily` (gün × grup harcama; günlük/haftalık grafik verisi) + `GET /stats/periods` (son 12 dönem: harcanabilir, harcanan, birikime aktarılan; aylık grafik ve toplam birikim)
|
||||||
- ⏳ 2. Frontend: özet kartları (gelir, sabit gider, toplam birikim, devreden borç, grup planlanan/gerçekleşen haftalık–aylık, **anlık durum** en belirgin) + "➕ Harcama Ekle" (mobilde yüzen düğme) + skeleton ve boş durumlar
|
- ✅ 2. Frontend: özet kartları (gelir, sabit gider, toplam birikim, devreden borç, grup planlanan/gerçekleşen haftalık–aylık, **anlık durum** en belirgin) + "➕ Harcama Ekle" (mobilde yüzen düğme) + skeleton ve boş durumlar
|
||||||
- ⏳ 3. Frontend: grafikler (Recharts) — kümülatif harcanabilir vs. gerçekleşen; 4 grup + harcanabilir; Günlük / Haftalık / Aylık sekmeleri, koyu tema, erişilebilir özet
|
- ⏳ 3. Frontend: grafikler (Recharts) — kümülatif harcanabilir vs. gerçekleşen; 4 grup + harcanabilir; Günlük / Haftalık / Aylık sekmeleri, koyu tema, erişilebilir özet
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/** Yükleniyor yer tutucusu: içeriğin şeklinde gri, nabız gibi atan kutu */
|
||||||
|
export function Skeleton({ className }: { className?: string }) {
|
||||||
|
return <div aria-hidden className={cn('animate-pulse rounded-xl bg-muted', className)} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Link } from 'react-router'
|
||||||
|
import { paths } from '@/components/layout/nav-items'
|
||||||
|
import { PageError } from '@/components/page'
|
||||||
|
import { Skeleton } from '@/components/skeleton'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { WeekSummary } from '@/features/ledger/week-summary'
|
||||||
|
import { PeriodHeading } from '@/features/planning/plan-read'
|
||||||
|
import { dailyAllowance } from '@/lib/dashboard'
|
||||||
|
import { formatDayMonth, formatMoney, toIsoDate } from '@/lib/format'
|
||||||
|
import { weekIndexFor } from '@/lib/ledger'
|
||||||
|
import { periodPath } from '@/lib/period-select'
|
||||||
|
import { useBudget, useStats, useSummary } from '@/lib/periods'
|
||||||
|
import { t } from '@/lib/strings'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import type { GroupBudget } from '@/types/GroupBudget'
|
||||||
|
import type { Period } from '@/types/Period'
|
||||||
|
import type { PeriodSummary } from '@/types/PeriodSummary'
|
||||||
|
|
||||||
|
const s = t.dashboard
|
||||||
|
|
||||||
|
/** Özet kartları: gelir, sabit gider, toplam birikim, devreden borç, anlık durum, gruplar */
|
||||||
|
export function Overview({ period, writable }: { period: Period; writable: boolean }) {
|
||||||
|
const summary = useSummary(period.id)
|
||||||
|
const budget = useBudget(period.id)
|
||||||
|
const stats = useStats()
|
||||||
|
const [today] = useState(() => toIsoDate(new Date()))
|
||||||
|
|
||||||
|
const failed = [summary, budget].find((q) => q.isError)
|
||||||
|
if (failed?.error) return <PageError message={failed.error.message} onRetry={failed.refetch} />
|
||||||
|
if (!summary.data || !budget.data) return <DashboardSkeleton />
|
||||||
|
const sum = summary.data
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<PeriodHeading period={period} />
|
||||||
|
{/* Borç kartı yalnızca varsa: 3 kartta mobilde sonuncusu tam genişlik, masaüstünde 3 sütun */}
|
||||||
|
<dl
|
||||||
|
className={cn(
|
||||||
|
'grid grid-cols-2 gap-3',
|
||||||
|
sum.carryover_debt > 0
|
||||||
|
? 'lg:grid-cols-4'
|
||||||
|
: 'lg:grid-cols-3 [&>*:last-child]:col-span-2 lg:[&>*:last-child]:col-span-1',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Tile emoji="💰" label={s.income} amount={sum.income_total} tone="text-income" />
|
||||||
|
<Tile emoji="📌" label={s.fixed} amount={sum.fixed_expense_total} />
|
||||||
|
<Tile
|
||||||
|
emoji="🐷"
|
||||||
|
label={s.saved}
|
||||||
|
amount={stats.data?.total_saved}
|
||||||
|
hint={s.savedHint}
|
||||||
|
tone="text-income"
|
||||||
|
/>
|
||||||
|
{sum.carryover_debt > 0 && (
|
||||||
|
<Tile
|
||||||
|
emoji="⚠️"
|
||||||
|
label={s.debt}
|
||||||
|
amount={sum.carryover_debt}
|
||||||
|
hint={s.debtHint}
|
||||||
|
tone="text-expense"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
{/* Anlık durum DOM'da önce (ekran okuyucu ve mobilde ilk), masaüstünde sağ sütunda */}
|
||||||
|
<div className="grid items-start gap-4 lg:grid-cols-[3fr_2fr]">
|
||||||
|
<StatusCard period={period} summary={sum} today={today} writable={writable} />
|
||||||
|
<GroupsCard
|
||||||
|
period={period}
|
||||||
|
budget={budget.data}
|
||||||
|
summary={sum}
|
||||||
|
today={today}
|
||||||
|
writable={writable}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tile({
|
||||||
|
emoji,
|
||||||
|
label,
|
||||||
|
amount,
|
||||||
|
hint,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
emoji: string
|
||||||
|
label: string
|
||||||
|
/** `undefined` → yükleniyor */
|
||||||
|
amount: number | undefined
|
||||||
|
hint?: string
|
||||||
|
tone?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border bg-card p-4">
|
||||||
|
<dt className="text-sm text-muted-foreground">
|
||||||
|
<span aria-hidden>{emoji}</span> {label}
|
||||||
|
</dt>
|
||||||
|
<dd className={cn('money mt-1 text-lg font-semibold sm:text-xl', tone)}>
|
||||||
|
{amount === undefined ? <Skeleton className="h-7 w-28" /> : formatMoney(amount)}
|
||||||
|
</dd>
|
||||||
|
{hint && <dd className="mt-1 text-xs text-muted-foreground">{hint}</dd>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** En belirgin kart: kalan harcanabilir + günlük öneri */
|
||||||
|
function StatusCard({
|
||||||
|
period,
|
||||||
|
summary,
|
||||||
|
today,
|
||||||
|
writable,
|
||||||
|
}: {
|
||||||
|
period: Period
|
||||||
|
summary: PeriodSummary
|
||||||
|
today: string
|
||||||
|
writable: boolean
|
||||||
|
}) {
|
||||||
|
const over = summary.remaining < 0
|
||||||
|
const { daysLeft, perDay } = dailyAllowance(summary.remaining, period, today)
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-labelledby="status-title"
|
||||||
|
className={cn(
|
||||||
|
'rounded-2xl border-2 p-5 lg:col-start-2 lg:row-start-1',
|
||||||
|
over ? 'border-expense/50 bg-expense/5' : 'border-primary/40 bg-primary/5',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<h2 id="status-title" className="text-sm font-medium text-muted-foreground">
|
||||||
|
<span aria-hidden>📍</span> {s.statusTitle}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm">{over ? s.overspent : s.remaining}</p>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'money text-4xl font-bold tracking-tight',
|
||||||
|
over ? 'text-expense' : 'text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatMoney(over ? -summary.remaining : summary.remaining)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
{s.statusDetail(formatMoney(summary.spendable), formatMoney(summary.spent_total))}
|
||||||
|
</p>
|
||||||
|
{writable &&
|
||||||
|
(daysLeft === 0 ? (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
<p className="text-sm">{s.ended}</p>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link to={periodPath(paths.review, period)}>{s.toReview}</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mt-3 text-sm font-medium">
|
||||||
|
{perDay !== null ? s.perDay(daysLeft, formatMoney(perDay)) : s.daysLeft(daysLeft)}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 4 grup: planlanan / gerçekleşen; Haftalık (bugünün haftası) ↔ Aylık (tüm dönem) */
|
||||||
|
function GroupsCard({
|
||||||
|
period,
|
||||||
|
budget,
|
||||||
|
summary,
|
||||||
|
today,
|
||||||
|
writable,
|
||||||
|
}: {
|
||||||
|
period: Period
|
||||||
|
budget: GroupBudget[]
|
||||||
|
summary: PeriodSummary
|
||||||
|
today: string
|
||||||
|
writable: boolean
|
||||||
|
}) {
|
||||||
|
const [mode, setMode] = useState<'week' | 'month'>('week')
|
||||||
|
const index = weekIndexFor(period.weeks, today)
|
||||||
|
const week = period.weeks[index - 1]
|
||||||
|
const range = (a: string, b: string) => `${formatDayMonth(a)}–${formatDayMonth(b)}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-labelledby="groups-title"
|
||||||
|
className="rounded-2xl border bg-card p-4 sm:p-5 lg:col-start-1 lg:row-start-1"
|
||||||
|
>
|
||||||
|
<div className="mb-1 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h2 id="groups-title" className="font-semibold">
|
||||||
|
<span aria-hidden>🎯</span> {s.groupsTitle}
|
||||||
|
</h2>
|
||||||
|
<div role="group" aria-label={s.groupsTitle} className="flex rounded-lg border p-0.5">
|
||||||
|
{(['week', 'month'] as const).map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={mode === m}
|
||||||
|
onClick={() => setMode(m)}
|
||||||
|
className={cn(
|
||||||
|
'min-h-11 rounded-md px-4 text-sm font-medium',
|
||||||
|
mode === m ? 'bg-primary text-primary-foreground' : 'hover:bg-muted',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{m === 'week' ? s.weekly : s.monthly}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mb-3 text-sm text-muted-foreground">
|
||||||
|
{mode === 'week' && week
|
||||||
|
? s.weekLabel(index, range(week.start_date, week.last_date))
|
||||||
|
: s.monthLabel(range(period.start_date, period.last_date))}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{summary.budget_total === 0 ? (
|
||||||
|
<Empty text={s.noBudget} to={periodPath(paths.planning, period)} action={s.toPlan} />
|
||||||
|
) : (
|
||||||
|
<WeekSummary budget={budget} index={mode === 'week' ? index : 'month'} />
|
||||||
|
)}
|
||||||
|
{writable && summary.spent_total === 0 && summary.budget_total > 0 && (
|
||||||
|
<p className="mt-3 text-sm text-muted-foreground">
|
||||||
|
<Link to={paths.ledger} state={{ focus: true }} className="underline">
|
||||||
|
{s.noEntries}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Empty({ text, to, action }: { text: string; to: string; action: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-start gap-3 rounded-xl bg-muted p-4">
|
||||||
|
<p className="text-sm text-muted-foreground">{text}</p>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link to={to}>{action}</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Yükleniyor: kartların şeklinde yer tutucular */
|
||||||
|
export function DashboardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div role="status" aria-label={t.common.loading} className="space-y-4">
|
||||||
|
<Skeleton className="h-12 w-56" />
|
||||||
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||||
|
{[0, 1, 2, 3].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-24" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[3fr_2fr]">
|
||||||
|
<Skeleton className="h-72" />
|
||||||
|
<Skeleton className="h-48" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,14 +1,85 @@
|
|||||||
import { ComingSoon, PageHeader } from '@/components/page'
|
import { Plus } from 'lucide-react'
|
||||||
|
import { Link } from 'react-router'
|
||||||
|
import { paths } from '@/components/layout/nav-items'
|
||||||
|
import { PageError, PageHeader } from '@/components/page'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
import { NoPeriodCard } from '@/features/period-wizard/no-period-card'
|
import { NoPeriodCard } from '@/features/period-wizard/no-period-card'
|
||||||
import { useCurrentPeriod } from '@/lib/periods'
|
import { dashboardPeriod } from '@/lib/dashboard'
|
||||||
|
import { useCurrentPeriod, usePeriods } from '@/lib/periods'
|
||||||
import { t } from '@/lib/strings'
|
import { t } from '@/lib/strings'
|
||||||
|
import { DashboardSkeleton, Overview } from './overview'
|
||||||
|
|
||||||
|
const s = t.dashboard
|
||||||
|
|
||||||
|
/** Defter'e git ve tutar alanına odaklan */
|
||||||
|
const addEntryLink = { to: paths.ledger, state: { focus: true } }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Panel: açık dönemin özeti. Açık dönem yoksa "Dönemi başlat" çağrısı ve
|
||||||
|
* (varsa) son dönemin özeti salt okunur. "Harcama Ekle": masaüstünde başlıkta,
|
||||||
|
* mobilde sağ altta yüzen düğme.
|
||||||
|
*/
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const current = useCurrentPeriod()
|
const current = useCurrentPeriod()
|
||||||
|
const periods = usePeriods()
|
||||||
|
const failed = [current, periods].find((q) => q.isError)
|
||||||
|
const ready = current.isSuccess && periods.isSuccess
|
||||||
|
const period = ready ? dashboardPeriod(periods.data, current.data) : undefined
|
||||||
|
const canAdd = !!current.data
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title={t.pages.dashboard.title} description={t.pages.dashboard.description} />
|
<PageHeader
|
||||||
{current.data === null ? <NoPeriodCard /> : <ComingSoon when="Faz 7'de" />}
|
title={t.pages.dashboard.title}
|
||||||
|
description={t.pages.dashboard.description}
|
||||||
|
actions={
|
||||||
|
canAdd && (
|
||||||
|
<Button asChild size="lg" className="hidden md:inline-flex">
|
||||||
|
<Link {...addEntryLink}>
|
||||||
|
<Plus aria-hidden />
|
||||||
|
{s.addEntry}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{failed?.error ? (
|
||||||
|
<PageError
|
||||||
|
message={failed.error.message}
|
||||||
|
onRetry={() => void Promise.all([current.refetch(), periods.refetch()])}
|
||||||
|
/>
|
||||||
|
) : period === undefined ? (
|
||||||
|
<DashboardSkeleton />
|
||||||
|
) : period === null ? (
|
||||||
|
<NoPeriodCard />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{!current.data && (
|
||||||
|
<>
|
||||||
|
<NoPeriodCard />
|
||||||
|
<p className="text-sm text-muted-foreground">{s.lastPeriod(period.name)}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Overview period={period} writable={canAdd} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canAdd && (
|
||||||
|
<>
|
||||||
|
{/* Yüzen düğme son içeriği örtmesin */}
|
||||||
|
<div className="h-16 md:hidden" aria-hidden />
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
size="lg"
|
||||||
|
className="fixed right-4 bottom-[calc(5rem+env(safe-area-inset-bottom))] z-20 h-14 rounded-full px-5 shadow-lg md:hidden"
|
||||||
|
>
|
||||||
|
<Link {...addEntryLink}>
|
||||||
|
<Plus aria-hidden />
|
||||||
|
{s.addEntry}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export function EntryForm({
|
|||||||
categories,
|
categories,
|
||||||
methods,
|
methods,
|
||||||
entry,
|
entry,
|
||||||
|
autoFocus = false,
|
||||||
onSaved,
|
onSaved,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: {
|
}: {
|
||||||
@@ -82,6 +83,8 @@ export function EntryForm({
|
|||||||
methods: CatalogItem[]
|
methods: CatalogItem[]
|
||||||
/** Düzenlenen kayıt */
|
/** Düzenlenen kayıt */
|
||||||
entry?: Entry
|
entry?: Entry
|
||||||
|
/** Yeni kayıtta açılışta tutar alanına odaklan */
|
||||||
|
autoFocus?: boolean
|
||||||
/** Kaydedilen kaydın tarihi (o haftaya geçmek için) */
|
/** Kaydedilen kaydın tarihi (o haftaya geçmek için) */
|
||||||
onSaved: (date: string) => void
|
onSaved: (date: string) => void
|
||||||
onCancel?: () => void
|
onCancel?: () => void
|
||||||
@@ -118,10 +121,10 @@ export function EntryForm({
|
|||||||
}
|
}
|
||||||
}, [entry, week, today, getValues, setValue])
|
}, [entry, week, today, getValues, setValue])
|
||||||
|
|
||||||
// Düzenlemeye geçince form görünür olsun ve tutara odaklansın
|
// Düzenlemeye geçince (ya da Panel'den gelince) tutara odaklan
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (entry) setFocus('amount')
|
if (entry || autoFocus) setFocus('amount')
|
||||||
}, [entry, setFocus])
|
}, [entry, autoFocus, setFocus])
|
||||||
|
|
||||||
function pickGroup(code: GroupCode) {
|
function pickGroup(code: GroupCode) {
|
||||||
if (code === getValues('group')) return
|
if (code === getValues('group')) return
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useRef, useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import { useSearchParams } from 'react-router'
|
import { useLocation, useSearchParams } from 'react-router'
|
||||||
import { Info } from 'lucide-react'
|
import { Info } from 'lucide-react'
|
||||||
import { PageError, PageHeader, PageLoading } from '@/components/page'
|
import { PageError, PageHeader, PageLoading } from '@/components/page'
|
||||||
import { PeriodPicker } from '@/features/periods/period-picker'
|
import { PeriodPicker } from '@/features/periods/period-picker'
|
||||||
@@ -40,6 +40,8 @@ function LedgerView({ period }: { period: Period }) {
|
|||||||
const [params, setParams] = useSearchParams()
|
const [params, setParams] = useSearchParams()
|
||||||
const [today] = useState(() => toIsoDate(new Date()))
|
const [today] = useState(() => toIsoDate(new Date()))
|
||||||
const [editing, setEditing] = useState<Entry | null>(null)
|
const [editing, setEditing] = useState<Entry | null>(null)
|
||||||
|
// Panel'deki "Harcama Ekle" → tutar alanına odaklan
|
||||||
|
const focusAmount = (useLocation().state as { focus?: boolean } | null)?.focus === true
|
||||||
const formRef = useRef<HTMLDivElement>(null)
|
const formRef = useRef<HTMLDivElement>(null)
|
||||||
const inPeriod = today >= period.start_date && today <= period.last_date
|
const inPeriod = today >= period.start_date && today <= period.last_date
|
||||||
const todayIndex = inPeriod ? weekIndexFor(period.weeks, today) : null
|
const todayIndex = inPeriod ? weekIndexFor(period.weeks, today) : null
|
||||||
@@ -108,6 +110,7 @@ function LedgerView({ period }: { period: Period }) {
|
|||||||
categories={catalog.c}
|
categories={catalog.c}
|
||||||
methods={catalog.m}
|
methods={catalog.m}
|
||||||
entry={editing ?? undefined}
|
entry={editing ?? undefined}
|
||||||
|
autoFocus={focusAmount}
|
||||||
onSaved={(date) => goTo(weekIndexFor(period.weeks, date))}
|
onSaved={(date) => goTo(weekIndexFor(period.weeks, date))}
|
||||||
onCancel={() => setEditing(null)}
|
onCancel={() => setEditing(null)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,16 +6,19 @@ import type { GroupBudget } from '@/types/GroupBudget'
|
|||||||
|
|
||||||
const s = t.ledger
|
const s = t.ledger
|
||||||
|
|
||||||
/** Seçili haftada grup bazlı bütçe / harcanan / kalan (+ ilerleme çubuğu) */
|
/**
|
||||||
export function WeekSummary({ budget, index }: { budget: GroupBudget[]; index: number }) {
|
* Grup bazlı bütçe / harcanan / kalan (+ ilerleme çubuğu): seçili hafta ya da `'month'` → tüm dönem.
|
||||||
|
* Defter (haftalık) ve Panel (haftalık/aylık) kullanır.
|
||||||
|
*/
|
||||||
|
export function WeekSummary({ budget, index }: { budget: GroupBudget[]; index: number | 'month' }) {
|
||||||
return (
|
return (
|
||||||
<div className="@container">
|
<div className="@container">
|
||||||
<ul className="grid gap-3 @lg:grid-cols-2">
|
<ul className="grid gap-3 @lg:grid-cols-2">
|
||||||
{budget.map((b) => {
|
{budget.map((b) => {
|
||||||
const g = group(b.group)
|
const g = group(b.group)
|
||||||
const w = b.weeks[index - 1]
|
const w = index === 'month' ? null : b.weeks[index - 1]
|
||||||
const planned = w?.amount ?? 0
|
const planned = index === 'month' ? b.monthly : (w?.amount ?? 0)
|
||||||
const spent = w?.spent ?? 0
|
const spent = index === 'month' ? b.spent : (w?.spent ?? 0)
|
||||||
const left = planned - spent
|
const left = planned - spent
|
||||||
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
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import type { Period } from '@/types/Period'
|
||||||
|
import { dailyAllowance, dashboardPeriod } from './dashboard'
|
||||||
|
|
||||||
|
const nov = { start_date: '2026-11-01', last_date: '2026-11-30', days: 30 }
|
||||||
|
|
||||||
|
describe('dashboard', () => {
|
||||||
|
it('picks the open period, else the latest one', () => {
|
||||||
|
const open = { id: 'o' } as Period
|
||||||
|
const last = { id: 'l' } as Period
|
||||||
|
expect(dashboardPeriod([last], open)).toBe(open)
|
||||||
|
expect(dashboardPeriod([last, { id: 'x' } as Period], null)).toBe(last)
|
||||||
|
expect(dashboardPeriod([], null)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('splits what is left over the remaining days', () => {
|
||||||
|
// 12.11 dahil 19 gün; 19.000 ₺ → günde 1.000 ₺
|
||||||
|
expect(dailyAllowance(1_900_000, nov, '2026-11-12')).toEqual({ daysLeft: 19, perDay: 100_000 })
|
||||||
|
expect(dailyAllowance(1_000, nov, '2026-11-30')).toEqual({ daysLeft: 1, perDay: 1_000 })
|
||||||
|
// kuruş aşağı yuvarlanır (önerilen tutar kalanı aşmasın)
|
||||||
|
expect(dailyAllowance(1_000, nov, '2026-11-28')).toEqual({ daysLeft: 3, perDay: 333 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has no daily suggestion when over budget or the period has ended', () => {
|
||||||
|
expect(dailyAllowance(-5_000, nov, '2026-11-12').perDay).toBeNull()
|
||||||
|
expect(dailyAllowance(0, nov, '2026-11-12').perDay).toBeNull()
|
||||||
|
expect(dailyAllowance(5_000, nov, '2026-12-01')).toEqual({ daysLeft: 0, perDay: null })
|
||||||
|
expect(dailyAllowance(3_000, nov, '2026-10-20')).toEqual({ daysLeft: 30, perDay: 100 })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Panel'in saf hesapları. Tutarlar backend'den gelir; burada yalnızca seçim ve yorum yapılır.
|
||||||
|
*/
|
||||||
|
import type { Period } from '@/types/Period'
|
||||||
|
import { daysInclusive } from './period-dates'
|
||||||
|
|
||||||
|
/** Panel'de gösterilecek dönem: açık dönem; yoksa en son dönem (salt okunur) */
|
||||||
|
export function dashboardPeriod(periods: Period[], current: Period | null): Period | null {
|
||||||
|
return current ?? periods[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anlık durum: kalan gün (bugün dahil) ve kalan tutarla günlük harcanabilir.
|
||||||
|
* Dönem başlamadıysa tüm günler, bittiyse 0 gün (günlük öneri yok).
|
||||||
|
*/
|
||||||
|
export function dailyAllowance(
|
||||||
|
remaining: number,
|
||||||
|
period: Pick<Period, 'start_date' | 'last_date' | 'days'>,
|
||||||
|
today: string,
|
||||||
|
): { daysLeft: number; perDay: number | null } {
|
||||||
|
const daysLeft =
|
||||||
|
today > period.last_date
|
||||||
|
? 0
|
||||||
|
: today < period.start_date
|
||||||
|
? period.days
|
||||||
|
: daysInclusive(today, period.last_date)
|
||||||
|
const perDay = daysLeft > 0 && remaining > 0 ? Math.floor(remaining / daysLeft) : null
|
||||||
|
return { daysLeft, perDay }
|
||||||
|
}
|
||||||
@@ -4,7 +4,13 @@
|
|||||||
* Kayıt değişince dönemin tüm sorguları yenilenir (`queryKeys.period(id)` öneki):
|
* Kayıt değişince dönemin tüm sorguları yenilenir (`queryKeys.period(id)` öneki):
|
||||||
* liste, özet (`spent_total`, `remaining`) ve bütçe (`spent`) birlikte güncellenir.
|
* liste, özet (`spent_total`, `remaining`) ve bütçe (`spent`) birlikte güncellenir.
|
||||||
*/
|
*/
|
||||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import {
|
||||||
|
keepPreviousData,
|
||||||
|
useMutation,
|
||||||
|
useQuery,
|
||||||
|
useQueryClient,
|
||||||
|
type QueryClient,
|
||||||
|
} from '@tanstack/react-query'
|
||||||
import type { Entry } from '@/types/Entry'
|
import type { Entry } from '@/types/Entry'
|
||||||
import type { EntryCreateRequest } from '@/types/EntryCreateRequest'
|
import type { EntryCreateRequest } from '@/types/EntryCreateRequest'
|
||||||
import type { EntryUpdateRequest } from '@/types/EntryUpdateRequest'
|
import type { EntryUpdateRequest } from '@/types/EntryUpdateRequest'
|
||||||
@@ -12,6 +18,14 @@ import { api } from './api'
|
|||||||
import { queryKeys } from './query'
|
import { queryKeys } from './query'
|
||||||
|
|
||||||
/** Bir tarih aralığının kayıtları (ikisi de dahil); en yeni başta */
|
/** Bir tarih aralığının kayıtları (ikisi de dahil); en yeni başta */
|
||||||
|
/** Kayıt değişince: dönemin tüm verileri + Panel istatistikleri (harcanan) */
|
||||||
|
function refresh(qc: QueryClient, periodId: string) {
|
||||||
|
return Promise.all([
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.period(periodId) }),
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.stats }),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
export function useEntries(periodId: string, range: { from: string; to: string }) {
|
export function useEntries(periodId: string, range: { from: string; to: string }) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.entries(periodId, range),
|
queryKey: queryKeys.entries(periodId, range),
|
||||||
@@ -26,7 +40,7 @@ export function useCreateEntry(periodId: string) {
|
|||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (body: EntryCreateRequest) => api.post<Entry>(`/periods/${periodId}/entries`, body),
|
mutationFn: (body: EntryCreateRequest) => api.post<Entry>(`/periods/${periodId}/entries`, body),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.period(periodId) }),
|
onSuccess: () => refresh(qc, periodId),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +50,7 @@ export function useUpdateEntry(periodId: string) {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ id, ...body }: EntryUpdateRequest & { id: string }) =>
|
mutationFn: ({ id, ...body }: EntryUpdateRequest & { id: string }) =>
|
||||||
api.patch<Entry>(`/entries/${id}`, body),
|
api.patch<Entry>(`/entries/${id}`, body),
|
||||||
onSettled: () => qc.invalidateQueries({ queryKey: queryKeys.period(periodId) }),
|
onSettled: () => refresh(qc, periodId),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +59,7 @@ export function useDeleteEntry(periodId: string) {
|
|||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (id: string) => api.delete(`/entries/${id}`),
|
mutationFn: (id: string) => api.delete(`/entries/${id}`),
|
||||||
onSettled: () => qc.invalidateQueries({ queryKey: queryKeys.period(periodId) }),
|
onSettled: () => refresh(qc, periodId),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +67,6 @@ export function useRestoreEntry(periodId: string) {
|
|||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (id: string) => api.post<Entry>(`/entries/${id}/restore`),
|
mutationFn: (id: string) => api.post<Entry>(`/entries/${id}/restore`),
|
||||||
onSettled: () => qc.invalidateQueries({ queryKey: queryKeys.period(periodId) }),
|
onSettled: () => refresh(qc, periodId),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type { PeriodCloseRequest } from '@/types/PeriodCloseRequest'
|
|||||||
import type { PeriodCloseResult } from '@/types/PeriodCloseResult'
|
import type { PeriodCloseResult } from '@/types/PeriodCloseResult'
|
||||||
import type { PeriodCreateRequest } from '@/types/PeriodCreateRequest'
|
import type { PeriodCreateRequest } from '@/types/PeriodCreateRequest'
|
||||||
import type { PeriodReview } from '@/types/PeriodReview'
|
import type { PeriodReview } from '@/types/PeriodReview'
|
||||||
|
import type { PeriodStats } from '@/types/PeriodStats'
|
||||||
import type { PeriodSummary } from '@/types/PeriodSummary'
|
import type { PeriodSummary } from '@/types/PeriodSummary'
|
||||||
import type { PeriodUpdateRequest } from '@/types/PeriodUpdateRequest'
|
import type { PeriodUpdateRequest } from '@/types/PeriodUpdateRequest'
|
||||||
import type { PlanItem } from '@/types/PlanItem'
|
import type { PlanItem } from '@/types/PlanItem'
|
||||||
@@ -137,6 +138,16 @@ export function useCopyPrevious(periodId: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Panel ----------
|
||||||
|
|
||||||
|
/** Son dönemler (eskiden yeniye) + toplam birikim */
|
||||||
|
export function useStats(limit = 12) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [...queryKeys.stats, limit],
|
||||||
|
queryFn: () => api.get<PeriodStats>(`/stats/periods?limit=${limit}`),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Özet ve bütçe ----------
|
// ---------- Özet ve bütçe ----------
|
||||||
|
|
||||||
export function useSummary(periodId: string) {
|
export function useSummary(periodId: string) {
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export const queryKeys = {
|
|||||||
/** Dönem listesi; `currentPeriod` da bu önekle başlar (birlikte yenilenir) */
|
/** Dönem listesi; `currentPeriod` da bu önekle başlar (birlikte yenilenir) */
|
||||||
periods: ['periods'] as const,
|
periods: ['periods'] as const,
|
||||||
currentPeriod: ['periods', 'current'] as const,
|
currentPeriod: ['periods', 'current'] as const,
|
||||||
|
/** Panel: son dönemler + toplam birikim (dönem listesiyle birlikte yenilenir) */
|
||||||
|
stats: ['periods', 'stats'] as const,
|
||||||
/** Bir döneme bağlı tüm veriler bu önekle başlar → `invalidateQueries({ queryKey: period(id) })` */
|
/** Bir döneme bağlı tüm veriler bu önekle başlar → `invalidateQueries({ queryKey: period(id) })` */
|
||||||
period: (id: string) => ['period', id] as const,
|
period: (id: string) => ['period', id] as const,
|
||||||
planItems: (id: string) => ['period', id, 'plan-items'] as const,
|
planItems: (id: string) => ['period', id, 'plan-items'] as const,
|
||||||
|
|||||||
@@ -352,6 +352,35 @@ export const t = {
|
|||||||
fixInPlan: 'Plan’da düzelt',
|
fixInPlan: 'Plan’da düzelt',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
dashboard: {
|
||||||
|
income: 'Toplam gelir',
|
||||||
|
fixed: 'Sabit giderler',
|
||||||
|
saved: 'Toplam birikim',
|
||||||
|
savedHint: 'Kapanan dönemlerde birikime ayrılanlar',
|
||||||
|
debt: 'Devreden borç',
|
||||||
|
debtHint: 'Önceki dönemden; harcanabilirden düşüldü',
|
||||||
|
groupsTitle: 'Planlanan / gerçekleşen',
|
||||||
|
weekly: 'Haftalık',
|
||||||
|
monthly: 'Aylık',
|
||||||
|
weekLabel: (n: number, range: string) => `${n}. hafta · ${range}`,
|
||||||
|
monthLabel: (range: string) => `Tüm dönem · ${range}`,
|
||||||
|
statusTitle: 'Anlık durum',
|
||||||
|
remaining: 'Kalan harcanabilir',
|
||||||
|
overspent: 'Harcanabilir aşıldı',
|
||||||
|
statusDetail: (spendable: string, spent: string) =>
|
||||||
|
`Harcanabilir ${spendable} · harcanan ${spent}`,
|
||||||
|
perDay: (days: number, x: string) => `${days} gün kaldı · günde en fazla ${x}`,
|
||||||
|
daysLeft: (days: number) => `${days} gün kaldı`,
|
||||||
|
ended: 'Dönem bitti; değerlendirip kapatabilirsin.',
|
||||||
|
toReview: 'Değerlendirmeye git',
|
||||||
|
noBudget: 'Harcanabilir tutar henüz gruplara dağıtılmamış.',
|
||||||
|
toPlan: 'Plan’a git',
|
||||||
|
noEntries: 'Henüz harcama yok — ilkini ekle ✍️',
|
||||||
|
addEntry: 'Harcama Ekle',
|
||||||
|
lastPeriod: (name: string) =>
|
||||||
|
`Açık dönem yok. Son dönem (${name}) gösteriliyor; değiştirilemez.`,
|
||||||
|
},
|
||||||
|
|
||||||
periods: {
|
periods: {
|
||||||
picker: 'Dönem',
|
picker: 'Dönem',
|
||||||
status: { open: 'Açık', closed: '🔒 Kapalı', correcting: '✏️ Düzeltmede' },
|
status: { open: 'Açık', closed: '🔒 Kapalı', correcting: '✏️ Düzeltmede' },
|
||||||
|
|||||||
Reference in New Issue
Block a user