diff --git a/src/app/(dashboard)/services/components/delete-service-dialog.tsx b/src/app/(dashboard)/services/components/delete-service-dialog.tsx new file mode 100644 index 0000000..5ddc95e --- /dev/null +++ b/src/app/(dashboard)/services/components/delete-service-dialog.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useTransition } from "react"; +import { Loader2, 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 { deleteServiceAction } from "@/lib/appwrite/service-actions"; + +export function DeleteServiceDialog({ + open, + onOpenChange, + id, + name, +}: { + open: boolean; + onOpenChange: (v: boolean) => void; + id: string | null; + name: string; +}) { + const [isPending, startTransition] = useTransition(); + + const handleDelete = () => { + if (!id) return; + startTransition(async () => { + const fd = new FormData(); + fd.set("id", id); + const result = await deleteServiceAction(fd); + if (result.ok) { + toast.success("Hizmet silindi."); + onOpenChange(false); + } else { + toast.error(result.error ?? "Silme başarısız."); + } + }); + }; + + return ( + + + + Hizmeti sil + + {name} kalıcı olarak silinecek. Bu işlem geri alınamaz. + + + + onOpenChange(false)} disabled={isPending}> + Vazgeç + + + {isPending ? ( + <> + + Siliniyor... + > + ) : ( + <> + + Sil + > + )} + + + + + ); +} diff --git a/src/app/(dashboard)/services/components/service-form-sheet.tsx b/src/app/(dashboard)/services/components/service-form-sheet.tsx new file mode 100644 index 0000000..c234238 --- /dev/null +++ b/src/app/(dashboard)/services/components/service-form-sheet.tsx @@ -0,0 +1,204 @@ +"use client"; + +import { useActionState, useEffect } from "react"; +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 { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { + createServiceAction, + updateServiceAction, +} from "@/lib/appwrite/service-actions"; +import { initialServiceState } from "@/lib/appwrite/service-types"; +import type { CustomerOption, ServiceRow } from "./types"; + +type Props = { + open: boolean; + onOpenChange: (v: boolean) => void; + service?: ServiceRow | null; + customers: CustomerOption[]; + defaultCustomerId?: string; +}; + +export function ServiceFormSheet({ + open, + onOpenChange, + service, + customers, + defaultCustomerId, +}: Props) { + const isEdit = Boolean(service); + const action = isEdit ? updateServiceAction : createServiceAction; + const [state, formAction, isPending] = useActionState(action, initialServiceState); + + useEffect(() => { + if (state.ok) { + toast.success(isEdit ? "Hizmet güncellendi." : "Hizmet eklendi."); + onOpenChange(false); + } else if (state.error) { + toast.error(state.error); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state]); + + return ( + + + + {isEdit ? "Hizmeti düzenle" : "Yeni hizmet"} + + {customers.length === 0 + ? "Hizmet eklemek için önce en az bir müşteri tanımlamalısınız." + : "Müşteriye sunduğunuz hizmeti tanımlayın."} + + + + + {isEdit && service && } + + + + Müşteri * + + + + + + {customers.map((c) => ( + + {c.name} + + ))} + + + {state.fieldErrors?.customerId && ( + {state.fieldErrors.customerId} + )} + + + + Hizmet adı * + + {state.fieldErrors?.name && ( + {state.fieldErrors.name} + )} + + + + Açıklama + + + + + + Birim fiyat (₺) * + + {state.fieldErrors?.unitPrice && ( + {state.fieldErrors.unitPrice} + )} + + + Faturalama dönemi + + + + + + Tek seferlik + Aylık + Yıllık + + + + + + + + + Tekrarlayan hizmet + + + Bu hizmet düzenli olarak fatura kesilecek mi? + + + + + + + + + onOpenChange(false)} + disabled={isPending} + > + Vazgeç + + + {isPending ? ( + <> + + Kaydediliyor... + > + ) : ( + <> + + {isEdit ? "Güncelle" : "Kaydet"} + > + )} + + + + + + + ); +} diff --git a/src/app/(dashboard)/services/components/services-client.tsx b/src/app/(dashboard)/services/components/services-client.tsx new file mode 100644 index 0000000..83c0ba0 --- /dev/null +++ b/src/app/(dashboard)/services/components/services-client.tsx @@ -0,0 +1,308 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { + type ColumnDef, + type SortingState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { + ArrowUpDown, + Briefcase, + ChevronLeft, + ChevronRight, + MoreHorizontal, + Pencil, + Plus, + Repeat, + Search, + Trash2, +} from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +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 { BILLING_PERIOD_LABEL, formatTRY } from "@/lib/format"; + +import { ServiceFormSheet } from "./service-form-sheet"; +import { DeleteServiceDialog } from "./delete-service-dialog"; +import type { CustomerOption, ServiceRow } from "./types"; + +type Props = { + services: ServiceRow[]; + customers: CustomerOption[]; +}; + +export function ServicesClient({ services, customers }: Props) { + const [globalFilter, setGlobalFilter] = useState(""); + const [sorting, setSorting] = useState([]); + const [formOpen, setFormOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [deleting, setDeleting] = useState(null); + + const columns = useMemo[]>( + () => [ + { + accessorKey: "name", + header: ({ column }) => ( + column.toggleSorting(column.getIsSorted() === "asc")} + > + Hizmet + + + ), + cell: ({ row }) => ( + + {row.original.name} + {row.original.description && ( + + {row.original.description} + + )} + + ), + }, + { + accessorKey: "customerName", + header: "Müşteri", + cell: ({ row }) => {row.original.customerName}, + }, + { + accessorKey: "unitPrice", + header: ({ column }) => ( + column.toggleSorting(column.getIsSorted() === "asc")} + > + Fiyat + + + ), + cell: ({ row }) => ( + {formatTRY(row.original.unitPrice)} + ), + }, + { + accessorKey: "billingPeriod", + header: "Dönem", + cell: ({ row }) => ( + + {BILLING_PERIOD_LABEL[row.original.billingPeriod]} + {row.original.recurring && ( + + )} + + ), + }, + { + id: "actions", + cell: ({ row }) => ( + + + + + + + + + { + setEditing(row.original); + setFormOpen(true); + }} + > + + Düzenle + + setDeleting(row.original)} + > + + Sil + + + + + ), + }, + ], + [], + ); + + const table = useReactTable({ + data: services, + columns, + state: { globalFilter, sorting }, + onGlobalFilterChange: setGlobalFilter, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + initialState: { pagination: { pageSize: 20 } }, + globalFilterFn: (row, _id, filterValue) => { + const v = String(filterValue).toLowerCase(); + return [row.original.name, row.original.customerName, row.original.description] + .join(" ") + .toLowerCase() + .includes(v); + }, + }); + + return ( + + + + + + setGlobalFilter(e.target.value)} + placeholder="Hizmet adı, müşteri..." + className="pl-9" + /> + + { + setEditing(null); + setFormOpen(true); + }} + disabled={customers.length === 0} + > + + Yeni hizmet + + + + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} + + + {table.getRowModel().rows.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + + + + {customers.length === 0 + ? "Önce bir müşteri ekleyin, sonra hizmet tanımlayabilirsiniz." + : "Henüz hizmet eklenmemiş."} + + {customers.length > 0 && ( + { + setEditing(null); + setFormOpen(true); + }} + > + + İlk hizmeti ekle + + )} + + + + )} + + + + + + Toplam {table.getFilteredRowModel().rows.length} hizmet + + + table.previousPage()} + disabled={!table.getCanPreviousPage()} + > + + + + Sayfa {table.getState().pagination.pageIndex + 1} /{" "} + {Math.max(table.getPageCount(), 1)} + + table.nextPage()} + disabled={!table.getCanNextPage()} + > + + + + + + + { + setFormOpen(v); + if (!v) setEditing(null); + }} + service={editing} + customers={customers} + /> + + !v && setDeleting(null)} + id={deleting?.id ?? null} + name={deleting?.name ?? ""} + /> + + ); +} diff --git a/src/app/(dashboard)/services/components/types.ts b/src/app/(dashboard)/services/components/types.ts new file mode 100644 index 0000000..e097d3d --- /dev/null +++ b/src/app/(dashboard)/services/components/types.ts @@ -0,0 +1,13 @@ +export type ServiceRow = { + id: string; + customerId: string; + customerName: string; + name: string; + description: string; + unitPrice: number; + recurring: boolean; + billingPeriod: "monthly" | "yearly" | "onetime"; + createdAt: string; +}; + +export type CustomerOption = { id: string; name: string }; diff --git a/src/app/(dashboard)/services/page.tsx b/src/app/(dashboard)/services/page.tsx new file mode 100644 index 0000000..543bbc3 --- /dev/null +++ b/src/app/(dashboard)/services/page.tsx @@ -0,0 +1,54 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; + +import { listCustomers } from "@/lib/appwrite/customer-queries"; +import { listServices } from "@/lib/appwrite/service-queries"; +import { requireTenant } from "@/lib/appwrite/tenant-guard"; +import { ServicesClient } from "./components/services-client"; + +export const metadata: Metadata = { + title: "İşletmem — Hizmetler", +}; + +export default async function ServicesPage() { + let ctx; + try { + ctx = await requireTenant(); + } catch { + redirect("/onboarding"); + } + + const [services, customers] = await Promise.all([ + listServices(ctx.tenantId), + listCustomers(ctx.tenantId), + ]); + + const customerMap = new Map(customers.map((c) => [c.$id, c.name])); + + return ( + + + {ctx.settings?.companyName ?? "Çalışma alanı"} + Hizmetler + + Müşterilere sunduğunuz hizmetleri ve fiyatlarını yönetin. + + + + ({ + id: s.$id, + customerId: s.customerId, + customerName: customerMap.get(s.customerId) ?? "—", + name: s.name, + description: s.description ?? "", + unitPrice: s.unitPrice, + recurring: Boolean(s.recurring), + billingPeriod: s.billingPeriod ?? "onetime", + createdAt: s.$createdAt, + }))} + customers={customers.map((c) => ({ id: c.$id, name: c.name }))} + /> + + ); +} diff --git a/src/lib/appwrite/service-actions.ts b/src/lib/appwrite/service-actions.ts new file mode 100644 index 0000000..eebcae7 --- /dev/null +++ b/src/lib/appwrite/service-actions.ts @@ -0,0 +1,185 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { AppwriteException, ID, Permission, Role } from "node-appwrite"; +import { z } from "zod"; + +import { logAudit } from "./audit"; +import { DATABASE_ID, TABLES, type Service } from "./schema"; +import { createAdminClient } from "./server"; +import { requireTenant } from "./tenant-guard"; +import type { ServiceActionState } from "./service-types"; +import { serviceSchema } from "@/lib/validation/services"; + +function appwriteError(e: unknown): string { + if (e instanceof AppwriteException) { + return e.message || "Beklenmeyen bir hata oluştu."; + } + return "Bağlantı hatası. Tekrar deneyin."; +} + +function pickFormFields(formData: FormData) { + return { + customerId: String(formData.get("customerId") ?? ""), + name: String(formData.get("name") ?? "").trim(), + description: String(formData.get("description") ?? "").trim(), + unitPrice: String(formData.get("unitPrice") ?? "0"), + recurring: formData.get("recurring") ?? false, + billingPeriod: (formData.get("billingPeriod") as "monthly" | "yearly" | "onetime" | null) ?? + "onetime", + }; +} + +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")), + ]; +} + +export async function createServiceAction( + _prev: ServiceActionState, + formData: FormData, +): Promise { + let ctx; + try { + ctx = await requireTenant(); + } catch { + return { ok: false, error: "Yetkiniz yok." }; + } + + const parsed = serviceSchema.safeParse(pickFormFields(formData)); + if (!parsed.success) { + return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) }; + } + + try { + const { tablesDB } = createAdminClient(); + const row = await tablesDB.createRow( + DATABASE_ID, + TABLES.services, + ID.unique(), + { + tenantId: ctx.tenantId, + createdBy: ctx.user.id, + ...parsed.data, + }, + teamRowPermissions(ctx.tenantId), + ); + + await logAudit({ + tenantId: ctx.tenantId, + userId: ctx.user.id, + action: "create", + entityType: "service", + entityId: row.$id, + changes: parsed.data, + }); + } catch (e) { + return { ok: false, error: appwriteError(e) }; + } + + revalidatePath("/services"); + return { ok: true }; +} + +export async function updateServiceAction( + _prev: ServiceActionState, + 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 = serviceSchema.safeParse(pickFormFields(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.services, + id, + )) as unknown as Service; + + if (existing.tenantId !== ctx.tenantId) { + return { ok: false, error: "Erişim engellendi." }; + } + + await tablesDB.updateRow(DATABASE_ID, TABLES.services, id, parsed.data); + + await logAudit({ + tenantId: ctx.tenantId, + userId: ctx.user.id, + action: "update", + entityType: "service", + entityId: id, + changes: parsed.data, + }); + } catch (e) { + return { ok: false, error: appwriteError(e) }; + } + + revalidatePath("/services"); + return { ok: true }; +} + +export async function deleteServiceAction(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.services, + id, + )) as unknown as Service; + + if (existing.tenantId !== ctx.tenantId) { + return { ok: false, error: "Erişim engellendi." }; + } + + await tablesDB.deleteRow(DATABASE_ID, TABLES.services, id); + + await logAudit({ + tenantId: ctx.tenantId, + userId: ctx.user.id, + action: "delete", + entityType: "service", + entityId: id, + changes: { name: existing.name }, + }); + } catch (e) { + return { ok: false, error: appwriteError(e) }; + } + + revalidatePath("/services"); + return { ok: true }; +} diff --git a/src/lib/appwrite/service-queries.ts b/src/lib/appwrite/service-queries.ts new file mode 100644 index 0000000..b9f7a6b --- /dev/null +++ b/src/lib/appwrite/service-queries.ts @@ -0,0 +1,46 @@ +import "server-only"; + +import { Query } from "node-appwrite"; + +import { createAdminClient } from "./server"; +import { DATABASE_ID, TABLES, type Service } from "./schema"; + +export async function listServices(tenantId: string): Promise { + try { + const { tablesDB } = createAdminClient(); + const result = await tablesDB.listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.services, + queries: [ + Query.equal("tenantId", tenantId), + Query.orderDesc("$createdAt"), + Query.limit(500), + ], + }); + return result.rows as unknown as Service[]; + } catch { + return []; + } +} + +export async function listServicesByCustomer( + tenantId: string, + customerId: string, +): Promise { + try { + const { tablesDB } = createAdminClient(); + const result = await tablesDB.listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.services, + queries: [ + Query.equal("tenantId", tenantId), + Query.equal("customerId", customerId), + Query.orderDesc("$createdAt"), + Query.limit(500), + ], + }); + return result.rows as unknown as Service[]; + } catch { + return []; + } +} diff --git a/src/lib/appwrite/service-types.ts b/src/lib/appwrite/service-types.ts new file mode 100644 index 0000000..2460937 --- /dev/null +++ b/src/lib/appwrite/service-types.ts @@ -0,0 +1,7 @@ +export type ServiceActionState = { + ok: boolean; + error?: string; + fieldErrors?: Record; +}; + +export const initialServiceState: ServiceActionState = { ok: false }; diff --git a/src/lib/format.ts b/src/lib/format.ts new file mode 100644 index 0000000..f6414cf --- /dev/null +++ b/src/lib/format.ts @@ -0,0 +1,34 @@ +export function formatTRY(amount: number): string { + return new Intl.NumberFormat("tr-TR", { + style: "currency", + currency: "TRY", + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(amount); +} + +export function formatDate(iso: string | undefined | null): string { + if (!iso) return "—"; + return new Date(iso).toLocaleDateString("tr-TR", { + day: "2-digit", + month: "short", + year: "numeric", + }); +} + +export function formatDateTime(iso: string | undefined | null): string { + if (!iso) return "—"; + return new Date(iso).toLocaleString("tr-TR", { + day: "2-digit", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +export const BILLING_PERIOD_LABEL: Record = { + monthly: "Aylık", + yearly: "Yıllık", + onetime: "Tek seferlik", +}; diff --git a/src/lib/validation/services.ts b/src/lib/validation/services.ts new file mode 100644 index 0000000..523cd40 --- /dev/null +++ b/src/lib/validation/services.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; + +export const serviceSchema = z.object({ + customerId: z.string().min(1, "Müşteri seçin."), + name: z.string().trim().min(1, "Hizmet adı zorunlu.").max(255), + description: z + .string() + .trim() + .max(2000) + .optional() + .transform((v) => (v ? v : undefined)), + unitPrice: z + .union([z.number(), z.string()]) + .transform((v) => (typeof v === "string" ? Number(v.replace(",", ".")) : v)) + .pipe(z.number().nonnegative("Negatif olamaz.")), + recurring: z + .union([z.boolean(), z.literal("on"), z.literal(""), z.undefined()]) + .transform((v) => v === true || v === "on") + .optional(), + billingPeriod: z.enum(["monthly", "yearly", "onetime"]).optional().default("onetime"), +}); + +export type ServiceInput = z.infer;
{state.fieldErrors.customerId}
{state.fieldErrors.name}
{state.fieldErrors.unitPrice}
+ Bu hizmet düzenli olarak fatura kesilecek mi? +
+ {customers.length === 0 + ? "Önce bir müşteri ekleyin, sonra hizmet tanımlayabilirsiniz." + : "Henüz hizmet eklenmemiş."} +
+ Toplam {table.getFilteredRowModel().rows.length} hizmet +
{ctx.settings?.companyName ?? "Çalışma alanı"}
+ Müşterilere sunduğunuz hizmetleri ve fiyatlarını yönetin. +