-
- {a.name}
-
+ {/* Gizli hesabın ekstresi yalnızca sahibine açılır. */}
+ {a.balanceMinor === null ? (
+
{a.name}
+ ) : (
+
+ {a.name}
+
+ )}
{a.type === "meal_card" && a.mealCardProvider ? (
{t.providers[a.mealCardProvider]}
diff --git a/src/features/attachments/components/attachment-picker.tsx b/src/features/attachments/components/attachment-picker.tsx
new file mode 100644
index 0000000..c3b0f7d
--- /dev/null
+++ b/src/features/attachments/components/attachment-picker.tsx
@@ -0,0 +1,168 @@
+"use client";
+
+import { Loader2, Paperclip, X } from "lucide-react";
+import { useRef, useState } from "react";
+import { Button } from "@/components/ui/button";
+import { tr } from "@/i18n/tr";
+import type { ActionResult } from "@/lib/action-result";
+import { cn } from "@/lib/utils";
+import {
+ acceptAttribute,
+ MAX_ATTACHMENTS_PER_RECORD,
+ type AttachmentKind,
+} from "@/shared/attachments";
+import type { AttachmentInfo } from "../service";
+import { AttachmentThumb } from "./attachment-strip";
+import { AttachmentViewer } from "./attachment-viewer";
+
+const t = tr.attachments;
+
+/** Tek dosyayı /api/files'a yükler. */
+async function uploadFile(file: File): Promise
> {
+ const body = new FormData();
+ body.append("file", file);
+ try {
+ const res = await fetch("/api/files", { method: "POST", body });
+ return (await res.json()) as ActionResult;
+ } catch {
+ return { ok: false, error: t.errors.upload };
+ }
+}
+
+/**
+ * Formdaki küçük "Ek ekle" alanı (Bölüm 6.3): seçilen dosyalar hemen yüklenir (bekleyen ek),
+ * küçük önizleme ve sayaç gösterilir; kayıt kaydedilince ekler kayda bağlanır.
+ */
+export function AttachmentPicker({
+ value,
+ onChange,
+ allowedKinds,
+ onBusyChange,
+}: {
+ value: AttachmentInfo[];
+ onChange: (items: AttachmentInfo[]) => void;
+ allowedKinds: readonly AttachmentKind[];
+ onBusyChange?: (busy: boolean) => void;
+}) {
+ const inputRef = useRef(null);
+ const [uploading, setUploading] = useState(0);
+ const [errors, setErrors] = useState([]);
+ const [viewer, setViewer] = useState(null);
+ const invalid = value.filter((a) => !allowedKinds.includes(a.kind));
+ const room = MAX_ATTACHMENTS_PER_RECORD - value.length;
+
+ async function onFiles(files: FileList | null) {
+ if (!files?.length) return;
+ const list = [...files];
+ const accepted = list.slice(0, Math.max(0, room));
+ const nextErrors: string[] = [];
+ if (list.length > accepted.length)
+ nextErrors.push(t.errors.tooMany(MAX_ATTACHMENTS_PER_RECORD));
+ setUploading(accepted.length);
+ onBusyChange?.(true);
+ let items = value;
+ for (const file of accepted) {
+ const result = await uploadFile(file);
+ if (result.ok) {
+ items = [...items, result.data];
+ onChange(items);
+ } else {
+ nextErrors.push(`${file.name}: ${result.error}`);
+ }
+ setUploading((n) => n - 1);
+ }
+ setErrors(nextErrors);
+ onBusyChange?.(false);
+ if (inputRef.current) inputRef.current.value = "";
+ }
+
+ function remove(id: string) {
+ onChange(value.filter((a) => a.id !== id));
+ }
+
+ return (
+
+
+
+
+ {value.length
+ ? t.count(value.length)
+ : allowedKinds.includes("pdf")
+ ? t.addHint.imagePdf
+ : t.addHint.image}
+
+
onFiles(e.target.files)}
+ />
+
+
+ {value.length || uploading ? (
+
+ {value.map((item, i) => (
+ -
+ setViewer(i)}
+ />
+
+
+ ))}
+ {Array.from({ length: uploading }, (_, i) => (
+ -
+
+
+ ))}
+
+ ) : null}
+
+ {invalid.length ? (
+
+ {t.errors.kindNotAllowed}
+
+ ) : null}
+ {errors.map((e) => (
+
+ {e}
+
+ ))}
+
+
!open && setViewer(null)}
+ />
+
+ );
+}
diff --git a/src/features/attachments/components/attachment-strip.tsx b/src/features/attachments/components/attachment-strip.tsx
new file mode 100644
index 0000000..d24849c
--- /dev/null
+++ b/src/features/attachments/components/attachment-strip.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import { FileText, Paperclip } from "lucide-react";
+import { useState } from "react";
+import { tr } from "@/i18n/tr";
+import type { AttachmentInfo } from "../service";
+import { AttachmentViewer } from "./attachment-viewer";
+
+const t = tr.attachments;
+const VISIBLE = 4;
+
+/** Ayrıntıda küçük önizleme şeridi (en fazla 4 + "+N"); tıklayınca görüntüleyici açılır. */
+export function AttachmentStrip({ items }: { items: AttachmentInfo[] }) {
+ const [viewer, setViewer] = useState(null);
+ if (!items.length) return null;
+ const shown = items.slice(0, VISIBLE);
+ const rest = items.length - shown.length;
+
+ return (
+
+
+
{t.count(items.length)}
+
+ {shown.map((item, i) => (
+ -
+ setViewer(i)} />
+ {rest > 0 && i === shown.length - 1 ? (
+
+ ) : null}
+
+ ))}
+
+
!open && setViewer(null)}
+ />
+
+ );
+}
+
+export function AttachmentThumb({
+ item,
+ onClick,
+ className = "size-12",
+}: {
+ item: AttachmentInfo;
+ onClick?: () => void;
+ className?: string;
+}) {
+ return (
+
+ );
+}
diff --git a/src/features/attachments/components/attachment-viewer.tsx b/src/features/attachments/components/attachment-viewer.tsx
new file mode 100644
index 0000000..341eb79
--- /dev/null
+++ b/src/features/attachments/components/attachment-viewer.tsx
@@ -0,0 +1,170 @@
+"use client";
+
+import { Download, ExternalLink, FileText, Maximize, Minimize } from "lucide-react";
+import { useEffect, useRef, useState } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Carousel,
+ CarouselContent,
+ CarouselItem,
+ CarouselNext,
+ CarouselPrevious,
+ type CarouselApi,
+} from "@/components/ui/carousel";
+import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
+import { tr } from "@/i18n/tr";
+import { formatFileSize } from "@/shared/attachments";
+import type { AttachmentInfo } from "../service";
+
+const t = tr.attachments;
+
+/**
+ * Ek dosya görüntüleyici (Bölüm 6.3): carousel (oklar, kaydırma, klavye okları), tam ekran ve
+ * "2 / 5" göstergesi. PDF'ler ikon + "Aç" düğmesiyle gösterilir (tarayıcının PDF görüntüleyicisi).
+ */
+export function AttachmentViewer({
+ items,
+ startIndex,
+ open,
+ onOpenChange,
+}: {
+ items: AttachmentInfo[];
+ startIndex: number;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}) {
+ const [api, setApi] = useState();
+ const [index, setIndex] = useState(startIndex);
+ const [fullscreen, setFullscreen] = useState(false);
+ const containerRef = useRef(null);
+
+ useEffect(() => {
+ if (!api) return;
+ const update = () => setIndex(api.selectedScrollSnap());
+ api.on("select", update);
+ api.on("reInit", update);
+ return () => {
+ api.off("select", update);
+ api.off("reInit", update);
+ };
+ }, [api]);
+
+ useEffect(() => {
+ const onChange = () => setFullscreen(document.fullscreenElement === containerRef.current);
+ document.addEventListener("fullscreenchange", onChange);
+ return () => document.removeEventListener("fullscreenchange", onChange);
+ }, []);
+
+ function handleOpenChange(next: boolean) {
+ if (!next && document.fullscreenElement) void document.exitFullscreen();
+ if (next) setIndex(startIndex);
+ onOpenChange(next);
+ }
+
+ async function toggleFullscreen() {
+ if (document.fullscreenElement) await document.exitFullscreen();
+ else await containerRef.current?.requestFullscreen().catch(() => {});
+ }
+
+ /** Odak carousel dışındayken de (ör. kapat düğmesi) ok tuşları çalışsın. */
+ function onKeyDown(event: React.KeyboardEvent) {
+ if (event.defaultPrevented) return;
+ if (event.key === "ArrowLeft") {
+ event.preventDefault();
+ api?.scrollPrev();
+ } else if (event.key === "ArrowRight") {
+ event.preventDefault();
+ api?.scrollNext();
+ }
+ }
+
+ const current = items[Math.min(index, items.length - 1)];
+
+ return (
+
+ );
+}
diff --git a/src/features/transactions/components/statement-view.tsx b/src/features/transactions/components/statement-view.tsx
new file mode 100644
index 0000000..9627412
--- /dev/null
+++ b/src/features/transactions/components/statement-view.tsx
@@ -0,0 +1,387 @@
+"use client";
+
+import {
+ ArrowLeft,
+ CheckCheck,
+ ChevronLeft,
+ ChevronRight,
+ List,
+ Loader2,
+ Scale,
+} from "lucide-react";
+import Link from "next/link";
+import { useState } from "react";
+import { toast } from "sonner";
+import { MoneyInput } from "@/components/money-input";
+import { PageHeader } from "@/components/page-header";
+import { ResponsiveDialog } from "@/components/responsive-dialog";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
+import { Input } from "@/components/ui/input";
+import { tr } from "@/i18n/tr";
+import { cn } from "@/lib/utils";
+import { todayIstanbul } from "@/shared/dates";
+import { formatDate, formatMoney } from "@/shared/format";
+import { previewReconciliationAction, reconcileAccountAction } from "../actions";
+import type { AccountStatement, ReconcilePreview, StatementRow } from "../statement";
+import { RowIcon, rowSubtitle, rowTitle, useTransactionDetail } from "./transaction-parts";
+
+const t = tr.statement;
+const r = t.reconcile;
+const monthFmt = new Intl.DateTimeFormat("tr-TR", {
+ month: "long",
+ year: "numeric",
+ timeZone: "Europe/Istanbul",
+});
+const monthLabel = (ym: string) => monthFmt.format(new Date(`${ym}-15T12:00:00+03:00`));
+const dateLabel = (ymd: string) => formatDate(new Date(`${ymd}T12:00:00+03:00`));
+
+function SignedMoney({ minor, className }: { minor: number; className?: string }) {
+ return (
+ 0 && "text-income",
+ minor < 0 && "text-expense",
+ className,
+ )}
+ >
+ {minor > 0 ? "+" : minor < 0 ? "−" : ""}
+ {formatMoney(Math.abs(minor))}
+
+ );
+}
+
+export function StatementView({
+ statement: s,
+ canWrite,
+}: {
+ statement: AccountStatement;
+ canWrite: boolean;
+}) {
+ const { openDetail, element: detailElement } = useTransactionDetail(canWrite);
+ const [reconcileOpen, setReconcileOpen] = useState(false);
+ const base = `/hesaplar/${s.account.id}`;
+
+ return (
+
+
+
+
+ {canWrite && s.canReconcile ? (
+
+ ) : null}
+ >
+ }
+ />
+
+
+
+
{monthLabel(s.month)}
+ {s.nextMonth ? (
+
+ ) : (
+
+ )}
+
+
+
+ {(
+ [
+ [t.opening, s.openingMinor, false],
+ [t.inflow, s.inflowMinor, true],
+ [t.outflow, -s.outflowMinor, true],
+ [t.closing, s.closingMinor, false],
+ ] as const
+ ).map(([label, value, colored]) => (
+
+
+ {label}
+
+ {colored ? (
+
+ ) : (
+ {formatMoney(value)}
+ )}
+
+
+
+ ))}
+
+
+
+
+ {s.lastReconciliation
+ ? t.lastReconciliation(
+ dateLabel(s.lastReconciliation.date),
+ formatMoney(s.lastReconciliation.statementBalanceMinor),
+ )
+ : t.noReconciliation}
+
+
+ {s.rows.length === 0 ? (
+
+
+ {t.empty}
+
+
+ ) : (
+
+ {s.rows.map((row) => (
+ -
+ openDetail(row.item.id)} />
+
+ ))}
+
+ )}
+ {s.truncated ? {t.truncated}
: null}
+
+ {detailElement}
+
+
+ );
+}
+
+function StatementRowView({ row, onOpen }: { row: StatementRow; onOpen: () => void }) {
+ const content = (
+ <>
+
+
+
+ {rowTitle(row.item)}
+ {row.reconciled ? (
+
+ ) : null}
+
+
+ {dateLabel(row.item.date)} · {rowSubtitle(row.item)}
+
+
+
+
+
+ {formatMoney(row.balanceMinor)}
+
+
+ >
+ );
+ const cls = "flex w-full items-center gap-3 p-3 text-left";
+ // Gizli karşı hesaba dokunan işlemin ayrıntısı açılmaz.
+ return row.restricted ? (
+ {content}
+ ) : (
+
+ );
+}
+
+function ReconcileDialog({
+ open,
+ onOpenChange,
+ accountId,
+ isLiability,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ accountId: string;
+ isLiability: boolean;
+}) {
+ const [date, setDate] = useState(todayIstanbul);
+ const [balance, setBalance] = useState("");
+ const [preview, setPreview] = useState(null);
+ const [accept, setAccept] = useState(false);
+ const [errors, setErrors] = useState>({});
+ const [pending, setPending] = useState(false);
+
+ function reset(next: boolean) {
+ if (!next) {
+ setPreview(null);
+ setAccept(false);
+ setErrors({});
+ setBalance("");
+ setDate(todayIstanbul());
+ }
+ onOpenChange(next);
+ }
+
+ const input = () => ({
+ statementDate: date,
+ statementBalance: balance,
+ acceptAdjustment: accept,
+ });
+
+ async function check() {
+ setPending(true);
+ const res = await previewReconciliationAction(accountId, input());
+ setPending(false);
+ if (!res.ok) {
+ setErrors({ ...res.fieldErrors, root: res.fieldErrors ? undefined : res.error });
+ return;
+ }
+ setErrors({});
+ setAccept(false);
+ setPreview(res.data);
+ }
+
+ async function confirm() {
+ setPending(true);
+ const res = await reconcileAccountAction(accountId, input());
+ setPending(false);
+ if (!res.ok) {
+ setErrors({ root: res.error });
+ return;
+ }
+ toast.success(
+ res.data.adjustmentId
+ ? r.doneWithAdjustment(res.data.markedCount)
+ : r.done(res.data.markedCount),
+ );
+ reset(false);
+ }
+
+ const diff = preview?.differenceMinor ?? 0;
+ const display = (minor: number) => formatMoney(isLiability ? -minor : minor);
+
+ return (
+
+
+ {preview ? (
+
+ ) : (
+
+ )}
+ >
+ }
+ >
+
+
+ {r.date}
+ {
+ setDate(e.target.value);
+ setPreview(null);
+ }}
+ />
+ {errors.statementDate ? {errors.statementDate} : null}
+
+
+ {isLiability ? r.debt : r.balance}
+ {
+ setBalance(v);
+ setPreview(null);
+ }}
+ />
+ {isLiability ? r.debtHint : r.balanceHint}
+ {errors.statementBalance ? {errors.statementBalance} : null}
+
+
+ {preview ? (
+
+
+
+
- {r.statementBalance}
+ - {display(preview.statementBalanceMinor)}
+
+
+
- {r.appBalance}
+ - {display(preview.appBalanceMinor)}
+
+
+
- {r.difference}
+ -
+
+
+
+
+ {diff === 0 ? (
+
{r.matches}
+ ) : (
+ <>
+
{r.mismatch}
+
+ >
+ )}
+
+ {r.unreconciled(preview.unreconciledCount)}
+
+
+ ) : null}
+ {errors.root ? {errors.root} : null}
+
+
+ );
+}
diff --git a/src/features/transactions/components/transaction-form.tsx b/src/features/transactions/components/transaction-form.tsx
index be97413..15849c1 100644
--- a/src/features/transactions/components/transaction-form.tsx
+++ b/src/features/transactions/components/transaction-form.tsx
@@ -27,6 +27,9 @@ import { cn } from "@/lib/utils";
import { todayIstanbul } from "@/shared/dates";
import { formatMoney } from "@/shared/format";
import { formatMoneyInput, parseMoney } from "@/shared/money";
+import { allowedAttachmentKinds } from "@/shared/attachments";
+import { AttachmentPicker } from "@/features/attachments/components/attachment-picker";
+import type { AttachmentInfo } from "@/features/attachments/service";
import { transactionFormSchema, type TransactionForm } from "@/shared/schemas/transaction";
import { MAX_SPLITS, TRANSACTION_TYPES, type TransactionType } from "@/shared/transactions";
import {
@@ -57,6 +60,8 @@ type FormValues = {
payee: string;
description: string;
tags: string;
+ /** Önizleme bilgisiyle ekler; sunucuya yalnızca kimlikleri gider. */
+ attachments: AttachmentInfo[];
};
function toValues(mode: TransactionFormMode, options: TransactionFormOptions | null): FormValues {
@@ -72,6 +77,7 @@ function toValues(mode: TransactionFormMode, options: TransactionFormOptions | n
payee: "",
description: "",
tags: "",
+ attachments: [],
};
}
const x = mode.transaction;
@@ -90,6 +96,8 @@ function toValues(mode: TransactionFormMode, options: TransactionFormOptions | n
payee: x.payeeName,
description: x.description,
tags: x.tags.join(", "),
+ // Kopyada ekler taşınmaz (fiş o işleme aittir).
+ attachments: mode.kind === "edit" ? x.attachments : [],
};
}
@@ -298,6 +306,7 @@ export function TransactionFormDialog({
const [loadError, setLoadError] = useState(null);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [suggested, setSuggested] = useState(false);
+ const [uploadBusy, setUploadBusy] = useState(false);
const form = useForm({
// FormValues, şemanın giriş şekliyle birebir (tutarlar string; zod kuruşa çevirir).
resolver: zodResolver(transactionFormSchema) as unknown as Resolver<
@@ -332,6 +341,7 @@ export function TransactionFormDialog({
const amount = useWatch({ control: form.control, name: "amount" });
const accountId = useWatch({ control: form.control, name: "accountId" });
const splitValues = useWatch({ control: form.control, name: "splits" });
+ const attachmentCount = useWatch({ control: form.control, name: "attachments" })?.length ?? 0;
const splitMode = splits.fields.length > 0;
// Düzenlemede arşivli/kapalı hesap veya kategori seçenek listesinde yoksa ekle (kaybolmasın).
@@ -431,9 +441,14 @@ export function TransactionFormDialog({
async function onSubmit() {
if (!mode) return;
- const values = form.getValues();
+ if (uploadBusy) {
+ form.setError("root", { message: tr.attachments.waitForUploads });
+ return;
+ }
+ const { attachments, ...values } = form.getValues();
const payload = {
...values,
+ attachmentIds: attachments.map((a) => a.id),
toAccountId: values.type === "transfer" ? values.toAccountId : "",
categoryId: values.type === "transfer" || values.splits.length ? "" : values.categoryId,
splits: values.type === "transfer" ? [] : values.splits,
@@ -479,7 +494,7 @@ export function TransactionFormDialog({
+ (
+
+ {tr.attachments.title}
+
+
+ )}
+ />
+ {text}
+
+ );
+}
+
+export function RowIcon({ item }: { item: TransactionListItem }) {
+ if (item.type === "transfer") {
+ return (
+
+
+
+ );
+ }
+ if (item.isAdjustment) {
+ return (
+
+
+
+ );
+ }
+ if (item.splitCount) {
+ return (
+
+
+
+ );
+ }
+ return item.category ? (
+
+ ) : (
+
+ );
+}
+
+export function rowTitle(item: TransactionListItem) {
+ if (item.isAdjustment) return t.adjustment;
+ if (item.payeeName) return item.payeeName;
+ if (item.description) return item.description;
+ if (item.type === "transfer") return t.types.transfer;
+ return item.category?.name ?? t.types[item.type];
+}
+
+export function rowSubtitle(item: TransactionListItem) {
+ if (item.type === "transfer")
+ return t.list.transfer(item.account.name, item.toAccount?.name ?? "—");
+ const what = item.isAdjustment
+ ? item.description
+ : item.splitCount
+ ? t.list.split(item.splitCount)
+ : (item.category?.path ?? "—");
+ return `${what} · ${item.account.name}`;
+}
+
+function DetailDialog({
+ detail,
+ loading,
+ canWrite,
+ onOpenChange,
+ onEdit,
+ onCopy,
+ onDelete,
+}: {
+ detail: TransactionDetail | null;
+ loading: boolean;
+ canWrite: boolean;
+ onOpenChange: (o: boolean) => void;
+ onEdit: (d: TransactionDetail) => void;
+ onCopy: (d: TransactionDetail) => void;
+ onDelete: (d: TransactionDetail) => void;
+}) {
+ const d = t.detail;
+ const row = (label: string, value: React.ReactNode) => (
+
+
{label}
+ {value}
+
+ );
+ return (
+
+
+ {/* Mutabakat düzeltmesi düzenlenemez/kopyalanamaz; yalnızca silinebilir. */}
+ {detail.isAdjustment ? null : (
+ <>
+
+
+ >
+ )}
+ >
+ ) : null
+ }
+ >
+ {!detail ? (
+
+
+
+
+ ) : (
+
+
+
+
+
{rowTitle(detail)}
+
{dayLabel(detail.date)}
+
+
+
+
+ {row(t.filters.type, {t.types[detail.type]})}
+ {detail.status !== "posted" ? row(t.filters.status, t.statuses[detail.status]) : null}
+ {row(
+ detail.type === "transfer" ? t.fields.fromAccount : t.fields.account,
+ detail.account.name,
+ )}
+ {detail.toAccount ? row(t.fields.toAccount, detail.toAccount.name) : null}
+ {detail.category ? row(t.fields.category, detail.category.path) : null}
+ {detail.isAdjustment ? row(t.fields.category, t.adjustment) : null}
+ {detail.payeeName ? row(t.fields.payee, detail.payeeName) : null}
+ {detail.description ? row(t.fields.description, detail.description) : null}
+ {detail.tags.length
+ ? row(
+ t.fields.tags,
+
+ {detail.tags.map((tag) => (
+
+ #{tag}
+
+ ))}
+ ,
+ )
+ : null}
+ {detail.attachments.length
+ ? row(
+ tr.attachments.title,
+
+
+ ,
+ )
+ : null}
+ {detail.reconciledAccountIds.length
+ ? row(
+ tr.statement.reconcile.title,
+
+
+ {t.reconciled}
+ ,
+ )
+ : null}
+ {row(d.createdBy, `${detail.createdBy.name} · ${formatDateTime(detail.createdAt)}`)}
+ {new Date(detail.updatedAt).getTime() - new Date(detail.createdAt).getTime() > 1000
+ ? row(d.updatedBy, `${detail.updatedBy.name} · ${formatDateTime(detail.updatedAt)}`)
+ : null}
+
+ {detail.splits.length ? (
+
+
+ {d.lines}
+
+
+ {detail.splits.map((s, i) => (
+ -
+
+
+ {s.path}
+ {s.note ? (
+
+ {s.note}
+
+ ) : null}
+
+ {formatMoney(s.amountMinor)}
+
+ ))}
+
+
+ ) : null}
+
+ )}
+
+ );
+}
+
+/**
+ * Ayrıntı penceresi + silme onayı + geri al bildirimi. Liste ve ekstre aynı davranışı kullanır;
+ * düzenle/kopyala uygulama genelindeki işlem formunu açar.
+ */
+export function useTransactionDetail(canWrite: boolean) {
+ const { openTransactionForm } = useQuickAdd();
+ const [detail, setDetail] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [deleting, setDeleting] = useState(null);
+ const [, startTransition] = useTransition();
+
+ async function openDetail(id: string) {
+ setLoading(true);
+ setDetail(null);
+ const res = await getTransactionAction(id);
+ setLoading(false);
+ if (res.ok) setDetail(res.data);
+ else toast.error(res.error);
+ }
+
+ function close() {
+ setDetail(null);
+ setLoading(false);
+ }
+
+ function remove(d: TransactionDetail) {
+ setDeleting(null);
+ close();
+ startTransition(async () => {
+ const res = await setTransactionDeletedAction(d.id, true);
+ if (!res.ok) return void toast.error(res.error);
+ toast.success(t.toast.deleted, {
+ duration: 10_000,
+ action: {
+ label: t.actions.restore,
+ onClick: async () => {
+ const r = await setTransactionDeletedAction(d.id, false);
+ if (r.ok) toast.success(t.toast.restored);
+ else toast.error(r.error);
+ },
+ },
+ });
+ });
+ }
+
+ const element = (
+ <>
+ !o && close()}
+ onEdit={(d) => {
+ close();
+ openTransactionForm({ kind: "edit", transaction: d });
+ }}
+ onCopy={(d) => {
+ close();
+ openTransactionForm({ kind: "copy", transaction: d });
+ }}
+ onDelete={setDeleting}
+ />
+ !o && setDeleting(null)}>
+
+
+ {t.deleteTitle}
+ {t.deleteDescription}
+
+
+ {tr.common.cancel}
+
+
+
+
+ >
+ );
+ return { openDetail, element };
+}
diff --git a/src/features/transactions/components/transactions-view.tsx b/src/features/transactions/components/transactions-view.tsx
index 453fc78..4983c64 100644
--- a/src/features/transactions/components/transactions-view.tsx
+++ b/src/features/transactions/components/transactions-view.tsx
@@ -1,36 +1,12 @@
"use client";
-import {
- ArrowLeftRight,
- ChevronLeft,
- ChevronRight,
- Copy,
- Download,
- Filter,
- Paperclip,
- Pencil,
- Plus,
- Split,
- Trash2,
- X,
-} from "lucide-react";
+import { ChevronLeft, ChevronRight, Download, Filter, Paperclip, Plus, X } from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
-import { useState, useTransition } from "react";
-import { toast } from "sonner";
-import { IconBadge } from "@/components/appearance/swatch";
+import { useState } from "react";
import { useQuickAdd } from "@/components/layout/quick-add";
import { MoneyInput } from "@/components/money-input";
import { ResponsiveDialog } from "@/components/responsive-dialog";
-import {
- AlertDialog,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
-} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -43,103 +19,24 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
-import { Skeleton } from "@/components/ui/skeleton";
import { tr } from "@/i18n/tr";
import { cn } from "@/lib/utils";
-import { formatDate, formatDateTime, formatMoney } from "@/shared/format";
+import { formatMoney } from "@/shared/format";
import type { TransactionFilters } from "@/shared/schemas/transaction";
+import { TRANSACTION_STATUSES, TRANSACTION_TYPES } from "@/shared/transactions";
+import type { TransactionFormOptions, TransactionListItem, TransactionPage } from "../service";
import {
- TRANSACTION_STATUSES,
- TRANSACTION_TYPES,
- type TransactionType,
-} from "@/shared/transactions";
-import { getTransactionAction, setTransactionDeletedAction } from "../actions";
-import type {
- TransactionDetail,
- TransactionFormOptions,
- TransactionListItem,
- TransactionPage,
-} from "../service";
+ Amount,
+ dayLabel,
+ RowIcon,
+ rowSubtitle,
+ rowTitle,
+ signed,
+ useTransactionDetail,
+} from "./transaction-parts";
const t = tr.transactions;
const ALL = "__all__";
-const weekday = new Intl.DateTimeFormat("tr-TR", { weekday: "long", timeZone: "Europe/Istanbul" });
-
-/** "2026-11-05" → "05.11.2026 · Perşembe" */
-function dayLabel(ymd: string) {
- const d = new Date(`${ymd}T12:00:00+03:00`);
- return `${formatDate(d)} · ${weekday.format(d)}`;
-}
-
-function signed(type: TransactionType, amountMinor: number) {
- return type === "income" ? amountMinor : type === "expense" ? -amountMinor : 0;
-}
-
-function Amount({
- type,
- amountMinor,
- className,
-}: {
- type: TransactionType;
- amountMinor: number;
- className?: string;
-}) {
- const text =
- type === "income"
- ? `+${formatMoney(amountMinor)}`
- : type === "expense"
- ? `−${formatMoney(amountMinor)}`
- : formatMoney(amountMinor);
- return (
-
- {text}
-
- );
-}
-
-function RowIcon({ item }: { item: TransactionListItem }) {
- if (item.type === "transfer") {
- return (
-
-
-
- );
- }
- if (item.splitCount) {
- return (
-
-
-
- );
- }
- return item.category ? (
-
- ) : (
-
- );
-}
-
-function rowTitle(item: TransactionListItem) {
- if (item.payeeName) return item.payeeName;
- if (item.description) return item.description;
- if (item.type === "transfer") return t.types.transfer;
- return item.category?.name ?? t.types[item.type];
-}
-
-function rowSubtitle(item: TransactionListItem) {
- if (item.type === "transfer")
- return t.list.transfer(item.account.name, item.toAccount?.name ?? "—");
- const what = item.splitCount ? t.list.split(item.splitCount) : (item.category?.path ?? "—");
- return `${what} · ${item.account.name}`;
-}
// --- Filtreler ---
@@ -318,126 +215,6 @@ function FiltersDialog({
// --- Ayrıntı ---
-function DetailDialog({
- detail,
- loading,
- canWrite,
- onOpenChange,
- onEdit,
- onCopy,
- onDelete,
-}: {
- detail: TransactionDetail | null;
- loading: boolean;
- canWrite: boolean;
- onOpenChange: (o: boolean) => void;
- onEdit: (d: TransactionDetail) => void;
- onCopy: (d: TransactionDetail) => void;
- onDelete: (d: TransactionDetail) => void;
-}) {
- const d = t.detail;
- const row = (label: string, value: React.ReactNode) => (
-
-
{label}
- {value}
-
- );
- return (
-
-
-
-
- >
- ) : null
- }
- >
- {!detail ? (
-
-
-
-
- ) : (
-
-
-
-
-
{rowTitle(detail)}
-
{dayLabel(detail.date)}
-
-
-
-
- {row(t.filters.type, {t.types[detail.type]})}
- {detail.status !== "posted" ? row(t.filters.status, t.statuses[detail.status]) : null}
- {row(
- detail.type === "transfer" ? t.fields.fromAccount : t.fields.account,
- detail.account.name,
- )}
- {detail.toAccount ? row(t.fields.toAccount, detail.toAccount.name) : null}
- {detail.category ? row(t.fields.category, detail.category.path) : null}
- {detail.payeeName ? row(t.fields.payee, detail.payeeName) : null}
- {detail.description ? row(t.fields.description, detail.description) : null}
- {detail.tags.length
- ? row(
- t.fields.tags,
-
- {detail.tags.map((tag) => (
-
- #{tag}
-
- ))}
- ,
- )
- : null}
- {row(d.createdBy, `${detail.createdBy.name} · ${formatDateTime(detail.createdAt)}`)}
- {new Date(detail.updatedAt).getTime() - new Date(detail.createdAt).getTime() > 1000
- ? row(d.updatedBy, `${detail.updatedBy.name} · ${formatDateTime(detail.updatedAt)}`)
- : null}
-
- {detail.splits.length ? (
-
-
- {d.lines}
-
-
- {detail.splits.map((s, i) => (
- -
-
-
- {s.path}
- {s.note ? (
-
- {s.note}
-
- ) : null}
-
- {formatMoney(s.amountMinor)}
-
- ))}
-
-
- ) : null}
-
- )}
-
- );
-}
-
// --- Ana görünüm ---
export function TransactionsView({
@@ -454,12 +231,9 @@ export function TransactionsView({
canWrite: boolean;
}) {
const pathname = usePathname();
- const { openQuickAdd, openTransactionForm } = useQuickAdd();
+ const { openQuickAdd } = useQuickAdd();
const [filtersOpen, setFiltersOpen] = useState(false);
- const [detail, setDetail] = useState(null);
- const [detailLoading, setDetailLoading] = useState(false);
- const [deleting, setDeleting] = useState(null);
- const [, startTransition] = useTransition();
+ const { openDetail, element: detailElement } = useTransactionDetail(canWrite);
const active = countActive(filters);
const pageHref = (page: number) => {
@@ -474,40 +248,6 @@ export function TransactionsView({
return `/islemler/disa-aktar${params.size ? `?${params}` : ""}`;
};
- async function openDetail(id: string) {
- setDetailLoading(true);
- setDetail(null);
- const res = await getTransactionAction(id);
- setDetailLoading(false);
- if (res.ok) setDetail(res.data);
- else toast.error(res.error);
- }
-
- function closeDetail() {
- setDetail(null);
- setDetailLoading(false);
- }
-
- function remove(d: TransactionDetail) {
- setDeleting(null);
- closeDetail();
- startTransition(async () => {
- const res = await setTransactionDeletedAction(d.id, true);
- if (!res.ok) return void toast.error(res.error);
- toast.success(t.toast.deleted, {
- duration: 10_000,
- action: {
- label: t.actions.restore,
- onClick: async () => {
- const r = await setTransactionDeletedAction(d.id, false);
- if (r.ok) toast.success(t.toast.restored);
- else toast.error(r.error);
- },
- },
- });
- });
- }
-
// Sayfadaki işlemleri güne göre grupla (liste zaten tarihe göre sıralı).
const groups: { date: string; items: TransactionListItem[] }[] = [];
for (const item of data.items) {
@@ -679,35 +419,7 @@ export function TransactionsView({
raw={raw}
options={options}
/>
- !o && closeDetail()}
- onEdit={(d) => {
- closeDetail();
- openTransactionForm({ kind: "edit", transaction: d });
- }}
- onCopy={(d) => {
- closeDetail();
- openTransactionForm({ kind: "copy", transaction: d });
- }}
- onDelete={setDeleting}
- />
- !o && setDeleting(null)}>
-
-
- {t.deleteTitle}
- {t.deleteDescription}
-
-
- {tr.common.cancel}
-
-
-
-
+ {detailElement}
);
}
diff --git a/src/i18n/tr.ts b/src/i18n/tr.ts
index d172a56..2178555 100644
--- a/src/i18n/tr.ts
+++ b/src/i18n/tr.ts
@@ -256,7 +256,7 @@ export const tr = {
description: "Açıklama",
tags: "Etiketler",
tagsHint: "Virgülle ayırın (ör. tatil, indirim)",
- advanced: "Gelişmiş: etiket, bölme",
+ advanced: "Gelişmiş: ek, etiket, bölme",
split: "Bölünmüş işlem",
splitHint: "Tutarı birden çok kategoriye bölün; kalemlerin toplamı tutara eşit olmalı.",
addSplit: "Kalem ekle",
@@ -344,10 +344,96 @@ export const tr = {
],
filename: (date: string) => `butcem-islemler-${date}.csv`,
},
+ adjustment: "Mutabakat düzeltmesi",
+ adjustmentReadOnly:
+ "Mutabakat düzeltmesi düzenlenemez; gerekirse silip mutabakatı yeniden yapın.",
+ reconciled: "Mutabakatı yapıldı",
readOnly: "İzleyici rolündesiniz; işlem ekleyemezsiniz.",
noAccounts: "İşlem girmek için önce bir hesap gerekli.",
noAccountsLink: "Hesaplara git",
},
+ attachments: {
+ title: "Ekler",
+ add: "Ek ekle",
+ addHint: { image: "Fiş veya fatura fotoğrafı", imagePdf: "Görsel veya PDF (ör. bordro)" },
+ count: (n: number) => `${n} ek`,
+ uploading: "Yükleniyor…",
+ remove: "Eki kaldır",
+ open: "Aç",
+ download: "İndir",
+ fullscreen: "Tam ekran",
+ exitFullscreen: "Tam ekrandan çık",
+ previous: "Önceki",
+ next: "Sonraki",
+ position: (i: number, n: number) => `${i} / ${n}`,
+ viewerTitle: "Ek dosyalar",
+ pdf: "PDF belgesi",
+ moreCount: (n: number) => `+${n}`,
+ waitForUploads: "Yüklemeler bitmeden kaydedilemez.",
+ errors: {
+ empty: "Dosya seçilmedi veya dosya boş.",
+ tooLarge: (mb: number) => `Dosya çok büyük (en fazla ${mb} MB).`,
+ tooMany: (n: number) => `Bir kayda en fazla ${n} dosya eklenebilir.`,
+ tooManyPending: "Kaydedilmemiş çok fazla ek var; önce formu kaydedin.",
+ unsupported: "Desteklenmeyen dosya türü. JPEG, PNG, WebP, HEIC veya PDF yükleyin.",
+ unreadable: "Dosya okunamadı veya bozuk.",
+ kindNotAllowed: "Gider işlemlerine yalnızca görsel eklenebilir (PDF gelir ve transferlerde).",
+ invalid: "Ek dosya bulunamadı veya bu kayda eklenemez.",
+ upload: "Dosya yüklenemedi.",
+ },
+ },
+ statement: {
+ title: "Hesap ekstresi",
+ back: "Hesaplar",
+ allTransactions: "Tüm işlemler",
+ prevMonth: "Önceki ay",
+ nextMonth: "Sonraki ay",
+ opening: "Ay başı bakiye",
+ inflow: "Giriş",
+ outflow: "Çıkış",
+ closing: "Ay sonu bakiye",
+ current: "Güncel bakiye",
+ balanceAfter: "Bakiye",
+ empty: "Bu ayda işlem yok.",
+ truncated: "Çok fazla işlem var; yalnızca ilk 2.000 işlem gösteriliyor.",
+ lastReconciliation: (date: string, balance: string) =>
+ `Son mutabakat: ${date} · ekstre bakiyesi ${balance}`,
+ noReconciliation: "Henüz mutabakat yapılmadı.",
+ reconciledHint: "Mutabakatı yapılmış işlem",
+ adjustmentDescription: (statementBalance: string) =>
+ `Ekstre bakiyesi ${statementBalance} ile fark`,
+ reconcile: {
+ open: "Mutabakat yap",
+ title: "Mutabakat",
+ description:
+ "Banka veya kart ekstrenizdeki tarihi ve bakiyeyi girin; uygulamadaki bakiye ile karşılaştırılır.",
+ date: "Ekstre tarihi",
+ balance: "Ekstredeki bakiye (TL)",
+ debt: "Ekstredeki borç (TL)",
+ balanceHint: "Eksi bakiye için başına - koyun (ör. KMH kullanımı).",
+ debtHint: "Karttaki toplam borcu pozitif girin.",
+ check: "Karşılaştır",
+ statementBalance: "Ekstre",
+ appBalance: "Uygulama",
+ difference: "Fark",
+ matches: "Bakiyeler tutuyor.",
+ mismatch: "Bakiyeler tutmuyor. Eksik veya hatalı işlem olabilir.",
+ adjustmentOffer: (amount: string, kind: string) =>
+ `Farkı kapatmak için ${amount} tutarında bir “${kind}” düzeltme işlemi oluşturulsun.`,
+ acceptAdjustment: "Düzeltme işlemi oluştur",
+ unreconciled: (n: number) => `${n} işlem işaretlenecek.`,
+ confirm: "Mutabakatı tamamla",
+ confirmWithAdjustment: "Düzelt ve tamamla",
+ done: (n: number) => `Mutabakat tamamlandı; ${n} işlem işaretlendi.`,
+ doneWithAdjustment: (n: number) => `Düzeltme işlemi oluşturuldu ve ${n} işlem işaretlendi.`,
+ },
+ errors: {
+ date: "Geçerli ve bugünden ileri olmayan bir tarih girin.",
+ archived: "Arşivlenmiş hesapta mutabakat yapılamaz.",
+ needsAdjustment:
+ "Bakiyeler tutmuyor; düzeltme işlemini onaylayın veya işlemleri kontrol edin.",
+ },
+ },
categories: {
title: "Kategoriler",
description: "Gelir ve gider kategorileri. Alt kategoriler raporlarda üst kategoriye toplanır.",