From 5793490cb403fe66b402c47b90a704e97b51d363 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Sat, 7 Nov 2026 09:31:44 +0300 Subject: [PATCH] =?UTF-8?q?feat(ui):=20ekstre=20i=C3=A7e=20aktarma=20sihir?= =?UTF-8?q?baz=C4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 19 + .../(app)/islemler/ice-aktar/[id]/page.tsx | 17 + src/app/(app)/islemler/ice-aktar/page.tsx | 67 ++ .../imports/components/import-wizard.tsx | 678 ++++++++++++++++++ .../imports/components/upload-card.tsx | 54 ++ .../components/transactions-view.tsx | 19 +- 6 files changed, 853 insertions(+), 1 deletion(-) create mode 100644 src/app/(app)/islemler/ice-aktar/[id]/page.tsx create mode 100644 src/app/(app)/islemler/ice-aktar/page.tsx create mode 100644 src/features/imports/components/import-wizard.tsx create mode 100644 src/features/imports/components/upload-card.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a3606e..24bac95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/app/(app)/islemler/ice-aktar/[id]/page.tsx b/src/app/(app)/islemler/ice-aktar/[id]/page.tsx new file mode 100644 index 0000000..283f954 --- /dev/null +++ b/src/app/(app)/islemler/ice-aktar/[id]/page.tsx @@ -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 ; +} diff --git a/src/app/(app)/islemler/ice-aktar/page.tsx b/src/app/(app)/islemler/ice-aktar/page.tsx new file mode 100644 index 0000000..0395834 --- /dev/null +++ b/src/app/(app)/islemler/ice-aktar/page.tsx @@ -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 ( + <> + + + +
+

+ {t.history} +

+ {list.length ? ( +
    + {list.map((b) => ( +
  • + + + {b.fileName} + + {formatDateTime(b.createdAt)} · {t.rowsCount(b.rowCount)} + {b.committed ? ` · ${t.committed(b.committed.transactions)}` : ""} + + + + {t.statuses[b.status]} + + + +
  • + ))} +
+ ) : ( +

+ {t.empty} +

+ )} +
+ + ); +} diff --git a/src/features/imports/components/import-wizard.tsx b/src/features/imports/components/import-wizard.tsx new file mode 100644 index 0000000..9ff8d21 --- /dev/null +++ b/src/features/imports/components/import-wizard.tsx @@ -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 = { + 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 ( +
+

{title}

+ {children} +
+ ); +} + +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 ( +
  • +
    +
    +

    + {current ? ( + + ) : ( + + )} + {t.roles[role]} +

    +

    {t.roleHints[role]}

    +
    + + {draft.canCreateAccounts && preset && !current ? ( + + ) : null} +
    + {creating && preset ? ( +
    + + {t.accountName} + setV((s) => ({ ...s, name: e.target.value }))} + /> + + + + {isCard ? t.openingDebt : t.openingBalance} + +
    + {!isCard ? ( + + ) : null} + setV((s) => ({ ...s, opening: e.target.value }))} + className="text-right tabular-nums" + /> +
    +
    + + {t.openingDate} + setV((s) => ({ ...s, openingDate: e.target.value }))} + /> + + + {isCard ? t.openingHintCard : role === "bank" ? t.openingHintBank : ""} + + +
    + ) : null} +
  • + ); +} + +function RowItem({ + row, + draft, + onEdit, +}: { + row: ImportRowView; + draft: ImportDraft; + onEdit: ( + row: ImportRowView, + patch: Partial>, + ) => 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 ( +
  • +
    + {day(row.date)} + + {row.description || "—"} + + {row.installment ? ( + + {t.installment(row.installment.index, row.installment.count)} + + ) : null} + {row.duplicate ? ( + + + {t.duplicate} + + ) : null} + {row.review && !row.duplicate ? ( + + + {t.reviews[row.review] ?? row.review} + + ) : null} + {row.source.category || row.source.type ? ( + + {row.source.category || row.source.type} + + ) : null} + + + + {row.amountMinor < 0 ? "−" : "+"} + {formatMoney(Math.abs(row.amountMinor))} + +
    + {row.kind === "transfer" ? ( +

    + {t.kinds.transfer}{" "} + {row.amountMinor < 0 + ? t.transferTo(t.roles[row.counterRole ?? ""] ?? "") + : t.transferFrom(t.roles[row.counterRole ?? ""] ?? "")} +

    + ) : row.kind === "deferred" ? ( +

    + {t.kinds.deferred}: {t.roles.gold} +

    + ) : editable ? ( +
    + + {row.kind !== "skip" ? ( + + ) : ( + + )} + {row.kind === "expense" ? ( + + onEdit(row, { kakeiboGroup: (g || cat?.group || null) as KakeiboGroup | null }) + } + suggested={cat?.group ?? null} + bankCost={cat?.bankCost ?? false} + ariaLabel={t.columns.group} + /> + ) : null} +
    + ) : null} +
  • + ); +} + +/** /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("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>, + ) { + 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 ( +
    + + + {t.statuses[draft.status]} · {t.rowsCount(draft.rowCount)} + + } + /> + + {!isDraft ? ( + + {draft.committed ? ( +

    + {t.committedSummary( + draft.committed.transactions, + draft.committed.skipped, + draft.committed.categoriesCreated, + )} +

    + ) : null} +
    + + {draft.status === "committed" ? ( + + ) : null} +
    +
    + ) : null} + + {isDraft && draft.neededRoles.length ? ( + + {!draft.canCreateAccounts ? ( +

    {t.ownerOnly}

    + ) : null} +
      + {draft.neededRoles.map((role) => ( + + ))} +
    +
    + ) : null} + + +
      + {draft.checks.statements.length ? ( +
    • + {okStatements === draft.checks.statements.length ? ( + + ) : ( + + )} + + {t.statementsSummary(okStatements, draft.checks.statements.length)} + {draft.checks.statements + .filter((s) => !s.ok) + .map((s) => ( + + {t.statementBad( + day(s.cut), + formatMoney(s.rowsMinor), + s.statementMinor === null ? "—" : formatMoney(s.statementMinor), + )} + + ))} + +
    • + ) : ( +
    • {t.noStatements}
    • + )} + {draft.checks.balance.closingMinor !== null ? ( +
    • + {draft.checks.balance.mismatchLine === null ? ( + + ) : ( + + )} + {draft.checks.balance.mismatchLine === null + ? t.balanceOk(formatMoney(draft.checks.balance.closingMinor)) + : t.balanceBad(draft.checks.balance.mismatchLine)} +
    • + ) : null} + {duplicates ? ( +
    • {t.duplicates(duplicates)}
    • + ) : null} + {deferred ? ( +
    • + {t.deferred( + deferred, + `${deferredNet < 0 ? "−" : "+"}${formatMoney(Math.abs(deferredNet))}`, + )} +
    • + ) : null} + {isDraft && draft.newCategories.length ? ( +
    • + {t.newCategories(draft.newCategories.join(", "))} +
    • + ) : null} +
    +
    + + +
    + { + setSheet(x as "card" | "bank"); + setPage(0); + }} + > + + {(["card", "bank"] as const) + .filter((s) => rows.some((r) => r.sheet === s)) + .map((s) => ( + + {t.sheets[s]} ({rows.filter((r) => r.sheet === s).length}) + + ))} + + +
    + {filters.map((f) => ( + + ))} +
    + {isDraft ? ( + + ) : null} +
    +
      + {pageRows.map((r) => ( + + ))} + {!pageRows.length ? ( +
    • {t.empty}
    • + ) : null} +
    + {visible.length > PAGE ? ( +
    + + {t.page(page * PAGE + 1, Math.min(visible.length, (page + 1) * PAGE), visible.length)} + + + +
    + ) : null} +
    + + {isDraft ? ( + +

    + {t.commitSummary(toCreate.length, rows.length - toCreate.length)} +

    + {missingRoles.map((r) => ( +

    + {t.errors.roleMissing(t.roles[r] ?? r)} +

    + ))} + {missingCategory.length ? ( +

    + {t.errors.category(String(missingCategory.length))} +

    + ) : null} +

    {t.commitHint}

    +
    + + +
    +
    + ) : null} + + + + + {t.revertTitle} + {t.revertDescription} + + + {tr.common.cancel} + + + + +
    + ); +} diff --git a/src/features/imports/components/upload-card.tsx b/src/features/imports/components/upload-card.tsx new file mode 100644 index 0000000..797a444 --- /dev/null +++ b/src/features/imports/components/upload-card.tsx @@ -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(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 ( +
    + + + +

    {t.uploadHint}

    + onPick(e.target.files?.[0])} + /> + +
    + ); +} diff --git a/src/features/transactions/components/transactions-view.tsx b/src/features/transactions/components/transactions-view.tsx index 4983c64..82e7a6f 100644 --- a/src/features/transactions/components/transactions-view.tsx +++ b/src/features/transactions/components/transactions-view.tsx @@ -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({ {t.export} + {canWrite ? ( + + ) : null} {canWrite ? (