From a15a1c1c1a425878cdfe55cb5ec04e4e2d7ee9fc Mon Sep 17 00:00:00 2001 From: kovakmedya Date: Thu, 30 Apr 2026 05:46:55 +0300 Subject: [PATCH] feat(services): customer-linked services CRUD module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same pattern as customers. Each service belongs to one customer (FK), has unit price (TRY), billing period (monthly/yearly/onetime), and recurring flag. - lib/validation/services.ts: Zod schema with TRY price coercion (accepts comma decimal), enum billing period, recurring boolean coercion. - lib/appwrite/service-actions.ts: createServiceAction, updateServiceAction, deleteServiceAction. Same tenant guard, audit log, team-scoped row perms. - lib/appwrite/service-queries.ts: listServices, listServicesByCustomer. - lib/format.ts: formatTRY (Intl.NumberFormat tr-TR), formatDate/DateTime, BILLING_PERIOD_LABEL mapping (Aylık/Yıllık/Tek seferlik). - /services page (server): joins services with customer names via in-memory map. - ServicesClient: TanStack table with global filter (name/customer/desc), Repeat icon next to recurring badges, formatted TRY column. - ServiceFormSheet: customer dropdown (disabled if no customers exist), unit price + billing period + recurring switch. - DeleteServiceDialog: same destructive confirmation pattern. Empty state on /services CTA's user back to add a customer first if none exist. --- .../components/delete-service-dialog.tsx | 76 +++++ .../components/service-form-sheet.tsx | 204 ++++++++++++ .../services/components/services-client.tsx | 308 ++++++++++++++++++ .../(dashboard)/services/components/types.ts | 13 + src/app/(dashboard)/services/page.tsx | 54 +++ src/lib/appwrite/service-actions.ts | 185 +++++++++++ src/lib/appwrite/service-queries.ts | 46 +++ src/lib/appwrite/service-types.ts | 7 + src/lib/format.ts | 34 ++ src/lib/validation/services.ts | 23 ++ 10 files changed, 950 insertions(+) create mode 100644 src/app/(dashboard)/services/components/delete-service-dialog.tsx create mode 100644 src/app/(dashboard)/services/components/service-form-sheet.tsx create mode 100644 src/app/(dashboard)/services/components/services-client.tsx create mode 100644 src/app/(dashboard)/services/components/types.ts create mode 100644 src/app/(dashboard)/services/page.tsx create mode 100644 src/lib/appwrite/service-actions.ts create mode 100644 src/lib/appwrite/service-queries.ts create mode 100644 src/lib/appwrite/service-types.ts create mode 100644 src/lib/format.ts create mode 100644 src/lib/validation/services.ts 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. + + + + + + + + + ); +} 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 && } + +
+
+ + + {state.fieldErrors?.customerId && ( +

{state.fieldErrors.customerId}

+ )} +
+ +
+ + + {state.fieldErrors?.name && ( +

{state.fieldErrors.name}

+ )} +
+ +
+ +