diff --git a/src/app/(app)/islemler/disa-aktar/route.ts b/src/app/(app)/islemler/disa-aktar/route.ts new file mode 100644 index 0000000..e61ecc9 --- /dev/null +++ b/src/app/(app)/islemler/disa-aktar/route.ts @@ -0,0 +1,44 @@ +import { exportTransactions } from "@/features/transactions/service"; +import { tr } from "@/i18n/tr"; +import { getHouseholdContext } from "@/lib/dal"; +import { csvMoney, toCsv } from "@/shared/csv"; +import { todayIstanbul } from "@/shared/dates"; +import { formatDate } from "@/shared/format"; +import { transactionFilterSchema } from "@/shared/schemas/transaction"; + +/** GET /islemler/disa-aktar?…filtreler → CSV (listedeki filtrelerle, sayfalama yok). */ +export async function GET(request: Request) { + const ctx = await getHouseholdContext(); + if (!ctx?.household) return new Response(tr.common.unauthorized, { status: 401 }); + const params = Object.fromEntries(new URL(request.url).searchParams); + const filters = transactionFilterSchema.parse(params); + const rows = await exportTransactions(ctx.user.id, ctx.household.id, filters); + if (!rows) return new Response(tr.common.unauthorized, { status: 403 }); + + const t = tr.transactions; + const csv = toCsv( + t.csv.header, + rows.map((r) => [ + r.no, + formatDate(`${r.date}T12:00:00+03:00`), + t.types[r.type], + t.statuses[r.status], + r.account, + r.toAccount, + r.category, + r.payee, + r.description, + r.note, + r.tags, + csvMoney(r.amountMinor), + r.createdBy, + ]), + ); + return new Response(csv, { + headers: { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": `attachment; filename="${t.csv.filename(todayIstanbul())}"`, + "Cache-Control": "no-store", + }, + }); +} diff --git a/src/app/(app)/islemler/page.tsx b/src/app/(app)/islemler/page.tsx new file mode 100644 index 0000000..bf6662c --- /dev/null +++ b/src/app/(app)/islemler/page.tsx @@ -0,0 +1,44 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { PageHeader } from "@/components/page-header"; +import { listMemberOptions } from "@/features/accounts/service"; +import { hasHouseholdRole } from "@/features/households/roles"; +import { TransactionsView } from "@/features/transactions/components/transactions-view"; +import { getTransactionFormOptions, listTransactions } from "@/features/transactions/service"; +import { tr } from "@/i18n/tr"; +import { requireActiveHousehold } from "@/lib/dal"; +import { transactionFilterSchema } from "@/shared/schemas/transaction"; + +export const metadata: Metadata = { title: tr.transactions.title }; + +export default async function TransactionsPage({ searchParams }: PageProps<"/islemler">) { + const { user, household } = await requireActiveHousehold("viewer"); + const params = await searchParams; + // Yalnızca tekil string değerler (dizi parametreleri yok sayılır). + const raw = Object.fromEntries( + Object.entries(params).filter( + (e): e is [string, string] => typeof e[1] === "string" && e[1] !== "", + ), + ); + const filters = transactionFilterSchema.parse(raw); + const [data, options, members] = await Promise.all([ + listTransactions(user.id, household.id, filters), + getTransactionFormOptions(user.id, household.id), + listMemberOptions(user.id, household.id), + ]); + if (!data || !options || !members) notFound(); + return ( + <> + + + + ); +} diff --git a/src/components/layout/bottom-nav.tsx b/src/components/layout/bottom-nav.tsx index 812d6d2..82d1583 100644 --- a/src/components/layout/bottom-nav.tsx +++ b/src/components/layout/bottom-nav.tsx @@ -56,7 +56,7 @@ export function BottomNav({ canWrite }: { canWrite: boolean }) {
- ))} -
- + !open && setMode(null)} /> ); } @@ -94,7 +43,7 @@ export function QuickAddProvider({ children }: { children: React.ReactNode }) { export function QuickAddButton({ className }: { className?: string }) { const { openQuickAdd } = useQuickAdd(); return ( - diff --git a/src/features/transactions/components/transaction-form.tsx b/src/features/transactions/components/transaction-form.tsx new file mode 100644 index 0000000..be97413 --- /dev/null +++ b/src/features/transactions/components/transaction-form.tsx @@ -0,0 +1,821 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { ChevronDown, Loader2, Plus, Split, Trash2, Wand2 } from "lucide-react"; +import Link from "next/link"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Controller, useFieldArray, useForm, useWatch, type Resolver } from "react-hook-form"; +import { toast } from "sonner"; +import { IconBadge } from "@/components/appearance/swatch"; +import { MoneyInput } from "@/components/money-input"; +import { ResponsiveDialog } from "@/components/responsive-dialog"; +import { Button } from "@/components/ui/button"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { tr } from "@/i18n/tr"; +import { cn } from "@/lib/utils"; +import { todayIstanbul } from "@/shared/dates"; +import { formatMoney } from "@/shared/format"; +import { formatMoneyInput, parseMoney } from "@/shared/money"; +import { transactionFormSchema, type TransactionForm } from "@/shared/schemas/transaction"; +import { MAX_SPLITS, TRANSACTION_TYPES, type TransactionType } from "@/shared/transactions"; +import { + createTransactionAction, + getTransactionFormOptionsAction, + searchPayeesAction, + updateTransactionAction, +} from "../actions"; +import type { PayeeSuggestion, TransactionDetail, TransactionFormOptions } from "../service"; + +const t = tr.transactions; +const f = t.fields; + +export type TransactionFormMode = + | { kind: "create"; type: TransactionType } + | { kind: "edit"; transaction: TransactionDetail } + | { kind: "copy"; transaction: TransactionDetail }; + +type SplitValue = { categoryId: string; amount: string; note: string }; +type FormValues = { + type: TransactionType; + amount: string; + date: string; + accountId: string; + toAccountId: string; + categoryId: string; + splits: SplitValue[]; + payee: string; + description: string; + tags: string; +}; + +function toValues(mode: TransactionFormMode, options: TransactionFormOptions | null): FormValues { + if (mode.kind === "create") { + return { + type: mode.type, + amount: "", + date: todayIstanbul(), + accountId: options?.accounts[0]?.id ?? "", + toAccountId: "", + categoryId: "", + splits: [], + payee: "", + description: "", + tags: "", + }; + } + const x = mode.transaction; + return { + type: x.type, + amount: formatMoneyInput(x.amountMinor), + date: mode.kind === "copy" ? todayIstanbul() : x.date, + accountId: x.account.id, + toAccountId: x.toAccount?.id ?? "", + categoryId: x.categoryId ?? "", + splits: x.splits.map((s) => ({ + categoryId: s.categoryId, + amount: formatMoneyInput(s.amountMinor), + note: s.note, + })), + payee: x.payeeName, + description: x.description, + tags: x.tags.join(", "), + }; +} + +function AccountSelect({ + id, + value, + onChange, + accounts, + invalid, + exclude, +}: { + id: string; + value: string; + onChange: (v: string) => void; + accounts: { + id: string; + name: string; + icon?: TransactionFormOptions["accounts"][number]["icon"]; + color?: TransactionFormOptions["accounts"][number]["color"]; + }[]; + invalid?: boolean; + exclude?: string; +}) { + const current = accounts.find((a) => a.id === value); + return ( + + ); +} + +function CategorySelect({ + id, + value, + onChange, + options, + appearance, + invalid, + className, +}: { + id: string; + value: string; + onChange: (v: string) => void; + options: TransactionFormOptions["categories"]; + appearance: TransactionFormOptions["categoryAppearance"]; + invalid?: boolean; + className?: string; +}) { + const current = options.find((o) => o.id === value); + return ( + + ); +} + +/** Satıcı alanı: yazdıkça öneri; seçilince son kullanılan kategori/hesap önerilir. */ +function PayeeInput({ + value, + onChange, + onPick, +}: { + value: string; + onChange: (v: string) => void; + onPick: (p: PayeeSuggestion) => void; +}) { + const [items, setItems] = useState([]); + const [open, setOpen] = useState(false); + const [active, setActive] = useState(-1); + const timer = useRef | null>(null); + + function query(v: string) { + if (timer.current) clearTimeout(timer.current); + timer.current = setTimeout(async () => { + const res = await searchPayeesAction(v); + setItems( + res.filter( + (p) => + p.name.toLocaleLowerCase("tr-TR") !== v.trim().toLocaleLowerCase("tr-TR") || + res.length > 1, + ), + ); + setActive(-1); + }, 150); + } + + function pick(p: PayeeSuggestion) { + onChange(p.name); + onPick(p); + setOpen(false); + } + + return ( +
+ 0} + aria-controls="tx-payee-list" + aria-autocomplete="list" + onChange={(e) => { + onChange(e.target.value); + setOpen(true); + query(e.target.value); + }} + onFocus={() => { + setOpen(true); + query(value); + }} + onBlur={() => setTimeout(() => setOpen(false), 150)} + onKeyDown={(e) => { + if (!open || !items.length) return; + if (e.key === "ArrowDown") { + e.preventDefault(); + setActive((i) => Math.min(i + 1, items.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setActive((i) => Math.max(i - 1, 0)); + } else if (e.key === "Enter" && active >= 0) { + e.preventDefault(); + pick(items[active]!); + } else if (e.key === "Escape") setOpen(false); + }} + /> + {open && items.length > 0 ? ( +
    + {items.map((p, i) => ( +
  • { + e.preventDefault(); + pick(p); + }} + className="cursor-pointer rounded-md px-2 py-1.5 text-sm hover:bg-accent aria-selected:bg-accent" + > + {p.name} +
  • + ))} +
+ ) : null} +
+ ); +} + +export function TransactionFormDialog({ + mode, + onOpenChange, +}: { + mode: TransactionFormMode | null; + onOpenChange: (open: boolean) => void; +}) { + const [options, setOptions] = useState(null); + const [loadError, setLoadError] = useState(null); + const [advancedOpen, setAdvancedOpen] = useState(false); + const [suggested, setSuggested] = useState(false); + const form = useForm({ + // FormValues, şemanın giriş şekliyle birebir (tutarlar string; zod kuruşa çevirir). + resolver: zodResolver(transactionFormSchema) as unknown as Resolver< + FormValues, + unknown, + TransactionForm + >, + defaultValues: toValues({ kind: "create", type: "expense" }, null), + }); + const splits = useFieldArray({ control: form.control, name: "splits" }); + + // Form her açıldığında seçenekleri sunucudan al (hesaplar/kategoriler güncel olsun). + useEffect(() => { + if (!mode) return; + let cancelled = false; + getTransactionFormOptionsAction().then((res) => { + if (cancelled) return; + if (!res.ok) { + setLoadError(res.error); + return; + } + setLoadError(null); + setOptions(res.data); + form.reset(toValues(mode, res.data)); + }); + return () => { + cancelled = true; + }; + }, [mode, form]); + + const type = useWatch({ control: form.control, name: "type" }); + 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 splitMode = splits.fields.length > 0; + + // Düzenlemede arşivli/kapalı hesap veya kategori seçenek listesinde yoksa ekle (kaybolmasın). + const accounts = useMemo(() => { + const list = [...(options?.accounts ?? [])]; + if (mode && mode.kind !== "create") { + for (const a of [mode.transaction.account, mode.transaction.toAccount]) { + if (a && !list.some((x) => x.id === a.id)) + list.push({ ...a, type: "bank", icon: "wallet", color: "slate" }); + } + } + return list; + }, [options, mode]); + const categories = useMemo(() => { + const kind = type === "income" ? "income" : "expense"; + const list = (options?.categories ?? []).filter((c) => c.kind === kind); + if (mode && mode.kind !== "create") { + const extra = [ + ...(mode.transaction.category + ? [{ id: mode.transaction.category.id, path: mode.transaction.category.path }] + : []), + ...mode.transaction.splits.map((s) => ({ id: s.categoryId, path: s.path })), + ]; + for (const c of extra) { + if (!list.some((x) => x.id === c.id)) + list.push({ id: c.id, label: c.path, path: c.path, depth: 0, kind }); + } + } + return list; + }, [options, type, mode]); + + const totalMinor = parseMoney(amount ?? "") ?? 0; + const splitSum = (splitValues ?? []).reduce((a, s) => a + (parseMoney(s.amount ?? "") ?? 0), 0); + const remaining = totalMinor - splitSum; + + function handleOpenChange(open: boolean) { + if (!open) { + setAdvancedOpen(false); + setSuggested(false); + } + onOpenChange(open); + } + + function changeType(next: TransactionType) { + form.setValue("type", next); + // Kategoriler türe bağlıdır; tür değişince temizlenir. + form.setValue("categoryId", ""); + splits.replace([]); + if (next !== "transfer") form.setValue("toAccountId", ""); + form.clearErrors(); + } + + function toggleSplit() { + if (splitMode) { + splits.replace([]); + return; + } + const first = form.getValues("categoryId"); + splits.replace([ + { categoryId: first, amount: form.getValues("amount"), note: "" }, + { categoryId: "", amount: "", note: "" }, + ]); + form.setValue("categoryId", ""); + setAdvancedOpen(true); + } + + function fillRemaining() { + const last = splits.fields.length - 1; + if (last < 0) return; + const current = parseMoney(form.getValues(`splits.${last}.amount`) ?? "") ?? 0; + const next = current + remaining; + if (next > 0) + form.setValue(`splits.${last}.amount`, formatMoneyInput(next), { shouldValidate: true }); + } + + function onPayeePicked(p: PayeeSuggestion) { + let changed = false; + // Kullanıcı seçimini ezme: yalnızca boş alanları doldur. + if (p.lastAccountId && !form.getValues("accountId")) { + form.setValue("accountId", p.lastAccountId); + changed = true; + } else if (p.lastAccountId && form.formState.dirtyFields.accountId === undefined) { + form.setValue("accountId", p.lastAccountId); + changed = true; + } + if ( + p.lastCategoryId && + !splitMode && + !form.getValues("categoryId") && + p.lastType === form.getValues("type") + ) { + form.setValue("categoryId", p.lastCategoryId); + changed = true; + } + setSuggested(changed); + } + + async function onSubmit() { + if (!mode) return; + const values = form.getValues(); + const payload = { + ...values, + toAccountId: values.type === "transfer" ? values.toAccountId : "", + categoryId: values.type === "transfer" || values.splits.length ? "" : values.categoryId, + splits: values.type === "transfer" ? [] : values.splits, + }; + const result = + mode.kind === "edit" + ? await updateTransactionAction(mode.transaction.id, payload) + : await createTransactionAction(payload); + if (result.ok) { + toast.success(mode.kind === "edit" ? t.toast.updated : t.toast.created); + handleOpenChange(false); + return; + } + for (const [name, message] of Object.entries(result.fieldErrors ?? {})) { + if (!message) continue; + form.setError(name as keyof FormValues, { message }); + if (name.startsWith("splits") || name === "tags") setAdvancedOpen(true); + } + if (!result.fieldErrors) form.setError("root", { message: result.error }); + } + + const title = + mode?.kind === "edit" + ? t.editTitle + : mode?.kind === "copy" + ? t.copyTitle + : t.createTitle[mode?.type ?? "expense"]; + const loading = Boolean(mode) && !options && !loadError; + const noAccounts = options && options.accounts.length === 0; + const errors = form.formState.errors; + + return ( + + + + + ) + } + > + {loadError ? ( +

{loadError}

+ ) : loading ? ( +
+ + + + +
+ ) : noAccounts ? ( +
+

{t.noAccounts}

+ +
+ ) : ( +
+ + {mode?.kind !== "edit" ? ( + changeType(v as TransactionType)}> + + {TRANSACTION_TYPES.map((x) => ( + + {t.types[x]} + + ))} + + + ) : null} + +
+ ( + + {f.amount} + + {fieldState.invalid ? : null} + + )} + /> + ( + + {f.date} + + {fieldState.invalid ? : null} + + )} + /> +
+ +
+ ( + + + {type === "transfer" ? f.fromAccount : f.account} + + + {fieldState.invalid ? : null} + + )} + /> + {type === "transfer" ? ( + ( + + {f.toAccount} + + {fieldState.invalid ? : null} + + )} + /> + ) : null} +
+ + {type !== "transfer" && !splitMode ? ( + ( + + {f.category} +
+ + +
+ {fieldState.invalid ? : null} +
+ )} + /> + ) : null} + + ( + + {f.payee} + + {suggested ? {f.suggested} : null} + + )} + /> + ( + + {f.description} + + {fieldState.invalid ? : null} + + )} + /> + + + + + + + + ( + + {f.tags} + + {f.tagsHint} + + )} + /> + + {type !== "transfer" ? ( +
+
+
+

{f.split}

+

{f.splitHint}

+
+ +
+ {splits.fields.map((row, i) => ( +
+ ( +
+ + {fieldState.invalid ? ( + + ) : null} +
+ )} + /> + ( + + )} + /> + + ( + + )} + /> +
+ ))} + {splitMode ? ( +
+ + + {remaining === 0 + ? f.remainingOk + : `${f.remaining}: ${formatMoney(remaining)}`} + + {remaining !== 0 ? ( + + ) : null} +
+ ) : null} + {errors.splits?.message || errors.splits?.root?.message ? ( + + {errors.splits.message ?? errors.splits.root?.message} + + ) : null} +
+ ) : null} +
+
+
+ {errors.root ? {errors.root.message} : null} +
+
+ )} +
+ ); +} diff --git a/src/features/transactions/components/transactions-view.tsx b/src/features/transactions/components/transactions-view.tsx new file mode 100644 index 0000000..453fc78 --- /dev/null +++ b/src/features/transactions/components/transactions-view.tsx @@ -0,0 +1,713 @@ +"use client"; + +import { + ArrowLeftRight, + ChevronLeft, + ChevronRight, + Copy, + Download, + Filter, + Paperclip, + Pencil, + Plus, + Split, + Trash2, + 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 { 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"; +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + 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 type { TransactionFilters } from "@/shared/schemas/transaction"; +import { + TRANSACTION_STATUSES, + TRANSACTION_TYPES, + type TransactionType, +} from "@/shared/transactions"; +import { getTransactionAction, setTransactionDeletedAction } from "../actions"; +import type { + TransactionDetail, + TransactionFormOptions, + TransactionListItem, + TransactionPage, +} from "../service"; + +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 --- + +type Options = TransactionFormOptions & { members: { id: string; name: string }[] }; + +function countActive(f: TransactionFilters) { + return ( + [ + "from", + "to", + "type", + "hesap", + "kategori", + "kisi", + "durum", + "min", + "max", + "etiket", + "q", + ] as const + ).filter((k) => f[k] !== undefined && f[k] !== "").length; +} + +function FiltersDialog({ + open, + onOpenChange, + filters, + raw, + options, +}: { + open: boolean; + onOpenChange: (o: boolean) => void; + filters: TransactionFilters; + raw: Record; + options: Options; +}) { + const router = useRouter(); + const pathname = usePathname(); + const [v, setV] = useState>(raw); + const set = (k: string, value: string) => setV((prev) => ({ ...prev, [k]: value })); + const f = t.filters; + + function apply(next: Record) { + const params = new URLSearchParams(); + for (const [k, value] of Object.entries(next)) if (value && k !== "sayfa") params.set(k, value); + router.push(params.size ? `${pathname}?${params}` : pathname); + onOpenChange(false); + } + + const select = (key: string, label: string, items: { value: string; label: string }[]) => ( + + {label} + + + ); + + return ( + { + if (o) setV(raw); + onOpenChange(o); + }} + title={f.title} + footer={ + <> + + + + } + > +
{ + e.preventDefault(); + apply(v); + }} + > + + + {f.search} + set("q", e.target.value)} + placeholder={f.searchPlaceholder} + /> + +
+ + {f.from} + set("from", e.target.value)} + /> + + + {f.to} + set("to", e.target.value)} + /> + +
+
+ {select( + "type", + f.type, + TRANSACTION_TYPES.map((x) => ({ value: x, label: t.types[x] })), + )} + {select( + "durum", + f.status, + TRANSACTION_STATUSES.map((x) => ({ value: x, label: t.statuses[x] })), + )} +
+ {select( + "hesap", + f.account, + options.accounts.map((a) => ({ value: a.id, label: a.name })), + )} + {select( + "kategori", + f.category, + options.categories.map((c) => ({ + value: c.id, + label: `${c.kind === "income" ? "↑" : "↓"} ${c.path}`, + })), + )} + {select( + "kisi", + f.person, + options.members.map((m) => ({ value: m.id, label: m.name })), + )} +
+ + {f.min} + set("min", x)} /> + + + {f.max} + set("max", x)} /> + +
+ + {f.tag} + set("etiket", e.target.value)} + /> + + + + + + ) : 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({ + data, + filters, + raw, + options, + canWrite, +}: { + data: TransactionPage; + filters: TransactionFilters; + raw: Record; + options: Options; + canWrite: boolean; +}) { + const pathname = usePathname(); + const { openQuickAdd, openTransactionForm } = 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 active = countActive(filters); + + const pageHref = (page: number) => { + const params = new URLSearchParams(raw); + if (page > 1) params.set("sayfa", String(page)); + else params.delete("sayfa"); + return params.size ? `${pathname}?${params}` : pathname; + }; + const exportHref = () => { + const params = new URLSearchParams(raw); + params.delete("sayfa"); + 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) { + const last = groups.at(-1); + if (last?.date === item.date) last.items.push(item); + else groups.push({ date: item.date, items: [item] }); + } + + return ( +
+
+ + {active ? ( + + ) : null} + + {canWrite ? ( + + ) : null} +
+ +
+ {( + [ + ["income", data.sums.incomeMinor, "text-income"], + ["expense", data.sums.expenseMinor, "text-expense"], + ["net", data.sums.incomeMinor - data.sums.expenseMinor, ""], + ] as const + ).map(([key, value, tone]) => ( + + + {t.list.sums[key]} + + {formatMoney(value)} + + + + ))} +
+ + {data.items.length === 0 ? ( +

+ {active ? t.list.empty : t.list.emptyAll} +

+ ) : ( + groups.map((g) => { + const net = g.items.reduce( + (a, i) => a + (i.status === "posted" ? signed(i.type, i.amountMinor) : 0), + 0, + ); + return ( +
+
+

{dayLabel(g.date)}

+ 0 && "text-income", + net < 0 && "text-expense", + )} + title={t.list.dayTotal} + > + {net === 0 ? "" : `${net > 0 ? "+" : "−"}${formatMoney(Math.abs(net))}`} + +
+
    + {g.items.map((item) => ( +
  • + +
  • + ))} +
+
+ ); + }) + )} + + {data.pageCount > 1 ? ( + + ) : data.total ? ( +

{t.list.total(data.total)}

+ ) : null} + + + !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} + + + + +
+ ); +} diff --git a/src/i18n/tr.ts b/src/i18n/tr.ts index c2ad9e8..d172a56 100644 --- a/src/i18n/tr.ts +++ b/src/i18n/tr.ts @@ -233,6 +233,121 @@ export const tr = { typeLocked: "Hesap türü değiştirilemez.", ownerOnly: "Hesapları yalnızca hane sahipleri yönetebilir.", }, + transactions: { + title: "İşlemler", + description: "Gelir, gider ve transferler.", + new: "Yeni işlem", + types: { expense: "Gider", income: "Gelir", transfer: "Transfer" }, + statuses: { posted: "Gerçekleşti", planned: "Planlı", void: "İptal" }, + createTitle: { expense: "Yeni gider", income: "Yeni gelir", transfer: "Yeni transfer" }, + editTitle: "İşlemi düzenle", + copyTitle: "İşlemi kopyala", + fields: { + amount: "Tutar", + date: "Tarih", + account: "Hesap", + fromAccount: "Kaynak hesap", + toAccount: "Hedef hesap", + category: "Kategori", + selectCategory: "Kategori seçin", + selectAccount: "Hesap seçin", + payee: "Satıcı / karşı taraf", + payeePlaceholder: "ör. Migros", + description: "Açıklama", + tags: "Etiketler", + tagsHint: "Virgülle ayırın (ör. tatil, indirim)", + advanced: "Gelişmiş: 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", + removeSplit: "Kalemi kaldır", + splitNote: "Not", + remaining: "Kalan", + remainingOk: "Kalemler toplamı tutara eşit", + fillRemaining: "Kalanı son kaleme ekle", + suggested: "Bu satıcı için son kullanılan kategori ve hesap önerildi.", + }, + validation: { + amountPositive: "Tutar sıfırdan büyük olmalı", + selectAccount: "Hesap seçin", + selectToAccount: "Hedef hesabı seçin", + sameAccount: "Kaynak ve hedef hesap aynı olamaz", + selectCategory: "Kategori seçin", + splitMin: "Bölünmüş işlemde en az 2 kalem olmalı", + splitSum: "Kalemlerin toplamı tutara eşit olmalı", + accountInvalid: "Hesap bu hanede yok, arşivli veya size kapalı.", + categoryInvalid: "Kategori bu hanede yok, arşivli veya işlem türüyle uyuşmuyor.", + }, + list: { + empty: "Bu filtrelerle işlem bulunamadı.", + emptyAll: "Henüz işlem yok.", + split: (n: number) => `Bölünmüş · ${n} kalem`, + transfer: (from: string, to: string) => `${from} → ${to}`, + dayTotal: "Günlük net", + total: (n: number) => `${n} işlem`, + sums: { income: "Gelir", expense: "Gider", net: "Net" }, + hiddenAccount: "Gizli hesap", + }, + filters: { + title: "Filtreler", + open: "Filtrele", + apply: "Uygula", + clear: "Temizle", + from: "Başlangıç", + to: "Bitiş", + type: "Tür", + account: "Hesap", + category: "Kategori", + person: "Ekleyen", + status: "Durum", + min: "En az (TL)", + max: "En çok (TL)", + tag: "Etiket", + search: "Ara", + searchPlaceholder: "Açıklama, satıcı veya not…", + all: "Tümü", + active: (n: number) => `${n} filtre`, + }, + detail: { + title: "İşlem ayrıntısı", + lines: "Kalemler", + createdBy: "Ekleyen", + updatedBy: "Son düzenleyen", + createdAt: "Oluşturulma", + }, + actions: { edit: "Düzenle", copy: "Kopyala", delete: "Sil", restore: "Geri al" }, + toast: { + created: "İşlem kaydedildi.", + updated: "İşlem güncellendi.", + deleted: "İşlem silindi.", + restored: "İşlem geri alındı.", + }, + deleteTitle: "İşlem silinsin mi?", + deleteDescription: + "İşlem listeden ve bakiyelerden kaldırılır. Bildirimdeki “Geri al” ile geri getirebilirsiniz.", + export: "CSV indir", + csv: { + header: [ + "İşlem No", + "Tarih", + "Tür", + "Durum", + "Hesap", + "Hedef hesap", + "Kategori", + "Satıcı", + "Açıklama", + "Kalem notu", + "Etiketler", + "Tutar", + "Ekleyen", + ], + filename: (date: string) => `butcem-islemler-${date}.csv`, + }, + readOnly: "İzleyici rolündesiniz; işlem ekleyemezsiniz.", + noAccounts: "İşlem girmek için önce bir hesap gerekli.", + noAccountsLink: "Hesaplara git", + }, categories: { title: "Kategoriler", description: "Gelir ve gider kategorileri. Alt kategoriler raporlarda üst kategoriye toplanır.", @@ -315,7 +430,7 @@ export const tr = { expenseHint: "Harcama, fatura, alışveriş", incomeHint: "Maaş, ek gelir, iade", transferHint: "Hesaplar ve cariler arası", - notYet: "İşlem formu Faz 6'da eklenecek.", + noAccounts: "İşlem girmek için önce bir hesap gerekli.", }, auth: { loginTitle: "Giriş yap",