feat(ui): ekstre içe aktarma sihirbazı

Yükleme ve geçmiş, hesap kurulumu, kontroller, düzenlenebilir önizleme
(süzgeçler, sayfalama, Kakeibo grubu) ve işleme / geri alma ekranı;
İşlemler sayfasına "İçe aktar" girişi.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 09:31:44 +03:00
parent ebcdf3f741
commit 5793490cb4
6 changed files with 853 additions and 1 deletions
+19
View File
@@ -6,6 +6,25 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır
## [Yayınlanmamış]
Faz 17a: Kakeibo K2 — ekstre içe aktarma altyapısı.
### Eklendi
- **Ekstre içe aktarma** (`/islemler/ice-aktar`, İşlemler sayfasında "İçe aktar"): Kakeibo çalışma kitabı (.xlsx; "Kredi Kartı" ve "Maaş Hesabı" sayfaları) yüklenir, önce taslak olarak önizlenir, sonra tek seferde işlenir.
- Kurulum adımı: dosyanın ihtiyaç duyduğu hesaplar (kredi kartı, maaş hesabı, nakit, ek hesap) mevcut hesaplardan seçilir ya da önerilen açılış bakiyesiyle tek tıkla oluşturulur (açılış bakiyesi dosyadaki devreden bakiyeden, kart borcu ilk kart ödemesinden).
- Kontroller: her ekstre için **ekstre toplamı = satır toplamı**, maaş hesabı bakiye zinciri ve son bakiye, daha önce aktarılmış satırlar, bekletilen altın satırları ve oluşturulacak kategoriler.
- Önizleme: satır başına tür (gider / gelir / atla), kategori ve Kakeibo grubu düzenlenebilir; "aynı açıklamalı tüm satırlara uygula"; İnceleme gereken / Grupsuz / Transfer / Atlanacak süzgeçleri.
- Muhasebe kuralları: kart ödemesi, ATM çekimi ve hesaplar arası havale transfer (çift sayma yok); kampanya iadesi ve iadeler gelir ("İade"); KMH faizi, KKDF/BSMV ve banka ücretleri banka maliyeti; taksitler ekstreye yansıdığı kesim tarihinde, ortak taksit planıyla; aynı gün çıkan promosyon çifti atlanır.
- Tekrar engeli: her satırın parmak izi saklanır; aynı dosya yeniden yüklendiğinde aktarılmış satırlar atlanır. **Toplu geri alma** aktarmanın tüm işlemlerini siler ve dosya yeniden aktarılabilir hale gelir.
- Eksik Kakeibo kategorileri (Sigorta, Online alışveriş, Elektronik ve hobi, Kitap ve kültür; Prim, Yol parası, Avans, Eş katkısı, Aile desteği) işlenirken oluşturulur.
- Migration `017-imports`: aktarma partileri ve işlem parmak izi dizinleri (geri alınabilir).
- Testler: çalışma kitabı ayrıştırma, taksit metni, ekstre ve bakiye kontrolleri (birim); taslak, hesap oluşturma, toplu düzenleme, işleme, bakiyeler, tekrar yükleme ve geri alma (entegrasyon).
### Bilinen sınırlamalar
- Altın alım/satım satırları Faz 17b'de altın hesabı dosyasıyla eşleştirilene dek bekletilir; o zamana dek maaş hesabı bakiyesi bu satırların neti kadar farklı görünür.
- Yapı Kredi "Güncel Dönem" (.xls) ve Ek Hesap / Altın Hesabı (PDF) dosyaları Faz 17b'de.
## [1.1.0] - 2026-11-07
Faz 16: Kakeibo K1 — dört harcama grubu.
@@ -0,0 +1,17 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { ImportWizard } from "@/features/imports/components/import-wizard";
import { getImportDraft } from "@/features/imports/service";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
export const metadata: Metadata = { title: tr.imports.title };
/** /islemler/ice-aktar/:id — aktarma taslağı ya da işlenmiş aktarma. */
export default async function ImportPage({ params }: PageProps<"/islemler/ice-aktar/[id]">) {
const { user, household } = await requireActiveHousehold("member");
const { id } = await params;
const draft = await getImportDraft(user.id, household.id, id);
if (!draft) notFound();
return <ImportWizard key={`${draft.id}-${draft.status}`} draft={draft} />;
}
+67
View File
@@ -0,0 +1,67 @@
import { ArrowLeft, ChevronRight } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { PageHeader } from "@/components/page-header";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { UploadCard } from "@/features/imports/components/upload-card";
import { listImports } from "@/features/imports/service";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
import { formatDateTime } from "@/shared/format";
const t = tr.imports;
export const metadata: Metadata = { title: t.title };
/** /islemler/ice-aktar — Kakeibo çalışma kitabı yükleme ve aktarma geçmişi. */
export default async function ImportsPage() {
const { user, household } = await requireActiveHousehold("member");
const list = await listImports(user.id, household.id);
if (!list) notFound();
return (
<>
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
<Link href="/islemler">
<ArrowLeft />
{tr.nav.transactions}
</Link>
</Button>
<PageHeader title={t.title} description={t.description} />
<UploadCard />
<section aria-labelledby="imp-h" className="flex flex-col gap-2">
<h2 id="imp-h" className="text-sm font-medium text-muted-foreground">
{t.history}
</h2>
{list.length ? (
<ul className="divide-y rounded-xl border bg-card">
{list.map((b) => (
<li key={b.id}>
<Link
href={`/islemler/ice-aktar/${b.id}`}
className="flex items-center gap-3 p-3 text-sm hover:bg-accent/40"
>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{b.fileName}</span>
<span className="block text-xs text-muted-foreground">
{formatDateTime(b.createdAt)} · {t.rowsCount(b.rowCount)}
{b.committed ? ` · ${t.committed(b.committed.transactions)}` : ""}
</span>
</span>
<Badge variant={b.status === "committed" ? "default" : "secondary"}>
{t.statuses[b.status]}
</Badge>
<ChevronRight className="size-4 text-muted-foreground" aria-hidden />
</Link>
</li>
))}
</ul>
) : (
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
{t.empty}
</p>
)}
</section>
</>
);
}
@@ -0,0 +1,678 @@
"use client";
import {
AlertTriangle,
ArrowLeft,
CheckCircle2,
Copy,
Loader2,
Plus,
Undo2,
XCircle,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { PageHeader } from "@/components/page-header";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { KakeiboSelect } from "@/features/transactions/components/kakeibo-select";
import { tr } from "@/i18n/tr";
import { cn } from "@/lib/utils";
import type { AccountType } from "@/shared/accounts";
import { formatDate, formatMoney } from "@/shared/format";
import type { ImportRole } from "@/shared/import/kakeibo-workbook";
import type { KakeiboGroup } from "@/shared/kakeibo";
import { formatMoneyInput } from "@/shared/money";
import {
commitImportAction,
createImportAccountAction,
deleteImportDraftAction,
revertImportAction,
setImportAccountAction,
updateImportRowAction,
} from "../actions";
import type { ImportDraft, ImportRowView } from "../service";
const t = tr.imports;
const PAGE = 50;
const ROLE_TYPES: Record<string, AccountType[]> = {
card: ["credit_card"],
bank: ["bank"],
cash: ["cash"],
extra: ["bank", "cash"],
};
type Filter = "all" | "review" | "ungrouped" | "transfer" | "skip";
const day = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
function Step({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="flex flex-col gap-3 rounded-xl border bg-card p-4">
<h2 className="font-medium">{title}</h2>
{children}
</section>
);
}
function RoleRow({ draft, role }: { draft: ImportDraft; role: ImportRole }) {
const router = useRouter();
const preset = draft.presets[role];
const current = draft.accounts[role] ?? "";
const options = draft.accountOptions.filter((a) => ROLE_TYPES[role]?.includes(a.type));
const [creating, setCreating] = useState(false);
const [busy, setBusy] = useState(false);
const isCard = role === "card";
const [v, setV] = useState({
name: preset?.name ?? "",
opening: preset ? formatMoneyInput(Math.abs(preset.openingMinor)) : "",
sign: (preset?.openingMinor ?? 0) < 0 ? "-" : "",
openingDate: preset?.openingDate ?? "",
});
async function pick(accountId: string) {
const r = await setImportAccountAction(draft.id, role, accountId);
if (!r.ok) toast.error(r.error);
else router.refresh();
}
async function create() {
setBusy(true);
const r = await createImportAccountAction(draft.id, role, {
name: v.name,
opening: `${v.sign}${v.opening}`,
openingDate: v.openingDate,
});
setBusy(false);
if (!r.ok) toast.error(r.error);
else {
setCreating(false);
router.refresh();
}
}
return (
<li className="flex flex-col gap-2 py-3 first:pt-0 last:pb-0">
<div className="flex flex-wrap items-center gap-3">
<div className="min-w-0 flex-1">
<p className="flex items-center gap-1.5 text-sm font-medium">
{current ? (
<CheckCircle2 className="size-4 text-income" aria-hidden />
) : (
<AlertTriangle className="size-4 text-amber-600" aria-hidden />
)}
{t.roles[role]}
</p>
<p className="text-xs text-muted-foreground">{t.roleHints[role]}</p>
</div>
<Select value={current || undefined} onValueChange={pick}>
<SelectTrigger className="w-56" aria-label={t.roles[role]}>
<SelectValue placeholder={t.selectAccount} />
</SelectTrigger>
<SelectContent>
{options.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.name}
</SelectItem>
))}
</SelectContent>
</Select>
{draft.canCreateAccounts && preset && !current ? (
<Button size="sm" variant="outline" onClick={() => setCreating((x) => !x)}>
<Plus />
{t.createSuggested}
</Button>
) : null}
</div>
{creating && preset ? (
<div className="grid gap-3 rounded-lg border bg-muted/30 p-3 sm:grid-cols-3">
<Field>
<FieldLabel htmlFor={`acc-${role}-name`}>{t.accountName}</FieldLabel>
<Input
id={`acc-${role}-name`}
value={v.name}
onChange={(e) => setV((s) => ({ ...s, name: e.target.value }))}
/>
</Field>
<Field>
<FieldLabel htmlFor={`acc-${role}-open`}>
{isCard ? t.openingDebt : t.openingBalance}
</FieldLabel>
<div className="flex gap-1">
{!isCard ? (
<Button
type="button"
variant="outline"
size="icon"
aria-label="±"
onClick={() => setV((s) => ({ ...s, sign: s.sign ? "" : "-" }))}
>
{v.sign ? "−" : "+"}
</Button>
) : null}
<Input
id={`acc-${role}-open`}
inputMode="decimal"
value={v.opening}
onChange={(e) => setV((s) => ({ ...s, opening: e.target.value }))}
className="text-right tabular-nums"
/>
</div>
</Field>
<Field>
<FieldLabel htmlFor={`acc-${role}-date`}>{t.openingDate}</FieldLabel>
<Input
id={`acc-${role}-date`}
type="date"
value={v.openingDate}
onChange={(e) => setV((s) => ({ ...s, openingDate: e.target.value }))}
/>
</Field>
<FieldDescription className="sm:col-span-2">
{isCard ? t.openingHintCard : role === "bank" ? t.openingHintBank : ""}
</FieldDescription>
<Button className="sm:justify-self-end" onClick={create} disabled={busy}>
{busy ? <Loader2 className="animate-spin" /> : null}
{tr.common.create}
</Button>
</div>
) : null}
</li>
);
}
function RowItem({
row,
draft,
onEdit,
}: {
row: ImportRowView;
draft: ImportDraft;
onEdit: (
row: ImportRowView,
patch: Partial<Pick<ImportRowView, "kind" | "categoryId" | "kakeiboGroup">>,
) => void;
}) {
const cats = draft.categories.filter(
(c) => c.kind === (row.kind === "income" ? "income" : "expense"),
);
const cat = draft.categories.find((c) => c.id === row.categoryId);
const editable =
!row.duplicate && (row.kind === "expense" || row.kind === "income" || row.kind === "skip");
const muted = row.duplicate || row.kind === "skip" || row.kind === "deferred";
return (
<li
className={cn(
"flex flex-col gap-2 p-3 text-sm",
muted && "bg-muted/30 text-muted-foreground",
)}
>
<div className="flex items-start gap-3">
<span className="w-20 shrink-0 tabular-nums">{day(row.date)}</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{row.description || "—"}</span>
<span className="mt-0.5 flex flex-wrap gap-1">
{row.installment ? (
<Badge variant="outline">
{t.installment(row.installment.index, row.installment.count)}
</Badge>
) : null}
{row.duplicate ? (
<Badge variant="secondary" className="gap-1">
<Copy className="size-3" aria-hidden />
{t.duplicate}
</Badge>
) : null}
{row.review && !row.duplicate ? (
<Badge
variant="outline"
className="gap-1 border-amber-500/50 text-amber-700 dark:text-amber-400"
>
<AlertTriangle className="size-3" aria-hidden />
{t.reviews[row.review] ?? row.review}
</Badge>
) : null}
{row.source.category || row.source.type ? (
<span className="text-xs text-muted-foreground">
{row.source.category || row.source.type}
</span>
) : null}
</span>
</span>
<span
className={cn(
"shrink-0 text-right font-medium tabular-nums",
row.amountMinor < 0 ? "" : "text-income",
)}
>
{row.amountMinor < 0 ? "−" : "+"}
{formatMoney(Math.abs(row.amountMinor))}
</span>
</div>
{row.kind === "transfer" ? (
<p className="pl-23 text-xs">
{t.kinds.transfer}{" "}
{row.amountMinor < 0
? t.transferTo(t.roles[row.counterRole ?? ""] ?? "")
: t.transferFrom(t.roles[row.counterRole ?? ""] ?? "")}
</p>
) : row.kind === "deferred" ? (
<p className="pl-23 text-xs">
{t.kinds.deferred}: {t.roles.gold}
</p>
) : editable ? (
<div className="grid gap-2 sm:grid-cols-[8rem_minmax(0,1fr)_12rem] sm:pl-23">
<Select
value={row.kind}
onValueChange={(k) => onEdit(row, { kind: k as ImportRowView["kind"] })}
>
<SelectTrigger className="h-8 w-full text-sm" aria-label={t.columns.kind}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(["expense", "income", "skip"] as const).map((k) => (
<SelectItem key={k} value={k}>
{t.kinds[k]}
</SelectItem>
))}
</SelectContent>
</Select>
{row.kind !== "skip" ? (
<Select
value={row.categoryId ?? undefined}
onValueChange={(c) => onEdit(row, { categoryId: c })}
>
<SelectTrigger className="h-8 w-full text-sm" aria-label={t.columns.category}>
<SelectValue placeholder={tr.transactions.fields.selectCategory} />
</SelectTrigger>
<SelectContent className="max-h-72">
{cats.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.path}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span />
)}
{row.kind === "expense" ? (
<KakeiboSelect
compact
value={row.kakeiboGroup && row.kakeiboGroup !== cat?.group ? row.kakeiboGroup : ""}
onChange={(g) =>
onEdit(row, { kakeiboGroup: (g || cat?.group || null) as KakeiboGroup | null })
}
suggested={cat?.group ?? null}
bankCost={cat?.bankCost ?? false}
ariaLabel={t.columns.group}
/>
) : null}
</div>
) : null}
</li>
);
}
/** /islemler/ice-aktar/:id — hesaplar, kontroller, önizleme ve işleme. */
export function ImportWizard({ draft }: { draft: ImportDraft }) {
const router = useRouter();
const [rows, setRows] = useState(draft.rows);
const [sheet, setSheet] = useState<"card" | "bank">(
draft.rows.some((r) => r.sheet === "card") ? "card" : "bank",
);
const [filter, setFilter] = useState<Filter>("all");
const [page, setPage] = useState(0);
const [applyToSame, setApplyToSame] = useState(true);
const [busy, setBusy] = useState(false);
const [confirmRevert, setConfirmRevert] = useState(false);
const isDraft = draft.status === "draft";
const catOf = (id: string | null) => draft.categories.find((c) => c.id === id);
const visible = useMemo(() => {
const ofSheet = rows.filter((r) => r.sheet === sheet);
switch (filter) {
case "review":
return ofSheet.filter((r) => r.review && !r.duplicate);
case "ungrouped":
return ofSheet.filter(
(r) =>
r.kind === "expense" &&
!r.duplicate &&
!(r.kakeiboGroup ?? catOf(r.categoryId)?.group) &&
!catOf(r.categoryId)?.bankCost,
);
case "transfer":
return ofSheet.filter((r) => r.kind === "transfer");
case "skip":
return ofSheet.filter((r) => r.kind === "skip" || r.kind === "deferred" || r.duplicate);
default:
return ofSheet;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rows, sheet, filter]);
const pageRows = visible.slice(page * PAGE, page * PAGE + PAGE);
const toCreate = rows.filter((r) => !r.duplicate && r.kind !== "skip" && r.kind !== "deferred");
const missingCategory = toCreate.filter(
(r) => (r.kind === "expense" || r.kind === "income") && !r.categoryId,
);
const missingRoles = draft.neededRoles.filter((r) => !draft.accounts[r]);
const deferredRows = rows.filter((r) => r.kind === "deferred");
const deferred = deferredRows.length;
const deferredNet = deferredRows.reduce((sum, r) => sum + r.amountMinor, 0);
const duplicates = rows.filter((r) => r.duplicate).length;
const okStatements = draft.checks.statements.filter((s) => s.ok).length;
async function edit(
row: ImportRowView,
patch: Partial<Pick<ImportRowView, "kind" | "categoryId" | "kakeiboGroup">>,
) {
const same = (x: ImportRowView) =>
applyToSame
? x.sheet === row.sheet &&
x.description.toLocaleLowerCase("tr-TR") === row.description.toLocaleLowerCase("tr-TR") &&
x.kind === row.kind
: x.id === row.id;
const before = rows;
setRows((rs) => rs.map((x) => (same(x) ? { ...x, ...patch, edited: true } : x)));
const r = await updateImportRowAction(draft.id, { rowId: row.id, ...patch, applyToSame });
if (!r.ok) {
setRows(before);
toast.error(r.error);
}
}
async function commit() {
setBusy(true);
const r = await commitImportAction(draft.id);
setBusy(false);
if (!r.ok) toast.error(r.error);
else {
toast.success(t.committed(r.data.transactions));
router.refresh();
}
}
async function revert() {
setConfirmRevert(false);
setBusy(true);
const r = await revertImportAction(draft.id);
setBusy(false);
if (!r.ok) toast.error(r.error);
else {
toast.success(t.reverted(r.data.transactions));
router.refresh();
}
}
async function removeDraft() {
const r = await deleteImportDraftAction(draft.id);
if (!r.ok) toast.error(r.error);
else router.push("/islemler/ice-aktar");
}
const filters: Filter[] = ["all", "review", "ungrouped", "transfer", "skip"];
return (
<div className="flex flex-col gap-4">
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
<Link href="/islemler/ice-aktar">
<ArrowLeft />
{t.back}
</Link>
</Button>
<PageHeader
title={draft.fileName}
description={
<>
{t.statuses[draft.status]} · {t.rowsCount(draft.rowCount)}
</>
}
/>
{!isDraft ? (
<Step title={t.statuses[draft.status]!}>
{draft.committed ? (
<p className="text-sm">
{t.committedSummary(
draft.committed.transactions,
draft.committed.skipped,
draft.committed.categoriesCreated,
)}
</p>
) : null}
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" asChild>
<Link href="/islemler">{t.goTransactions}</Link>
</Button>
{draft.status === "committed" ? (
<Button
variant="destructive"
size="sm"
onClick={() => setConfirmRevert(true)}
disabled={busy}
>
<Undo2 />
{t.revert}
</Button>
) : null}
</div>
</Step>
) : null}
{isDraft && draft.neededRoles.length ? (
<Step title={t.steps.accounts}>
{!draft.canCreateAccounts ? (
<p className="text-xs text-muted-foreground">{t.ownerOnly}</p>
) : null}
<ul className="divide-y">
{draft.neededRoles.map((role) => (
<RoleRow key={role} draft={draft} role={role} />
))}
</ul>
</Step>
) : null}
<Step title={t.steps.checks}>
<ul className="flex flex-col gap-1.5 text-sm">
{draft.checks.statements.length ? (
<li className="flex items-start gap-2">
{okStatements === draft.checks.statements.length ? (
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-income" aria-hidden />
) : (
<XCircle className="mt-0.5 size-4 shrink-0 text-expense" aria-hidden />
)}
<span>
{t.statementsSummary(okStatements, draft.checks.statements.length)}
{draft.checks.statements
.filter((s) => !s.ok)
.map((s) => (
<span key={s.cut} className="block text-xs text-expense">
{t.statementBad(
day(s.cut),
formatMoney(s.rowsMinor),
s.statementMinor === null ? "—" : formatMoney(s.statementMinor),
)}
</span>
))}
</span>
</li>
) : (
<li className="text-muted-foreground">{t.noStatements}</li>
)}
{draft.checks.balance.closingMinor !== null ? (
<li className="flex items-start gap-2">
{draft.checks.balance.mismatchLine === null ? (
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-income" aria-hidden />
) : (
<XCircle className="mt-0.5 size-4 shrink-0 text-expense" aria-hidden />
)}
{draft.checks.balance.mismatchLine === null
? t.balanceOk(formatMoney(draft.checks.balance.closingMinor))
: t.balanceBad(draft.checks.balance.mismatchLine)}
</li>
) : null}
{duplicates ? (
<li className="text-muted-foreground">{t.duplicates(duplicates)}</li>
) : null}
{deferred ? (
<li className="text-muted-foreground">
{t.deferred(
deferred,
`${deferredNet < 0 ? "−" : "+"}${formatMoney(Math.abs(deferredNet))}`,
)}
</li>
) : null}
{isDraft && draft.newCategories.length ? (
<li className="text-muted-foreground">
{t.newCategories(draft.newCategories.join(", "))}
</li>
) : null}
</ul>
</Step>
<Step title={t.steps.rows}>
<div className="flex flex-wrap items-center gap-2">
<Tabs
value={sheet}
onValueChange={(x) => {
setSheet(x as "card" | "bank");
setPage(0);
}}
>
<TabsList>
{(["card", "bank"] as const)
.filter((s) => rows.some((r) => r.sheet === s))
.map((s) => (
<TabsTrigger key={s} value={s}>
{t.sheets[s]} ({rows.filter((r) => r.sheet === s).length})
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className="flex flex-wrap gap-1">
{filters.map((f) => (
<Button
key={f}
size="sm"
variant={filter === f ? "secondary" : "ghost"}
onClick={() => {
setFilter(f);
setPage(0);
}}
>
{t.filters[f]}
</Button>
))}
</div>
{isDraft ? (
<label className="ml-auto flex items-center gap-2 text-sm">
<Checkbox checked={applyToSame} onCheckedChange={(x) => setApplyToSame(x === true)} />
{t.applyToSame}
</label>
) : null}
</div>
<ul
className={cn("divide-y rounded-lg border", !isDraft && "pointer-events-none opacity-90")}
>
{pageRows.map((r) => (
<RowItem key={r.id} row={r} draft={draft} onEdit={edit} />
))}
{!pageRows.length ? (
<li className="p-6 text-center text-sm text-muted-foreground">{t.empty}</li>
) : null}
</ul>
{visible.length > PAGE ? (
<div className="flex items-center justify-end gap-2 text-sm">
<span className="text-muted-foreground tabular-nums">
{t.page(page * PAGE + 1, Math.min(visible.length, (page + 1) * PAGE), visible.length)}
</span>
<Button
size="sm"
variant="outline"
disabled={page === 0}
onClick={() => setPage((p) => p - 1)}
>
{t.prev}
</Button>
<Button
size="sm"
variant="outline"
disabled={(page + 1) * PAGE >= visible.length}
onClick={() => setPage((p) => p + 1)}
>
{t.next}
</Button>
</div>
) : null}
</Step>
{isDraft ? (
<Step title={t.steps.commit}>
<p className="text-sm">
{t.commitSummary(toCreate.length, rows.length - toCreate.length)}
</p>
{missingRoles.map((r) => (
<p key={r} className="text-sm text-expense">
{t.errors.roleMissing(t.roles[r] ?? r)}
</p>
))}
{missingCategory.length ? (
<p className="text-sm text-expense">
{t.errors.category(String(missingCategory.length))}
</p>
) : null}
<p className="text-xs text-muted-foreground">{t.commitHint}</p>
<div className="flex flex-wrap gap-2">
<Button
onClick={commit}
disabled={busy || missingRoles.length > 0 || missingCategory.length > 0}
>
{busy ? <Loader2 className="animate-spin" /> : null}
{busy ? t.committing : t.commit}
</Button>
<Button variant="ghost" onClick={removeDraft} disabled={busy}>
{t.deleteDraft}
</Button>
</div>
</Step>
) : null}
<AlertDialog open={confirmRevert} onOpenChange={setConfirmRevert}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t.revertTitle}</AlertDialogTitle>
<AlertDialogDescription>{t.revertDescription}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
<Button variant="destructive" onClick={revert}>
{t.revert}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -0,0 +1,54 @@
"use client";
import { FileSpreadsheet, Loader2, Upload } from "lucide-react";
import { useRouter } from "next/navigation";
import { useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { tr } from "@/i18n/tr";
import { uploadImportAction } from "../actions";
const t = tr.imports;
/** Kakeibo çalışma kitabı yükleme: dosya seçilince okunur ve taslağa gidilir. */
export function UploadCard() {
const router = useRouter();
const input = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState(false);
async function onPick(file: File | undefined) {
if (!file) return;
setBusy(true);
const form = new FormData();
form.set("file", file);
const r = await uploadImportAction(form);
setBusy(false);
if (input.current) input.current.value = "";
if (!r.ok) {
toast.error(r.error);
return;
}
router.push(`/islemler/ice-aktar/${r.data.id}`);
}
return (
<section className="flex flex-col items-start gap-3 rounded-xl border border-dashed bg-card p-5 sm:flex-row sm:items-center">
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted">
<FileSpreadsheet className="size-5 text-muted-foreground" aria-hidden />
</span>
<p className="min-w-0 flex-1 text-sm text-muted-foreground">{t.uploadHint}</p>
<input
ref={input}
type="file"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
className="sr-only"
id="import-file"
onChange={(e) => onPick(e.target.files?.[0])}
/>
<Button onClick={() => input.current?.click()} disabled={busy}>
{busy ? <Loader2 className="animate-spin" /> : <Upload />}
{busy ? t.uploading : t.upload}
</Button>
</section>
);
}
@@ -1,6 +1,15 @@
"use client";
import { ChevronLeft, ChevronRight, Download, Filter, Paperclip, Plus, X } from "lucide-react";
import {
ChevronLeft,
ChevronRight,
Download,
Filter,
Paperclip,
Plus,
Upload,
X,
} from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useState } from "react";
@@ -279,6 +288,14 @@ export function TransactionsView({
<span className="hidden sm:inline">{t.export}</span>
</a>
</Button>
{canWrite ? (
<Button variant="ghost" asChild>
<Link href="/islemler/ice-aktar">
<Upload />
<span className="hidden sm:inline">{t.import}</span>
</Link>
</Button>
) : null}
{canWrite ? (
<Button onClick={() => openQuickAdd()}>
<Plus />