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

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

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

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

All new mutations follow the memory rules: schema-checked row payloads,
admin client behind requireTenant + requireRole/requireTenantKind, audit
log calls best-effort, no empty-string Radix Select values.
This commit is contained in:
kovakmedya
2026-05-21 19:59:23 +03:00
parent 7fb8288f79
commit 76e02754b8
26 changed files with 2765 additions and 42 deletions
+190
View File
@@ -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>
);
}