diff --git a/src/app/(dashboard)/connections/components/connection-code-card.tsx b/src/app/(dashboard)/connections/components/connection-code-card.tsx new file mode 100644 index 0000000..da1a862 --- /dev/null +++ b/src/app/(dashboard)/connections/components/connection-code-card.tsx @@ -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 ( + + + Bağlantı kodunuz + + Karşı taraf bu kodu girerek size bağlantı talebi gönderir. + + + +
+ {memberNumber || "—"} + +
+
+
+ ); +} diff --git a/src/app/(dashboard)/connections/components/connection-request-form.tsx b/src/app/(dashboard)/connections/components/connection-request-form.tsx new file mode 100644 index 0000000..ba31bd8 --- /dev/null +++ b/src/app/(dashboard)/connections/components/connection-request-form.tsx @@ -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(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)) : "—"} + + + + + + + + + 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. + + + + + + +
{ + startTransition(() => action(fd)); + }} + > + + +
+
+
+
+
+
+ ); +} 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))} + + +
+
{ + startTransition(() => approveAction(fd)); + }} + > + + +
+
{ + startTransition(() => rejectAction(fd)); + }} + > + + +
+
+
+
+ ); +} 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))} + + +
{ + startTransition(() => action(fd)); + }} + > + + +
+
+
+ ); +} 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 ( +
  1. + + {idx + 1} + + {JOB_STEP_LABELS[step]} +
  2. + ); + })} +
+

+ Aşama güncelleme ve dosya yükleme sonraki sürümde. +

+
+
+
+ +
+ +
+
+ ); +} + +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))} + + + + + + ))} + +
+ ); +} 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 ( +
+
+
+ + + {state.fieldErrors?.labTenantId && ( +

{state.fieldErrors.labTenantId}

+ )} +
+ +
+ + + {state.fieldErrors?.patientCode && ( +

{state.fieldErrors.patientCode}

+ )} +
+ +
+ + + {state.fieldErrors?.memberCount && ( +

{state.fieldErrors.memberCount}

+ )} +
+ +
+ + + {state.fieldErrors?.prostheticType && ( +

{state.fieldErrors.prostheticType}

+ )} +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ +