feat(software): catalog + customer assignments (M2M)

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.
This commit is contained in:
kovakmedya
2026-04-30 05:50:33 +03:00
parent a15a1c1c1a
commit 113988273f
9 changed files with 1483 additions and 0 deletions
@@ -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<string>(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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-xl">
<SheetHeader className="border-b px-6 py-4">
<SheetTitle>
{isEdit ? "Atamayı düzenle" : "Müşteriye yazılım ata"}
</SheetTitle>
<SheetDescription>
{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."}
</SheetDescription>
</SheetHeader>
<form action={formAction} className="flex flex-1 flex-col">
{isEdit && assignment && <input type="hidden" name="id" value={assignment.id} />}
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
<div className="grid gap-2">
<Label htmlFor="customerId">Müşteri *</Label>
<Select
name="customerId"
defaultValue={assignment?.customerId ?? ""}
disabled={blocked}
>
<SelectTrigger id="customerId">
<SelectValue placeholder="Müşteri seçin" />
</SelectTrigger>
<SelectContent>
{customers.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
{state.fieldErrors?.customerId && (
<p className="text-destructive text-xs">{state.fieldErrors.customerId}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="softwareId">Yazılım *</Label>
<Select
name="softwareId"
value={selectedSoftware}
onValueChange={setSelectedSoftware}
disabled={blocked}
>
<SelectTrigger id="softwareId">
<SelectValue placeholder="Yazılım seçin" />
</SelectTrigger>
<SelectContent>
{softwareOptions.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
{s.version ? ` v${s.version}` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
{state.fieldErrors?.softwareId && (
<p className="text-destructive text-xs">{state.fieldErrors.softwareId}</p>
)}
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="fee">Bu müşteri için ücret ()</Label>
<Input
id="fee"
name="fee"
type="number"
step="0.01"
min="0"
defaultValue={assignment?.fee ?? defaultFee ?? ""}
placeholder={defaultFee !== "" ? `Varsayılan: ${defaultFee}` : "0.00"}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="billingPeriod">Faturalama dönemi</Label>
<Select
name="billingPeriod"
defaultValue={assignment?.billingPeriod ?? "monthly"}
>
<SelectTrigger id="billingPeriod">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">Aylık</SelectItem>
<SelectItem value="yearly">Yıllık</SelectItem>
<SelectItem value="onetime">Tek seferlik</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="startDate">Başlangıç</Label>
<Input
id="startDate"
name="startDate"
type="date"
defaultValue={isoToInputDate(assignment?.startDate ?? "")}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="endDate">Bitiş (opsiyonel)</Label>
<Input
id="endDate"
name="endDate"
type="date"
defaultValue={isoToInputDate(assignment?.endDate ?? "")}
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="notes">Notlar</Label>
<Textarea
id="notes"
name="notes"
rows={3}
defaultValue={assignment?.notes ?? ""}
placeholder="Lisans bilgileri, özel koşullar"
/>
</div>
</div>
<SheetFooter className="border-t bg-muted/30 px-6 py-4">
<div className="flex w-full justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
Vazgeç
</Button>
<Button type="submit" disabled={isPending || blocked}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Kaydediliyor...
</>
) : (
<>
<Save className="size-4" />
{isEdit ? "Güncelle" : "Ata"}
</>
)}
</Button>
</div>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}