From 113988273f4b39bfcb207a3484eb5707f549272a Mon Sep 17 00:00:00 2001 From: kovakmedya Date: Thu, 30 Apr 2026 05:50:33 +0300 Subject: [PATCH] feat(software): catalog + customer assignments (M2M) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Software catalog with per-customer assignments via the customer_software join table. Two tabs in one /software page: Catalog tab: - Software CRUD: name, version, description, defaultFee (TRY). - Deleting a software cascades and removes all its assignments first (best-effort loop, then the catalog row), all wrapped in audit logs. Assignments tab: - M2M between customer and software with own fee (overrides defaultFee), billingPeriod (monthly default), startDate/endDate, notes. - Form auto-fills fee from selected software's defaultFee. - Both Sheet forms localized; date inputs round-tripped via toIsoDate (Appwrite expects ISO 8601 with TZ; HTML date input gives YYYY-MM-DD). - Delete dialogs differentiated for catalog ('siliniyor') vs assignment ('kaldırılıyor'). New files: - lib/validation/software.ts (softwareSchema + customerSoftwareSchema) - lib/appwrite/software-actions.ts (6 server actions) - lib/appwrite/software-queries.ts (listSoftware, listAssignments) - lib/appwrite/software-types.ts (form state) - /software route with SoftwareClient (Tabs), SoftwareFormSheet, AssignmentFormSheet, inline delete dialogs. Empty states surface the right next-step CTA: 'önce müşteri ekleyin', or 'önce yazılım ekleyin', as appropriate. --- .../components/assignment-form-sheet.tsx | 236 ++++++++ .../software/components/software-client.tsx | 553 ++++++++++++++++++ .../components/software-form-sheet.tsx | 140 +++++ .../(dashboard)/software/components/types.ts | 24 + src/app/(dashboard)/software/page.tsx | 65 ++ src/lib/appwrite/software-actions.ts | 370 ++++++++++++ src/lib/appwrite/software-queries.ts | 47 ++ src/lib/appwrite/software-types.ts | 7 + src/lib/validation/software.ts | 41 ++ 9 files changed, 1483 insertions(+) create mode 100644 src/app/(dashboard)/software/components/assignment-form-sheet.tsx create mode 100644 src/app/(dashboard)/software/components/software-client.tsx create mode 100644 src/app/(dashboard)/software/components/software-form-sheet.tsx create mode 100644 src/app/(dashboard)/software/components/types.ts create mode 100644 src/app/(dashboard)/software/page.tsx create mode 100644 src/lib/appwrite/software-actions.ts create mode 100644 src/lib/appwrite/software-queries.ts create mode 100644 src/lib/appwrite/software-types.ts create mode 100644 src/lib/validation/software.ts diff --git a/src/app/(dashboard)/software/components/assignment-form-sheet.tsx b/src/app/(dashboard)/software/components/assignment-form-sheet.tsx new file mode 100644 index 0000000..644a22e --- /dev/null +++ b/src/app/(dashboard)/software/components/assignment-form-sheet.tsx @@ -0,0 +1,236 @@ +"use client"; + +import { useActionState, useEffect, useState } 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 { Textarea } from "@/components/ui/textarea"; +import { + createAssignmentAction, + updateAssignmentAction, +} from "@/lib/appwrite/software-actions"; +import { initialSoftwareState } from "@/lib/appwrite/software-types"; +import type { AssignmentRow, CustomerOption, SoftwareOption } from "./types"; + +type Props = { + open: boolean; + onOpenChange: (v: boolean) => void; + assignment?: AssignmentRow | null; + customers: CustomerOption[]; + softwareOptions: SoftwareOption[]; +}; + +function isoToInputDate(iso: string): string { + if (!iso) return ""; + return iso.slice(0, 10); +} + +export function AssignmentFormSheet({ + open, + onOpenChange, + assignment, + customers, + softwareOptions, +}: Props) { + const isEdit = Boolean(assignment); + const action = isEdit ? updateAssignmentAction : createAssignmentAction; + const [state, formAction, isPending] = useActionState(action, initialSoftwareState); + const [selectedSoftware, setSelectedSoftware] = useState(assignment?.softwareId ?? ""); + + useEffect(() => { + setSelectedSoftware(assignment?.softwareId ?? ""); + }, [assignment]); + + useEffect(() => { + if (state.ok) { + toast.success(isEdit ? "Atama güncellendi." : "Atama oluşturuldu."); + onOpenChange(false); + } else if (state.error) { + toast.error(state.error); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state]); + + const defaultFee = + softwareOptions.find((s) => s.id === selectedSoftware)?.defaultFee ?? ""; + + const blocked = customers.length === 0 || softwareOptions.length === 0; + + return ( + + + + + {isEdit ? "Atamayı düzenle" : "Müşteriye yazılım ata"} + + + {blocked + ? "Atama yapmak için en az bir müşteri ve bir yazılım gerekli." + : "Yazılımı bir müşteriye atayın; varsayılan ücret özelleştirilebilir."} + + + +
+ {isEdit && assignment && } + +
+
+ + + {state.fieldErrors?.customerId && ( +

{state.fieldErrors.customerId}

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

{state.fieldErrors.softwareId}

+ )} +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ +