53e443b4f1
Up to now the only thing a clinic could do on a prova was approve it.
If the casting didn't fit there was no way to bounce the case back to
the lab short of cancelling the whole thing. Real-world flow needs a
'try again, this is what's wrong' lever, so:
- requestRevisionAction (clinic only): pre-conditions
in_progress + at_clinic + currentStep set; flips location → at_lab
while leaving currentStep untouched so the same prova stage repeats
after the lab redoes the work. Requires a note (the lab can't fix
what it doesn't know is broken) — appended to job_status_history
with a '[Düzeltme talebi]' prefix and surfaced to the lab via
notification.
- JobActionsPanel: when the clinic side sees a prova (in_progress +
at_clinic) it now shows two buttons — Onayla as before, plus
Düzeltme İste (variant=destructive). The dialog requires a note
before submit.
379 lines
12 KiB
TypeScript
379 lines
12 KiB
TypeScript
"use client";
|
||
|
||
import { useActionState, useEffect, useState } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import {
|
||
ArrowRight,
|
||
Check,
|
||
CircleAlert,
|
||
Loader2,
|
||
PackageCheck,
|
||
Play,
|
||
RotateCcw,
|
||
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,
|
||
requestRevisionAction,
|
||
} 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} />
|
||
<RequestRevisionButton 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 RequestRevisionButton({ job }: { job: Job }) {
|
||
const router = useRouter();
|
||
const [state, action, pending] = useActionState(
|
||
requestRevisionAction,
|
||
initialJobActionState,
|
||
);
|
||
const [open, setOpen] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (state.ok) {
|
||
toast.success("Düzeltme talebi gönderildi.");
|
||
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)}>
|
||
<RotateCcw className="size-4" />
|
||
Düzeltme İste
|
||
</Button>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>Provayı reddet, lab'a geri gönder</DialogTitle>
|
||
<DialogDescription>
|
||
Bu aşamayı reddettiğinizde iş aynı adımda kalır ve laboratuvar
|
||
yeniden çalışır. Neyin düzeltilmesi gerektiğini lütfen yazın.
|
||
</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">Düzeltme notu *</Label>
|
||
<Textarea
|
||
id="note"
|
||
name="note"
|
||
rows={4}
|
||
required
|
||
maxLength={1000}
|
||
placeholder="Örn. Distalde temas yok, oklüzyon yüksek geldi."
|
||
/>
|
||
</div>
|
||
<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" /> : <RotateCcw className="size-4" />}
|
||
Düzeltme İste
|
||
</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>
|
||
);
|
||
}
|