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>
);
}
@@ -0,0 +1,553 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
useReactTable,
} from "@tanstack/react-table";
import {
Loader2,
MoreHorizontal,
Package,
Pencil,
Plus,
Search,
Trash2,
Users,
} 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
deleteAssignmentAction,
deleteSoftwareAction,
} from "@/lib/appwrite/software-actions";
import { BILLING_PERIOD_LABEL, formatDate, formatTRY } from "@/lib/format";
import { AssignmentFormSheet } from "./assignment-form-sheet";
import { SoftwareFormSheet } from "./software-form-sheet";
import type {
AssignmentRow,
CustomerOption,
SoftwareOption,
SoftwareRow,
} from "./types";
type Props = {
software: SoftwareRow[];
assignments: AssignmentRow[];
customers: CustomerOption[];
};
export function SoftwareClient({ software, assignments, customers }: Props) {
const [tab, setTab] = useState<"catalog" | "assignments">("catalog");
const [softwareSearch, setSoftwareSearch] = useState("");
const [assignmentSearch, setAssignmentSearch] = useState("");
const [softwareFormOpen, setSoftwareFormOpen] = useState(false);
const [editingSoftware, setEditingSoftware] = useState<SoftwareRow | null>(null);
const [deletingSoftware, setDeletingSoftware] = useState<SoftwareRow | null>(null);
const [assignmentFormOpen, setAssignmentFormOpen] = useState(false);
const [editingAssignment, setEditingAssignment] = useState<AssignmentRow | null>(null);
const [deletingAssignment, setDeletingAssignment] = useState<AssignmentRow | null>(null);
const [busy, startTransition] = useTransition();
const softwareOptions: SoftwareOption[] = useMemo(
() =>
software.map((s) => ({
id: s.id,
name: s.name,
version: s.version,
defaultFee: s.defaultFee,
})),
[software],
);
const softwareCols = useMemo<ColumnDef<SoftwareRow>[]>(
() => [
{
accessorKey: "name",
header: "Ad",
cell: ({ row }) => (
<div className="flex flex-col">
<span className="font-medium">{row.original.name}</span>
{row.original.description && (
<span className="text-muted-foreground line-clamp-1 text-xs">
{row.original.description}
</span>
)}
</div>
),
},
{
accessorKey: "version",
header: "Sürüm",
cell: ({ row }) =>
row.original.version ? (
<Badge variant="outline">v{row.original.version}</Badge>
) : (
<span className="text-muted-foreground"></span>
),
},
{
accessorKey: "defaultFee",
header: "Varsayılan ücret",
cell: ({ row }) =>
row.original.defaultFee !== null ? (
<span>{formatTRY(row.original.defaultFee)}</span>
) : (
<span className="text-muted-foreground"></span>
),
},
{
id: "actions",
cell: ({ row }) => (
<div className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setEditingSoftware(row.original);
setSoftwareFormOpen(true);
}}
>
<Pencil className="size-3.5" />
Düzenle
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => setDeletingSoftware(row.original)}
>
<Trash2 className="size-3.5" />
Sil
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
),
},
],
[],
);
const assignmentCols = useMemo<ColumnDef<AssignmentRow>[]>(
() => [
{
accessorKey: "softwareName",
header: "Yazılım",
cell: ({ row }) => (
<div className="flex flex-col">
<span className="font-medium">{row.original.softwareName}</span>
{row.original.softwareVersion && (
<span className="text-muted-foreground text-xs">v{row.original.softwareVersion}</span>
)}
</div>
),
},
{
accessorKey: "customerName",
header: "Müşteri",
cell: ({ row }) => (
<span className="text-muted-foreground">{row.original.customerName}</span>
),
},
{
accessorKey: "fee",
header: "Ücret",
cell: ({ row }) => (
<div className="flex flex-col">
<span className="font-medium">
{row.original.fee !== null ? formatTRY(row.original.fee) : "—"}
</span>
<span className="text-muted-foreground text-xs">
{BILLING_PERIOD_LABEL[row.original.billingPeriod]}
</span>
</div>
),
},
{
id: "dates",
header: "Süre",
cell: ({ row }) => (
<span className="text-muted-foreground text-sm">
{formatDate(row.original.startDate)}
{" → "}
{row.original.endDate ? formatDate(row.original.endDate) : "süresiz"}
</span>
),
},
{
id: "actions",
cell: ({ row }) => (
<div className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setEditingAssignment(row.original);
setAssignmentFormOpen(true);
}}
>
<Pencil className="size-3.5" />
Düzenle
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => setDeletingAssignment(row.original)}
>
<Trash2 className="size-3.5" />
Kaldır
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
),
},
],
[],
);
const softwareTable = useReactTable({
data: software,
columns: softwareCols,
state: { globalFilter: softwareSearch },
onGlobalFilterChange: setSoftwareSearch,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: { pagination: { pageSize: 20 } },
globalFilterFn: (row, _id, fv) => {
const v = String(fv).toLowerCase();
return [row.original.name, row.original.version, row.original.description]
.join(" ")
.toLowerCase()
.includes(v);
},
});
const assignmentTable = useReactTable({
data: assignments,
columns: assignmentCols,
state: { globalFilter: assignmentSearch },
onGlobalFilterChange: setAssignmentSearch,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: { pagination: { pageSize: 20 } },
globalFilterFn: (row, _id, fv) => {
const v = String(fv).toLowerCase();
return [row.original.softwareName, row.original.customerName, row.original.notes]
.join(" ")
.toLowerCase()
.includes(v);
},
});
const deleteSoftware = () => {
if (!deletingSoftware) return;
startTransition(async () => {
const fd = new FormData();
fd.set("id", deletingSoftware.id);
const result = await deleteSoftwareAction(fd);
if (result.ok) {
toast.success("Yazılım silindi.");
setDeletingSoftware(null);
} else {
toast.error(result.error ?? "Silme başarısız.");
}
});
};
const deleteAssignment = () => {
if (!deletingAssignment) return;
startTransition(async () => {
const fd = new FormData();
fd.set("id", deletingAssignment.id);
const result = await deleteAssignmentAction(fd);
if (result.ok) {
toast.success("Atama kaldırıldı.");
setDeletingAssignment(null);
} else {
toast.error(result.error ?? "İşlem başarısız.");
}
});
};
return (
<Card>
<CardContent className="p-0">
<Tabs value={tab} onValueChange={(v) => setTab(v as typeof tab)}>
<div className="flex flex-col gap-3 p-4 md:flex-row md:items-center md:justify-between">
<TabsList>
<TabsTrigger value="catalog">
<Package className="size-3.5" />
Katalog ({software.length})
</TabsTrigger>
<TabsTrigger value="assignments">
<Users className="size-3.5" />
Atamalar ({assignments.length})
</TabsTrigger>
</TabsList>
{tab === "catalog" ? (
<Button
onClick={() => {
setEditingSoftware(null);
setSoftwareFormOpen(true);
}}
>
<Plus className="size-4" />
Yeni yazılım
</Button>
) : (
<Button
onClick={() => {
setEditingAssignment(null);
setAssignmentFormOpen(true);
}}
disabled={customers.length === 0 || software.length === 0}
>
<Plus className="size-4" />
Yeni atama
</Button>
)}
</div>
<TabsContent value="catalog" className="m-0">
<div className="px-4 pb-3">
<div className="relative max-w-xs">
<Search className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
<Input
value={softwareSearch}
onChange={(e) => setSoftwareSearch(e.target.value)}
placeholder="Yazılım ara..."
className="pl-9"
/>
</div>
</div>
<Table>
<TableHeader>
{softwareTable.getHeaderGroups().map((hg) => (
<TableRow key={hg.id}>
{hg.headers.map((h) => (
<TableHead key={h.id}>
{h.isPlaceholder
? null
: flexRender(h.column.columnDef.header, h.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{softwareTable.getRowModel().rows.length ? (
softwareTable.getRowModel().rows.map((r) => (
<TableRow key={r.id}>
{r.getVisibleCells().map((c) => (
<TableCell key={c.id}>
{flexRender(c.column.columnDef.cell, c.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={softwareCols.length} className="h-32 text-center">
<div className="text-muted-foreground flex flex-col items-center gap-2">
<Package className="size-6" />
<p className="text-sm">Henüz yazılım eklenmemiş.</p>
<Button
variant="outline"
size="sm"
onClick={() => {
setEditingSoftware(null);
setSoftwareFormOpen(true);
}}
>
<Plus className="size-3.5" />
İlk yazılımı ekle
</Button>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TabsContent>
<TabsContent value="assignments" className="m-0">
<div className="px-4 pb-3">
<div className="relative max-w-xs">
<Search className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
<Input
value={assignmentSearch}
onChange={(e) => setAssignmentSearch(e.target.value)}
placeholder="Yazılım veya müşteri ara..."
className="pl-9"
/>
</div>
</div>
<Table>
<TableHeader>
{assignmentTable.getHeaderGroups().map((hg) => (
<TableRow key={hg.id}>
{hg.headers.map((h) => (
<TableHead key={h.id}>
{h.isPlaceholder
? null
: flexRender(h.column.columnDef.header, h.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{assignmentTable.getRowModel().rows.length ? (
assignmentTable.getRowModel().rows.map((r) => (
<TableRow key={r.id}>
{r.getVisibleCells().map((c) => (
<TableCell key={c.id}>
{flexRender(c.column.columnDef.cell, c.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={assignmentCols.length} className="h-32 text-center">
<div className="text-muted-foreground flex flex-col items-center gap-2">
<Users className="size-6" />
<p className="text-sm">
{customers.length === 0 || software.length === 0
? "Önce müşteri ve yazılım ekleyin, sonra atayabilirsiniz."
: "Henüz atama yapılmamış."}
</p>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TabsContent>
</Tabs>
</CardContent>
<SoftwareFormSheet
open={softwareFormOpen}
onOpenChange={(v) => {
setSoftwareFormOpen(v);
if (!v) setEditingSoftware(null);
}}
software={editingSoftware}
/>
<AssignmentFormSheet
open={assignmentFormOpen}
onOpenChange={(v) => {
setAssignmentFormOpen(v);
if (!v) setEditingAssignment(null);
}}
assignment={editingAssignment}
customers={customers}
softwareOptions={softwareOptions}
/>
<Dialog
open={Boolean(deletingSoftware)}
onOpenChange={(v) => !v && setDeletingSoftware(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Yazılımı sil</DialogTitle>
<DialogDescription>
<strong>{deletingSoftware?.name}</strong> ve müşterilerle olan tüm atamaları silinecek.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeletingSoftware(null)}
disabled={busy}
>
Vazgeç
</Button>
<Button variant="destructive" onClick={deleteSoftware} disabled={busy}>
{busy ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
Sil
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={Boolean(deletingAssignment)}
onOpenChange={(v) => !v && setDeletingAssignment(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Atamayı kaldır</DialogTitle>
<DialogDescription>
<strong>{deletingAssignment?.softwareName}</strong> {" "}
<strong>{deletingAssignment?.customerName}</strong> ataması kaldırılacak.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeletingAssignment(null)}
disabled={busy}
>
Vazgeç
</Button>
<Button variant="destructive" onClick={deleteAssignment} disabled={busy}>
{busy ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
Kaldır
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}
@@ -0,0 +1,140 @@
"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 {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea";
import {
createSoftwareAction,
updateSoftwareAction,
} from "@/lib/appwrite/software-actions";
import { initialSoftwareState } from "@/lib/appwrite/software-types";
import type { SoftwareRow } from "./types";
type Props = {
open: boolean;
onOpenChange: (v: boolean) => void;
software?: SoftwareRow | null;
};
export function SoftwareFormSheet({ open, onOpenChange, software }: Props) {
const isEdit = Boolean(software);
const action = isEdit ? updateSoftwareAction : createSoftwareAction;
const [state, formAction, isPending] = useActionState(action, initialSoftwareState);
useEffect(() => {
if (state.ok) {
toast.success(isEdit ? "Yazılım güncellendi." : "Yazılım eklendi.");
onOpenChange(false);
} else if (state.error) {
toast.error(state.error);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state]);
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 ? "Yazılımı düzenle" : "Yeni yazılım"}</SheetTitle>
<SheetDescription>
Kataloğunuzdaki yazılım. Müşterilere ayrı ayrı ücretlerle atayabilirsiniz.
</SheetDescription>
</SheetHeader>
<form action={formAction} className="flex flex-1 flex-col">
{isEdit && software && <input type="hidden" name="id" value={software.id} />}
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
<div className="grid gap-2">
<Label htmlFor="name">Yazılım adı *</Label>
<Input
id="name"
name="name"
defaultValue={software?.name ?? ""}
placeholder="Örn. KovakCRM"
required
/>
{state.fieldErrors?.name && (
<p className="text-destructive text-xs">{state.fieldErrors.name}</p>
)}
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="version">Sürüm</Label>
<Input
id="version"
name="version"
defaultValue={software?.version ?? ""}
placeholder="1.0.0"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="defaultFee">Varsayılan ücret ()</Label>
<Input
id="defaultFee"
name="defaultFee"
type="number"
step="0.01"
min="0"
defaultValue={software?.defaultFee ?? ""}
placeholder="0.00"
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="description">Açıklama</Label>
<Textarea
id="description"
name="description"
rows={3}
defaultValue={software?.description ?? ""}
placeholder="Yazılımın kapsamı, modülleri, vb."
/>
</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}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Kaydediliyor...
</>
) : (
<>
<Save className="size-4" />
{isEdit ? "Güncelle" : "Kaydet"}
</>
)}
</Button>
</div>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,24 @@
export type SoftwareRow = {
id: string;
name: string;
version: string;
description: string;
defaultFee: number | null;
};
export type AssignmentRow = {
id: string;
customerId: string;
customerName: string;
softwareId: string;
softwareName: string;
softwareVersion: string;
startDate: string;
endDate: string;
fee: number | null;
billingPeriod: "monthly" | "yearly" | "onetime";
notes: string;
};
export type SoftwareOption = { id: string; name: string; version: string; defaultFee: number | null };
export type CustomerOption = { id: string; name: string };
+65
View File
@@ -0,0 +1,65 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { listCustomers } from "@/lib/appwrite/customer-queries";
import { listAssignments, listSoftware } from "@/lib/appwrite/software-queries";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { SoftwareClient } from "./components/software-client";
export const metadata: Metadata = {
title: "İşletmem — Yazılımlarımız",
};
export default async function SoftwarePage() {
let ctx;
try {
ctx = await requireTenant();
} catch {
redirect("/onboarding");
}
const [softwareList, customers, assignments] = await Promise.all([
listSoftware(ctx.tenantId),
listCustomers(ctx.tenantId),
listAssignments(ctx.tenantId),
]);
const softwareMap = new Map(softwareList.map((s) => [s.$id, s]));
const customerMap = new Map(customers.map((c) => [c.$id, c.name]));
return (
<div className="flex-1 space-y-6 px-6 pt-0">
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-sm">{ctx.settings?.companyName ?? "Çalışma alanı"}</p>
<h1 className="text-2xl font-bold tracking-tight">Yazılımlarımız</h1>
<p className="text-muted-foreground text-sm">
Yazılım kataloğunuzu yönetin ve müşterilere atayın.
</p>
</div>
<SoftwareClient
software={softwareList.map((s) => ({
id: s.$id,
name: s.name,
version: s.version ?? "",
description: s.description ?? "",
defaultFee: s.defaultFee ?? null,
}))}
assignments={assignments.map((a) => ({
id: a.$id,
customerId: a.customerId,
customerName: customerMap.get(a.customerId) ?? "—",
softwareId: a.softwareId,
softwareName: softwareMap.get(a.softwareId)?.name ?? "—",
softwareVersion: softwareMap.get(a.softwareId)?.version ?? "",
startDate: a.startDate ?? "",
endDate: a.endDate ?? "",
fee: a.fee ?? null,
billingPeriod: a.billingPeriod ?? "monthly",
notes: a.notes ?? "",
}))}
customers={customers.map((c) => ({ id: c.$id, name: c.name }))}
/>
</div>
);
}