feat(modules): connections, products, jobs (list/form/detail-placeholder)

Connections (clinic ↔ lab)
  - request via member number, approve/reject (counterparty), cancel pending,
    delete approved
  - permission rows opened to both teams; audit log for every mutation
  - /connections page: own code card, request form, pending inbound/outbound
    tables, approved connections table with delete confirm

Products (lab catalog)
  - createProstheticAction + update + archive/restore + delete (lab-only)
  - zod validation, dev-mode error surfacing
  - /products page: catalog table + add form + edit dialog. Hidden from
    clinic accounts via requireTenantKind.

Jobs (work orders)
  - createJobAction (clinic-only) — checks approved connection before write,
    permissions opened to both clinic and lab teams
  - listInboundJobs (lab perspective), listOutboundJobs (clinic perspective),
    listApprovedLabsForClinic for the new-job form
  - /jobs/inbound + /jobs/outbound tables with role-aware copy
  - /jobs/new full form (lab select, patient code, prosthetic type, member
    count, color, due date, price/currency, description)
  - /jobs/[jobId] placeholder detail page with stepper visualisation;
    status/step updates and file upload come next session

All new mutations follow the memory rules: schema-checked row payloads,
admin client behind requireTenant + requireRole/requireTenantKind, audit
log calls best-effort, no empty-string Radix Select values.
This commit is contained in:
kovakmedya
2026-05-21 19:59:23 +03:00
parent 7fb8288f79
commit 76e02754b8
26 changed files with 2765 additions and 42 deletions
@@ -0,0 +1,135 @@
"use client";
import { useActionState, useEffect, useState, useTransition } from "react";
import { Loader2, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { deleteConnectionAction } from "@/lib/appwrite/connection-actions";
import { initialConnectionActionState } from "@/lib/appwrite/connection-types";
import type { ConnectionWithCounterpart } from "@/lib/appwrite/connection-queries";
const dateFormatter = new Intl.DateTimeFormat("tr-TR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
export function ConnectionsTable({ rows }: { rows: ConnectionWithCounterpart[] }) {
if (rows.length === 0) {
return (
<p className="text-muted-foreground py-6 text-center text-sm">
Henüz onaylanmış bağlantınız yok. Yukarıdan talep gönderebilir veya kodunuzu paylaşabilirsiniz.
</p>
);
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Karşı taraf</TableHead>
<TableHead>Tür</TableHead>
<TableHead>Onay tarihi</TableHead>
<TableHead className="text-right">İşlem</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r) => (
<ApprovedRow key={r.$id} row={r} />
))}
</TableBody>
</Table>
);
}
function ApprovedRow({ row }: { row: ConnectionWithCounterpart }) {
const [state, action, pending] = useActionState(
deleteConnectionAction,
initialConnectionActionState,
);
const [, startTransition] = useTransition();
const [open, setOpen] = useState(false);
useEffect(() => {
if (state.ok) {
toast.success("Bağlantı silindi.");
setOpen(false);
} else if (state.error) {
toast.error(state.error);
}
}, [state]);
const kindLabel =
row.counterpart?.kind === "lab"
? "Laboratuvar"
: row.counterpart?.kind === "clinic"
? "Klinik"
: "—";
return (
<TableRow>
<TableCell className="font-medium">{row.counterpart?.companyName ?? "—"}</TableCell>
<TableCell>
<Badge variant="secondary">{kindLabel}</Badge>
</TableCell>
<TableCell className="text-muted-foreground">
{row.approvedAt ? dateFormatter.format(new Date(row.approvedAt)) : "—"}
</TableCell>
<TableCell className="text-right">
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline">
<Trash2 className="size-4" />
Sil
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Bağlantı silinsin mi?</DialogTitle>
<DialogDescription>
{row.counterpart?.companyName ?? "Karşı taraf"} ile bağlantınız sonlandırılacak.
Mevcut işleriniz etkilenmez ancak yeni gönderemezsiniz.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">Vazgeç</Button>
</DialogClose>
<form
action={(fd) => {
startTransition(() => action(fd));
}}
>
<input type="hidden" name="connectionId" value={row.$id} />
<Button type="submit" disabled={pending} variant="destructive">
{pending ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
Sil
</Button>
</form>
</DialogFooter>
</DialogContent>
</Dialog>
</TableCell>
</TableRow>
);
}