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:
@@ -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 };
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { logAudit } from "./audit";
|
||||||
|
import {
|
||||||
|
DATABASE_ID,
|
||||||
|
TABLES,
|
||||||
|
type CustomerSoftware,
|
||||||
|
type Software,
|
||||||
|
} from "./schema";
|
||||||
|
import { createAdminClient } from "./server";
|
||||||
|
import { requireTenant } from "./tenant-guard";
|
||||||
|
import type { SoftwareActionState } from "./software-types";
|
||||||
|
import { customerSoftwareSchema, softwareSchema } from "@/lib/validation/software";
|
||||||
|
|
||||||
|
function appwriteError(e: unknown): string {
|
||||||
|
if (e instanceof AppwriteException) {
|
||||||
|
return e.message || "Beklenmeyen bir hata oluştu.";
|
||||||
|
}
|
||||||
|
return "Bağlantı hatası. Tekrar deneyin.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenErrors(err: z.ZodError): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
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")),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- Software (catalog) --------------------
|
||||||
|
|
||||||
|
function pickSoftwareFields(formData: FormData) {
|
||||||
|
return {
|
||||||
|
name: String(formData.get("name") ?? "").trim(),
|
||||||
|
version: String(formData.get("version") ?? "").trim(),
|
||||||
|
description: String(formData.get("description") ?? "").trim(),
|
||||||
|
defaultFee: String(formData.get("defaultFee") ?? ""),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSoftwareAction(
|
||||||
|
_prev: SoftwareActionState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<SoftwareActionState> {
|
||||||
|
let ctx;
|
||||||
|
try {
|
||||||
|
ctx = await requireTenant();
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: "Yetkiniz yok." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = softwareSchema.safeParse(pickSoftwareFields(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.software,
|
||||||
|
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: "software",
|
||||||
|
entityId: row.$id,
|
||||||
|
changes: parsed.data,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: appwriteError(e) };
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/software");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSoftwareAction(
|
||||||
|
_prev: SoftwareActionState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<SoftwareActionState> {
|
||||||
|
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 = softwareSchema.safeParse(pickSoftwareFields(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.software,
|
||||||
|
id,
|
||||||
|
)) as unknown as Software;
|
||||||
|
|
||||||
|
if (existing.tenantId !== ctx.tenantId) {
|
||||||
|
return { ok: false, error: "Erişim engellendi." };
|
||||||
|
}
|
||||||
|
|
||||||
|
await tablesDB.updateRow(DATABASE_ID, TABLES.software, id, parsed.data);
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: "update",
|
||||||
|
entityType: "software",
|
||||||
|
entityId: id,
|
||||||
|
changes: parsed.data,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: appwriteError(e) };
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/software");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSoftwareAction(formData: FormData): Promise<SoftwareActionState> {
|
||||||
|
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.software,
|
||||||
|
id,
|
||||||
|
)) as unknown as Software;
|
||||||
|
|
||||||
|
if (existing.tenantId !== ctx.tenantId) {
|
||||||
|
return { ok: false, error: "Erişim engellendi." };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detach from all customer_software rows first
|
||||||
|
const assignments = await tablesDB.listRows({
|
||||||
|
databaseId: DATABASE_ID,
|
||||||
|
tableId: TABLES.customerSoftware,
|
||||||
|
queries: [
|
||||||
|
Query.equal("tenantId", ctx.tenantId),
|
||||||
|
Query.equal("softwareId", id),
|
||||||
|
Query.limit(500),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
for (const row of assignments.rows) {
|
||||||
|
await tablesDB.deleteRow(DATABASE_ID, TABLES.customerSoftware, row.$id);
|
||||||
|
}
|
||||||
|
|
||||||
|
await tablesDB.deleteRow(DATABASE_ID, TABLES.software, id);
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: "delete",
|
||||||
|
entityType: "software",
|
||||||
|
entityId: id,
|
||||||
|
changes: { name: existing.name, detachedAssignments: assignments.rows.length },
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: appwriteError(e) };
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/software");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- customer_software (assignments) --------------------
|
||||||
|
|
||||||
|
function pickAssignmentFields(formData: FormData) {
|
||||||
|
return {
|
||||||
|
customerId: String(formData.get("customerId") ?? ""),
|
||||||
|
softwareId: String(formData.get("softwareId") ?? ""),
|
||||||
|
startDate: String(formData.get("startDate") ?? ""),
|
||||||
|
endDate: String(formData.get("endDate") ?? ""),
|
||||||
|
fee: String(formData.get("fee") ?? ""),
|
||||||
|
billingPeriod: (formData.get("billingPeriod") as "monthly" | "yearly" | "onetime" | null) ??
|
||||||
|
"monthly",
|
||||||
|
notes: String(formData.get("notes") ?? "").trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIsoDate(v?: string): string | undefined {
|
||||||
|
if (!v) return undefined;
|
||||||
|
// input type=date sends YYYY-MM-DD; Appwrite expects ISO with timezone
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(v)) return `${v}T00:00:00.000+00:00`;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAssignmentAction(
|
||||||
|
_prev: SoftwareActionState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<SoftwareActionState> {
|
||||||
|
let ctx;
|
||||||
|
try {
|
||||||
|
ctx = await requireTenant();
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: "Yetkiniz yok." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = customerSoftwareSchema.safeParse(pickAssignmentFields(formData));
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
const data = {
|
||||||
|
...parsed.data,
|
||||||
|
startDate: toIsoDate(parsed.data.startDate),
|
||||||
|
endDate: toIsoDate(parsed.data.endDate),
|
||||||
|
};
|
||||||
|
const row = await tablesDB.createRow(
|
||||||
|
DATABASE_ID,
|
||||||
|
TABLES.customerSoftware,
|
||||||
|
ID.unique(),
|
||||||
|
{
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
createdBy: ctx.user.id,
|
||||||
|
...data,
|
||||||
|
},
|
||||||
|
teamRowPermissions(ctx.tenantId),
|
||||||
|
);
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: "create",
|
||||||
|
entityType: "customer_software",
|
||||||
|
entityId: row.$id,
|
||||||
|
changes: data,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: appwriteError(e) };
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/software");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAssignmentAction(
|
||||||
|
_prev: SoftwareActionState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<SoftwareActionState> {
|
||||||
|
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 = customerSoftwareSchema.safeParse(pickAssignmentFields(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.customerSoftware,
|
||||||
|
id,
|
||||||
|
)) as unknown as CustomerSoftware;
|
||||||
|
|
||||||
|
if (existing.tenantId !== ctx.tenantId) {
|
||||||
|
return { ok: false, error: "Erişim engellendi." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
...parsed.data,
|
||||||
|
startDate: toIsoDate(parsed.data.startDate),
|
||||||
|
endDate: toIsoDate(parsed.data.endDate),
|
||||||
|
};
|
||||||
|
await tablesDB.updateRow(DATABASE_ID, TABLES.customerSoftware, id, data);
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: "update",
|
||||||
|
entityType: "customer_software",
|
||||||
|
entityId: id,
|
||||||
|
changes: data,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: appwriteError(e) };
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/software");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAssignmentAction(formData: FormData): Promise<SoftwareActionState> {
|
||||||
|
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.customerSoftware,
|
||||||
|
id,
|
||||||
|
)) as unknown as CustomerSoftware;
|
||||||
|
|
||||||
|
if (existing.tenantId !== ctx.tenantId) {
|
||||||
|
return { ok: false, error: "Erişim engellendi." };
|
||||||
|
}
|
||||||
|
|
||||||
|
await tablesDB.deleteRow(DATABASE_ID, TABLES.customerSoftware, id);
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: "delete",
|
||||||
|
entityType: "customer_software",
|
||||||
|
entityId: id,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: appwriteError(e) };
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/software");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { Query } from "node-appwrite";
|
||||||
|
|
||||||
|
import { createAdminClient } from "./server";
|
||||||
|
import {
|
||||||
|
DATABASE_ID,
|
||||||
|
TABLES,
|
||||||
|
type CustomerSoftware,
|
||||||
|
type Software,
|
||||||
|
} from "./schema";
|
||||||
|
|
||||||
|
export async function listSoftware(tenantId: string): Promise<Software[]> {
|
||||||
|
try {
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
const result = await tablesDB.listRows({
|
||||||
|
databaseId: DATABASE_ID,
|
||||||
|
tableId: TABLES.software,
|
||||||
|
queries: [
|
||||||
|
Query.equal("tenantId", tenantId),
|
||||||
|
Query.orderAsc("name"),
|
||||||
|
Query.limit(500),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return result.rows as unknown as Software[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAssignments(tenantId: string): Promise<CustomerSoftware[]> {
|
||||||
|
try {
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
const result = await tablesDB.listRows({
|
||||||
|
databaseId: DATABASE_ID,
|
||||||
|
tableId: TABLES.customerSoftware,
|
||||||
|
queries: [
|
||||||
|
Query.equal("tenantId", tenantId),
|
||||||
|
Query.orderDesc("$createdAt"),
|
||||||
|
Query.limit(1000),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return result.rows as unknown as CustomerSoftware[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export type SoftwareActionState = {
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
fieldErrors?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const initialSoftwareState: SoftwareActionState = { ok: false };
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const softwareSchema = z.object({
|
||||||
|
name: z.string().trim().min(1, "Yazılım adı zorunlu.").max(255),
|
||||||
|
version: z.string().trim().max(50).optional().transform((v) => (v ? v : undefined)),
|
||||||
|
description: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(2000)
|
||||||
|
.optional()
|
||||||
|
.transform((v) => (v ? v : undefined)),
|
||||||
|
defaultFee: z
|
||||||
|
.union([z.number(), z.string()])
|
||||||
|
.optional()
|
||||||
|
.transform((v) => {
|
||||||
|
if (v === undefined || v === "") return undefined;
|
||||||
|
const n = typeof v === "string" ? Number(v.replace(",", ".")) : v;
|
||||||
|
return Number.isFinite(n) ? n : undefined;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SoftwareInput = z.infer<typeof softwareSchema>;
|
||||||
|
|
||||||
|
export const customerSoftwareSchema = z.object({
|
||||||
|
customerId: z.string().min(1, "Müşteri seçin."),
|
||||||
|
softwareId: z.string().min(1, "Yazılım seçin."),
|
||||||
|
startDate: z.string().optional().transform((v) => (v ? v : undefined)),
|
||||||
|
endDate: z.string().optional().transform((v) => (v ? v : undefined)),
|
||||||
|
fee: z
|
||||||
|
.union([z.number(), z.string()])
|
||||||
|
.optional()
|
||||||
|
.transform((v) => {
|
||||||
|
if (v === undefined || v === "") return undefined;
|
||||||
|
const n = typeof v === "string" ? Number(v.replace(",", ".")) : v;
|
||||||
|
return Number.isFinite(n) ? n : undefined;
|
||||||
|
}),
|
||||||
|
billingPeriod: z.enum(["monthly", "yearly", "onetime"]).optional().default("monthly"),
|
||||||
|
notes: z.string().trim().max(1000).optional().transform((v) => (v ? v : undefined)),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CustomerSoftwareInput = z.infer<typeof customerSoftwareSchema>;
|
||||||
Reference in New Issue
Block a user