diff --git a/src/app/(dashboard)/invoices/[id]/components/header-actions.tsx b/src/app/(dashboard)/invoices/[id]/components/header-actions.tsx
new file mode 100644
index 0000000..1693043
--- /dev/null
+++ b/src/app/(dashboard)/invoices/[id]/components/header-actions.tsx
@@ -0,0 +1,95 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+import { Loader2, Pencil, Printer, Trash2 } from "lucide-react";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { deleteInvoiceAction } from "@/lib/appwrite/invoice-actions";
+
+import { InvoiceFormSheet } from "../../components/invoice-form-sheet";
+import type { Customer, InvoiceRow } from "../../components/types";
+
+type Props = { invoice: InvoiceRow; customers: Customer[] };
+
+export function InvoiceHeaderActions({ invoice, customers }: Props) {
+ const router = useRouter();
+ const [editOpen, setEditOpen] = useState(false);
+ const [deleting, setDeleting] = useState(false);
+ const [busy, startTransition] = useTransition();
+
+ const handleDelete = () => {
+ startTransition(async () => {
+ const fd = new FormData();
+ fd.set("id", invoice.id);
+ const result = await deleteInvoiceAction(fd);
+ if (result.ok) {
+ toast.success("Fatura silindi.");
+ router.push("/invoices");
+ } else {
+ toast.error(result.error ?? "Silme başarısız.");
+ setDeleting(false);
+ }
+ });
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/(dashboard)/invoices/[id]/components/items-editor.tsx b/src/app/(dashboard)/invoices/[id]/components/items-editor.tsx
new file mode 100644
index 0000000..52b3157
--- /dev/null
+++ b/src/app/(dashboard)/invoices/[id]/components/items-editor.tsx
@@ -0,0 +1,303 @@
+"use client";
+
+import { useActionState, useEffect, useState, useTransition } from "react";
+import { Loader2, Plus, Save, Trash2 } from "lucide-react";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Sheet,
+ SheetContent,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import {
+ addInvoiceItemAction,
+ deleteInvoiceItemAction,
+ updateInvoiceItemAction,
+} from "@/lib/appwrite/invoice-actions";
+import { initialInvoiceState } from "@/lib/appwrite/invoice-types";
+import { formatTRY } from "@/lib/format";
+
+export type InvoiceItemRow = {
+ id: string;
+ description: string;
+ quantity: number;
+ unitPrice: number;
+ vatRate: number;
+ lineTotal: number;
+};
+
+type Props = { invoiceId: string; items: InvoiceItemRow[] };
+
+export function InvoiceItemsEditor({ invoiceId, items }: Props) {
+ const [formOpen, setFormOpen] = useState(false);
+ const [editing, setEditing] = useState(null);
+ const [deleting, setDeleting] = useState(null);
+ const [busy, startTransition] = useTransition();
+
+ const handleDelete = () => {
+ if (!deleting) return;
+ startTransition(async () => {
+ const fd = new FormData();
+ fd.set("id", deleting.id);
+ const result = await deleteInvoiceItemAction(fd);
+ if (result.ok) {
+ toast.success("Kalem silindi.");
+ setDeleting(null);
+ } else {
+ toast.error(result.error ?? "Silme başarısız.");
+ }
+ });
+ };
+
+ return (
+ <>
+
+
+
+
Kalemler ({items.length})
+
+
+
+
+
+
+ Açıklama
+ Miktar
+ Birim fiyat
+ KDV %
+ Toplam
+
+
+
+
+ {items.length ? (
+ items.map((it) => (
+ {
+ setEditing(it);
+ setFormOpen(true);
+ }}
+ >
+ {it.description}
+ {it.quantity}
+
+ {formatTRY(it.unitPrice)}
+
+ {it.vatRate}%
+
+ {formatTRY(it.lineTotal)}
+
+
+
+
+
+ ))
+ ) : (
+
+
+
+ Henüz kalem eklenmemiş. Yukarıdan ekleyin.
+
+
+
+ )}
+
+
+
+
+
+ {
+ setFormOpen(v);
+ if (!v) setEditing(null);
+ }}
+ invoiceId={invoiceId}
+ item={editing}
+ />
+
+
+ >
+ );
+}
+
+function ItemFormSheet({
+ open,
+ onOpenChange,
+ invoiceId,
+ item,
+}: {
+ open: boolean;
+ onOpenChange: (v: boolean) => void;
+ invoiceId: string;
+ item?: InvoiceItemRow | null;
+}) {
+ const isEdit = Boolean(item);
+ const action = isEdit ? updateInvoiceItemAction : addInvoiceItemAction;
+ const [state, formAction, isPending] = useActionState(action, initialInvoiceState);
+
+ useEffect(() => {
+ if (state.ok) {
+ toast.success(isEdit ? "Kalem güncellendi." : "Kalem eklendi.");
+ onOpenChange(false);
+ } else if (state.error) {
+ toast.error(state.error);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [state]);
+
+ return (
+
+
+
+ {isEdit ? "Kalemi düzenle" : "Yeni kalem"}
+
+
+
+
+
+ );
+}
diff --git a/src/app/(dashboard)/invoices/[id]/page.tsx b/src/app/(dashboard)/invoices/[id]/page.tsx
new file mode 100644
index 0000000..9563e0c
--- /dev/null
+++ b/src/app/(dashboard)/invoices/[id]/page.tsx
@@ -0,0 +1,134 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { notFound, redirect } from "next/navigation";
+import { ArrowLeft } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { listCustomers } from "@/lib/appwrite/customer-queries";
+import { getInvoice, listInvoiceItems } from "@/lib/appwrite/invoice-queries";
+import { requireTenant } from "@/lib/appwrite/tenant-guard";
+import { formatDate, formatTRY } from "@/lib/format";
+import { cn } from "@/lib/utils";
+
+import { STATUS_COLOR, STATUS_LABEL } from "../components/types";
+import { InvoiceItemsEditor } from "./components/items-editor";
+import { InvoiceHeaderActions } from "./components/header-actions";
+
+export const metadata: Metadata = {
+ title: "İşletmem — Fatura",
+};
+
+export default async function InvoiceDetailPage({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ const { id } = await params;
+
+ let ctx;
+ try {
+ ctx = await requireTenant();
+ } catch {
+ redirect("/onboarding");
+ }
+
+ const invoice = await getInvoice(ctx.tenantId, id);
+ if (!invoice) notFound();
+
+ const [items, customers] = await Promise.all([
+ listInvoiceItems(ctx.tenantId, id),
+ listCustomers(ctx.tenantId),
+ ]);
+
+ const customerName = customers.find((c) => c.$id === invoice.customerId)?.name ?? "—";
+
+ return (
+
+
+
+
+
+
{customerName}
+
{invoice.number}
+
+
+ {STATUS_LABEL[invoice.status ?? "draft"]}
+
+
+ Düzenleme: {formatDate(invoice.issueDate)}
+
+ Vade: {formatDate(invoice.dueDate)}
+
+
+
({ id: c.$id, name: c.name }))}
+ />
+
+
+
({
+ id: it.$id,
+ description: it.description,
+ quantity: it.quantity,
+ unitPrice: it.unitPrice,
+ vatRate: it.vatRate ?? 0,
+ lineTotal: it.lineTotal,
+ }))}
+ />
+
+
+
+
+ Ara toplam
+ {formatTRY(invoice.subtotal ?? 0)}
+
+
+ KDV
+ {formatTRY(invoice.vatTotal ?? 0)}
+
+
+
+ Genel toplam
+ {formatTRY(invoice.total ?? 0)}
+
+
+
+
+ {invoice.notes && (
+
+
+ Notlar
+ {invoice.notes}
+
+
+ )}
+
+ );
+}
diff --git a/src/app/(dashboard)/invoices/components/invoice-form-sheet.tsx b/src/app/(dashboard)/invoices/components/invoice-form-sheet.tsx
new file mode 100644
index 0000000..e09fed6
--- /dev/null
+++ b/src/app/(dashboard)/invoices/components/invoice-form-sheet.tsx
@@ -0,0 +1,190 @@
+"use client";
+
+import { useActionState, useEffect } from "react";
+import { useRouter } from "next/navigation";
+import { Loader2, Save } from "lucide-react";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ createInvoiceAction,
+ updateInvoiceAction,
+} from "@/lib/appwrite/invoice-actions";
+import { initialInvoiceState } from "@/lib/appwrite/invoice-types";
+
+import type { Customer, InvoiceRow } from "./types";
+
+type Props = {
+ open: boolean;
+ onOpenChange: (v: boolean) => void;
+ invoice?: InvoiceRow | null;
+ customers: Customer[];
+};
+
+function isoToDate(iso: string): string {
+ if (!iso) return "";
+ return iso.slice(0, 10);
+}
+
+export function InvoiceFormSheet({ open, onOpenChange, invoice, customers }: Props) {
+ const isEdit = Boolean(invoice);
+ const action = isEdit ? updateInvoiceAction : createInvoiceAction;
+ const [state, formAction, isPending] = useActionState(action, initialInvoiceState);
+ const router = useRouter();
+
+ useEffect(() => {
+ if (state.ok) {
+ toast.success(isEdit ? "Fatura güncellendi." : "Fatura oluşturuldu.");
+ onOpenChange(false);
+ if (!isEdit && state.invoiceId) {
+ router.push(`/invoices/${state.invoiceId}`);
+ }
+ } else if (state.error) {
+ toast.error(state.error);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [state]);
+
+ const today = new Date().toISOString().slice(0, 10);
+ const inThirty = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
+ .toISOString()
+ .slice(0, 10);
+
+ return (
+
+
+
+ {isEdit ? "Faturayı düzenle" : "Yeni fatura"}
+
+ {isEdit
+ ? "Fatura bilgilerini güncelleyin. Kalem eklemek için fatura detayına gidin."
+ : "Faturayı oluşturun, ardından detay sayfasında kalemleri ekleyin. Numara otomatik üretilir."}
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(dashboard)/invoices/components/invoices-client.tsx b/src/app/(dashboard)/invoices/components/invoices-client.tsx
new file mode 100644
index 0000000..8d2f1de
--- /dev/null
+++ b/src/app/(dashboard)/invoices/components/invoices-client.tsx
@@ -0,0 +1,372 @@
+"use client";
+
+import { useMemo, useState, useTransition } from "react";
+import Link from "next/link";
+import {
+ type ColumnDef,
+ flexRender,
+ getCoreRowModel,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getSortedRowModel,
+ type SortingState,
+ useReactTable,
+} from "@tanstack/react-table";
+import {
+ ArrowUpRight,
+ ChevronLeft,
+ ChevronRight,
+ ExternalLink,
+ Loader2,
+ MoreHorizontal,
+ Plus,
+ Receipt,
+ Search,
+ Trash2,
+} from "lucide-react";
+import { toast } from "sonner";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Input } from "@/components/ui/input";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { deleteInvoiceAction } from "@/lib/appwrite/invoice-actions";
+import { formatDate, formatTRY } from "@/lib/format";
+import { cn } from "@/lib/utils";
+
+import { InvoiceFormSheet } from "./invoice-form-sheet";
+import { type Customer, type InvoiceRow, STATUS_COLOR, STATUS_LABEL } from "./types";
+
+type Props = { invoices: InvoiceRow[]; customers: Customer[] };
+
+export function InvoicesClient({ invoices, customers }: Props) {
+ const [search, setSearch] = useState("");
+ const [sorting, setSorting] = useState([]);
+ const [formOpen, setFormOpen] = useState(false);
+ const [deleting, setDeleting] = useState(null);
+ const [busy, startTransition] = useTransition();
+
+ const stats = useMemo(() => {
+ let total = 0;
+ let outstanding = 0;
+ let paid = 0;
+ let overdue = 0;
+ for (const i of invoices) {
+ total += i.total;
+ if (i.status === "paid") paid += i.total;
+ else if (i.status === "overdue") {
+ outstanding += i.total;
+ overdue += i.total;
+ } else if (i.status === "sent" || i.status === "draft") outstanding += i.total;
+ }
+ return { total, outstanding, paid, overdue };
+ }, [invoices]);
+
+ const columns = useMemo[]>(
+ () => [
+ {
+ accessorKey: "number",
+ header: "Numara",
+ cell: ({ row }) => (
+
+ {row.original.number}
+
+
+ ),
+ },
+ {
+ accessorKey: "customerName",
+ header: "Müşteri",
+ cell: ({ row }) => row.original.customerName,
+ },
+ {
+ accessorKey: "issueDate",
+ header: "Tarih",
+ cell: ({ row }) => (
+ {formatDate(row.original.issueDate)}
+ ),
+ },
+ {
+ accessorKey: "dueDate",
+ header: "Vade",
+ cell: ({ row }) => {
+ const overdue =
+ row.original.status !== "paid" &&
+ row.original.status !== "cancelled" &&
+ new Date(row.original.dueDate) < new Date();
+ return (
+
+ {formatDate(row.original.dueDate)}
+
+ );
+ },
+ },
+ {
+ accessorKey: "status",
+ header: "Durum",
+ cell: ({ row }) => (
+
+ {STATUS_LABEL[row.original.status]}
+
+ ),
+ },
+ {
+ accessorKey: "total",
+ header: "Toplam",
+ cell: ({ row }) => (
+ {formatTRY(row.original.total)}
+ ),
+ },
+ {
+ id: "actions",
+ cell: ({ row }) => (
+
+
+
+
+
+
+
+
+
+ Aç
+
+
+ setDeleting(row.original)}
+ >
+
+ Sil
+
+
+
+
+ ),
+ },
+ ],
+ [],
+ );
+
+ const table = useReactTable({
+ data: invoices,
+ columns,
+ state: { globalFilter: search, sorting },
+ onGlobalFilterChange: setSearch,
+ onSortingChange: setSorting,
+ getCoreRowModel: getCoreRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ initialState: { pagination: { pageSize: 25 } },
+ globalFilterFn: (row, _id, fv) => {
+ const v = String(fv).toLowerCase();
+ return [row.original.number, row.original.customerName, row.original.notes]
+ .join(" ")
+ .toLowerCase()
+ .includes(v);
+ },
+ });
+
+ const handleDelete = () => {
+ if (!deleting) return;
+ startTransition(async () => {
+ const fd = new FormData();
+ fd.set("id", deleting.id);
+ const result = await deleteInvoiceAction(fd);
+ if (result.ok) {
+ toast.success("Fatura silindi.");
+ setDeleting(null);
+ } else {
+ toast.error(result.error ?? "Silme başarısız.");
+ }
+ });
+ };
+
+ return (
+ <>
+
+
+
+ Toplam
+ {formatTRY(stats.total)}
+
+
+
+
+ Tahsil edildi
+
+ {formatTRY(stats.paid)}
+
+
+
+
+
+ Bekleyen
+
+ {formatTRY(stats.outstanding)}
+
+
+
+
+
+ Gecikmiş
+
+ {formatTRY(stats.overdue)}
+
+
+
+
+
+
+
+
+
+
+ setSearch(e.target.value)}
+ placeholder="Numara, müşteri, not..."
+ className="pl-9"
+ />
+
+
+
+
+
+
+ {table.getHeaderGroups().map((hg) => (
+
+ {hg.headers.map((h) => (
+
+ {h.isPlaceholder
+ ? null
+ : flexRender(h.column.columnDef.header, h.getContext())}
+
+ ))}
+
+ ))}
+
+
+ {table.getRowModel().rows.length ? (
+ table.getRowModel().rows.map((r) => (
+
+ {r.getVisibleCells().map((c) => (
+
+ {flexRender(c.column.columnDef.cell, c.getContext())}
+
+ ))}
+
+ ))
+ ) : (
+
+
+
+
+
+ {customers.length === 0
+ ? "Önce müşteri ekleyin, sonra fatura kesebilirsiniz."
+ : "Henüz fatura yok."}
+
+
+
+
+ )}
+
+
+
+
+
+ Toplam {table.getFilteredRowModel().rows.length} fatura
+
+
+
+
+ Sayfa {table.getState().pagination.pageIndex + 1} /{" "}
+ {Math.max(table.getPageCount(), 1)}
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/(dashboard)/invoices/components/types.ts b/src/app/(dashboard)/invoices/components/types.ts
new file mode 100644
index 0000000..524f2a7
--- /dev/null
+++ b/src/app/(dashboard)/invoices/components/types.ts
@@ -0,0 +1,33 @@
+export type InvoiceStatus = "draft" | "sent" | "paid" | "overdue" | "cancelled";
+
+export type InvoiceRow = {
+ id: string;
+ number: string;
+ customerId: string;
+ customerName: string;
+ issueDate: string;
+ dueDate: string;
+ status: InvoiceStatus;
+ subtotal: number;
+ vatTotal: number;
+ total: number;
+ notes: string;
+};
+
+export type Customer = { id: string; name: string };
+
+export const STATUS_LABEL: Record = {
+ draft: "Taslak",
+ sent: "Gönderildi",
+ paid: "Ödendi",
+ overdue: "Gecikmiş",
+ cancelled: "İptal",
+};
+
+export const STATUS_COLOR: Record = {
+ draft: "bg-slate-500/15 text-slate-700 dark:text-slate-300 border-slate-500/30",
+ sent: "bg-blue-500/15 text-blue-700 dark:text-blue-300 border-blue-500/30",
+ paid: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border-emerald-500/30",
+ overdue: "bg-red-500/15 text-red-700 dark:text-red-300 border-red-500/30",
+ cancelled: "bg-muted text-muted-foreground border-muted-foreground/30",
+};
diff --git a/src/app/(dashboard)/invoices/page.tsx b/src/app/(dashboard)/invoices/page.tsx
new file mode 100644
index 0000000..6d0eaec
--- /dev/null
+++ b/src/app/(dashboard)/invoices/page.tsx
@@ -0,0 +1,56 @@
+import type { Metadata } from "next";
+import { redirect } from "next/navigation";
+
+import { listCustomers } from "@/lib/appwrite/customer-queries";
+import { listInvoices } from "@/lib/appwrite/invoice-queries";
+import { requireTenant } from "@/lib/appwrite/tenant-guard";
+import { InvoicesClient } from "./components/invoices-client";
+
+export const metadata: Metadata = {
+ title: "İşletmem — Faturalar",
+};
+
+export default async function InvoicesPage() {
+ let ctx;
+ try {
+ ctx = await requireTenant();
+ } catch {
+ redirect("/onboarding");
+ }
+
+ const [invoices, customers] = await Promise.all([
+ listInvoices(ctx.tenantId),
+ listCustomers(ctx.tenantId),
+ ]);
+
+ const customerMap = new Map(customers.map((c) => [c.$id, c.name]));
+
+ return (
+
+
+
{ctx.settings?.companyName ?? "Çalışma alanı"}
+
Faturalar
+
+ Müşterilerinize fatura kesin, kalemleri yönetin, durumu takip edin.
+
+
+
+
({
+ id: i.$id,
+ number: i.number,
+ customerId: i.customerId,
+ customerName: customerMap.get(i.customerId) ?? "—",
+ issueDate: i.issueDate,
+ dueDate: i.dueDate,
+ status: i.status ?? "draft",
+ subtotal: i.subtotal ?? 0,
+ vatTotal: i.vatTotal ?? 0,
+ total: i.total ?? 0,
+ notes: i.notes ?? "",
+ }))}
+ customers={customers.map((c) => ({ id: c.$id, name: c.name }))}
+ />
+
+ );
+}
diff --git a/src/lib/appwrite/invoice-actions.ts b/src/lib/appwrite/invoice-actions.ts
new file mode 100644
index 0000000..5071758
--- /dev/null
+++ b/src/lib/appwrite/invoice-actions.ts
@@ -0,0 +1,486 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite";
+import { z } from "zod";
+
+import { logAudit } from "./audit";
+import {
+ DATABASE_ID,
+ TABLES,
+ type Invoice,
+ type InvoiceItem,
+ type TenantSettings,
+} from "./schema";
+import { createAdminClient } from "./server";
+import { requireTenant } from "./tenant-guard";
+import type { InvoiceActionState } from "./invoice-types";
+import { invoiceItemSchema, invoiceSchema } from "@/lib/validation/invoices";
+
+function appwriteError(e: unknown): string {
+ if (e instanceof AppwriteException) return e.message || "Beklenmeyen hata.";
+ return "Bağlantı hatası. Tekrar deneyin.";
+}
+
+function flattenErrors(err: z.ZodError): Record {
+ const out: Record = {};
+ for (const issue of err.issues) {
+ const key = issue.path.join(".");
+ if (key && !out[key]) out[key] = issue.message;
+ }
+ return out;
+}
+
+function teamRowPermissions(tenantId: string) {
+ return [
+ Permission.read(Role.team(tenantId)),
+ Permission.update(Role.team(tenantId)),
+ Permission.delete(Role.team(tenantId, "owner")),
+ Permission.delete(Role.team(tenantId, "admin")),
+ ];
+}
+
+function toIso(v: string): string {
+ if (!v) return v;
+ if (/^\d{4}-\d{2}-\d{2}$/.test(v)) return `${v}T00:00:00.000+00:00`;
+ return v;
+}
+
+async function nextInvoiceNumber(
+ tenantId: string,
+): Promise<{ number: string; settingsId: string | null }> {
+ const { tablesDB } = createAdminClient();
+ const result = await tablesDB.listRows({
+ databaseId: DATABASE_ID,
+ tableId: TABLES.tenantSettings,
+ queries: [Query.equal("tenantId", tenantId), Query.limit(1)],
+ });
+ const settings = result.rows[0] as unknown as TenantSettings | undefined;
+ const prefix = settings?.invoicePrefix || "INV";
+ const counter = (settings?.invoiceCounter ?? 0) + 1;
+ const year = new Date().getFullYear();
+ const number = `${prefix}-${year}-${String(counter).padStart(4, "0")}`;
+
+ if (settings) {
+ await tablesDB.updateRow(DATABASE_ID, TABLES.tenantSettings, settings.$id, {
+ invoiceCounter: counter,
+ });
+ return { number, settingsId: settings.$id };
+ }
+ return { number, settingsId: null };
+}
+
+async function recomputeTotals(
+ tenantId: string,
+ invoiceId: string,
+): Promise<{ subtotal: number; vatTotal: number; total: number }> {
+ const { tablesDB } = createAdminClient();
+ const result = await tablesDB.listRows({
+ databaseId: DATABASE_ID,
+ tableId: TABLES.invoiceItems,
+ queries: [
+ Query.equal("tenantId", tenantId),
+ Query.equal("invoiceId", invoiceId),
+ Query.limit(500),
+ ],
+ });
+ let subtotal = 0;
+ let vatTotal = 0;
+ for (const r of result.rows as unknown as InvoiceItem[]) {
+ const lineNet = (r.quantity ?? 0) * (r.unitPrice ?? 0);
+ const lineVat = lineNet * ((r.vatRate ?? 0) / 100);
+ subtotal += lineNet;
+ vatTotal += lineVat;
+ }
+ const total = subtotal + vatTotal;
+ await tablesDB.updateRow(DATABASE_ID, TABLES.invoices, invoiceId, {
+ subtotal: Number(subtotal.toFixed(2)),
+ vatTotal: Number(vatTotal.toFixed(2)),
+ total: Number(total.toFixed(2)),
+ });
+ return { subtotal, vatTotal, total };
+}
+
+// -------------------- Invoice header --------------------
+
+function pickInvoiceFields(formData: FormData) {
+ return {
+ customerId: String(formData.get("customerId") ?? ""),
+ issueDate: String(formData.get("issueDate") ?? ""),
+ dueDate: String(formData.get("dueDate") ?? ""),
+ status:
+ (formData.get("status") as "draft" | "sent" | "paid" | "overdue" | "cancelled" | null) ??
+ "draft",
+ notes: String(formData.get("notes") ?? "").trim(),
+ };
+}
+
+export async function createInvoiceAction(
+ _prev: InvoiceActionState,
+ formData: FormData,
+): Promise {
+ let ctx;
+ try {
+ ctx = await requireTenant();
+ } catch {
+ return { ok: false, error: "Yetkiniz yok." };
+ }
+
+ const parsed = invoiceSchema.safeParse(pickInvoiceFields(formData));
+ if (!parsed.success) {
+ return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
+ }
+
+ let invoiceId = "";
+ try {
+ const { tablesDB } = createAdminClient();
+ const { number } = await nextInvoiceNumber(ctx.tenantId);
+
+ const data = {
+ ...parsed.data,
+ issueDate: toIso(parsed.data.issueDate),
+ dueDate: toIso(parsed.data.dueDate),
+ number,
+ subtotal: 0,
+ vatTotal: 0,
+ total: 0,
+ };
+
+ const row = await tablesDB.createRow(
+ DATABASE_ID,
+ TABLES.invoices,
+ ID.unique(),
+ {
+ tenantId: ctx.tenantId,
+ createdBy: ctx.user.id,
+ ...data,
+ },
+ teamRowPermissions(ctx.tenantId),
+ );
+ invoiceId = row.$id;
+
+ await logAudit({
+ tenantId: ctx.tenantId,
+ userId: ctx.user.id,
+ action: "create",
+ entityType: "invoice",
+ entityId: row.$id,
+ changes: { number, customerId: parsed.data.customerId },
+ });
+ } catch (e) {
+ return { ok: false, error: appwriteError(e) };
+ }
+
+ revalidatePath("/invoices");
+ return { ok: true, invoiceId };
+}
+
+export async function updateInvoiceAction(
+ _prev: InvoiceActionState,
+ formData: FormData,
+): Promise {
+ const id = String(formData.get("id") ?? "");
+ if (!id) return { ok: false, error: "ID eksik." };
+
+ let ctx;
+ try {
+ ctx = await requireTenant();
+ } catch {
+ return { ok: false, error: "Yetkiniz yok." };
+ }
+
+ const parsed = invoiceSchema.safeParse(pickInvoiceFields(formData));
+ if (!parsed.success) {
+ return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
+ }
+
+ try {
+ const { tablesDB } = createAdminClient();
+ const existing = (await tablesDB.getRow(
+ DATABASE_ID,
+ TABLES.invoices,
+ id,
+ )) as unknown as Invoice;
+ if (existing.tenantId !== ctx.tenantId) {
+ return { ok: false, error: "Erişim engellendi." };
+ }
+
+ const data = {
+ ...parsed.data,
+ issueDate: toIso(parsed.data.issueDate),
+ dueDate: toIso(parsed.data.dueDate),
+ };
+ await tablesDB.updateRow(DATABASE_ID, TABLES.invoices, id, data);
+
+ await logAudit({
+ tenantId: ctx.tenantId,
+ userId: ctx.user.id,
+ action: "update",
+ entityType: "invoice",
+ entityId: id,
+ changes: data,
+ });
+ } catch (e) {
+ return { ok: false, error: appwriteError(e) };
+ }
+
+ revalidatePath("/invoices");
+ revalidatePath(`/invoices/${id}`);
+ return { ok: true, invoiceId: id };
+}
+
+export async function deleteInvoiceAction(
+ formData: FormData,
+): Promise {
+ const id = String(formData.get("id") ?? "");
+ if (!id) return { ok: false, error: "ID eksik." };
+
+ let ctx;
+ try {
+ ctx = await requireTenant();
+ } catch {
+ return { ok: false, error: "Yetkiniz yok." };
+ }
+
+ try {
+ const { tablesDB } = createAdminClient();
+ const existing = (await tablesDB.getRow(
+ DATABASE_ID,
+ TABLES.invoices,
+ id,
+ )) as unknown as Invoice;
+ if (existing.tenantId !== ctx.tenantId) {
+ return { ok: false, error: "Erişim engellendi." };
+ }
+
+ // Delete items first
+ const items = await tablesDB.listRows({
+ databaseId: DATABASE_ID,
+ tableId: TABLES.invoiceItems,
+ queries: [
+ Query.equal("tenantId", ctx.tenantId),
+ Query.equal("invoiceId", id),
+ Query.limit(500),
+ ],
+ });
+ for (const it of items.rows) {
+ await tablesDB.deleteRow(DATABASE_ID, TABLES.invoiceItems, it.$id);
+ }
+
+ await tablesDB.deleteRow(DATABASE_ID, TABLES.invoices, id);
+
+ await logAudit({
+ tenantId: ctx.tenantId,
+ userId: ctx.user.id,
+ action: "delete",
+ entityType: "invoice",
+ entityId: id,
+ changes: { number: existing.number, items: items.rows.length },
+ });
+ } catch (e) {
+ return { ok: false, error: appwriteError(e) };
+ }
+
+ revalidatePath("/invoices");
+ return { ok: true };
+}
+
+// -------------------- Invoice items --------------------
+
+function pickItemFields(formData: FormData) {
+ return {
+ description: String(formData.get("description") ?? "").trim(),
+ quantity: String(formData.get("quantity") ?? "1"),
+ unitPrice: String(formData.get("unitPrice") ?? "0"),
+ vatRate: String(formData.get("vatRate") ?? "20"),
+ };
+}
+
+function lineTotal(qty: number, unitPrice: number, vatRate: number): number {
+ const net = qty * unitPrice;
+ const vat = net * (vatRate / 100);
+ return Number((net + vat).toFixed(2));
+}
+
+export async function addInvoiceItemAction(
+ _prev: InvoiceActionState,
+ formData: FormData,
+): Promise {
+ const invoiceId = String(formData.get("invoiceId") ?? "");
+ if (!invoiceId) return { ok: false, error: "Fatura ID eksik." };
+
+ let ctx;
+ try {
+ ctx = await requireTenant();
+ } catch {
+ return { ok: false, error: "Yetkiniz yok." };
+ }
+
+ const parsed = invoiceItemSchema.safeParse(pickItemFields(formData));
+ if (!parsed.success) {
+ return { ok: false, error: "Kalem geçersiz.", fieldErrors: flattenErrors(parsed.error) };
+ }
+
+ try {
+ const { tablesDB } = createAdminClient();
+ const invoice = (await tablesDB.getRow(
+ DATABASE_ID,
+ TABLES.invoices,
+ invoiceId,
+ )) as unknown as Invoice;
+ if (invoice.tenantId !== ctx.tenantId) {
+ return { ok: false, error: "Erişim engellendi." };
+ }
+
+ const total = lineTotal(parsed.data.quantity, parsed.data.unitPrice, parsed.data.vatRate ?? 0);
+
+ const row = await tablesDB.createRow(
+ DATABASE_ID,
+ TABLES.invoiceItems,
+ ID.unique(),
+ {
+ tenantId: ctx.tenantId,
+ createdBy: ctx.user.id,
+ invoiceId,
+ description: parsed.data.description,
+ quantity: parsed.data.quantity,
+ unitPrice: parsed.data.unitPrice,
+ vatRate: parsed.data.vatRate ?? 0,
+ lineTotal: total,
+ },
+ teamRowPermissions(ctx.tenantId),
+ );
+
+ await recomputeTotals(ctx.tenantId, invoiceId);
+
+ await logAudit({
+ tenantId: ctx.tenantId,
+ userId: ctx.user.id,
+ action: "create",
+ entityType: "invoice_item",
+ entityId: row.$id,
+ changes: { invoiceId, ...parsed.data, lineTotal: total },
+ });
+ } catch (e) {
+ return { ok: false, error: appwriteError(e) };
+ }
+
+ revalidatePath(`/invoices/${invoiceId}`);
+ return { ok: true, invoiceId };
+}
+
+export async function updateInvoiceItemAction(
+ _prev: InvoiceActionState,
+ formData: FormData,
+): Promise {
+ const id = String(formData.get("id") ?? "");
+ if (!id) return { ok: false, error: "ID eksik." };
+
+ let ctx;
+ try {
+ ctx = await requireTenant();
+ } catch {
+ return { ok: false, error: "Yetkiniz yok." };
+ }
+
+ const parsed = invoiceItemSchema.safeParse(pickItemFields(formData));
+ if (!parsed.success) {
+ return { ok: false, error: "Kalem geçersiz.", fieldErrors: flattenErrors(parsed.error) };
+ }
+
+ try {
+ const { tablesDB } = createAdminClient();
+ const existing = (await tablesDB.getRow(
+ DATABASE_ID,
+ TABLES.invoiceItems,
+ id,
+ )) as unknown as InvoiceItem;
+ if (existing.tenantId !== ctx.tenantId) {
+ return { ok: false, error: "Erişim engellendi." };
+ }
+
+ const total = lineTotal(parsed.data.quantity, parsed.data.unitPrice, parsed.data.vatRate ?? 0);
+
+ await tablesDB.updateRow(DATABASE_ID, TABLES.invoiceItems, id, {
+ description: parsed.data.description,
+ quantity: parsed.data.quantity,
+ unitPrice: parsed.data.unitPrice,
+ vatRate: parsed.data.vatRate ?? 0,
+ lineTotal: total,
+ });
+
+ await recomputeTotals(ctx.tenantId, existing.invoiceId);
+
+ await logAudit({
+ tenantId: ctx.tenantId,
+ userId: ctx.user.id,
+ action: "update",
+ entityType: "invoice_item",
+ entityId: id,
+ changes: { ...parsed.data, lineTotal: total },
+ });
+ } catch (e) {
+ return { ok: false, error: appwriteError(e) };
+ }
+
+ revalidatePath(`/invoices/${(await getItemInvoiceId(id)) ?? ""}`);
+ return { ok: true };
+}
+
+async function getItemInvoiceId(itemId: string): Promise {
+ try {
+ const { tablesDB } = createAdminClient();
+ const row = (await tablesDB.getRow(
+ DATABASE_ID,
+ TABLES.invoiceItems,
+ itemId,
+ )) as unknown as InvoiceItem;
+ return row.invoiceId;
+ } catch {
+ return null;
+ }
+}
+
+export async function deleteInvoiceItemAction(
+ formData: FormData,
+): Promise {
+ const id = String(formData.get("id") ?? "");
+ if (!id) return { ok: false, error: "ID eksik." };
+
+ let ctx;
+ try {
+ ctx = await requireTenant();
+ } catch {
+ return { ok: false, error: "Yetkiniz yok." };
+ }
+
+ try {
+ const { tablesDB } = createAdminClient();
+ const existing = (await tablesDB.getRow(
+ DATABASE_ID,
+ TABLES.invoiceItems,
+ id,
+ )) as unknown as InvoiceItem;
+ if (existing.tenantId !== ctx.tenantId) {
+ return { ok: false, error: "Erişim engellendi." };
+ }
+
+ await tablesDB.deleteRow(DATABASE_ID, TABLES.invoiceItems, id);
+ await recomputeTotals(ctx.tenantId, existing.invoiceId);
+
+ await logAudit({
+ tenantId: ctx.tenantId,
+ userId: ctx.user.id,
+ action: "delete",
+ entityType: "invoice_item",
+ entityId: id,
+ changes: { invoiceId: existing.invoiceId },
+ });
+
+ revalidatePath(`/invoices/${existing.invoiceId}`);
+ } catch (e) {
+ return { ok: false, error: appwriteError(e) };
+ }
+
+ return { ok: true };
+}
diff --git a/src/lib/appwrite/invoice-queries.ts b/src/lib/appwrite/invoice-queries.ts
new file mode 100644
index 0000000..c7d90e7
--- /dev/null
+++ b/src/lib/appwrite/invoice-queries.ts
@@ -0,0 +1,64 @@
+import "server-only";
+
+import { Query } from "node-appwrite";
+
+import { createAdminClient } from "./server";
+import { DATABASE_ID, TABLES, type Invoice, type InvoiceItem } from "./schema";
+
+export async function listInvoices(tenantId: string): Promise {
+ try {
+ const { tablesDB } = createAdminClient();
+ const result = await tablesDB.listRows({
+ databaseId: DATABASE_ID,
+ tableId: TABLES.invoices,
+ queries: [
+ Query.equal("tenantId", tenantId),
+ Query.orderDesc("issueDate"),
+ Query.limit(500),
+ ],
+ });
+ return result.rows as unknown as Invoice[];
+ } catch {
+ return [];
+ }
+}
+
+export async function getInvoice(
+ tenantId: string,
+ id: string,
+): Promise {
+ try {
+ const { tablesDB } = createAdminClient();
+ const row = (await tablesDB.getRow(
+ DATABASE_ID,
+ TABLES.invoices,
+ id,
+ )) as unknown as Invoice;
+ if (row.tenantId !== tenantId) return null;
+ return row;
+ } catch {
+ return null;
+ }
+}
+
+export async function listInvoiceItems(
+ tenantId: string,
+ invoiceId: string,
+): Promise {
+ try {
+ const { tablesDB } = createAdminClient();
+ const result = await tablesDB.listRows({
+ databaseId: DATABASE_ID,
+ tableId: TABLES.invoiceItems,
+ queries: [
+ Query.equal("tenantId", tenantId),
+ Query.equal("invoiceId", invoiceId),
+ Query.orderAsc("$createdAt"),
+ Query.limit(500),
+ ],
+ });
+ return result.rows as unknown as InvoiceItem[];
+ } catch {
+ return [];
+ }
+}
diff --git a/src/lib/appwrite/invoice-types.ts b/src/lib/appwrite/invoice-types.ts
new file mode 100644
index 0000000..5a269db
--- /dev/null
+++ b/src/lib/appwrite/invoice-types.ts
@@ -0,0 +1,8 @@
+export type InvoiceActionState = {
+ ok: boolean;
+ error?: string;
+ fieldErrors?: Record;
+ invoiceId?: string;
+};
+
+export const initialInvoiceState: InvoiceActionState = { ok: false };
diff --git a/src/lib/validation/invoices.ts b/src/lib/validation/invoices.ts
new file mode 100644
index 0000000..72e3444
--- /dev/null
+++ b/src/lib/validation/invoices.ts
@@ -0,0 +1,36 @@
+import { z } from "zod";
+
+export const invoiceSchema = z.object({
+ customerId: z.string().min(1, "Müşteri seçin."),
+ issueDate: z.string().min(1, "Düzenleme tarihi zorunlu."),
+ dueDate: z.string().min(1, "Vade tarihi zorunlu."),
+ status: z
+ .enum(["draft", "sent", "paid", "overdue", "cancelled"])
+ .optional()
+ .default("draft"),
+ notes: z.string().trim().max(2000).optional().transform((v) => (v ? v : undefined)),
+});
+
+export type InvoiceInput = z.infer;
+
+export const invoiceItemSchema = z.object({
+ description: z.string().trim().min(1, "Açıklama zorunlu.").max(1000),
+ quantity: z
+ .union([z.number(), z.string()])
+ .transform((v) => (typeof v === "string" ? Number(v.replace(",", ".")) : v))
+ .pipe(z.number().positive("Miktar 0'dan büyük olmalı.")),
+ unitPrice: z
+ .union([z.number(), z.string()])
+ .transform((v) => (typeof v === "string" ? Number(v.replace(",", ".")) : v))
+ .pipe(z.number().nonnegative("Negatif olamaz.")),
+ vatRate: z
+ .union([z.number(), z.string()])
+ .optional()
+ .transform((v) => {
+ if (v === undefined || v === "") return 0;
+ const n = typeof v === "string" ? Number(v.replace(",", ".")) : v;
+ return Number.isFinite(n) ? n : 0;
+ }),
+});
+
+export type InvoiceItemInput = z.infer;