feat(workflow): split job step from location, model back-and-forth between lab and clinic
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.
This commit is contained in:
@@ -7,8 +7,9 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
CircleAlert,
|
CircleAlert,
|
||||||
Loader2,
|
Loader2,
|
||||||
Play,
|
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
|
Play,
|
||||||
|
Send,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -27,15 +28,12 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
acceptJobAction,
|
acceptJobAction,
|
||||||
advanceStepAction,
|
approveAtClinicAction,
|
||||||
cancelJobAction,
|
cancelJobAction,
|
||||||
|
handToClinicAction,
|
||||||
markDeliveredAction,
|
markDeliveredAction,
|
||||||
} from "@/lib/appwrite/job-actions";
|
} from "@/lib/appwrite/job-actions";
|
||||||
import {
|
import { initialJobActionState } from "@/lib/appwrite/job-types";
|
||||||
JOB_STEP_LABELS,
|
|
||||||
JOB_STEP_ORDER,
|
|
||||||
initialJobActionState,
|
|
||||||
} from "@/lib/appwrite/job-types";
|
|
||||||
import type { Job, TenantKind } from "@/lib/appwrite/schema";
|
import type { Job, TenantKind } from "@/lib/appwrite/schema";
|
||||||
|
|
||||||
type Side = "clinic" | "lab";
|
type Side = "clinic" | "lab";
|
||||||
@@ -53,12 +51,29 @@ export function JobActionsPanel({
|
|||||||
|
|
||||||
const isLab = side === "lab";
|
const isLab = side === "lab";
|
||||||
const isClinic = side === "clinic";
|
const isClinic = side === "clinic";
|
||||||
|
const location = job.location ?? "at_lab";
|
||||||
|
const isAtLab = location === "at_lab";
|
||||||
|
const isAtClinic = location === "at_clinic";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{/* Pending pickup — lab accepts */}
|
||||||
{isLab && job.status === "pending" && <AcceptButton jobId={job.$id} />}
|
{isLab && job.status === "pending" && <AcceptButton jobId={job.$id} />}
|
||||||
{isLab && job.status === "in_progress" && <AdvanceButton job={job} />}
|
|
||||||
|
{/* 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} />}
|
{isClinic && job.status === "sent" && <DeliverButton jobId={job.$id} />}
|
||||||
|
|
||||||
|
{/* Cancel — only while the job hasn't started yet */}
|
||||||
{(isClinic || isLab) && job.status === "pending" && (
|
{(isClinic || isLab) && job.status === "pending" && (
|
||||||
<CancelButton jobId={job.$id} />
|
<CancelButton jobId={job.$id} />
|
||||||
)}
|
)}
|
||||||
@@ -72,7 +87,7 @@ function AcceptButton({ jobId }: { jobId: string }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.ok) {
|
if (state.ok) {
|
||||||
toast.success("İş işleme alındı.");
|
toast.success("İş işleme alındı, alt yapı üretimi başladı.");
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} else if (state.error) {
|
} else if (state.error) {
|
||||||
toast.error(state.error);
|
toast.error(state.error);
|
||||||
@@ -90,14 +105,14 @@ function AcceptButton({ jobId }: { jobId: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AdvanceButton({ job }: { job: Job }) {
|
function HandToClinicButton({ job }: { job: Job }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [state, action, pending] = useActionState(advanceStepAction, initialJobActionState);
|
const [state, action, pending] = useActionState(handToClinicAction, initialJobActionState);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.ok) {
|
if (state.ok) {
|
||||||
toast.success("Aşama ilerletildi.");
|
toast.success("Klinik tarafına gönderildi.");
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} else if (state.error) {
|
} else if (state.error) {
|
||||||
@@ -105,28 +120,29 @@ function AdvanceButton({ job }: { job: Job }) {
|
|||||||
}
|
}
|
||||||
}, [state, router]);
|
}, [state, router]);
|
||||||
|
|
||||||
const currentIdx = job.currentStep ? JOB_STEP_ORDER.indexOf(job.currentStep) : -1;
|
const isFinal = job.currentStep === "cila_bitim";
|
||||||
const isFinal = currentIdx === JOB_STEP_ORDER.length - 1;
|
const stageLabel =
|
||||||
const currentLabel = job.currentStep ? JOB_STEP_LABELS[job.currentStep] : "—";
|
job.currentStep === "alt_yapi_prova"
|
||||||
const nextLabel = isFinal
|
? "alt yapı"
|
||||||
? "Gönderildi olarak işaretle"
|
: job.currentStep === "ust_yapi_prova"
|
||||||
: JOB_STEP_LABELS[JOB_STEP_ORDER[currentIdx + 1]];
|
? "üst yapı"
|
||||||
|
: "cila/bitim";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<Button onClick={() => setOpen(true)}>
|
<Button onClick={() => setOpen(true)}>
|
||||||
{isFinal ? <PackageCheck className="size-4" /> : <ArrowRight className="size-4" />}
|
{isFinal ? <PackageCheck className="size-4" /> : <Send className="size-4" />}
|
||||||
{isFinal ? "Gönderildi" : "Sonraki Aşama"}
|
{isFinal ? "Cila Bitim — Nihai Teslime Gönder" : `${stageLabel === "alt yapı" ? "Alt Yapı" : "Üst Yapı"} Provaya Gönder`}
|
||||||
</Button>
|
</Button>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{isFinal ? "İşi gönderilmiş olarak işaretle" : `${currentLabel} tamamlandı`}
|
{isFinal ? "Nihai teslime gönderilsin mi?" : "Kliniğe gönderilsin mi?"}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{isFinal
|
{isFinal
|
||||||
? "İş artık 'Gönderildi' durumuna geçecek; klinik 'Teslim Aldım' onayını verecek."
|
? "Cila ve bitim tamamlandı; iş 'Gönderildi' durumuna geçer. Klinik teslim aldığında nihai onay verecek."
|
||||||
: `Sonraki aşama: ${nextLabel}. İsterseniz bir not ekleyebilirsiniz.`}
|
: `${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>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form action={action} className="grid gap-3">
|
<form action={action} className="grid gap-3">
|
||||||
@@ -138,7 +154,7 @@ function AdvanceButton({ job }: { job: Job }) {
|
|||||||
name="note"
|
name="note"
|
||||||
rows={3}
|
rows={3}
|
||||||
maxLength={1000}
|
maxLength={1000}
|
||||||
placeholder="Örn. Renk kontrolü yapıldı, hasta provası onaylandı."
|
placeholder="Örn. Renk A2, oklüzal kontak tamam"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@@ -148,8 +164,70 @@ function AdvanceButton({ job }: { job: Job }) {
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
<Button type="submit" disabled={pending}>
|
<Button type="submit" disabled={pending}>
|
||||||
{pending ? <Loader2 className="size-4 animate-spin" /> : <Check className="size-4" />}
|
{pending ? <Loader2 className="size-4 animate-spin" /> : <Send className="size-4" />}
|
||||||
Onayla
|
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>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { listJobHistory } from "@/lib/appwrite/job-history-queries";
|
|||||||
import { getPatient } from "@/lib/appwrite/patient-queries";
|
import { getPatient } from "@/lib/appwrite/patient-queries";
|
||||||
import { toPlain } from "@/lib/appwrite/serialize";
|
import { toPlain } from "@/lib/appwrite/serialize";
|
||||||
import {
|
import {
|
||||||
|
JOB_LOCATION_LABELS,
|
||||||
JOB_STATUS_LABELS,
|
JOB_STATUS_LABELS,
|
||||||
JOB_STEP_LABELS,
|
JOB_STEP_LABELS,
|
||||||
JOB_STEP_ORDER,
|
JOB_STEP_ORDER,
|
||||||
@@ -141,6 +142,15 @@ export default async function JobDetailPage({
|
|||||||
<Info label="Mevcut Aşama">
|
<Info label="Mevcut Aşama">
|
||||||
{job.currentStep ? JOB_STEP_LABELS[job.currentStep] : "—"}
|
{job.currentStep ? JOB_STEP_LABELS[job.currentStep] : "—"}
|
||||||
</Info>
|
</Info>
|
||||||
|
<Info label="Şu An">
|
||||||
|
{job.status === "pending"
|
||||||
|
? "Klinikte (lab teslim alacak)"
|
||||||
|
: job.status === "delivered"
|
||||||
|
? "Hasta'ya teslim edildi"
|
||||||
|
: job.status === "cancelled"
|
||||||
|
? "İptal"
|
||||||
|
: JOB_LOCATION_LABELS[job.location ?? "at_lab"]}
|
||||||
|
</Info>
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<p className="text-muted-foreground mb-1 text-xs font-medium uppercase tracking-wide">
|
<p className="text-muted-foreground mb-1 text-xs font-medium uppercase tracking-wide">
|
||||||
Dişler ({job.teeth?.length ?? job.memberCount})
|
Dişler ({job.teeth?.length ?? job.memberCount})
|
||||||
|
|||||||
+148
-43
@@ -297,9 +297,12 @@ export async function acceptJobAction(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { tablesDB } = createAdminClient();
|
const { tablesDB } = createAdminClient();
|
||||||
|
// Accepting the job = lab took the impression, started substructure work.
|
||||||
|
// Step jumps straight to alt_yapi_prova; location flips to at_lab.
|
||||||
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
|
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
|
||||||
status: "in_progress",
|
status: "in_progress",
|
||||||
currentStep: "olcu",
|
currentStep: "alt_yapi_prova",
|
||||||
|
location: "at_lab",
|
||||||
});
|
});
|
||||||
await appendJobHistory({ job, step: "olcu", completedBy: ctx.user.id });
|
await appendJobHistory({ job, step: "olcu", completedBy: ctx.user.id });
|
||||||
void logAudit({
|
void logAudit({
|
||||||
@@ -308,12 +311,16 @@ export async function acceptJobAction(
|
|||||||
action: "update",
|
action: "update",
|
||||||
entityType: "job",
|
entityType: "job",
|
||||||
entityId: jobId,
|
entityId: jobId,
|
||||||
changes: { status: "in_progress", currentStep: "olcu" },
|
changes: {
|
||||||
|
status: "in_progress",
|
||||||
|
currentStep: "alt_yapi_prova",
|
||||||
|
location: "at_lab",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
void createNotification({
|
void createNotification({
|
||||||
tenantId: job.clinicTenantId,
|
tenantId: job.clinicTenantId,
|
||||||
jobId,
|
jobId,
|
||||||
message: `${ctx.settings?.companyName ?? "Lab"} hasta ${job.patientCode} işini işleme aldı.`,
|
message: `${ctx.settings?.companyName ?? "Lab"} hasta ${job.patientCode} işini işleme aldı, alt yapı üretiminde.`,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { ok: false, error: appwriteError(e, "Kabul edilemedi.") };
|
return { ok: false, error: appwriteError(e, "Kabul edilemedi.") };
|
||||||
@@ -325,7 +332,14 @@ export async function acceptJobAction(
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function advanceStepAction(
|
/**
|
||||||
|
* Lab hands the work back to the clinic for the next physical step
|
||||||
|
* (prova or final delivery). The current step stays the same — only the
|
||||||
|
* location flips at_lab → at_clinic. If the lab is finishing the last
|
||||||
|
* production step (cila_bitim), that's the final delivery and the job
|
||||||
|
* status becomes "sent".
|
||||||
|
*/
|
||||||
|
export async function handToClinicAction(
|
||||||
_prev: JobActionState,
|
_prev: JobActionState,
|
||||||
formData: FormData,
|
formData: FormData,
|
||||||
): Promise<JobActionState> {
|
): Promise<JobActionState> {
|
||||||
@@ -339,7 +353,7 @@ export async function advanceStepAction(
|
|||||||
requireRole(ctx, ["owner", "admin", "member"]);
|
requireRole(ctx, ["owner", "admin", "member"]);
|
||||||
requireTenantKind(ctx, ["lab"]);
|
requireTenantKind(ctx, ["lab"]);
|
||||||
} catch {
|
} catch {
|
||||||
return { ok: false, error: "Sadece laboratuvar aşama ilerletebilir." };
|
return { ok: false, error: "Sadece laboratuvar kliniğe gönderebilir." };
|
||||||
}
|
}
|
||||||
|
|
||||||
const job = await loadJobForTenant(jobId, ctx.tenantId);
|
const job = await loadJobForTenant(jobId, ctx.tenantId);
|
||||||
@@ -347,36 +361,28 @@ export async function advanceStepAction(
|
|||||||
return { ok: false, error: "İş bulunamadı." };
|
return { ok: false, error: "İş bulunamadı." };
|
||||||
}
|
}
|
||||||
if (job.status !== "in_progress") {
|
if (job.status !== "in_progress") {
|
||||||
return { ok: false, error: "Yalnızca işleme alınmış işler ilerletilebilir." };
|
return { ok: false, error: "Sadece işlemdeki işler kliniğe gönderilebilir." };
|
||||||
|
}
|
||||||
|
if (job.location !== "at_lab") {
|
||||||
|
return { ok: false, error: "İş zaten kliniğe gönderilmiş." };
|
||||||
|
}
|
||||||
|
if (!job.currentStep) {
|
||||||
|
return { ok: false, error: "Mevcut aşama bilinmiyor." };
|
||||||
}
|
}
|
||||||
const currentIdx = job.currentStep ? JOB_STEP_ORDER.indexOf(job.currentStep) : -1;
|
|
||||||
if (currentIdx < 0) return { ok: false, error: "Mevcut aşama bilinmiyor." };
|
|
||||||
|
|
||||||
const nextIdx = currentIdx + 1;
|
const isFinalStep = job.currentStep === "cila_bitim";
|
||||||
const isFinalStepComplete = currentIdx === JOB_STEP_ORDER.length - 1;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { tablesDB } = createAdminClient();
|
const { tablesDB } = createAdminClient();
|
||||||
if (isFinalStepComplete) {
|
if (isFinalStep) {
|
||||||
|
// Final delivery — production is done, status moves to sent.
|
||||||
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
|
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
|
||||||
status: "sent",
|
status: "sent",
|
||||||
});
|
location: "at_clinic",
|
||||||
void logAudit({
|
|
||||||
tenantId: ctx.tenantId,
|
|
||||||
userId: ctx.user.id,
|
|
||||||
action: "update",
|
|
||||||
entityType: "job",
|
|
||||||
entityId: jobId,
|
|
||||||
changes: { status: "sent" },
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const nextStep = JOB_STEP_ORDER[nextIdx];
|
|
||||||
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
|
|
||||||
currentStep: nextStep,
|
|
||||||
});
|
});
|
||||||
await appendJobHistory({
|
await appendJobHistory({
|
||||||
job,
|
job,
|
||||||
step: job.currentStep!,
|
step: "cila_bitim",
|
||||||
completedBy: ctx.user.id,
|
completedBy: ctx.user.id,
|
||||||
note,
|
note,
|
||||||
});
|
});
|
||||||
@@ -386,27 +392,43 @@ export async function advanceStepAction(
|
|||||||
action: "update",
|
action: "update",
|
||||||
entityType: "job",
|
entityType: "job",
|
||||||
entityId: jobId,
|
entityId: jobId,
|
||||||
changes: { currentStep: nextStep, completedStep: job.currentStep },
|
changes: { status: "sent", location: "at_clinic" },
|
||||||
|
});
|
||||||
|
void syncFinanceForJob({ ...job, status: "sent" });
|
||||||
|
void createNotification({
|
||||||
|
tenantId: job.clinicTenantId,
|
||||||
|
jobId,
|
||||||
|
message: `Hasta ${job.patientCode} cila/bitim tamamlandı, nihai teslime gönderildi.`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Prova için klinike geçici teslim — step aynı, location değişti.
|
||||||
|
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
|
||||||
|
location: "at_clinic",
|
||||||
|
});
|
||||||
|
await appendJobHistory({
|
||||||
|
job,
|
||||||
|
step: job.currentStep,
|
||||||
|
completedBy: ctx.user.id,
|
||||||
|
note,
|
||||||
|
});
|
||||||
|
void logAudit({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: "update",
|
||||||
|
entityType: "job",
|
||||||
|
entityId: jobId,
|
||||||
|
changes: { location: "at_clinic", handedOffStep: job.currentStep },
|
||||||
|
});
|
||||||
|
const stepLabel =
|
||||||
|
job.currentStep === "alt_yapi_prova" ? "alt yapı" : "üst yapı";
|
||||||
|
void createNotification({
|
||||||
|
tenantId: job.clinicTenantId,
|
||||||
|
jobId,
|
||||||
|
message: `Hasta ${job.patientCode} ${stepLabel} provasına hazır, kliniğe gönderildi.`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { ok: false, error: appwriteError(e, "İlerletilemedi.") };
|
return { ok: false, error: appwriteError(e, "Gönderilemedi.") };
|
||||||
}
|
|
||||||
|
|
||||||
if (isFinalStepComplete) {
|
|
||||||
// Record completion of the last step too, then mark sent.
|
|
||||||
await appendJobHistory({
|
|
||||||
job,
|
|
||||||
step: job.currentStep!,
|
|
||||||
completedBy: ctx.user.id,
|
|
||||||
note,
|
|
||||||
});
|
|
||||||
void syncFinanceForJob({ ...job, status: "sent" });
|
|
||||||
void createNotification({
|
|
||||||
tenantId: job.clinicTenantId,
|
|
||||||
jobId,
|
|
||||||
message: `Hasta ${job.patientCode} işi gönderildi. Teslim alındığında onaylayın.`,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath(`/jobs/${jobId}`);
|
revalidatePath(`/jobs/${jobId}`);
|
||||||
@@ -416,6 +438,89 @@ export async function advanceStepAction(
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clinic confirms the prova was successful. Step advances to the next
|
||||||
|
* production stage and location flips back at_clinic → at_lab so the
|
||||||
|
* lab can pick the work back up.
|
||||||
|
*/
|
||||||
|
export async function approveAtClinicAction(
|
||||||
|
_prev: JobActionState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<JobActionState> {
|
||||||
|
const jobId = String(formData.get("jobId") ?? "").trim();
|
||||||
|
if (!jobId) return { ok: false, error: "İş bulunamadı." };
|
||||||
|
const note = String(formData.get("note") ?? "").trim() || undefined;
|
||||||
|
|
||||||
|
let ctx;
|
||||||
|
try {
|
||||||
|
ctx = await requireTenant();
|
||||||
|
requireRole(ctx, ["owner", "admin", "member"]);
|
||||||
|
requireTenantKind(ctx, ["clinic"]);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: "Sadece klinik provayı onaylayabilir." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const job = await loadJobForTenant(jobId, ctx.tenantId);
|
||||||
|
if (!job || job.clinicTenantId !== ctx.tenantId) {
|
||||||
|
return { ok: false, error: "İş bulunamadı." };
|
||||||
|
}
|
||||||
|
if (job.status !== "in_progress") {
|
||||||
|
return { ok: false, error: "Yalnızca işlemdeki provalar onaylanabilir." };
|
||||||
|
}
|
||||||
|
if (job.location !== "at_clinic") {
|
||||||
|
return { ok: false, error: "İş şu an klinikte değil." };
|
||||||
|
}
|
||||||
|
if (!job.currentStep) {
|
||||||
|
return { ok: false, error: "Mevcut aşama bilinmiyor." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentIdx = JOB_STEP_ORDER.indexOf(job.currentStep);
|
||||||
|
const nextStep = JOB_STEP_ORDER[currentIdx + 1];
|
||||||
|
if (!nextStep) {
|
||||||
|
return { ok: false, error: "Bu aşamadan ileri gidilemez." };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
|
||||||
|
currentStep: nextStep,
|
||||||
|
location: "at_lab",
|
||||||
|
});
|
||||||
|
await appendJobHistory({
|
||||||
|
job,
|
||||||
|
step: job.currentStep,
|
||||||
|
completedBy: ctx.user.id,
|
||||||
|
note,
|
||||||
|
});
|
||||||
|
void logAudit({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: "update",
|
||||||
|
entityType: "job",
|
||||||
|
entityId: jobId,
|
||||||
|
changes: {
|
||||||
|
currentStep: nextStep,
|
||||||
|
location: "at_lab",
|
||||||
|
completedStep: job.currentStep,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const stepLabel =
|
||||||
|
job.currentStep === "alt_yapi_prova" ? "alt yapı" : "üst yapı";
|
||||||
|
void createNotification({
|
||||||
|
tenantId: job.labTenantId,
|
||||||
|
jobId,
|
||||||
|
message: `Hasta ${job.patientCode} ${stepLabel} provası onaylandı, lab tarafına geri döndü.`,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: appwriteError(e, "Onaylanamadı.") };
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath(`/jobs/${jobId}`);
|
||||||
|
revalidatePath("/jobs/inbound");
|
||||||
|
revalidatePath("/jobs/outbound");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
export async function markDeliveredAction(
|
export async function markDeliveredAction(
|
||||||
_prev: JobActionState,
|
_prev: JobActionState,
|
||||||
formData: FormData,
|
formData: FormData,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { JobStatus, JobStep, ProstheticType } from "./schema";
|
import type { JobLocation, JobStatus, JobStep, ProstheticType } from "./schema";
|
||||||
|
|
||||||
export type JobFormState = {
|
export type JobFormState = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
@@ -38,6 +38,11 @@ export const JOB_STEP_ORDER: JobStep[] = [
|
|||||||
"cila_bitim",
|
"cila_bitim",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const JOB_LOCATION_LABELS: Record<JobLocation, string> = {
|
||||||
|
at_clinic: "Klinikte",
|
||||||
|
at_lab: "Laboratuvarda",
|
||||||
|
};
|
||||||
|
|
||||||
export const PROSTHETIC_TYPE_LABELS: Record<ProstheticType, string> = {
|
export const PROSTHETIC_TYPE_LABELS: Record<ProstheticType, string> = {
|
||||||
metal_porselen: "Metal Porselen",
|
metal_porselen: "Metal Porselen",
|
||||||
zirkonyum: "Zirkonyum",
|
zirkonyum: "Zirkonyum",
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export interface Connection extends Row {
|
|||||||
|
|
||||||
export type JobStatus = "pending" | "in_progress" | "sent" | "delivered" | "cancelled";
|
export type JobStatus = "pending" | "in_progress" | "sent" | "delivered" | "cancelled";
|
||||||
export type JobStep = "olcu" | "alt_yapi_prova" | "ust_yapi_prova" | "cila_bitim";
|
export type JobStep = "olcu" | "alt_yapi_prova" | "ust_yapi_prova" | "cila_bitim";
|
||||||
|
export type JobLocation = "at_clinic" | "at_lab";
|
||||||
export type ProstheticType =
|
export type ProstheticType =
|
||||||
| "metal_porselen"
|
| "metal_porselen"
|
||||||
| "zirkonyum"
|
| "zirkonyum"
|
||||||
@@ -109,6 +110,7 @@ export interface Job extends Row {
|
|||||||
currency?: string;
|
currency?: string;
|
||||||
status: JobStatus;
|
status: JobStatus;
|
||||||
currentStep?: JobStep;
|
currentStep?: JobStep;
|
||||||
|
location?: JobLocation;
|
||||||
dueDate?: string;
|
dueDate?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user