feat(borç): borç takibi ekranları (Panel kartı, /borc, /taksitler)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-14 04:40:51 +03:00
parent 11402ba660
commit c063186e80
18 changed files with 1490 additions and 6 deletions
@@ -0,0 +1,125 @@
import { useRef, useState } from 'react'
import { CircleCheck, FileUp, Loader2 } from 'lucide-react'
import { toast } from 'sonner'
import { FormError } from '@/components/form-field'
import { Button } from '@/components/ui/button'
import { useDebtImport } from '@/lib/debt'
import { formatDate, formatMoney } from '@/lib/format'
import { formatMonth } from '@/lib/reports'
import { t } from '@/lib/strings'
import { cn } from '@/lib/utils'
import type { DebtImportReport } from '@/types/DebtImportReport'
const s = t.debt
/** Borç verisi dosyası → ön izleme → onay (aynı dosya iki kez eklenmez) */
export function ImportSection() {
const input = useRef<HTMLInputElement>(null)
const run = useDebtImport()
const [json, setJson] = useState<unknown>(null)
const [report, setReport] = useState<DebtImportReport | null>(null)
const [error, setError] = useState<string>()
async function choose(f: File) {
setError(undefined)
setReport(null)
try {
const parsed: unknown = JSON.parse(await f.text())
setJson(parsed)
setReport(await run.mutateAsync({ json: parsed, commit: false }))
} catch (e) {
setError(e instanceof SyntaxError ? 'Dosya okunamadı: geçerli bir JSON değil.' : String(e))
}
}
async function commit() {
try {
const r = await run.mutateAsync({ json, commit: true })
setReport(r)
toast.success(s.imported)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}
const fresh = report?.rows.filter((r) => r.status === 'new' || r.status === 'update').length ?? 0
const label = (kind: string, l: string) =>
kind === 'snapshot' ? formatDate(l) : kind === 'target' ? formatMonth(l) : l
return (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">{s.importHint}</p>
<input
ref={input}
id="debt-import"
type="file"
accept=".json,application/json"
className="sr-only"
onChange={(e) => {
const f = e.target.files?.[0]
if (f) void choose(f)
e.target.value = ''
}}
/>
<Button asChild variant="outline" disabled={run.isPending}>
<label htmlFor="debt-import" className="cursor-pointer">
<FileUp aria-hidden />
{s.chooseFile}
</label>
</Button>
<FormError message={error} />
{report && (
<div className="space-y-3" aria-live="polite">
{report.errors.length > 0 && (
<ul className="rounded-xl bg-expense/5 p-3 text-sm text-expense">
{report.errors.map((e) => (
<li key={e}>{e}</li>
))}
</ul>
)}
<div className="table-wrap overflow-x-auto">
<table className="w-full text-sm">
<tbody>
{report.rows.map((r, i) => (
<tr key={i} className="border-b last:border-0">
<td className="py-1.5 pr-3 text-muted-foreground">{s.kinds[r.kind]}</td>
<td className="py-1.5 pr-3">{label(r.kind, r.label)}</td>
<td className="money py-1.5 pr-3 text-right whitespace-nowrap">
{formatMoney(r.amount)}
</td>
<td
className={cn(
'py-1.5 whitespace-nowrap',
r.status === 'error'
? 'text-expense'
: r.status === 'exists'
? 'text-muted-foreground'
: 'font-medium',
)}
>
{s.statuses[r.status]}
{r.message && <div className="text-xs">{r.message}</div>}
</td>
</tr>
))}
</tbody>
</table>
</div>
{report.committed ? (
<p className="flex items-center gap-2 font-medium text-income">
<CircleCheck className="size-5" aria-hidden />
{s.imported}
</p>
) : fresh > 0 ? (
<Button onClick={() => void commit()} disabled={run.isPending}>
{run.isPending && <Loader2 className="animate-spin" aria-hidden />}
{s.importCommit(fresh)}
</Button>
) : (
<p className="text-sm text-muted-foreground">{s.nothingNew}</p>
)}
</div>
)}
</div>
)
}