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:
@@ -0,0 +1,42 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Check, Copy } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
export function ConnectionCodeCard({ memberNumber }: { memberNumber: string }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const copy = async () => {
|
||||||
|
if (!memberNumber) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(memberNumber);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Bağlantı kodunuz</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Karşı taraf bu kodu girerek size bağlantı talebi gönderir.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="bg-muted/40 flex items-center justify-between gap-3 rounded-md border px-4 py-3">
|
||||||
|
<span className="font-mono text-2xl tracking-[0.4em]">{memberNumber || "—"}</span>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={copy} disabled={!memberNumber}>
|
||||||
|
{copied ? <Check className="size-4" /> : <Copy className="size-4" />}
|
||||||
|
{copied ? "Kopyalandı" : "Kopyala"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState, useEffect, useRef } from "react";
|
||||||
|
import { Link2, Loader2 } 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 { requestConnectionAction } from "@/lib/appwrite/connection-actions";
|
||||||
|
import { initialConnectionRequestState } from "@/lib/appwrite/connection-types";
|
||||||
|
|
||||||
|
export function ConnectionRequestForm({ counterpartLabel }: { counterpartLabel: string }) {
|
||||||
|
const [state, formAction, isPending] = useActionState(
|
||||||
|
requestConnectionAction,
|
||||||
|
initialConnectionRequestState,
|
||||||
|
);
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.ok) {
|
||||||
|
toast.success("Bağlantı talebi gönderildi.");
|
||||||
|
formRef.current?.reset();
|
||||||
|
} else if (state.error) {
|
||||||
|
toast.error(state.error);
|
||||||
|
}
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form ref={formRef} action={formAction} className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-end">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="memberNumber">{counterpartLabel.charAt(0).toUpperCase() + counterpartLabel.slice(1)} bağlantı kodu</Label>
|
||||||
|
<Input
|
||||||
|
id="memberNumber"
|
||||||
|
name="memberNumber"
|
||||||
|
type="text"
|
||||||
|
placeholder="6 hane"
|
||||||
|
maxLength={12}
|
||||||
|
autoCapitalize="characters"
|
||||||
|
autoComplete="off"
|
||||||
|
required
|
||||||
|
style={{ textTransform: "uppercase", letterSpacing: "0.3em" }}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={isPending}>
|
||||||
|
{isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Gönderiliyor...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link2 className="size-4" />
|
||||||
|
Talep gönder
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 iş 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState, useEffect, useTransition } from "react";
|
||||||
|
import { Check, Loader2, X } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
approveConnectionAction,
|
||||||
|
rejectConnectionAction,
|
||||||
|
} 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 PendingInboundTable({ rows }: { rows: ConnectionWithCounterpart[] }) {
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-muted-foreground py-6 text-center text-sm">
|
||||||
|
Bekleyen talep yok.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Talep eden</TableHead>
|
||||||
|
<TableHead>Tür</TableHead>
|
||||||
|
<TableHead>Tarih</TableHead>
|
||||||
|
<TableHead className="text-right">İşlem</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<InboundRow key={r.$id} row={r} />
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InboundRow({ row }: { row: ConnectionWithCounterpart }) {
|
||||||
|
const [approveState, approveAction, approvePending] = useActionState(
|
||||||
|
approveConnectionAction,
|
||||||
|
initialConnectionActionState,
|
||||||
|
);
|
||||||
|
const [rejectState, rejectAction, rejectPending] = useActionState(
|
||||||
|
rejectConnectionAction,
|
||||||
|
initialConnectionActionState,
|
||||||
|
);
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (approveState.ok) toast.success("Bağlantı onaylandı.");
|
||||||
|
else if (approveState.error) toast.error(approveState.error);
|
||||||
|
}, [approveState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (rejectState.ok) toast.success("Talep reddedildi.");
|
||||||
|
else if (rejectState.error) toast.error(rejectState.error);
|
||||||
|
}, [rejectState]);
|
||||||
|
|
||||||
|
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">
|
||||||
|
{dateFormatter.format(new Date(row.requestedAt))}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<form
|
||||||
|
action={(fd) => {
|
||||||
|
startTransition(() => approveAction(fd));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="connectionId" value={row.$id} />
|
||||||
|
<Button type="submit" size="sm" disabled={approvePending || rejectPending}>
|
||||||
|
{approvePending ? <Loader2 className="size-4 animate-spin" /> : <Check className="size-4" />}
|
||||||
|
Onayla
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<form
|
||||||
|
action={(fd) => {
|
||||||
|
startTransition(() => rejectAction(fd));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="connectionId" value={row.$id} />
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={approvePending || rejectPending}
|
||||||
|
>
|
||||||
|
{rejectPending ? <Loader2 className="size-4 animate-spin" /> : <X className="size-4" />}
|
||||||
|
Reddet
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState, useEffect, useTransition } from "react";
|
||||||
|
import { Loader2, X } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { cancelConnectionAction } 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 PendingOutboundTable({ rows }: { rows: ConnectionWithCounterpart[] }) {
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-muted-foreground py-6 text-center text-sm">
|
||||||
|
Gönderilmiş talebiniz yok.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Karşı taraf</TableHead>
|
||||||
|
<TableHead>Tür</TableHead>
|
||||||
|
<TableHead>Gönderim</TableHead>
|
||||||
|
<TableHead className="text-right">İşlem</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<OutboundRow key={r.$id} row={r} />
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OutboundRow({ row }: { row: ConnectionWithCounterpart }) {
|
||||||
|
const [state, action, pending] = useActionState(
|
||||||
|
cancelConnectionAction,
|
||||||
|
initialConnectionActionState,
|
||||||
|
);
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.ok) toast.success("Talep iptal edildi.");
|
||||||
|
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">
|
||||||
|
{dateFormatter.format(new Date(row.requestedAt))}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<form
|
||||||
|
action={(fd) => {
|
||||||
|
startTransition(() => action(fd));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="connectionId" value={row.$id} />
|
||||||
|
<Button type="submit" size="sm" variant="outline" disabled={pending}>
|
||||||
|
{pending ? <Loader2 className="size-4 animate-spin" /> : <X className="size-4" />}
|
||||||
|
İptal et
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,21 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
listApprovedConnections,
|
||||||
|
listPendingInbound,
|
||||||
|
listPendingOutbound,
|
||||||
|
} from "@/lib/appwrite/connection-queries";
|
||||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||||
|
import { ConnectionCodeCard } from "./components/connection-code-card";
|
||||||
|
import { ConnectionRequestForm } from "./components/connection-request-form";
|
||||||
|
import { PendingInboundTable } from "./components/pending-inbound-table";
|
||||||
|
import { PendingOutboundTable } from "./components/pending-outbound-table";
|
||||||
|
import { ConnectionsTable } from "./components/connections-table";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "DLS — Bağlantı Kur",
|
||||||
|
};
|
||||||
|
|
||||||
export default async function ConnectionsPage() {
|
export default async function ConnectionsPage() {
|
||||||
let ctx;
|
let ctx;
|
||||||
@@ -11,31 +25,76 @@ export default async function ConnectionsPage() {
|
|||||||
redirect("/onboarding");
|
redirect("/onboarding");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const memberNumber = ctx.settings?.memberNumber ?? "";
|
||||||
|
const isLab = ctx.kind === "lab";
|
||||||
|
const counterpartLabel = isLab ? "klinik" : "laboratuvar";
|
||||||
|
|
||||||
|
const [approved, pendingInbound, pendingOutbound] = await Promise.all([
|
||||||
|
listApprovedConnections(ctx.tenantId),
|
||||||
|
listPendingInbound(ctx.tenantId, ctx.user.id),
|
||||||
|
listPendingOutbound(ctx.tenantId, ctx.user.id),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 space-y-6 px-6">
|
<div className="flex-1 space-y-6 px-6">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Bağlantı Kur</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Bağlantı Kur</h1>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
Klinik ve laboratuvar arasında bağlantı taleplerini yönetin.
|
{isLab
|
||||||
|
? "Sizinle çalışan klinikleri yönetin. Bağlantı kodunuzu paylaşın veya bir kliniğe talep gönderin."
|
||||||
|
: "Çalıştığınız laboratuvarları yönetin. Bağlantı kodunuzu paylaşın veya bir laboratuvara talep gönderin."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<ConnectionCodeCard memberNumber={memberNumber} />
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Bağlantı kodunuz</CardTitle>
|
<CardTitle>Bağlantı talep et</CardTitle>
|
||||||
<CardDescription>Karşı taraf bu kodu girerek size bağlantı talebi gönderir.</CardDescription>
|
<CardDescription>
|
||||||
|
Karşı tarafın 6 haneli kodunu girin, onaylarsa bağlantı kurulur.
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="bg-muted/40 rounded-md border px-4 py-3 font-mono text-lg tracking-widest">
|
<ConnectionRequestForm counterpartLabel={counterpartLabel} />
|
||||||
{ctx.settings?.memberNumber ?? "—"}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-2">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Yapım aşamasında</CardTitle>
|
<CardTitle>Gelen Talepler</CardTitle>
|
||||||
<CardDescription>Bağlantı talepleri ve bağlı taraflar listesi sonraki sürümde eklenecek.</CardDescription>
|
<CardDescription>
|
||||||
|
Size gönderilen, onayınızı bekleyen bağlantı talepleri.
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent />
|
<CardContent>
|
||||||
|
<PendingInboundTable rows={pendingInbound} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Gönderilen Talepler</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Sizin gönderdiğiniz, karşı tarafın yanıtını bekleyen talepler.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<PendingOutboundTable rows={pendingOutbound} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Bağlantılarım</CardTitle>
|
||||||
|
<CardDescription>Onaylanmış aktif bağlantılar.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ConnectionsTable rows={approved} />
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { createAdminClient } from "@/lib/appwrite/server";
|
||||||
|
import { DATABASE_ID, TABLES, type Job, type TenantSettings } from "@/lib/appwrite/schema";
|
||||||
|
import { Query } from "node-appwrite";
|
||||||
|
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||||
|
import {
|
||||||
|
JOB_STATUS_LABELS,
|
||||||
|
JOB_STEP_LABELS,
|
||||||
|
JOB_STEP_ORDER,
|
||||||
|
PROSTHETIC_TYPE_LABELS,
|
||||||
|
} from "@/lib/appwrite/job-types";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "DLS — İş Detay",
|
||||||
|
};
|
||||||
|
|
||||||
|
const dateFormatter = new Intl.DateTimeFormat("tr-TR", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatMoney(amount: number, currency: string) {
|
||||||
|
try {
|
||||||
|
return new Intl.NumberFormat("tr-TR", { style: "currency", currency }).format(amount);
|
||||||
|
} catch {
|
||||||
|
return `${amount.toFixed(2)} ${currency}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function JobDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ jobId: string }>;
|
||||||
|
}) {
|
||||||
|
const { jobId } = await params;
|
||||||
|
|
||||||
|
let ctx;
|
||||||
|
try {
|
||||||
|
ctx = await requireTenant();
|
||||||
|
} catch {
|
||||||
|
redirect("/onboarding");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
let job: Job;
|
||||||
|
try {
|
||||||
|
const row = await tablesDB.getRow(DATABASE_ID, TABLES.jobs, jobId);
|
||||||
|
job = row as unknown as Job;
|
||||||
|
} catch {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (job.clinicTenantId !== ctx.tenantId && job.labTenantId !== ctx.tenantId) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const counterpartId =
|
||||||
|
job.clinicTenantId === ctx.tenantId ? job.labTenantId : job.clinicTenantId;
|
||||||
|
const counterpartLabel = job.clinicTenantId === ctx.tenantId ? "Laboratuvar" : "Klinik";
|
||||||
|
|
||||||
|
const counterpartRes = await tablesDB.listRows({
|
||||||
|
databaseId: DATABASE_ID,
|
||||||
|
tableId: TABLES.tenantSettings,
|
||||||
|
queries: [Query.equal("tenantId", counterpartId), Query.limit(1)],
|
||||||
|
});
|
||||||
|
const counterpart = counterpartRes.rows[0] as unknown as TenantSettings | undefined;
|
||||||
|
|
||||||
|
const currentStepIdx = job.currentStep ? JOB_STEP_ORDER.indexOf(job.currentStep) : -1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 space-y-6 px-6">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{counterpartLabel}: {counterpart?.companyName ?? "—"}
|
||||||
|
</p>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">
|
||||||
|
Hasta {job.patientCode}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{PROSTHETIC_TYPE_LABELS[job.prostheticType]} · {job.memberCount} üye
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary" className="text-sm">
|
||||||
|
{JOB_STATUS_LABELS[job.status]}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>İş Bilgileri</CardTitle>
|
||||||
|
<CardDescription>{dateFormatter.format(new Date(job.$createdAt))}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-4 text-sm md:grid-cols-2">
|
||||||
|
<Info label="Renk">{job.color || "—"}</Info>
|
||||||
|
<Info label="Termin">
|
||||||
|
{job.dueDate ? dateFormatter.format(new Date(job.dueDate)) : "—"}
|
||||||
|
</Info>
|
||||||
|
<Info label="Fiyat">
|
||||||
|
{typeof job.price === "number"
|
||||||
|
? formatMoney(job.price, job.currency || "TRY")
|
||||||
|
: "—"}
|
||||||
|
</Info>
|
||||||
|
<Info label="Mevcut Aşama">
|
||||||
|
{job.currentStep ? JOB_STEP_LABELS[job.currentStep] : "—"}
|
||||||
|
</Info>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<p className="text-muted-foreground mb-1 text-xs font-medium uppercase tracking-wide">
|
||||||
|
Açıklama
|
||||||
|
</p>
|
||||||
|
<p className="whitespace-pre-wrap text-sm">{job.description || "—"}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Aşamalar</CardTitle>
|
||||||
|
<CardDescription>Ölçü → Alt Yapı → Üst Yapı → Cila/Bitim</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ol className="space-y-3">
|
||||||
|
{JOB_STEP_ORDER.map((step, idx) => {
|
||||||
|
const done = currentStepIdx > idx || job.status === "delivered";
|
||||||
|
const active = currentStepIdx === idx && job.status !== "delivered";
|
||||||
|
return (
|
||||||
|
<li key={step} className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
done
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: active
|
||||||
|
? "bg-primary/15 text-primary"
|
||||||
|
: "bg-muted text-muted-foreground"
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: "50%",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{idx + 1}
|
||||||
|
</span>
|
||||||
|
<span className={active ? "font-medium" : ""}>{JOB_STEP_LABELS[step]}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
<p className="text-muted-foreground mt-4 text-xs">
|
||||||
|
Aşama güncelleme ve dosya yükleme sonraki sürümde.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href={ctx.kind === "clinic" ? "/jobs/outbound" : "/jobs/inbound"}>
|
||||||
|
← Listeye dön
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Info({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-sm">{children}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
JOB_STATUS_LABELS,
|
||||||
|
PROSTHETIC_TYPE_LABELS,
|
||||||
|
} from "@/lib/appwrite/job-types";
|
||||||
|
import type { JobWithCounterpart } from "@/lib/appwrite/job-queries";
|
||||||
|
import type { JobStatus } from "@/lib/appwrite/schema";
|
||||||
|
|
||||||
|
const dateFormatter = new Intl.DateTimeFormat("tr-TR", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
|
||||||
|
function statusVariant(status: JobStatus): "default" | "secondary" | "outline" | "destructive" {
|
||||||
|
switch (status) {
|
||||||
|
case "delivered":
|
||||||
|
return "default";
|
||||||
|
case "sent":
|
||||||
|
return "secondary";
|
||||||
|
case "in_progress":
|
||||||
|
return "secondary";
|
||||||
|
case "cancelled":
|
||||||
|
return "destructive";
|
||||||
|
default:
|
||||||
|
return "outline";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JobsTable({
|
||||||
|
rows,
|
||||||
|
counterpartLabel,
|
||||||
|
emptyMessage,
|
||||||
|
}: {
|
||||||
|
rows: JobWithCounterpart[];
|
||||||
|
counterpartLabel: string;
|
||||||
|
emptyMessage: string;
|
||||||
|
}) {
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-muted-foreground py-6 text-center text-sm">{emptyMessage}</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{counterpartLabel}</TableHead>
|
||||||
|
<TableHead>Hasta Kodu</TableHead>
|
||||||
|
<TableHead>Üye</TableHead>
|
||||||
|
<TableHead>Renk</TableHead>
|
||||||
|
<TableHead>Tür</TableHead>
|
||||||
|
<TableHead>Durum</TableHead>
|
||||||
|
<TableHead>Tarih</TableHead>
|
||||||
|
<TableHead className="text-right">İşlem</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((j) => (
|
||||||
|
<TableRow key={j.$id}>
|
||||||
|
<TableCell className="font-medium">{j.counterpart?.companyName ?? "—"}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{j.patientCode}</TableCell>
|
||||||
|
<TableCell className="tabular-nums">{j.memberCount}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{j.color || "—"}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{PROSTHETIC_TYPE_LABELS[j.prostheticType] ?? j.prostheticType}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={statusVariant(j.status)}>{JOB_STATUS_LABELS[j.status]}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground text-xs">
|
||||||
|
{dateFormatter.format(new Date(j.$createdAt))}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button asChild size="sm" variant="outline">
|
||||||
|
<Link href={`/jobs/${j.$id}`}>Detay</Link>
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,20 +1,60 @@
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { listInboundJobs } from "@/lib/appwrite/job-queries";
|
||||||
|
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||||
|
import { JobsTable } from "../_components/jobs-table";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "DLS — Gelen İşler",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function InboundJobsPage() {
|
||||||
|
let ctx;
|
||||||
|
try {
|
||||||
|
ctx = await requireTenant();
|
||||||
|
} catch {
|
||||||
|
redirect("/onboarding");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inbound = jobs where this tenant is the lab side.
|
||||||
|
// A clinic tenant can also receive jobs only via labTenantId match, which
|
||||||
|
// would be unusual; we still surface whatever matches.
|
||||||
|
const rows = ctx.kind === "lab" ? await listInboundJobs(ctx.tenantId) : [];
|
||||||
|
|
||||||
export default function InboundJobsPage() {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 space-y-6 px-6">
|
<div className="flex-1 space-y-6 px-6">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Gelen İşler</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Gelen İşler</h1>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
Bağlı kliniklerden gelen protez işleri burada listelenecek.
|
Bağlı kliniklerden size yönlendirilmiş protez işleri.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Yapım aşamasında</CardTitle>
|
<CardTitle>Tüm Gelen İşler</CardTitle>
|
||||||
<CardDescription>Gelen iş listesi, filtreleme ve detay görünümü sonraki sürümde eklenecek.</CardDescription>
|
<CardDescription>
|
||||||
|
{ctx.kind === "lab"
|
||||||
|
? rows.length === 0
|
||||||
|
? "Henüz gelen iş yok."
|
||||||
|
: `${rows.length} kalem`
|
||||||
|
: "Bu sayfa laboratuvar hesapları içindir."}
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent />
|
<CardContent>
|
||||||
|
{ctx.kind === "lab" ? (
|
||||||
|
<JobsTable
|
||||||
|
rows={rows}
|
||||||
|
counterpartLabel="Klinik"
|
||||||
|
emptyMessage="Henüz size gönderilmiş iş yok. Klinik tarafa Bağlantı Kodunuzu paylaşın."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground py-6 text-center text-sm">
|
||||||
|
Klinik hesabıyla giriş yaptınız — gelen iş listesi sadece laboratuvar tarafında görünür.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Loader2, Send } 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 { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { createJobAction } from "@/lib/appwrite/job-actions";
|
||||||
|
import {
|
||||||
|
PROSTHETIC_TYPE_LABELS,
|
||||||
|
initialJobFormState,
|
||||||
|
} from "@/lib/appwrite/job-types";
|
||||||
|
import type { JobCounterpart } from "@/lib/appwrite/job-queries";
|
||||||
|
import type { ProstheticType } from "@/lib/appwrite/schema";
|
||||||
|
|
||||||
|
const PROSTHETIC_TYPES: ProstheticType[] = [
|
||||||
|
"metal_porselen",
|
||||||
|
"zirkonyum",
|
||||||
|
"implant_ustu_zirkonyum",
|
||||||
|
"gecici",
|
||||||
|
"e_max",
|
||||||
|
"diger",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function NewJobForm({
|
||||||
|
labs,
|
||||||
|
defaultCurrency,
|
||||||
|
}: {
|
||||||
|
labs: JobCounterpart[];
|
||||||
|
defaultCurrency: string;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [state, action, pending] = useActionState(createJobAction, initialJobFormState);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.ok) {
|
||||||
|
toast.success("İş yayınlandı.");
|
||||||
|
router.push("/jobs/outbound");
|
||||||
|
} else if (state.error) {
|
||||||
|
toast.error(state.error);
|
||||||
|
}
|
||||||
|
}, [state, router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={action} className="grid gap-5">
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<div className="grid gap-2 md:col-span-2">
|
||||||
|
<Label htmlFor="labTenantId">Laboratuvar *</Label>
|
||||||
|
<Select name="labTenantId" required defaultValue={labs[0]?.tenantId}>
|
||||||
|
<SelectTrigger id="labTenantId">
|
||||||
|
<SelectValue placeholder="Bir laboratuvar seçin" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{labs.map((l) => (
|
||||||
|
<SelectItem key={l.tenantId} value={l.tenantId}>
|
||||||
|
{l.companyName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{state.fieldErrors?.labTenantId && (
|
||||||
|
<p className="text-destructive text-xs">{state.fieldErrors.labTenantId}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="patientCode">Hasta Kodu *</Label>
|
||||||
|
<Input
|
||||||
|
id="patientCode"
|
||||||
|
name="patientCode"
|
||||||
|
required
|
||||||
|
maxLength={50}
|
||||||
|
placeholder="Örn. 000892"
|
||||||
|
/>
|
||||||
|
{state.fieldErrors?.patientCode && (
|
||||||
|
<p className="text-destructive text-xs">{state.fieldErrors.patientCode}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="memberCount">Üye Sayısı *</Label>
|
||||||
|
<Input
|
||||||
|
id="memberCount"
|
||||||
|
name="memberCount"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="32"
|
||||||
|
required
|
||||||
|
defaultValue={1}
|
||||||
|
/>
|
||||||
|
{state.fieldErrors?.memberCount && (
|
||||||
|
<p className="text-destructive text-xs">{state.fieldErrors.memberCount}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="prostheticType">Protez Türü *</Label>
|
||||||
|
<Select name="prostheticType" required>
|
||||||
|
<SelectTrigger id="prostheticType">
|
||||||
|
<SelectValue placeholder="Tür seçin" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PROSTHETIC_TYPES.map((t) => (
|
||||||
|
<SelectItem key={t} value={t}>
|
||||||
|
{PROSTHETIC_TYPE_LABELS[t]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{state.fieldErrors?.prostheticType && (
|
||||||
|
<p className="text-destructive text-xs">{state.fieldErrors.prostheticType}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="color">Renk (Vita)</Label>
|
||||||
|
<Input id="color" name="color" maxLength={20} placeholder="Örn. A2" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="dueDate">Termin Tarihi</Label>
|
||||||
|
<Input id="dueDate" name="dueDate" type="date" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[1fr_100px] gap-2">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="price">Fiyat</Label>
|
||||||
|
<Input
|
||||||
|
id="price"
|
||||||
|
name="price"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="currency">Para</Label>
|
||||||
|
<Input
|
||||||
|
id="currency"
|
||||||
|
name="currency"
|
||||||
|
defaultValue={defaultCurrency}
|
||||||
|
maxLength={8}
|
||||||
|
style={{ textTransform: "uppercase" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2 md:col-span-2">
|
||||||
|
<Label htmlFor="description">Açıklama</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
rows={4}
|
||||||
|
maxLength={2000}
|
||||||
|
placeholder="Lab'a iletmek istediğiniz notlar — hijyenik gövde, materyal tercihi, vs."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="submit" disabled={pending || labs.length === 0}>
|
||||||
|
{pending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Gönderiliyor...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send className="size-4" />
|
||||||
|
İşi Yayınla
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,15 @@
|
|||||||
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { listApprovedLabsForClinic } from "@/lib/appwrite/job-queries";
|
||||||
import { requireTenant, requireTenantKind } from "@/lib/appwrite/tenant-guard";
|
import { requireTenant, requireTenantKind } from "@/lib/appwrite/tenant-guard";
|
||||||
|
import { NewJobForm } from "./components/new-job-form";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "DLS — Yeni İş Yayınla",
|
||||||
|
};
|
||||||
|
|
||||||
export default async function NewJobPage() {
|
export default async function NewJobPage() {
|
||||||
let ctx;
|
let ctx;
|
||||||
@@ -12,6 +20,9 @@ export default async function NewJobPage() {
|
|||||||
redirect("/dashboard");
|
redirect("/dashboard");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const labs = await listApprovedLabsForClinic(ctx.tenantId);
|
||||||
|
const defaultCurrency = ctx.settings?.defaultCurrency ?? "TRY";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 space-y-6 px-6">
|
<div className="flex-1 space-y-6 px-6">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
@@ -20,15 +31,34 @@ export default async function NewJobPage() {
|
|||||||
Bağlı laboratuvarınıza yeni bir protez işi gönderin.
|
Bağlı laboratuvarınıza yeni bir protez işi gönderin.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{labs.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Yapım aşamasında</CardTitle>
|
<CardTitle>Önce bir laboratuvarla bağlantı kurun</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Form (lab seçimi, hasta kodu, protez türü, renk, dosya yükleme) sonraki sürümde eklenecek.
|
İş gönderebilmeniz için onaylanmış bir laboratuvar bağlantınız olmalı.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent />
|
<CardContent>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/connections">Bağlantı Kur</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>İş Bilgileri</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Hasta kodu, protez türü ve diğer detayları girin. Dosya yüklemesi sonraki sürümde.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<NewJobForm labs={labs} defaultCurrency={defaultCurrency} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,57 @@
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { listOutboundJobs } from "@/lib/appwrite/job-queries";
|
||||||
|
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||||
|
import { JobsTable } from "../_components/jobs-table";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "DLS — Giden İşler",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function OutboundJobsPage() {
|
||||||
|
let ctx;
|
||||||
|
try {
|
||||||
|
ctx = await requireTenant();
|
||||||
|
} catch {
|
||||||
|
redirect("/onboarding");
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = ctx.kind === "clinic" ? await listOutboundJobs(ctx.tenantId) : [];
|
||||||
|
|
||||||
export default function OutboundJobsPage() {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 space-y-6 px-6">
|
<div className="flex-1 space-y-6 px-6">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Giden İşler</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Giden İşler</h1>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
Karşı tarafa gönderilen protez işleri burada listelenecek.
|
Bağlı laboratuvarlara gönderdiğiniz işler.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Yapım aşamasında</CardTitle>
|
<CardTitle>Tüm Giden İşler</CardTitle>
|
||||||
<CardDescription>Giden iş listesi sonraki sürümde eklenecek.</CardDescription>
|
<CardDescription>
|
||||||
|
{ctx.kind === "clinic"
|
||||||
|
? rows.length === 0
|
||||||
|
? "Henüz iş göndermediniz."
|
||||||
|
: `${rows.length} kalem`
|
||||||
|
: "Bu sayfa klinik hesapları içindir."}
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent />
|
<CardContent>
|
||||||
|
{ctx.kind === "clinic" ? (
|
||||||
|
<JobsTable
|
||||||
|
rows={rows}
|
||||||
|
counterpartLabel="Laboratuvar"
|
||||||
|
emptyMessage="Henüz iş göndermediniz. 'Yeni İş Yayınla' butonundan başlayabilirsiniz."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground py-6 text-center text-sm">
|
||||||
|
Laboratuvar hesabıyla giriş yaptınız — giden iş listesi sadece klinik tarafında görünür.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState, useEffect, useRef } from "react";
|
||||||
|
import { Loader2, Plus } 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 { createProstheticAction } from "@/lib/appwrite/prosthetic-actions";
|
||||||
|
import {
|
||||||
|
PROSTHETIC_TYPE_OPTIONS,
|
||||||
|
initialProstheticFormState,
|
||||||
|
} from "@/lib/appwrite/prosthetic-types";
|
||||||
|
|
||||||
|
export function ProstheticForm({ defaultCurrency }: { defaultCurrency: string }) {
|
||||||
|
const [state, formAction, isPending] = useActionState(
|
||||||
|
createProstheticAction,
|
||||||
|
initialProstheticFormState,
|
||||||
|
);
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.ok) {
|
||||||
|
toast.success("Ürün eklendi.");
|
||||||
|
formRef.current?.reset();
|
||||||
|
} else if (state.error) {
|
||||||
|
toast.error(state.error);
|
||||||
|
}
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form ref={formRef} action={formAction} className="grid gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="name">Protez Adı *</Label>
|
||||||
|
<Input id="name" name="name" required placeholder="Örn. Premium Zirkonyum" maxLength={255} />
|
||||||
|
{state.fieldErrors?.name && (
|
||||||
|
<p className="text-destructive text-xs">{state.fieldErrors.name}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="type">Protez Türü *</Label>
|
||||||
|
<Select name="type" required>
|
||||||
|
<SelectTrigger id="type">
|
||||||
|
<SelectValue placeholder="Tür seçin" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PROSTHETIC_TYPE_OPTIONS.map((o) => (
|
||||||
|
<SelectItem key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{state.fieldErrors?.type && (
|
||||||
|
<p className="text-destructive text-xs">{state.fieldErrors.type}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2 grid-cols-[1fr_100px]">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="unitPrice">Birim Fiyatı *</Label>
|
||||||
|
<Input
|
||||||
|
id="unitPrice"
|
||||||
|
name="unitPrice"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
required
|
||||||
|
placeholder="0.00"
|
||||||
|
inputMode="decimal"
|
||||||
|
/>
|
||||||
|
{state.fieldErrors?.unitPrice && (
|
||||||
|
<p className="text-destructive text-xs">{state.fieldErrors.unitPrice}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="currency">Para birimi</Label>
|
||||||
|
<Input
|
||||||
|
id="currency"
|
||||||
|
name="currency"
|
||||||
|
defaultValue={defaultCurrency}
|
||||||
|
maxLength={8}
|
||||||
|
style={{ textTransform: "uppercase" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={isPending}>
|
||||||
|
{isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Ekleniyor...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Ürün ekle
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState, useEffect, useState, useTransition } from "react";
|
||||||
|
import { Archive, ArchiveRestore, Loader2, Pencil, 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,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
archiveProstheticAction,
|
||||||
|
deleteProstheticAction,
|
||||||
|
updateProstheticAction,
|
||||||
|
} from "@/lib/appwrite/prosthetic-actions";
|
||||||
|
import {
|
||||||
|
PROSTHETIC_TYPE_OPTIONS,
|
||||||
|
initialProstheticActionState,
|
||||||
|
initialProstheticFormState,
|
||||||
|
} from "@/lib/appwrite/prosthetic-types";
|
||||||
|
import type { Prosthetic } from "@/lib/appwrite/schema";
|
||||||
|
|
||||||
|
const TYPE_LABELS = Object.fromEntries(
|
||||||
|
PROSTHETIC_TYPE_OPTIONS.map((o) => [o.value, o.label]),
|
||||||
|
) as Record<string, string>;
|
||||||
|
|
||||||
|
function formatMoney(amount: number, currency: string) {
|
||||||
|
try {
|
||||||
|
return new Intl.NumberFormat("tr-TR", { style: "currency", currency }).format(amount);
|
||||||
|
} catch {
|
||||||
|
return `${amount.toFixed(2)} ${currency}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProstheticsTable({
|
||||||
|
rows,
|
||||||
|
defaultCurrency,
|
||||||
|
}: {
|
||||||
|
rows: Prosthetic[];
|
||||||
|
defaultCurrency: string;
|
||||||
|
}) {
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-muted-foreground py-6 text-center text-sm">
|
||||||
|
Henüz ürün eklemediniz. Sağdaki formdan ekleyebilirsiniz.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Protez Adı</TableHead>
|
||||||
|
<TableHead>Tür</TableHead>
|
||||||
|
<TableHead className="text-right">Birim Fiyat</TableHead>
|
||||||
|
<TableHead>Durum</TableHead>
|
||||||
|
<TableHead className="text-right">İşlem</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<ProstheticRow key={r.$id} row={r} defaultCurrency={defaultCurrency} />
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProstheticRow({
|
||||||
|
row,
|
||||||
|
defaultCurrency,
|
||||||
|
}: {
|
||||||
|
row: Prosthetic;
|
||||||
|
defaultCurrency: string;
|
||||||
|
}) {
|
||||||
|
const [archiveState, archiveAction, archivePending] = useActionState(
|
||||||
|
archiveProstheticAction,
|
||||||
|
initialProstheticActionState,
|
||||||
|
);
|
||||||
|
const [deleteState, deleteAction, deletePending] = useActionState(
|
||||||
|
deleteProstheticAction,
|
||||||
|
initialProstheticActionState,
|
||||||
|
);
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (archiveState.ok) toast.success(row.archived ? "Aktifleştirildi." : "Arşivlendi.");
|
||||||
|
else if (archiveState.error) toast.error(archiveState.error);
|
||||||
|
}, [archiveState, row.archived]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (deleteState.ok) {
|
||||||
|
toast.success("Ürün silindi.");
|
||||||
|
setDeleteOpen(false);
|
||||||
|
} else if (deleteState.error) {
|
||||||
|
toast.error(deleteState.error);
|
||||||
|
}
|
||||||
|
}, [deleteState]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow className={row.archived ? "opacity-60" : ""}>
|
||||||
|
<TableCell className="font-medium">{row.name}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{TYPE_LABELS[row.type] ?? row.type}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">
|
||||||
|
{formatMoney(row.unitPrice, row.currency || defaultCurrency)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{row.archived ? (
|
||||||
|
<Badge variant="outline">Arşiv</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">Aktif</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-1.5">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setEditOpen(true)}
|
||||||
|
disabled={archivePending || deletePending}
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<form
|
||||||
|
action={(fd) => {
|
||||||
|
startTransition(() => archiveAction(fd));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={row.$id} />
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={archivePending || deletePending}
|
||||||
|
>
|
||||||
|
{archivePending ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : row.archived ? (
|
||||||
|
<ArchiveRestore className="size-4" />
|
||||||
|
) : (
|
||||||
|
<Archive className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDeleteOpen(true)}
|
||||||
|
disabled={archivePending || deletePending}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditDialog
|
||||||
|
row={row}
|
||||||
|
defaultCurrency={defaultCurrency}
|
||||||
|
open={editOpen}
|
||||||
|
onOpenChange={setEditOpen}
|
||||||
|
/>
|
||||||
|
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Ürün silinsin mi?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{row.name} kalıcı olarak silinecek. Mevcut işlerden bu ürünün referansı kaldırılmaz.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="outline">
|
||||||
|
Vazgeç
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<form
|
||||||
|
action={(fd) => {
|
||||||
|
startTransition(() => deleteAction(fd));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={row.$id} />
|
||||||
|
<Button type="submit" variant="destructive" disabled={deletePending}>
|
||||||
|
{deletePending ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
)}
|
||||||
|
Sil
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditDialog({
|
||||||
|
row,
|
||||||
|
defaultCurrency,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
row: Prosthetic;
|
||||||
|
defaultCurrency: string;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const [state, action, pending] = useActionState(
|
||||||
|
updateProstheticAction,
|
||||||
|
initialProstheticFormState,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.ok) {
|
||||||
|
toast.success("Ürün güncellendi.");
|
||||||
|
onOpenChange(false);
|
||||||
|
} else if (state.error) {
|
||||||
|
toast.error(state.error);
|
||||||
|
}
|
||||||
|
}, [state, onOpenChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Ürünü düzenle</DialogTitle>
|
||||||
|
<DialogDescription>{row.name}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form action={action} className="grid gap-4">
|
||||||
|
<input type="hidden" name="id" value={row.$id} />
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor={`edit-name-${row.$id}`}>Protez Adı *</Label>
|
||||||
|
<Input
|
||||||
|
id={`edit-name-${row.$id}`}
|
||||||
|
name="name"
|
||||||
|
defaultValue={row.name}
|
||||||
|
required
|
||||||
|
maxLength={255}
|
||||||
|
/>
|
||||||
|
{state.fieldErrors?.name && (
|
||||||
|
<p className="text-destructive text-xs">{state.fieldErrors.name}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor={`edit-type-${row.$id}`}>Protez Türü *</Label>
|
||||||
|
<Select name="type" defaultValue={row.type}>
|
||||||
|
<SelectTrigger id={`edit-type-${row.$id}`}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PROSTHETIC_TYPE_OPTIONS.map((o) => (
|
||||||
|
<SelectItem key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-[1fr_100px] gap-3">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor={`edit-price-${row.$id}`}>Birim Fiyatı *</Label>
|
||||||
|
<Input
|
||||||
|
id={`edit-price-${row.$id}`}
|
||||||
|
name="unitPrice"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
defaultValue={row.unitPrice}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor={`edit-currency-${row.$id}`}>Para</Label>
|
||||||
|
<Input
|
||||||
|
id={`edit-currency-${row.$id}`}
|
||||||
|
name="currency"
|
||||||
|
defaultValue={row.currency || defaultCurrency}
|
||||||
|
maxLength={8}
|
||||||
|
style={{ textTransform: "uppercase" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="outline">
|
||||||
|
Vazgeç
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button type="submit" disabled={pending}>
|
||||||
|
{pending ? <Loader2 className="size-4 animate-spin" /> : <Pencil className="size-4" />}
|
||||||
|
Kaydet
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,14 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { listProsthetics } from "@/lib/appwrite/prosthetic-queries";
|
||||||
import { requireTenant, requireTenantKind } from "@/lib/appwrite/tenant-guard";
|
import { requireTenant, requireTenantKind } from "@/lib/appwrite/tenant-guard";
|
||||||
|
import { ProstheticForm } from "./components/prosthetic-form";
|
||||||
|
import { ProstheticsTable } from "./components/prosthetics-table";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "DLS — Ürünler",
|
||||||
|
};
|
||||||
|
|
||||||
export default async function ProductsPage() {
|
export default async function ProductsPage() {
|
||||||
let ctx;
|
let ctx;
|
||||||
@@ -12,21 +19,41 @@ export default async function ProductsPage() {
|
|||||||
redirect("/dashboard");
|
redirect("/dashboard");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rows = await listProsthetics(ctx.tenantId);
|
||||||
|
const defaultCurrency = ctx.settings?.defaultCurrency ?? "TRY";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 space-y-6 px-6">
|
<div className="flex-1 space-y-6 px-6">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Ürünler</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Ürünler</h1>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
Sunduğunuz protez türleri ve fiyatlandırma katalogu.
|
Sunduğunuz protez türleri ve fiyatlandırma katalogu. Klinikler iş yayınlarken bu listeden seçebilir.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[1fr_360px]">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Yapım aşamasında</CardTitle>
|
<CardTitle>Eklenen Ürünler</CardTitle>
|
||||||
<CardDescription>Ürün ekleme/düzenleme sonraki sürümde eklenecek.</CardDescription>
|
<CardDescription>
|
||||||
|
{rows.length === 0 ? "Henüz ürün yok." : `${rows.length} kalem`}
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent />
|
<CardContent>
|
||||||
|
<ProstheticsTable rows={rows} defaultCurrency={defaultCurrency} />
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Ürün Ekle</CardTitle>
|
||||||
|
<CardDescription>Protez türü ve birim fiyatını girin.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ProstheticForm defaultCurrency={defaultCurrency} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,353 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite";
|
||||||
|
|
||||||
|
import { logAudit } from "./audit";
|
||||||
|
import {
|
||||||
|
DATABASE_ID,
|
||||||
|
TABLES,
|
||||||
|
type Connection,
|
||||||
|
type TenantSettings,
|
||||||
|
} from "./schema";
|
||||||
|
import { createAdminClient } from "./server";
|
||||||
|
import { requireRole, requireTenant } from "./tenant-guard";
|
||||||
|
import type {
|
||||||
|
ConnectionActionState,
|
||||||
|
ConnectionRequestState,
|
||||||
|
} from "./connection-types";
|
||||||
|
|
||||||
|
function appwriteError(e: unknown, fallback = "Beklenmeyen bir hata oluştu."): string {
|
||||||
|
if (e instanceof AppwriteException) return e.message || fallback;
|
||||||
|
return process.env.NODE_ENV !== "production" && e instanceof Error
|
||||||
|
? `${fallback} (${e.message})`
|
||||||
|
: fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectionPermissions(clinicTenantId: string, labTenantId: string): string[] {
|
||||||
|
return [
|
||||||
|
Permission.read(Role.team(clinicTenantId)),
|
||||||
|
Permission.read(Role.team(labTenantId)),
|
||||||
|
Permission.update(Role.team(clinicTenantId, "owner")),
|
||||||
|
Permission.update(Role.team(clinicTenantId, "admin")),
|
||||||
|
Permission.update(Role.team(labTenantId, "owner")),
|
||||||
|
Permission.update(Role.team(labTenantId, "admin")),
|
||||||
|
Permission.delete(Role.team(clinicTenantId, "owner")),
|
||||||
|
Permission.delete(Role.team(labTenantId, "owner")),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestConnectionAction(
|
||||||
|
_prev: ConnectionRequestState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<ConnectionRequestState> {
|
||||||
|
const rawCode = String(formData.get("memberNumber") ?? "").trim().toUpperCase();
|
||||||
|
if (!rawCode) {
|
||||||
|
return { ok: false, error: "Bağlantı kodu zorunlu." };
|
||||||
|
}
|
||||||
|
|
||||||
|
let ctx;
|
||||||
|
try {
|
||||||
|
ctx = await requireTenant();
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: "Oturum bulunamadı." };
|
||||||
|
}
|
||||||
|
if (!ctx.kind) {
|
||||||
|
return { ok: false, error: "Hesap türünüz tanımlı değil. Ayarlar > Çalışma alanı." };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
|
||||||
|
// Resolve counterpart by memberNumber
|
||||||
|
const counterpartRes = await tablesDB.listRows({
|
||||||
|
databaseId: DATABASE_ID,
|
||||||
|
tableId: TABLES.tenantSettings,
|
||||||
|
queries: [Query.equal("memberNumber", rawCode), Query.limit(1)],
|
||||||
|
});
|
||||||
|
const counterpart = counterpartRes.rows[0] as unknown as TenantSettings | undefined;
|
||||||
|
if (!counterpart) {
|
||||||
|
return { ok: false, error: "Bu kod ile eşleşen bir hesap bulunamadı." };
|
||||||
|
}
|
||||||
|
if (counterpart.tenantId === ctx.tenantId) {
|
||||||
|
return { ok: false, error: "Kendi kodunuzla bağlantı kuramazsınız." };
|
||||||
|
}
|
||||||
|
if (counterpart.kind === ctx.kind) {
|
||||||
|
const same = ctx.kind === "lab" ? "laboratuvar" : "klinik";
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `Bu kod bir ${same} hesabına ait. Bağlantı sadece klinik ↔ laboratuvar arasında olur.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const clinicTenantId = ctx.kind === "clinic" ? ctx.tenantId : counterpart.tenantId;
|
||||||
|
const labTenantId = ctx.kind === "lab" ? ctx.tenantId : counterpart.tenantId;
|
||||||
|
|
||||||
|
// Already connected or pending?
|
||||||
|
const existingRes = await tablesDB.listRows({
|
||||||
|
databaseId: DATABASE_ID,
|
||||||
|
tableId: TABLES.connections,
|
||||||
|
queries: [
|
||||||
|
Query.equal("clinicTenantId", clinicTenantId),
|
||||||
|
Query.equal("labTenantId", labTenantId),
|
||||||
|
Query.limit(1),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const existing = existingRes.rows[0] as unknown as Connection | undefined;
|
||||||
|
if (existing) {
|
||||||
|
if (existing.status === "approved") {
|
||||||
|
return { ok: false, error: "Bu hesapla zaten bağlantınız var." };
|
||||||
|
}
|
||||||
|
if (existing.status === "pending") {
|
||||||
|
return { ok: false, error: "Bekleyen bir talep zaten var." };
|
||||||
|
}
|
||||||
|
// status === 'rejected' → re-open: reset to pending, requesterBy = current user
|
||||||
|
await tablesDB.updateRow(DATABASE_ID, TABLES.connections, existing.$id, {
|
||||||
|
status: "pending",
|
||||||
|
requestedBy: ctx.user.id,
|
||||||
|
requestedAt: new Date().toISOString(),
|
||||||
|
approvedAt: null,
|
||||||
|
rejectedAt: null,
|
||||||
|
});
|
||||||
|
await logAudit({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: "update",
|
||||||
|
entityType: "connection",
|
||||||
|
entityId: existing.$id,
|
||||||
|
changes: { status: "pending", reopened: true },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const created = await tablesDB.createRow(
|
||||||
|
DATABASE_ID,
|
||||||
|
TABLES.connections,
|
||||||
|
ID.unique(),
|
||||||
|
{
|
||||||
|
clinicTenantId,
|
||||||
|
labTenantId,
|
||||||
|
status: "pending",
|
||||||
|
requestedBy: ctx.user.id,
|
||||||
|
requestedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
connectionPermissions(clinicTenantId, labTenantId),
|
||||||
|
);
|
||||||
|
await logAudit({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: "create",
|
||||||
|
entityType: "connection",
|
||||||
|
entityId: created.$id,
|
||||||
|
changes: { clinicTenantId, labTenantId, status: "pending" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: appwriteError(e, "Bağlantı talebi gönderilemedi.") };
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/connections");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConnectionForMutation(
|
||||||
|
connectionId: string,
|
||||||
|
expectedTenant: string,
|
||||||
|
): Promise<Connection | null> {
|
||||||
|
try {
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
const row = await tablesDB.getRow(DATABASE_ID, TABLES.connections, connectionId);
|
||||||
|
const conn = row as unknown as Connection;
|
||||||
|
if (
|
||||||
|
conn.clinicTenantId !== expectedTenant &&
|
||||||
|
conn.labTenantId !== expectedTenant
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return conn;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function approveConnectionAction(
|
||||||
|
_prev: ConnectionActionState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<ConnectionActionState> {
|
||||||
|
const connectionId = String(formData.get("connectionId") ?? "").trim();
|
||||||
|
if (!connectionId) return { ok: false, error: "Bağlantı bulunamadı." };
|
||||||
|
|
||||||
|
let ctx;
|
||||||
|
try {
|
||||||
|
ctx = await requireTenant();
|
||||||
|
requireRole(ctx, ["owner", "admin"]);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: "Bu işlem için yetkiniz yok." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const conn = await loadConnectionForMutation(connectionId, ctx.tenantId);
|
||||||
|
if (!conn) return { ok: false, error: "Bağlantı bulunamadı." };
|
||||||
|
if (conn.status !== "pending") {
|
||||||
|
return { ok: false, error: "Bu talep zaten yanıtlandı." };
|
||||||
|
}
|
||||||
|
// Requester cannot self-approve
|
||||||
|
const requesterTenant =
|
||||||
|
conn.requestedBy && conn.clinicTenantId === ctx.tenantId
|
||||||
|
? conn.clinicTenantId
|
||||||
|
: conn.labTenantId;
|
||||||
|
// Better: simply require that current tenant is NOT the side that initiated.
|
||||||
|
// We don't have requesterTenant explicit on row, but requestedBy.userId belongs
|
||||||
|
// to one side. As a guard, approver must be on the other side from the user
|
||||||
|
// who created it. Best signal we have: requestedBy is the originator userId.
|
||||||
|
// If approver is same user as requester → block.
|
||||||
|
if (conn.requestedBy === ctx.user.id) {
|
||||||
|
return { ok: false, error: "Kendi talebinizi siz onaylayamazsınız." };
|
||||||
|
}
|
||||||
|
void requesterTenant;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
await tablesDB.updateRow(DATABASE_ID, TABLES.connections, connectionId, {
|
||||||
|
status: "approved",
|
||||||
|
approvedAt: new Date().toISOString(),
|
||||||
|
rejectedAt: null,
|
||||||
|
});
|
||||||
|
await logAudit({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: "update",
|
||||||
|
entityType: "connection",
|
||||||
|
entityId: connectionId,
|
||||||
|
changes: { status: "approved" },
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: appwriteError(e, "Onaylanamadı.") };
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/connections");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rejectConnectionAction(
|
||||||
|
_prev: ConnectionActionState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<ConnectionActionState> {
|
||||||
|
const connectionId = String(formData.get("connectionId") ?? "").trim();
|
||||||
|
if (!connectionId) return { ok: false, error: "Bağlantı bulunamadı." };
|
||||||
|
|
||||||
|
let ctx;
|
||||||
|
try {
|
||||||
|
ctx = await requireTenant();
|
||||||
|
requireRole(ctx, ["owner", "admin"]);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: "Bu işlem için yetkiniz yok." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const conn = await loadConnectionForMutation(connectionId, ctx.tenantId);
|
||||||
|
if (!conn) return { ok: false, error: "Bağlantı bulunamadı." };
|
||||||
|
if (conn.status !== "pending") {
|
||||||
|
return { ok: false, error: "Bu talep zaten yanıtlandı." };
|
||||||
|
}
|
||||||
|
if (conn.requestedBy === ctx.user.id) {
|
||||||
|
return { ok: false, error: "Kendi talebinizi reddedemezsiniz." };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
await tablesDB.updateRow(DATABASE_ID, TABLES.connections, connectionId, {
|
||||||
|
status: "rejected",
|
||||||
|
rejectedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
await logAudit({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: "update",
|
||||||
|
entityType: "connection",
|
||||||
|
entityId: connectionId,
|
||||||
|
changes: { status: "rejected" },
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: appwriteError(e, "Reddedilemedi.") };
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/connections");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelConnectionAction(
|
||||||
|
_prev: ConnectionActionState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<ConnectionActionState> {
|
||||||
|
// Used when the requester wants to withdraw their own pending request.
|
||||||
|
const connectionId = String(formData.get("connectionId") ?? "").trim();
|
||||||
|
if (!connectionId) return { ok: false, error: "Bağlantı bulunamadı." };
|
||||||
|
|
||||||
|
let ctx;
|
||||||
|
try {
|
||||||
|
ctx = await requireTenant();
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: "Oturum bulunamadı." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const conn = await loadConnectionForMutation(connectionId, ctx.tenantId);
|
||||||
|
if (!conn) return { ok: false, error: "Bağlantı bulunamadı." };
|
||||||
|
if (conn.status !== "pending") {
|
||||||
|
return { ok: false, error: "Bu talep iptal edilemez." };
|
||||||
|
}
|
||||||
|
if (conn.requestedBy !== ctx.user.id) {
|
||||||
|
return { ok: false, error: "Sadece talebi gönderen iptal edebilir." };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
await tablesDB.deleteRow(DATABASE_ID, TABLES.connections, connectionId);
|
||||||
|
await logAudit({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: "delete",
|
||||||
|
entityType: "connection",
|
||||||
|
entityId: connectionId,
|
||||||
|
changes: { status: "pending", reason: "cancelled" },
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: appwriteError(e, "İptal edilemedi.") };
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/connections");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteConnectionAction(
|
||||||
|
_prev: ConnectionActionState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<ConnectionActionState> {
|
||||||
|
const connectionId = String(formData.get("connectionId") ?? "").trim();
|
||||||
|
if (!connectionId) return { ok: false, error: "Bağlantı bulunamadı." };
|
||||||
|
|
||||||
|
let ctx;
|
||||||
|
try {
|
||||||
|
ctx = await requireTenant();
|
||||||
|
requireRole(ctx, ["owner", "admin"]);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: "Bu işlem için yetkiniz yok." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const conn = await loadConnectionForMutation(connectionId, ctx.tenantId);
|
||||||
|
if (!conn) return { ok: false, error: "Bağlantı bulunamadı." };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
await tablesDB.deleteRow(DATABASE_ID, TABLES.connections, connectionId);
|
||||||
|
await logAudit({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: "delete",
|
||||||
|
entityType: "connection",
|
||||||
|
entityId: connectionId,
|
||||||
|
changes: { previousStatus: conn.status },
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: appwriteError(e, "Silinemedi.") };
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/connections");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { Query } from "node-appwrite";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DATABASE_ID,
|
||||||
|
TABLES,
|
||||||
|
type Connection,
|
||||||
|
type TenantKind,
|
||||||
|
type TenantSettings,
|
||||||
|
} from "./schema";
|
||||||
|
import { createAdminClient } from "./server";
|
||||||
|
|
||||||
|
export type CounterpartTenant = {
|
||||||
|
tenantId: string;
|
||||||
|
companyName: string;
|
||||||
|
memberNumber: string;
|
||||||
|
kind: TenantKind;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConnectionWithCounterpart = Connection & {
|
||||||
|
counterpart: CounterpartTenant | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function counterpartTenantId(conn: Connection, selfTenantId: string): string {
|
||||||
|
return conn.clinicTenantId === selfTenantId ? conn.labTenantId : conn.clinicTenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCounterparts(tenantIds: string[]): Promise<Map<string, CounterpartTenant>> {
|
||||||
|
if (tenantIds.length === 0) return new Map();
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
const result = await tablesDB.listRows({
|
||||||
|
databaseId: DATABASE_ID,
|
||||||
|
tableId: TABLES.tenantSettings,
|
||||||
|
queries: [Query.equal("tenantId", tenantIds), Query.limit(100)],
|
||||||
|
});
|
||||||
|
const map = new Map<string, CounterpartTenant>();
|
||||||
|
for (const row of result.rows as unknown as TenantSettings[]) {
|
||||||
|
map.set(row.tenantId, {
|
||||||
|
tenantId: row.tenantId,
|
||||||
|
companyName: row.companyName,
|
||||||
|
memberNumber: row.memberNumber,
|
||||||
|
kind: row.kind,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enrich(
|
||||||
|
rows: Connection[],
|
||||||
|
selfTenantId: string,
|
||||||
|
): Promise<ConnectionWithCounterpart[]> {
|
||||||
|
const counterpartIds = Array.from(
|
||||||
|
new Set(rows.map((r) => counterpartTenantId(r, selfTenantId))),
|
||||||
|
);
|
||||||
|
const map = await fetchCounterparts(counterpartIds);
|
||||||
|
return rows.map((r) => ({
|
||||||
|
...r,
|
||||||
|
counterpart: map.get(counterpartTenantId(r, selfTenantId)) ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listConnectionsByStatus(
|
||||||
|
tenantId: string,
|
||||||
|
status: Connection["status"],
|
||||||
|
): Promise<Connection[]> {
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
const result = await tablesDB.listRows({
|
||||||
|
databaseId: DATABASE_ID,
|
||||||
|
tableId: TABLES.connections,
|
||||||
|
queries: [
|
||||||
|
Query.or([
|
||||||
|
Query.equal("clinicTenantId", tenantId),
|
||||||
|
Query.equal("labTenantId", tenantId),
|
||||||
|
]),
|
||||||
|
Query.equal("status", status),
|
||||||
|
Query.orderDesc("$createdAt"),
|
||||||
|
Query.limit(100),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return result.rows as unknown as Connection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listApprovedConnections(
|
||||||
|
tenantId: string,
|
||||||
|
): Promise<ConnectionWithCounterpart[]> {
|
||||||
|
const rows = await listConnectionsByStatus(tenantId, "approved");
|
||||||
|
return enrich(rows, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPendingInbound(
|
||||||
|
tenantId: string,
|
||||||
|
selfUserId: string,
|
||||||
|
): Promise<ConnectionWithCounterpart[]> {
|
||||||
|
// Pending requests sent TO this tenant (this tenant must approve/reject).
|
||||||
|
const rows = await listConnectionsByStatus(tenantId, "pending");
|
||||||
|
const inbound = rows.filter((r) => r.requestedBy !== selfUserId);
|
||||||
|
return enrich(inbound, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPendingOutbound(
|
||||||
|
tenantId: string,
|
||||||
|
selfUserId: string,
|
||||||
|
): Promise<ConnectionWithCounterpart[]> {
|
||||||
|
// Pending requests this tenant sent — counterpart will approve/reject.
|
||||||
|
const rows = await listConnectionsByStatus(tenantId, "pending");
|
||||||
|
const outbound = rows.filter((r) => r.requestedBy === selfUserId);
|
||||||
|
return enrich(outbound, tenantId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export type ConnectionRequestState = {
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const initialConnectionRequestState: ConnectionRequestState = { ok: false };
|
||||||
|
|
||||||
|
export type ConnectionActionState = {
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const initialConnectionActionState: ConnectionActionState = { ok: false };
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"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 Connection,
|
||||||
|
} from "./schema";
|
||||||
|
import { createAdminClient } from "./server";
|
||||||
|
import { requireRole, requireTenant, requireTenantKind } from "./tenant-guard";
|
||||||
|
import type { JobFormState } from "./job-types";
|
||||||
|
import { createJobSchema } from "@/lib/validation/job";
|
||||||
|
|
||||||
|
function appwriteError(e: unknown, fallback = "Beklenmeyen bir hata oluştu."): string {
|
||||||
|
if (e instanceof AppwriteException) return e.message || fallback;
|
||||||
|
return process.env.NODE_ENV !== "production" && e instanceof Error
|
||||||
|
? `${fallback} (${e.message})`
|
||||||
|
: fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 pickFields(formData: FormData) {
|
||||||
|
return {
|
||||||
|
labTenantId: String(formData.get("labTenantId") ?? "").trim(),
|
||||||
|
patientCode: String(formData.get("patientCode") ?? "").trim(),
|
||||||
|
prostheticType: String(formData.get("prostheticType") ?? "").trim(),
|
||||||
|
memberCount: String(formData.get("memberCount") ?? ""),
|
||||||
|
color: String(formData.get("color") ?? "").trim(),
|
||||||
|
description: String(formData.get("description") ?? "").trim(),
|
||||||
|
price: String(formData.get("price") ?? "").trim(),
|
||||||
|
currency: String(formData.get("currency") ?? "").trim(),
|
||||||
|
dueDate: String(formData.get("dueDate") ?? "").trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function jobPermissions(clinicTenantId: string, labTenantId: string): string[] {
|
||||||
|
return [
|
||||||
|
Permission.read(Role.team(clinicTenantId)),
|
||||||
|
Permission.read(Role.team(labTenantId)),
|
||||||
|
Permission.update(Role.team(clinicTenantId, "owner")),
|
||||||
|
Permission.update(Role.team(clinicTenantId, "admin")),
|
||||||
|
Permission.update(Role.team(clinicTenantId, "member")),
|
||||||
|
Permission.update(Role.team(labTenantId, "owner")),
|
||||||
|
Permission.update(Role.team(labTenantId, "admin")),
|
||||||
|
Permission.update(Role.team(labTenantId, "member")),
|
||||||
|
Permission.delete(Role.team(clinicTenantId, "owner")),
|
||||||
|
Permission.delete(Role.team(clinicTenantId, "admin")),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createJobAction(
|
||||||
|
_prev: JobFormState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<JobFormState> {
|
||||||
|
let ctx;
|
||||||
|
try {
|
||||||
|
ctx = await requireTenant();
|
||||||
|
requireRole(ctx, ["owner", "admin", "member"]);
|
||||||
|
requireTenantKind(ctx, ["clinic"]);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: "İş yayınlama yalnızca klinik hesapları için." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = createJobSchema.safeParse(pickFields(formData));
|
||||||
|
if (!parsed.success) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "Form geçersiz.",
|
||||||
|
fieldErrors: flattenErrors(parsed.error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
|
||||||
|
// Verify the chosen lab is an approved connection of this clinic
|
||||||
|
const connRes = await tablesDB.listRows({
|
||||||
|
databaseId: DATABASE_ID,
|
||||||
|
tableId: TABLES.connections,
|
||||||
|
queries: [
|
||||||
|
Query.equal("clinicTenantId", ctx.tenantId),
|
||||||
|
Query.equal("labTenantId", parsed.data.labTenantId),
|
||||||
|
Query.equal("status", "approved"),
|
||||||
|
Query.limit(1),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const conn = connRes.rows[0] as unknown as Connection | undefined;
|
||||||
|
if (!conn) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "Seçilen laboratuvarla onaylanmış bir bağlantınız yok.",
|
||||||
|
fieldErrors: { labTenantId: "Onaylı bağlantı bulunamadı." },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await tablesDB.createRow(
|
||||||
|
DATABASE_ID,
|
||||||
|
TABLES.jobs,
|
||||||
|
ID.unique(),
|
||||||
|
{
|
||||||
|
clinicTenantId: ctx.tenantId,
|
||||||
|
labTenantId: parsed.data.labTenantId,
|
||||||
|
createdBy: ctx.user.id,
|
||||||
|
patientCode: parsed.data.patientCode,
|
||||||
|
prostheticType: parsed.data.prostheticType,
|
||||||
|
memberCount: parsed.data.memberCount,
|
||||||
|
color: parsed.data.color,
|
||||||
|
description: parsed.data.description,
|
||||||
|
price: parsed.data.price,
|
||||||
|
currency: parsed.data.currency,
|
||||||
|
dueDate: parsed.data.dueDate,
|
||||||
|
status: "pending",
|
||||||
|
},
|
||||||
|
jobPermissions(ctx.tenantId, parsed.data.labTenantId),
|
||||||
|
);
|
||||||
|
await logAudit({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: "create",
|
||||||
|
entityType: "job",
|
||||||
|
entityId: created.$id,
|
||||||
|
changes: { labTenantId: parsed.data.labTenantId, patientCode: parsed.data.patientCode },
|
||||||
|
});
|
||||||
|
revalidatePath("/jobs/outbound");
|
||||||
|
revalidatePath("/dashboard");
|
||||||
|
return { ok: true, jobId: created.$id };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: appwriteError(e, "İş oluşturulamadı.") };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { Query } from "node-appwrite";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DATABASE_ID,
|
||||||
|
TABLES,
|
||||||
|
type Job,
|
||||||
|
type TenantKind,
|
||||||
|
type TenantSettings,
|
||||||
|
} from "./schema";
|
||||||
|
import { createAdminClient } from "./server";
|
||||||
|
|
||||||
|
export type JobCounterpart = {
|
||||||
|
tenantId: string;
|
||||||
|
companyName: string;
|
||||||
|
kind: TenantKind;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type JobWithCounterpart = Job & {
|
||||||
|
counterpart: JobCounterpart | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchTenants(tenantIds: string[]): Promise<Map<string, JobCounterpart>> {
|
||||||
|
if (tenantIds.length === 0) return new Map();
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
const result = await tablesDB.listRows({
|
||||||
|
databaseId: DATABASE_ID,
|
||||||
|
tableId: TABLES.tenantSettings,
|
||||||
|
queries: [Query.equal("tenantId", tenantIds), Query.limit(200)],
|
||||||
|
});
|
||||||
|
const map = new Map<string, JobCounterpart>();
|
||||||
|
for (const row of result.rows as unknown as TenantSettings[]) {
|
||||||
|
map.set(row.tenantId, {
|
||||||
|
tenantId: row.tenantId,
|
||||||
|
companyName: row.companyName,
|
||||||
|
kind: row.kind,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enrichJob(j: Job, counterpartId: string, map: Map<string, JobCounterpart>): JobWithCounterpart {
|
||||||
|
return { ...j, counterpart: map.get(counterpartId) ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inbound for a lab tenant — jobs the lab has received. */
|
||||||
|
export async function listInboundJobs(labTenantId: string): Promise<JobWithCounterpart[]> {
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
const result = await tablesDB.listRows({
|
||||||
|
databaseId: DATABASE_ID,
|
||||||
|
tableId: TABLES.jobs,
|
||||||
|
queries: [
|
||||||
|
Query.equal("labTenantId", labTenantId),
|
||||||
|
Query.orderDesc("$createdAt"),
|
||||||
|
Query.limit(200),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const jobs = result.rows as unknown as Job[];
|
||||||
|
const map = await fetchTenants(Array.from(new Set(jobs.map((j) => j.clinicTenantId))));
|
||||||
|
return jobs.map((j) => enrichJob(j, j.clinicTenantId, map));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Outbound for a clinic tenant — jobs the clinic has sent. */
|
||||||
|
export async function listOutboundJobs(clinicTenantId: string): Promise<JobWithCounterpart[]> {
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
const result = await tablesDB.listRows({
|
||||||
|
databaseId: DATABASE_ID,
|
||||||
|
tableId: TABLES.jobs,
|
||||||
|
queries: [
|
||||||
|
Query.equal("clinicTenantId", clinicTenantId),
|
||||||
|
Query.orderDesc("$createdAt"),
|
||||||
|
Query.limit(200),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const jobs = result.rows as unknown as Job[];
|
||||||
|
const map = await fetchTenants(Array.from(new Set(jobs.map((j) => j.labTenantId))));
|
||||||
|
return jobs.map((j) => enrichJob(j, j.labTenantId, map));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** For clinic's "Yeni İş Yayınla" form: list approved labs they can send to. */
|
||||||
|
export async function listApprovedLabsForClinic(
|
||||||
|
clinicTenantId: string,
|
||||||
|
): Promise<JobCounterpart[]> {
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
const result = await tablesDB.listRows({
|
||||||
|
databaseId: DATABASE_ID,
|
||||||
|
tableId: TABLES.connections,
|
||||||
|
queries: [
|
||||||
|
Query.equal("clinicTenantId", clinicTenantId),
|
||||||
|
Query.equal("status", "approved"),
|
||||||
|
Query.limit(100),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const labIds = (result.rows as unknown as { labTenantId: string }[]).map(
|
||||||
|
(r) => r.labTenantId,
|
||||||
|
);
|
||||||
|
const map = await fetchTenants(labIds);
|
||||||
|
return labIds
|
||||||
|
.map((id) => map.get(id))
|
||||||
|
.filter((v): v is JobCounterpart => Boolean(v));
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { JobStatus, JobStep, ProstheticType } from "./schema";
|
||||||
|
|
||||||
|
export type JobFormState = {
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
fieldErrors?: Record<string, string>;
|
||||||
|
jobId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const initialJobFormState: JobFormState = { ok: false };
|
||||||
|
|
||||||
|
export type JobActionState = {
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const initialJobActionState: JobActionState = { ok: false };
|
||||||
|
|
||||||
|
export const JOB_STATUS_LABELS: Record<JobStatus, string> = {
|
||||||
|
pending: "Bekliyor",
|
||||||
|
in_progress: "İşlemde",
|
||||||
|
sent: "Gönderildi",
|
||||||
|
delivered: "Teslim alındı",
|
||||||
|
cancelled: "İptal",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const JOB_STEP_LABELS: Record<JobStep, string> = {
|
||||||
|
olcu: "Ölçü",
|
||||||
|
alt_yapi_prova: "Alt Yapı Prova",
|
||||||
|
ust_yapi_prova: "Üst Yapı Prova",
|
||||||
|
cila_bitim: "Cila / Bitim",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const JOB_STEP_ORDER: JobStep[] = [
|
||||||
|
"olcu",
|
||||||
|
"alt_yapi_prova",
|
||||||
|
"ust_yapi_prova",
|
||||||
|
"cila_bitim",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const PROSTHETIC_TYPE_LABELS: Record<ProstheticType, string> = {
|
||||||
|
metal_porselen: "Metal Porselen",
|
||||||
|
zirkonyum: "Zirkonyum",
|
||||||
|
implant_ustu_zirkonyum: "İmplant Üstü Zirkonyum",
|
||||||
|
gecici: "Geçici",
|
||||||
|
e_max: "E-Max",
|
||||||
|
diger: "Diğer",
|
||||||
|
};
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { AppwriteException, ID, Permission, Role } from "node-appwrite";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { logAudit } from "./audit";
|
||||||
|
import { DATABASE_ID, TABLES, type Prosthetic } from "./schema";
|
||||||
|
import { createAdminClient } from "./server";
|
||||||
|
import { requireRole, requireTenant, requireTenantKind } from "./tenant-guard";
|
||||||
|
import type { ProstheticActionState, ProstheticFormState } from "./prosthetic-types";
|
||||||
|
import { prostheticSchema } from "@/lib/validation/prosthetic";
|
||||||
|
|
||||||
|
function appwriteError(e: unknown, fallback = "Beklenmeyen bir hata oluştu."): string {
|
||||||
|
if (e instanceof AppwriteException) return e.message || fallback;
|
||||||
|
return process.env.NODE_ENV !== "production" && e instanceof Error
|
||||||
|
? `${fallback} (${e.message})`
|
||||||
|
: fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 pickFields(formData: FormData) {
|
||||||
|
return {
|
||||||
|
name: String(formData.get("name") ?? "").trim(),
|
||||||
|
type: String(formData.get("type") ?? "").trim(),
|
||||||
|
unitPrice: String(formData.get("unitPrice") ?? "0"),
|
||||||
|
currency: String(formData.get("currency") ?? "").trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function prostheticPermissions(tenantId: string): string[] {
|
||||||
|
return [
|
||||||
|
Permission.read(Role.team(tenantId)),
|
||||||
|
Permission.update(Role.team(tenantId, "owner")),
|
||||||
|
Permission.update(Role.team(tenantId, "admin")),
|
||||||
|
Permission.delete(Role.team(tenantId, "owner")),
|
||||||
|
Permission.delete(Role.team(tenantId, "admin")),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createProstheticAction(
|
||||||
|
_prev: ProstheticFormState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<ProstheticFormState> {
|
||||||
|
let ctx;
|
||||||
|
try {
|
||||||
|
ctx = await requireTenant();
|
||||||
|
requireRole(ctx, ["owner", "admin", "member"]);
|
||||||
|
requireTenantKind(ctx, ["lab"]);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: "Bu işlem yalnızca laboratuvar hesaplarında yapılabilir." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = prostheticSchema.safeParse(pickFields(formData));
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
const created = await tablesDB.createRow(
|
||||||
|
DATABASE_ID,
|
||||||
|
TABLES.prosthetics,
|
||||||
|
ID.unique(),
|
||||||
|
{
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
createdBy: ctx.user.id,
|
||||||
|
name: parsed.data.name,
|
||||||
|
type: parsed.data.type,
|
||||||
|
unitPrice: parsed.data.unitPrice,
|
||||||
|
currency: parsed.data.currency,
|
||||||
|
archived: false,
|
||||||
|
},
|
||||||
|
prostheticPermissions(ctx.tenantId),
|
||||||
|
);
|
||||||
|
await logAudit({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: "create",
|
||||||
|
entityType: "prosthetic",
|
||||||
|
entityId: created.$id,
|
||||||
|
changes: parsed.data,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: appwriteError(e, "Ürün eklenemedi.") };
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/products");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProstheticAction(
|
||||||
|
_prev: ProstheticFormState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<ProstheticFormState> {
|
||||||
|
const id = String(formData.get("id") ?? "").trim();
|
||||||
|
if (!id) return { ok: false, error: "Ürün bulunamadı." };
|
||||||
|
|
||||||
|
let ctx;
|
||||||
|
try {
|
||||||
|
ctx = await requireTenant();
|
||||||
|
requireRole(ctx, ["owner", "admin", "member"]);
|
||||||
|
requireTenantKind(ctx, ["lab"]);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: "Bu işlem yalnızca laboratuvar hesaplarında yapılabilir." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = prostheticSchema.safeParse(pickFields(formData));
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
const row = (await tablesDB.getRow(
|
||||||
|
DATABASE_ID,
|
||||||
|
TABLES.prosthetics,
|
||||||
|
id,
|
||||||
|
)) as unknown as Prosthetic;
|
||||||
|
if (row.tenantId !== ctx.tenantId) {
|
||||||
|
return { ok: false, error: "Bu ürünü düzenleme yetkiniz yok." };
|
||||||
|
}
|
||||||
|
await tablesDB.updateRow(DATABASE_ID, TABLES.prosthetics, id, {
|
||||||
|
name: parsed.data.name,
|
||||||
|
type: parsed.data.type,
|
||||||
|
unitPrice: parsed.data.unitPrice,
|
||||||
|
currency: parsed.data.currency,
|
||||||
|
});
|
||||||
|
await logAudit({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: "update",
|
||||||
|
entityType: "prosthetic",
|
||||||
|
entityId: id,
|
||||||
|
changes: parsed.data,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: appwriteError(e, "Güncellenemedi.") };
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/products");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function archiveProstheticAction(
|
||||||
|
_prev: ProstheticActionState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<ProstheticActionState> {
|
||||||
|
const id = String(formData.get("id") ?? "").trim();
|
||||||
|
if (!id) return { ok: false, error: "Ürün bulunamadı." };
|
||||||
|
|
||||||
|
let ctx;
|
||||||
|
try {
|
||||||
|
ctx = await requireTenant();
|
||||||
|
requireRole(ctx, ["owner", "admin"]);
|
||||||
|
requireTenantKind(ctx, ["lab"]);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: "Bu işlem için yetkiniz yok." };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
const row = (await tablesDB.getRow(
|
||||||
|
DATABASE_ID,
|
||||||
|
TABLES.prosthetics,
|
||||||
|
id,
|
||||||
|
)) as unknown as Prosthetic;
|
||||||
|
if (row.tenantId !== ctx.tenantId) {
|
||||||
|
return { ok: false, error: "Bu ürünü düzenleme yetkiniz yok." };
|
||||||
|
}
|
||||||
|
await tablesDB.updateRow(DATABASE_ID, TABLES.prosthetics, id, {
|
||||||
|
archived: !row.archived,
|
||||||
|
});
|
||||||
|
await logAudit({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: "update",
|
||||||
|
entityType: "prosthetic",
|
||||||
|
entityId: id,
|
||||||
|
changes: { archived: !row.archived },
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: appwriteError(e, "İşlem başarısız.") };
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/products");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteProstheticAction(
|
||||||
|
_prev: ProstheticActionState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<ProstheticActionState> {
|
||||||
|
const id = String(formData.get("id") ?? "").trim();
|
||||||
|
if (!id) return { ok: false, error: "Ürün bulunamadı." };
|
||||||
|
|
||||||
|
let ctx;
|
||||||
|
try {
|
||||||
|
ctx = await requireTenant();
|
||||||
|
requireRole(ctx, ["owner", "admin"]);
|
||||||
|
requireTenantKind(ctx, ["lab"]);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: "Bu işlem için yetkiniz yok." };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
const row = (await tablesDB.getRow(
|
||||||
|
DATABASE_ID,
|
||||||
|
TABLES.prosthetics,
|
||||||
|
id,
|
||||||
|
)) as unknown as Prosthetic;
|
||||||
|
if (row.tenantId !== ctx.tenantId) {
|
||||||
|
return { ok: false, error: "Bu ürünü silme yetkiniz yok." };
|
||||||
|
}
|
||||||
|
await tablesDB.deleteRow(DATABASE_ID, TABLES.prosthetics, id);
|
||||||
|
await logAudit({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: "delete",
|
||||||
|
entityType: "prosthetic",
|
||||||
|
entityId: id,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: appwriteError(e, "Silinemedi.") };
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/products");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { Query } from "node-appwrite";
|
||||||
|
|
||||||
|
import { DATABASE_ID, TABLES, type Prosthetic } from "./schema";
|
||||||
|
import { createAdminClient } from "./server";
|
||||||
|
|
||||||
|
export async function listProsthetics(tenantId: string): Promise<Prosthetic[]> {
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
const result = await tablesDB.listRows({
|
||||||
|
databaseId: DATABASE_ID,
|
||||||
|
tableId: TABLES.prosthetics,
|
||||||
|
queries: [
|
||||||
|
Query.equal("tenantId", tenantId),
|
||||||
|
Query.orderAsc("name"),
|
||||||
|
Query.limit(200),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return result.rows as unknown as Prosthetic[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listActiveProsthetics(tenantId: string): Promise<Prosthetic[]> {
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
const result = await tablesDB.listRows({
|
||||||
|
databaseId: DATABASE_ID,
|
||||||
|
tableId: TABLES.prosthetics,
|
||||||
|
queries: [
|
||||||
|
Query.equal("tenantId", tenantId),
|
||||||
|
Query.notEqual("archived", true),
|
||||||
|
Query.orderAsc("name"),
|
||||||
|
Query.limit(200),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return result.rows as unknown as Prosthetic[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export type ProstheticFormState = {
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
fieldErrors?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const initialProstheticFormState: ProstheticFormState = { ok: false };
|
||||||
|
|
||||||
|
export type ProstheticActionState = {
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const initialProstheticActionState: ProstheticActionState = { ok: false };
|
||||||
|
|
||||||
|
export const PROSTHETIC_TYPE_OPTIONS = [
|
||||||
|
{ value: "metal_porselen", label: "Metal Porselen" },
|
||||||
|
{ value: "zirkonyum", label: "Zirkonyum" },
|
||||||
|
{ value: "implant_ustu_zirkonyum", label: "İmplant Üstü Zirkonyum" },
|
||||||
|
{ value: "gecici", label: "Geçici" },
|
||||||
|
{ value: "e_max", label: "E-Max" },
|
||||||
|
{ value: "diger", label: "Diğer" },
|
||||||
|
] as const;
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const PROSTHETIC_TYPES = [
|
||||||
|
"metal_porselen",
|
||||||
|
"zirkonyum",
|
||||||
|
"implant_ustu_zirkonyum",
|
||||||
|
"gecici",
|
||||||
|
"e_max",
|
||||||
|
"diger",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const createJobSchema = z.object({
|
||||||
|
labTenantId: z.string().min(1, "Laboratuvar seçin."),
|
||||||
|
patientCode: z.string().trim().min(1, "Hasta kodu zorunlu.").max(50),
|
||||||
|
prostheticType: z.enum(PROSTHETIC_TYPES, { message: "Protez türü seçin." }),
|
||||||
|
memberCount: z
|
||||||
|
.union([z.string(), z.number()])
|
||||||
|
.transform((v) => {
|
||||||
|
if (typeof v === "number") return v;
|
||||||
|
const n = parseInt(v, 10);
|
||||||
|
return Number.isFinite(n) ? n : NaN;
|
||||||
|
})
|
||||||
|
.pipe(z.number().int().min(1, "En az 1 üye.").max(32, "En fazla 32 üye.")),
|
||||||
|
color: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(20)
|
||||||
|
.optional()
|
||||||
|
.transform((v) => (v ? v.toUpperCase() : undefined)),
|
||||||
|
description: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(2000)
|
||||||
|
.optional()
|
||||||
|
.transform((v) => (v ? v : undefined)),
|
||||||
|
price: z
|
||||||
|
.union([z.string(), z.number()])
|
||||||
|
.optional()
|
||||||
|
.transform((v) => {
|
||||||
|
if (v === undefined || v === "") return undefined;
|
||||||
|
const n = typeof v === "number" ? v : Number(String(v).replace(",", "."));
|
||||||
|
return Number.isFinite(n) ? n : undefined;
|
||||||
|
}),
|
||||||
|
currency: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(8)
|
||||||
|
.optional()
|
||||||
|
.transform((v) => (v ? v.toUpperCase() : "TRY")),
|
||||||
|
dueDate: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.optional()
|
||||||
|
.transform((v) => (v ? new Date(v).toISOString() : undefined)),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreateJobInput = z.infer<typeof createJobSchema>;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const PROSTHETIC_TYPES = [
|
||||||
|
"metal_porselen",
|
||||||
|
"zirkonyum",
|
||||||
|
"implant_ustu_zirkonyum",
|
||||||
|
"gecici",
|
||||||
|
"e_max",
|
||||||
|
"diger",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const prostheticSchema = z.object({
|
||||||
|
name: z.string().trim().min(1, "Ürün adı zorunlu.").max(255),
|
||||||
|
type: z.enum(PROSTHETIC_TYPES, { message: "Protez türü seçin." }),
|
||||||
|
unitPrice: z
|
||||||
|
.union([z.string(), z.number()])
|
||||||
|
.transform((v) => {
|
||||||
|
if (typeof v === "number") return v;
|
||||||
|
const n = Number(v.replace(",", "."));
|
||||||
|
return Number.isFinite(n) ? n : NaN;
|
||||||
|
})
|
||||||
|
.pipe(z.number().min(0, "Negatif olamaz.")),
|
||||||
|
currency: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(8)
|
||||||
|
.optional()
|
||||||
|
.transform((v) => (v ? v.toUpperCase() : "TRY")),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ProstheticInput = z.infer<typeof prostheticSchema>;
|
||||||
Reference in New Issue
Block a user