(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 (
+
+ );
+}
diff --git a/src/app/(dashboard)/connections/components/connections-table.tsx b/src/app/(dashboard)/connections/components/connections-table.tsx
new file mode 100644
index 0000000..e00ff49
--- /dev/null
+++ b/src/app/(dashboard)/connections/components/connections-table.tsx
@@ -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 (
+
+ Henüz onaylanmış bağlantınız yok. Yukarıdan talep gönderebilir veya kodunuzu paylaşabilirsiniz.
+
+ );
+ }
+
+ return (
+
+
+
+ Karşı taraf
+ Tür
+ Onay tarihi
+ İşlem
+
+
+
+ {rows.map((r) => (
+
+ ))}
+
+
+ );
+}
+
+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 (
+
+ {row.counterpart?.companyName ?? "—"}
+
+ {kindLabel}
+
+
+ {row.approvedAt ? dateFormatter.format(new Date(row.approvedAt)) : "—"}
+
+
+
+
+
+
+ Sil
+
+
+
+
+ Bağlantı silinsin mi?
+
+ {row.counterpart?.companyName ?? "Karşı taraf"} ile bağlantınız sonlandırılacak.
+ Mevcut işleriniz etkilenmez ancak yeni iş gönderemezsiniz.
+
+
+
+
+ Vazgeç
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(dashboard)/connections/components/pending-inbound-table.tsx b/src/app/(dashboard)/connections/components/pending-inbound-table.tsx
new file mode 100644
index 0000000..ac158b4
--- /dev/null
+++ b/src/app/(dashboard)/connections/components/pending-inbound-table.tsx
@@ -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 (
+
+ Bekleyen talep yok.
+
+ );
+ }
+
+ return (
+
+
+
+ Talep eden
+ Tür
+ Tarih
+ İşlem
+
+
+
+ {rows.map((r) => (
+
+ ))}
+
+
+ );
+}
+
+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 (
+
+ {row.counterpart?.companyName ?? "—"}
+
+ {kindLabel}
+
+
+ {dateFormatter.format(new Date(row.requestedAt))}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(dashboard)/connections/components/pending-outbound-table.tsx b/src/app/(dashboard)/connections/components/pending-outbound-table.tsx
new file mode 100644
index 0000000..eb401fa
--- /dev/null
+++ b/src/app/(dashboard)/connections/components/pending-outbound-table.tsx
@@ -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 (
+
+ Gönderilmiş talebiniz yok.
+
+ );
+ }
+
+ return (
+
+
+
+ Karşı taraf
+ Tür
+ Gönderim
+ İşlem
+
+
+
+ {rows.map((r) => (
+
+ ))}
+
+
+ );
+}
+
+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 (
+
+ {row.counterpart?.companyName ?? "—"}
+
+ {kindLabel}
+
+
+ {dateFormatter.format(new Date(row.requestedAt))}
+
+
+
+
+
+ );
+}
diff --git a/src/app/(dashboard)/connections/page.tsx b/src/app/(dashboard)/connections/page.tsx
index e357ea5..bfd22ca 100644
--- a/src/app/(dashboard)/connections/page.tsx
+++ b/src/app/(dashboard)/connections/page.tsx
@@ -1,7 +1,21 @@
import { redirect } from "next/navigation";
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 { 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() {
let ctx;
@@ -11,32 +25,77 @@ export default async function ConnectionsPage() {
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 (
Bağlantı Kur
- 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."}
+
+
+
+
+
+ Bağlantı talep et
+
+ Karşı tarafın 6 haneli kodunu girin, onaylarsa bağlantı kurulur.
+
+
+
+
+
+
+
+
+
+
+
+ Gelen Talepler
+
+ Size gönderilen, onayınızı bekleyen bağlantı talepleri.
+
+
+
+
+
+
+
+
+
+ Gönderilen Talepler
+
+ Sizin gönderdiğiniz, karşı tarafın yanıtını bekleyen talepler.
+
+
+
+
+
+
+
+
- Bağlantı kodunuz
- Karşı taraf bu kodu girerek size bağlantı talebi gönderir.
+ Bağlantılarım
+ Onaylanmış aktif bağlantılar.
-
- {ctx.settings?.memberNumber ?? "—"}
-
+
-
-
- Yapım aşamasında
- Bağlantı talepleri ve bağlı taraflar listesi sonraki sürümde eklenecek.
-
-
-
);
}
diff --git a/src/app/(dashboard)/jobs/[jobId]/page.tsx b/src/app/(dashboard)/jobs/[jobId]/page.tsx
new file mode 100644
index 0000000..2305c98
--- /dev/null
+++ b/src/app/(dashboard)/jobs/[jobId]/page.tsx
@@ -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 (
+
+
+
+
+ {counterpartLabel}: {counterpart?.companyName ?? "—"}
+
+
+ Hasta {job.patientCode}
+
+
+ {PROSTHETIC_TYPE_LABELS[job.prostheticType]} · {job.memberCount} üye
+
+
+
+ {JOB_STATUS_LABELS[job.status]}
+
+
+
+
+
+
+ İş Bilgileri
+ {dateFormatter.format(new Date(job.$createdAt))}
+
+
+ {job.color || "—"}
+
+ {job.dueDate ? dateFormatter.format(new Date(job.dueDate)) : "—"}
+
+
+ {typeof job.price === "number"
+ ? formatMoney(job.price, job.currency || "TRY")
+ : "—"}
+
+
+ {job.currentStep ? JOB_STEP_LABELS[job.currentStep] : "—"}
+
+
+
+ Açıklama
+
+
{job.description || "—"}
+
+
+
+
+
+
+ Aşamalar
+ Ölçü → Alt Yapı → Üst Yapı → Cila/Bitim
+
+
+
+ {JOB_STEP_ORDER.map((step, idx) => {
+ const done = currentStepIdx > idx || job.status === "delivered";
+ const active = currentStepIdx === idx && job.status !== "delivered";
+ return (
+
+
+ {idx + 1}
+
+ {JOB_STEP_LABELS[step]}
+
+ );
+ })}
+
+
+ Aşama güncelleme ve dosya yükleme sonraki sürümde.
+
+
+
+
+
+
+
+
+ ← Listeye dön
+
+
+
+
+ );
+}
+
+function Info({ label, children }: { label: string; children: React.ReactNode }) {
+ return (
+
+
+ {label}
+
+
{children}
+
+ );
+}
diff --git a/src/app/(dashboard)/jobs/_components/jobs-table.tsx b/src/app/(dashboard)/jobs/_components/jobs-table.tsx
new file mode 100644
index 0000000..7e87568
--- /dev/null
+++ b/src/app/(dashboard)/jobs/_components/jobs-table.tsx
@@ -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 (
+ {emptyMessage}
+ );
+ }
+
+ return (
+
+
+
+ {counterpartLabel}
+ Hasta Kodu
+ Üye
+ Renk
+ Tür
+ Durum
+ Tarih
+ İşlem
+
+
+
+ {rows.map((j) => (
+
+ {j.counterpart?.companyName ?? "—"}
+ {j.patientCode}
+ {j.memberCount}
+ {j.color || "—"}
+
+ {PROSTHETIC_TYPE_LABELS[j.prostheticType] ?? j.prostheticType}
+
+
+ {JOB_STATUS_LABELS[j.status]}
+
+
+ {dateFormatter.format(new Date(j.$createdAt))}
+
+
+
+ Detay
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/app/(dashboard)/jobs/inbound/page.tsx b/src/app/(dashboard)/jobs/inbound/page.tsx
index ecd77b4..6f78094 100644
--- a/src/app/(dashboard)/jobs/inbound/page.tsx
+++ b/src/app/(dashboard)/jobs/inbound/page.tsx
@@ -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 (
Gelen İşler
- Bağlı kliniklerden gelen protez işleri burada listelenecek.
+ Bağlı kliniklerden size yönlendirilmiş protez işleri.
+
- Yapım aşamasında
- Gelen iş listesi, filtreleme ve detay görünümü sonraki sürümde eklenecek.
+ Tüm Gelen İşler
+
+ {ctx.kind === "lab"
+ ? rows.length === 0
+ ? "Henüz gelen iş yok."
+ : `${rows.length} kalem`
+ : "Bu sayfa laboratuvar hesapları içindir."}
+
-
+
+ {ctx.kind === "lab" ? (
+
+ ) : (
+
+ Klinik hesabıyla giriş yaptınız — gelen iş listesi sadece laboratuvar tarafında görünür.
+
+ )}
+
);
diff --git a/src/app/(dashboard)/jobs/new/components/new-job-form.tsx b/src/app/(dashboard)/jobs/new/components/new-job-form.tsx
new file mode 100644
index 0000000..0062bc4
--- /dev/null
+++ b/src/app/(dashboard)/jobs/new/components/new-job-form.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/app/(dashboard)/jobs/new/page.tsx b/src/app/(dashboard)/jobs/new/page.tsx
index 3ea4a9b..f5a670d 100644
--- a/src/app/(dashboard)/jobs/new/page.tsx
+++ b/src/app/(dashboard)/jobs/new/page.tsx
@@ -1,7 +1,15 @@
+import Link from "next/link";
import { redirect } from "next/navigation";
+import { Button } from "@/components/ui/button";
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 { NewJobForm } from "./components/new-job-form";
+
+export const metadata = {
+ title: "DLS — Yeni İş Yayınla",
+};
export default async function NewJobPage() {
let ctx;
@@ -12,6 +20,9 @@ export default async function NewJobPage() {
redirect("/dashboard");
}
+ const labs = await listApprovedLabsForClinic(ctx.tenantId);
+ const defaultCurrency = ctx.settings?.defaultCurrency ?? "TRY";
+
return (
@@ -20,15 +31,34 @@ export default async function NewJobPage() {
Bağlı laboratuvarınıza yeni bir protez işi gönderin.
-
-
- Yapım aşamasında
-
- Form (lab seçimi, hasta kodu, protez türü, renk, dosya yükleme) sonraki sürümde eklenecek.
-
-
-
-
+
+ {labs.length === 0 ? (
+
+
+ Önce bir laboratuvarla bağlantı kurun
+
+ İş gönderebilmeniz için onaylanmış bir laboratuvar bağlantınız olmalı.
+
+
+
+
+ Bağlantı Kur
+
+
+
+ ) : (
+
+
+ İş Bilgileri
+
+ Hasta kodu, protez türü ve diğer detayları girin. Dosya yüklemesi sonraki sürümde.
+
+
+
+
+
+
+ )}
);
}
diff --git a/src/app/(dashboard)/jobs/outbound/page.tsx b/src/app/(dashboard)/jobs/outbound/page.tsx
index 69c54e6..a63dd3b 100644
--- a/src/app/(dashboard)/jobs/outbound/page.tsx
+++ b/src/app/(dashboard)/jobs/outbound/page.tsx
@@ -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 (
Giden İşler
- Karşı tarafa gönderilen protez işleri burada listelenecek.
+ Bağlı laboratuvarlara gönderdiğiniz işler.
+
- Yapım aşamasında
- Giden iş listesi sonraki sürümde eklenecek.
+ Tüm Giden İşler
+
+ {ctx.kind === "clinic"
+ ? rows.length === 0
+ ? "Henüz iş göndermediniz."
+ : `${rows.length} kalem`
+ : "Bu sayfa klinik hesapları içindir."}
+
-
+
+ {ctx.kind === "clinic" ? (
+
+ ) : (
+
+ Laboratuvar hesabıyla giriş yaptınız — giden iş listesi sadece klinik tarafında görünür.
+
+ )}
+
);
diff --git a/src/app/(dashboard)/products/components/prosthetic-form.tsx b/src/app/(dashboard)/products/components/prosthetic-form.tsx
new file mode 100644
index 0000000..72b31ce
--- /dev/null
+++ b/src/app/(dashboard)/products/components/prosthetic-form.tsx
@@ -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(null);
+
+ useEffect(() => {
+ if (state.ok) {
+ toast.success("Ürün eklendi.");
+ formRef.current?.reset();
+ } else if (state.error) {
+ toast.error(state.error);
+ }
+ }, [state]);
+
+ return (
+
+
+
Protez Adı *
+
+ {state.fieldErrors?.name && (
+
{state.fieldErrors.name}
+ )}
+
+
+
+
Protez Türü *
+
+
+
+
+
+ {PROSTHETIC_TYPE_OPTIONS.map((o) => (
+
+ {o.label}
+
+ ))}
+
+
+ {state.fieldErrors?.type && (
+
{state.fieldErrors.type}
+ )}
+
+
+
+
+
Birim Fiyatı *
+
+ {state.fieldErrors?.unitPrice && (
+
{state.fieldErrors.unitPrice}
+ )}
+
+
+ Para birimi
+
+
+
+
+
+ {isPending ? (
+ <>
+
+ Ekleniyor...
+ >
+ ) : (
+ <>
+
+ Ürün ekle
+ >
+ )}
+
+
+ );
+}
diff --git a/src/app/(dashboard)/products/components/prosthetics-table.tsx b/src/app/(dashboard)/products/components/prosthetics-table.tsx
new file mode 100644
index 0000000..aa45b48
--- /dev/null
+++ b/src/app/(dashboard)/products/components/prosthetics-table.tsx
@@ -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;
+
+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 (
+
+ Henüz ürün eklemediniz. Sağdaki formdan ekleyebilirsiniz.
+
+ );
+ }
+
+ return (
+
+
+
+ Protez Adı
+ Tür
+ Birim Fiyat
+ Durum
+ İşlem
+
+
+
+ {rows.map((r) => (
+
+ ))}
+
+
+ );
+}
+
+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 (
+
+ {row.name}
+
+ {TYPE_LABELS[row.type] ?? row.type}
+
+
+ {formatMoney(row.unitPrice, row.currency || defaultCurrency)}
+
+
+ {row.archived ? (
+ Arşiv
+ ) : (
+ Aktif
+ )}
+
+
+
+
setEditOpen(true)}
+ disabled={archivePending || deletePending}
+ >
+
+
+
{
+ startTransition(() => archiveAction(fd));
+ }}
+ >
+
+
+ {archivePending ? (
+
+ ) : row.archived ? (
+
+ ) : (
+
+ )}
+
+
+
setDeleteOpen(true)}
+ disabled={archivePending || deletePending}
+ >
+
+
+
+
+
+
+
+
+ Ürün silinsin mi?
+
+ {row.name} kalıcı olarak silinecek. Mevcut işlerden bu ürünün referansı kaldırılmaz.
+
+
+
+
+
+ Vazgeç
+
+
+ {
+ startTransition(() => deleteAction(fd));
+ }}
+ >
+
+
+ {deletePending ? (
+
+ ) : (
+
+ )}
+ Sil
+
+
+
+
+
+
+
+ );
+}
+
+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 (
+
+
+
+ Ürünü düzenle
+ {row.name}
+
+
+
+
+
Protez Adı *
+
+ {state.fieldErrors?.name && (
+
{state.fieldErrors.name}
+ )}
+
+
+ Protez Türü *
+
+
+
+
+
+ {PROSTHETIC_TYPE_OPTIONS.map((o) => (
+
+ {o.label}
+
+ ))}
+
+
+
+
+
+
+
+ Vazgeç
+
+
+
+ {pending ? : }
+ Kaydet
+
+
+
+
+
+ );
+}
diff --git a/src/app/(dashboard)/products/page.tsx b/src/app/(dashboard)/products/page.tsx
index d06980e..c1d4c15 100644
--- a/src/app/(dashboard)/products/page.tsx
+++ b/src/app/(dashboard)/products/page.tsx
@@ -1,7 +1,14 @@
import { redirect } from "next/navigation";
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 { ProstheticForm } from "./components/prosthetic-form";
+import { ProstheticsTable } from "./components/prosthetics-table";
+
+export const metadata = {
+ title: "DLS — Ürünler",
+};
export default async function ProductsPage() {
let ctx;
@@ -12,21 +19,41 @@ export default async function ProductsPage() {
redirect("/dashboard");
}
+ const rows = await listProsthetics(ctx.tenantId);
+ const defaultCurrency = ctx.settings?.defaultCurrency ?? "TRY";
+
return (
Ürünler
- 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.
-
-
- Yapım aşamasında
- Ürün ekleme/düzenleme sonraki sürümde eklenecek.
-
-
-
+
+
+
+
+ Eklenen Ürünler
+
+ {rows.length === 0 ? "Henüz ürün yok." : `${rows.length} kalem`}
+
+
+
+
+
+
+
+
+
+ Ürün Ekle
+ Protez türü ve birim fiyatını girin.
+
+
+
+
+
+
);
}
diff --git a/src/lib/appwrite/connection-actions.ts b/src/lib/appwrite/connection-actions.ts
new file mode 100644
index 0000000..8af77fb
--- /dev/null
+++ b/src/lib/appwrite/connection-actions.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ // 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 {
+ 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 };
+}
diff --git a/src/lib/appwrite/connection-queries.ts b/src/lib/appwrite/connection-queries.ts
new file mode 100644
index 0000000..31ad8f5
--- /dev/null
+++ b/src/lib/appwrite/connection-queries.ts
@@ -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> {
+ 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();
+ 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 {
+ 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 {
+ 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 {
+ const rows = await listConnectionsByStatus(tenantId, "approved");
+ return enrich(rows, tenantId);
+}
+
+export async function listPendingInbound(
+ tenantId: string,
+ selfUserId: string,
+): Promise {
+ // 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 {
+ // 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);
+}
diff --git a/src/lib/appwrite/connection-types.ts b/src/lib/appwrite/connection-types.ts
new file mode 100644
index 0000000..ef518de
--- /dev/null
+++ b/src/lib/appwrite/connection-types.ts
@@ -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 };
diff --git a/src/lib/appwrite/job-actions.ts b/src/lib/appwrite/job-actions.ts
new file mode 100644
index 0000000..c1a67e6
--- /dev/null
+++ b/src/lib/appwrite/job-actions.ts
@@ -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 {
+ const out: Record = {};
+ 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 {
+ 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ı.") };
+ }
+}
diff --git a/src/lib/appwrite/job-queries.ts b/src/lib/appwrite/job-queries.ts
new file mode 100644
index 0000000..cea378b
--- /dev/null
+++ b/src/lib/appwrite/job-queries.ts
@@ -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> {
+ 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();
+ 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): 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 {
+ 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 {
+ 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 {
+ 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));
+}
diff --git a/src/lib/appwrite/job-types.ts b/src/lib/appwrite/job-types.ts
new file mode 100644
index 0000000..7191d36
--- /dev/null
+++ b/src/lib/appwrite/job-types.ts
@@ -0,0 +1,48 @@
+import type { JobStatus, JobStep, ProstheticType } from "./schema";
+
+export type JobFormState = {
+ ok: boolean;
+ error?: string;
+ fieldErrors?: Record;
+ 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 = {
+ pending: "Bekliyor",
+ in_progress: "İşlemde",
+ sent: "Gönderildi",
+ delivered: "Teslim alındı",
+ cancelled: "İptal",
+};
+
+export const JOB_STEP_LABELS: Record = {
+ 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 = {
+ metal_porselen: "Metal Porselen",
+ zirkonyum: "Zirkonyum",
+ implant_ustu_zirkonyum: "İmplant Üstü Zirkonyum",
+ gecici: "Geçici",
+ e_max: "E-Max",
+ diger: "Diğer",
+};
diff --git a/src/lib/appwrite/prosthetic-actions.ts b/src/lib/appwrite/prosthetic-actions.ts
new file mode 100644
index 0000000..19e8335
--- /dev/null
+++ b/src/lib/appwrite/prosthetic-actions.ts
@@ -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 {
+ const out: Record = {};
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 };
+}
diff --git a/src/lib/appwrite/prosthetic-queries.ts b/src/lib/appwrite/prosthetic-queries.ts
new file mode 100644
index 0000000..bdbc6fc
--- /dev/null
+++ b/src/lib/appwrite/prosthetic-queries.ts
@@ -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 {
+ 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 {
+ 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[];
+}
diff --git a/src/lib/appwrite/prosthetic-types.ts b/src/lib/appwrite/prosthetic-types.ts
new file mode 100644
index 0000000..02c6d4f
--- /dev/null
+++ b/src/lib/appwrite/prosthetic-types.ts
@@ -0,0 +1,23 @@
+export type ProstheticFormState = {
+ ok: boolean;
+ error?: string;
+ fieldErrors?: Record;
+};
+
+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;
diff --git a/src/lib/validation/job.ts b/src/lib/validation/job.ts
new file mode 100644
index 0000000..da9ff19
--- /dev/null
+++ b/src/lib/validation/job.ts
@@ -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;
diff --git a/src/lib/validation/prosthetic.ts b/src/lib/validation/prosthetic.ts
new file mode 100644
index 0000000..b9bac31
--- /dev/null
+++ b/src/lib/validation/prosthetic.ts
@@ -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;