diff --git a/src/app/(app)/hesaplar/[id]/page.tsx b/src/app/(app)/hesaplar/[id]/page.tsx new file mode 100644 index 0000000..8bd5cfa --- /dev/null +++ b/src/app/(app)/hesaplar/[id]/page.tsx @@ -0,0 +1,31 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { hasHouseholdRole } from "@/features/households/roles"; +import { StatementView } from "@/features/transactions/components/statement-view"; +import { currentMonth, getAccountStatement } from "@/features/transactions/statement"; +import { tr } from "@/i18n/tr"; +import { requireActiveHousehold } from "@/lib/dal"; +import { MONTH_PATTERN } from "@/shared/statement"; + +export const metadata: Metadata = { title: tr.statement.title }; + +/** /hesaplar/:id?ay=2026-11 — aylık hesap ekstresi (yürüyen bakiye) ve mutabakat. */ +export default async function AccountStatementPage({ + params, + searchParams, +}: PageProps<"/hesaplar/[id]">) { + const { user, household } = await requireActiveHousehold("viewer"); + const [{ id }, sp] = await Promise.all([params, searchParams]); + const now = currentMonth(); + const requested = typeof sp.ay === "string" && MONTH_PATTERN.test(sp.ay) ? sp.ay : now; + const month = requested > now ? now : requested; + const statement = await getAccountStatement(user.id, household.id, id, month); + if (!statement) notFound(); + return ( + + ); +} diff --git a/src/components/layout/nav-config.ts b/src/components/layout/nav-config.ts index 8c2d6de..25e362a 100644 --- a/src/components/layout/nav-config.ts +++ b/src/components/layout/nav-config.ts @@ -27,7 +27,7 @@ export type NavItem = { /** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */ /** Alt fazlar ondalıkla: 6.1 = Faz 6a. */ -export const CURRENT_PHASE = 6.2; +export const CURRENT_PHASE = 6.3; export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE; diff --git a/src/components/ui/carousel.tsx b/src/components/ui/carousel.tsx new file mode 100644 index 0000000..e7b81b2 --- /dev/null +++ b/src/components/ui/carousel.tsx @@ -0,0 +1,242 @@ +"use client"; + +import * as React from "react"; +import { cn } from "cn"; +import useEmblaCarousel, { type UseEmblaCarouselType } from "embla-carousel-react"; + +import { Button } from "@/components/ui/button"; +import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; + +type CarouselApi = UseEmblaCarouselType[1]; +type UseCarouselParameters = Parameters; +type CarouselOptions = UseCarouselParameters[0]; +type CarouselPlugin = UseCarouselParameters[1]; + +type CarouselProps = { + opts?: CarouselOptions; + plugins?: CarouselPlugin; + orientation?: "horizontal" | "vertical"; + setApi?: (api: CarouselApi) => void; +}; + +type CarouselContextProps = { + carouselRef: ReturnType[0]; + api: ReturnType[1]; + scrollPrev: () => void; + scrollNext: () => void; + canScrollPrev: boolean; + canScrollNext: boolean; +} & CarouselProps; + +const CarouselContext = React.createContext(null); + +function useCarousel() { + const context = React.useContext(CarouselContext); + + if (!context) { + throw new Error("useCarousel must be used within a "); + } + + return context; +} + +function Carousel({ + orientation = "horizontal", + opts, + setApi, + plugins, + className, + children, + ...props +}: React.ComponentProps<"div"> & CarouselProps) { + const [carouselRef, api] = useEmblaCarousel( + { + ...opts, + axis: orientation === "horizontal" ? "x" : "y", + }, + plugins, + ); + const [canScrollPrev, setCanScrollPrev] = React.useState(false); + const [canScrollNext, setCanScrollNext] = React.useState(false); + + const onSelect = React.useCallback((api: CarouselApi) => { + if (!api) return; + setCanScrollPrev(api.canScrollPrev()); + setCanScrollNext(api.canScrollNext()); + }, []); + + const scrollPrev = React.useCallback(() => { + api?.scrollPrev(); + }, [api]); + + const scrollNext = React.useCallback(() => { + api?.scrollNext(); + }, [api]); + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "ArrowLeft") { + event.preventDefault(); + scrollPrev(); + } else if (event.key === "ArrowRight") { + event.preventDefault(); + scrollNext(); + } + }, + [scrollPrev, scrollNext], + ); + + React.useEffect(() => { + if (!api || !setApi) return; + setApi(api); + }, [api, setApi]); + + React.useEffect(() => { + if (!api) return; + // İlk durum bir sonraki karede okunur (effect içinde senkron setState yapılmaz). + const frame = requestAnimationFrame(() => onSelect(api)); + api.on("reInit", onSelect); + api.on("select", onSelect); + + return () => { + cancelAnimationFrame(frame); + api.off("select", onSelect); + api.off("reInit", onSelect); + }; + }, [api, onSelect]); + + return ( + +
+ {children} +
+
+ ); +} + +function CarouselContent({ + className, + containerClassName, + ...props +}: React.ComponentProps<"div"> & { containerClassName?: string }) { + const { carouselRef, orientation } = useCarousel(); + + return ( +
+
+
+ ); +} + +function CarouselItem({ className, ...props }: React.ComponentProps<"div">) { + const { orientation } = useCarousel(); + + return ( +
+ ); +} + +function CarouselPrevious({ + className, + variant = "outline", + size = "icon-sm", + ...props +}: React.ComponentProps) { + const { orientation, scrollPrev, canScrollPrev } = useCarousel(); + + return ( + + ); +} + +function CarouselNext({ + className, + variant = "outline", + size = "icon-sm", + ...props +}: React.ComponentProps) { + const { orientation, scrollNext, canScrollNext } = useCarousel(); + + return ( + + ); +} + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, + useCarousel, +}; diff --git a/src/features/accounts/components/accounts-view.tsx b/src/features/accounts/components/accounts-view.tsx index c8a994a..c78d641 100644 --- a/src/features/accounts/components/accounts-view.tsx +++ b/src/features/accounts/components/accounts-view.tsx @@ -110,12 +110,14 @@ export function AccountsView({
  • - - {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 ( + + +
    +
    + + {current?.name ?? t.viewerTitle} + + {t.viewerTitle} + + {t.position(index + 1, items.length)} + + {current ? ( + + ) : null} + +
    + + + {items.map((item) => ( + + {item.kind === "image" ? ( + // eslint-disable-next-line @next/next/no-img-element -- yetkili özel dosya; optimizasyon hattına girmez + {item.name} + ) : ( +
    + +
    +

    {item.name}

    +

    + {t.pdf} · {formatFileSize(item.size)} +

    +
    + +
    + )} +
    + ))} +
    + {items.length > 1 ? ( + <> + + + + ) : null} +
    +
    +
    +
    + ); +} 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.",