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:
@@ -15,12 +15,39 @@ const components: Components = {
|
||||
<table {...props} />
|
||||
</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 (
|
||||
<div className={cn('markdown', className)}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={shiftHeadings ? shifted : components}>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -87,7 +87,7 @@ function LedgerView({ period }: { period: Period }) {
|
||||
)}
|
||||
<div className="grid items-start gap-4 lg:grid-cols-2">
|
||||
{!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}>
|
||||
{catalogFailed?.error ? (
|
||||
<PageError message={catalogFailed.error.message} onRetry={catalogFailed.refetch} />
|
||||
@@ -110,7 +110,8 @@ function LedgerView({ period }: { period: Period }) {
|
||||
</Section>
|
||||
</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}>
|
||||
{budget.isError ? (
|
||||
<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 { 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)}
|
||||
>
|
||||
<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}%` }}
|
||||
/>
|
||||
</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">
|
||||
{s.spent} <span className="money">{formatMoney(spent)}</span> / {s.budget}{' '}
|
||||
<span className="money">{formatMoney(planned)}</span>
|
||||
|
||||
@@ -54,7 +54,7 @@ export function Section({
|
||||
className?: string
|
||||
}) {
|
||||
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">
|
||||
<h3 className="font-semibold">{title}</h3>
|
||||
{action}
|
||||
|
||||
@@ -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() {
|
||||
<PageError message={failed.error.message} onRetry={failed.refetch} />
|
||||
) : !periods.data || !categories.data || !methods.data ? (
|
||||
<PageLoading />
|
||||
) : periods.data.length === 0 ? (
|
||||
<EmptyReports />
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* 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} />
|
||||
) : !breakdown.data ? (
|
||||
<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">
|
||||
<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}
|
||||
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"
|
||||
>
|
||||
<div className="mb-3 flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -65,7 +65,9 @@ function ReportView({ period }: { period: Period }) {
|
||||
</div>
|
||||
<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">
|
||||
<Markdown className="report">{report.data}</Markdown>
|
||||
<Markdown className="report" shiftHeadings>
|
||||
{report.data}
|
||||
</Markdown>
|
||||
</article>
|
||||
</div>
|
||||
)
|
||||
|
||||
+13
-10
@@ -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]) {
|
||||
|
||||
@@ -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', () => {
|
||||
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],
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user