479972e9a9
Real prosthetic production isn't a one-way pipeline — the work moves
between lab and clinic multiple times. After substructure is produced
the lab hands it to the clinic for a fitting, the clinic approves it
back to the lab, the lab builds the superstructure, hands it back for
a second fitting, the clinic approves again, the lab does cila/bitim,
and finally delivers it to the clinic for handover to the patient.
Previously we only had a single 'advance step' action callable by the
lab, which collapsed all of that into a linear forward push and didn't
capture who physically had the work at any given moment.
DB
- New jobs.location enum (at_clinic | at_lab, default at_clinic).
- Existing jobs keep working via a 'location ?? at_lab' fallback in
code; no manual backfill required for the four test rows.
State machine
- acceptJobAction (lab): pending → in_progress, currentStep=alt_yapi_prova,
location=at_lab. Skips the implicit 'olcu' production step now that
accepting the job means the lab has the impression in hand.
- handToClinicAction (lab, NEW): at_lab → at_clinic, step stays the
same. If step is cila_bitim, status becomes 'sent' (final delivery)
and finance sync fires.
- approveAtClinicAction (clinic, NEW): at_clinic → at_lab, step
advances to the next stage so the lab knows what to produce next.
- markDeliveredAction unchanged — clinic confirms the final handoff.
- advanceStepAction removed; its single forward push doesn't fit the
new bidirectional flow.
UI
- JobActionsPanel now picks the right button from the role + status +
location matrix:
* Lab + pending → 'İşleme Al'
* Lab + in_progress + at_lab + cila_bitim → 'Cila Bitim — Nihai Teslime Gönder'
* Lab + in_progress + at_lab + other → '{stage} Provaya Gönder'
* Clinic + in_progress + at_clinic → '{stage} Provası Tamam'
* Clinic + sent → 'Teslim Aldım'
* Both + pending → 'İptal Et'
- Job detail surfaces a new 'Şu An' info row that resolves to a
human-readable location ('Klinikte', 'Laboratuvarda', 'Hasta'ya
teslim edildi', ...) so anyone glancing at the page can tell where
the work physically is.
312 lines
9.6 KiB
TypeScript
312 lines
9.6 KiB
TypeScript
"use client";
|
||
|
||
import { useActionState, useEffect, useState } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import {
|
||
ArrowRight,
|
||
Check,
|
||
CircleAlert,
|
||
Loader2,
|
||
PackageCheck,
|
||
Play,
|
||
Send,
|
||
X,
|
||
} from "lucide-react";
|
||
import { toast } from "sonner";
|
||
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
Dialog,
|
||
DialogClose,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import {
|
||
acceptJobAction,
|
||
approveAtClinicAction,
|
||
cancelJobAction,
|
||
handToClinicAction,
|
||
markDeliveredAction,
|
||
} from "@/lib/appwrite/job-actions";
|
||
import { initialJobActionState } from "@/lib/appwrite/job-types";
|
||
import type { Job, TenantKind } from "@/lib/appwrite/schema";
|
||
|
||
type Side = "clinic" | "lab";
|
||
|
||
export function JobActionsPanel({
|
||
job,
|
||
side,
|
||
kind,
|
||
}: {
|
||
job: Job;
|
||
side: Side;
|
||
kind: TenantKind | null;
|
||
}) {
|
||
if (!kind) return null;
|
||
|
||
const isLab = side === "lab";
|
||
const isClinic = side === "clinic";
|
||
const location = job.location ?? "at_lab";
|
||
const isAtLab = location === "at_lab";
|
||
const isAtClinic = location === "at_clinic";
|
||
|
||
return (
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
{/* Pending pickup — lab accepts */}
|
||
{isLab && job.status === "pending" && <AcceptButton jobId={job.$id} />}
|
||
|
||
{/* Lab is producing — push to clinic for prova / final delivery */}
|
||
{isLab && job.status === "in_progress" && isAtLab && (
|
||
<HandToClinicButton job={job} />
|
||
)}
|
||
|
||
{/* Clinic finished the prova — approve and send back to lab */}
|
||
{isClinic && job.status === "in_progress" && isAtClinic && (
|
||
<ApproveAtClinicButton job={job} />
|
||
)}
|
||
|
||
{/* Final delivery — clinic took it from the lab */}
|
||
{isClinic && job.status === "sent" && <DeliverButton jobId={job.$id} />}
|
||
|
||
{/* Cancel — only while the job hasn't started yet */}
|
||
{(isClinic || isLab) && job.status === "pending" && (
|
||
<CancelButton jobId={job.$id} />
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AcceptButton({ jobId }: { jobId: string }) {
|
||
const router = useRouter();
|
||
const [state, action, pending] = useActionState(acceptJobAction, initialJobActionState);
|
||
|
||
useEffect(() => {
|
||
if (state.ok) {
|
||
toast.success("İş işleme alındı, alt yapı üretimi başladı.");
|
||
router.refresh();
|
||
} else if (state.error) {
|
||
toast.error(state.error);
|
||
}
|
||
}, [state, router]);
|
||
|
||
return (
|
||
<form action={action}>
|
||
<input type="hidden" name="jobId" value={jobId} />
|
||
<Button type="submit" disabled={pending}>
|
||
{pending ? <Loader2 className="size-4 animate-spin" /> : <Play className="size-4" />}
|
||
İşleme Al
|
||
</Button>
|
||
</form>
|
||
);
|
||
}
|
||
|
||
function HandToClinicButton({ job }: { job: Job }) {
|
||
const router = useRouter();
|
||
const [state, action, pending] = useActionState(handToClinicAction, initialJobActionState);
|
||
const [open, setOpen] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (state.ok) {
|
||
toast.success("Klinik tarafına gönderildi.");
|
||
setOpen(false);
|
||
router.refresh();
|
||
} else if (state.error) {
|
||
toast.error(state.error);
|
||
}
|
||
}, [state, router]);
|
||
|
||
const isFinal = job.currentStep === "cila_bitim";
|
||
const stageLabel =
|
||
job.currentStep === "alt_yapi_prova"
|
||
? "alt yapı"
|
||
: job.currentStep === "ust_yapi_prova"
|
||
? "üst yapı"
|
||
: "cila/bitim";
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={setOpen}>
|
||
<Button onClick={() => setOpen(true)}>
|
||
{isFinal ? <PackageCheck className="size-4" /> : <Send className="size-4" />}
|
||
{isFinal ? "Cila Bitim — Nihai Teslime Gönder" : `${stageLabel === "alt yapı" ? "Alt Yapı" : "Üst Yapı"} Provaya Gönder`}
|
||
</Button>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>
|
||
{isFinal ? "Nihai teslime gönderilsin mi?" : "Kliniğe gönderilsin mi?"}
|
||
</DialogTitle>
|
||
<DialogDescription>
|
||
{isFinal
|
||
? "Cila ve bitim tamamlandı; iş 'Gönderildi' durumuna geçer. Klinik teslim aldığında nihai onay verecek."
|
||
: `${stageLabel === "alt yapı" ? "Alt yapı" : "Üst yapı"} provası için iş klinik tarafına geçer. Klinik provayı onayladığında size geri dönecek.`}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<form action={action} className="grid gap-3">
|
||
<input type="hidden" name="jobId" value={job.$id} />
|
||
<div className="grid gap-2">
|
||
<Label htmlFor="note">Not (opsiyonel)</Label>
|
||
<Textarea
|
||
id="note"
|
||
name="note"
|
||
rows={3}
|
||
maxLength={1000}
|
||
placeholder="Örn. Renk A2, oklüzal kontak tamam"
|
||
/>
|
||
</div>
|
||
<DialogFooter>
|
||
<DialogClose asChild>
|
||
<Button type="button" variant="outline">
|
||
Vazgeç
|
||
</Button>
|
||
</DialogClose>
|
||
<Button type="submit" disabled={pending}>
|
||
{pending ? <Loader2 className="size-4 animate-spin" /> : <Send className="size-4" />}
|
||
Gönder
|
||
</Button>
|
||
</DialogFooter>
|
||
</form>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
function ApproveAtClinicButton({ job }: { job: Job }) {
|
||
const router = useRouter();
|
||
const [state, action, pending] = useActionState(approveAtClinicAction, initialJobActionState);
|
||
const [open, setOpen] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (state.ok) {
|
||
toast.success("Prova onaylandı, lab tarafına gönderildi.");
|
||
setOpen(false);
|
||
router.refresh();
|
||
} else if (state.error) {
|
||
toast.error(state.error);
|
||
}
|
||
}, [state, router]);
|
||
|
||
const stageLabel = job.currentStep === "alt_yapi_prova" ? "alt yapı" : "üst yapı";
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={setOpen}>
|
||
<Button onClick={() => setOpen(true)}>
|
||
<Check className="size-4" />
|
||
{stageLabel === "alt yapı" ? "Alt Yapı Provası Tamam" : "Üst Yapı Provası Tamam"}
|
||
</Button>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>
|
||
{`${stageLabel === "alt yapı" ? "Alt yapı" : "Üst yapı"} provası onaylansın mı?`}
|
||
</DialogTitle>
|
||
<DialogDescription>
|
||
Prova başarılı işaretlendiğinde iş bir sonraki aşamaya geçer ve
|
||
laboratuvara geri döner.
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<form action={action} className="grid gap-3">
|
||
<input type="hidden" name="jobId" value={job.$id} />
|
||
<div className="grid gap-2">
|
||
<Label htmlFor="note">Not (opsiyonel)</Label>
|
||
<Textarea
|
||
id="note"
|
||
name="note"
|
||
rows={3}
|
||
maxLength={1000}
|
||
placeholder="Örn. Renk uyumlu, oklüzyon tamam"
|
||
/>
|
||
</div>
|
||
<DialogFooter>
|
||
<DialogClose asChild>
|
||
<Button type="button" variant="outline">
|
||
Vazgeç
|
||
</Button>
|
||
</DialogClose>
|
||
<Button type="submit" disabled={pending}>
|
||
{pending ? <Loader2 className="size-4 animate-spin" /> : <ArrowRight className="size-4" />}
|
||
Onayla ve gönder
|
||
</Button>
|
||
</DialogFooter>
|
||
</form>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
function DeliverButton({ jobId }: { jobId: string }) {
|
||
const router = useRouter();
|
||
const [state, action, pending] = useActionState(markDeliveredAction, initialJobActionState);
|
||
|
||
useEffect(() => {
|
||
if (state.ok) {
|
||
toast.success("İş teslim alındı.");
|
||
router.refresh();
|
||
} else if (state.error) {
|
||
toast.error(state.error);
|
||
}
|
||
}, [state, router]);
|
||
|
||
return (
|
||
<form action={action}>
|
||
<input type="hidden" name="jobId" value={jobId} />
|
||
<Button type="submit" disabled={pending}>
|
||
{pending ? <Loader2 className="size-4 animate-spin" /> : <PackageCheck className="size-4" />}
|
||
Teslim Aldım
|
||
</Button>
|
||
</form>
|
||
);
|
||
}
|
||
|
||
function CancelButton({ jobId }: { jobId: string }) {
|
||
const router = useRouter();
|
||
const [state, action, pending] = useActionState(cancelJobAction, initialJobActionState);
|
||
const [open, setOpen] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (state.ok) {
|
||
toast.success("İş iptal edildi.");
|
||
setOpen(false);
|
||
router.refresh();
|
||
} else if (state.error) {
|
||
toast.error(state.error);
|
||
}
|
||
}, [state, router]);
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={setOpen}>
|
||
<Button variant="outline" onClick={() => setOpen(true)}>
|
||
<X className="size-4" />
|
||
İptal Et
|
||
</Button>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>İş iptal edilsin mi?</DialogTitle>
|
||
<DialogDescription className="flex items-start gap-2">
|
||
<CircleAlert className="text-destructive size-4 shrink-0" />
|
||
<span>
|
||
İş geri alınamaz şekilde iptal duruma geçer. Karşı taraf da bu işi iptal edilmiş olarak görür.
|
||
</span>
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<form action={action}>
|
||
<input type="hidden" name="jobId" value={jobId} />
|
||
<DialogFooter>
|
||
<DialogClose asChild>
|
||
<Button type="button" variant="outline">
|
||
Vazgeç
|
||
</Button>
|
||
</DialogClose>
|
||
<Button type="submit" variant="destructive" disabled={pending}>
|
||
{pending ? <Loader2 className="size-4 animate-spin" /> : <X className="size-4" />}
|
||
İptal Et
|
||
</Button>
|
||
</DialogFooter>
|
||
</form>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|