feat(jobs/new): two-step wizard — details, then files

Previously creating a job dumped the clinic on /jobs/outbound and left
them to navigate into the new job's detail page and upload scans there.
Splitting the form into a wizard keeps the whole 'publish a job' flow
on one page.

Step 1 — İş Bilgileri (existing form): lab, patient, product, teeth,
notes, due date. The submit button is now 'Devam Et — Dosyalar'. On
success createJobAction returns the new jobId, we stash it in state and
flip step → 'files' instead of router.push'ing away.

Step 2 — Dosyalar (new): a FilesStep component with a file picker that
queues each selection, kicks off a parallel XHR upload to the existing
/api/jobs/[jobId]/files endpoint, and shows per-row progress. Three
states per row: uploading (real byte progress), processing (server is
writing to Appwrite, indeterminate), done. Errors surface inline.

User exits via:
  - 'İlanı Tamamla' → /jobs/[id] (the new job detail page).
  - 'Şimdilik atla' → /jobs/outbound, as before. Disabled while any
    upload is still in flight so files don't get abandoned mid-stream.

The shared StepIndicator (1 İş Bilgileri → 2 Dosyalar) sits at the top
of both screens; a checkmark replaces the number once a step is done.
This commit is contained in:
kovakmedya
2026-05-22 01:36:56 +03:00
parent 479972e9a9
commit 5dab958085
@@ -1,11 +1,13 @@
"use client";
import { useActionState, useEffect, useMemo, useState } from "react";
import { useActionState, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Loader2, Send, Sparkles, TrendingDown } from "lucide-react";
import { ArrowRight, CheckCircle2, FileUp, Loader2, Send, Sparkles, TrendingDown, Upload } from "lucide-react";
import { toast } from "sonner";
import { Progress } from "@/components/ui/progress";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -78,6 +80,10 @@ export function NewJobForm({
const [prostheticId, setProstheticId] = useState<string>("");
const [quote, setQuote] = useState<Quote | null>(null);
const [quoteLoading, setQuoteLoading] = useState(false);
// Wizard step — once the job is created we switch to the file upload step
// and keep the user on this page until they finish or skip.
const [step, setStep] = useState<"details" | "files">("details");
const [createdJobId, setCreatedJobId] = useState<string | null>(null);
const labProsthetics = prostheticsByLab[labTenantId] ?? [];
const selectedProsthetic = labProsthetics.find((p) => p.id === prostheticId);
@@ -89,13 +95,14 @@ export function NewJobForm({
const selectedPatient = patientId !== NONE_PATIENT ? patientById.get(patientId) : undefined;
useEffect(() => {
if (state.ok) {
toast.success("İş yayınlandı.");
router.push("/jobs/outbound");
if (state.ok && state.jobId) {
toast.success("İş kaydedildi. Dosyaları ekleyebilirsiniz.");
setCreatedJobId(state.jobId);
setStep("files");
} else if (state.error) {
toast.error(state.error);
}
}, [state, router]);
}, [state]);
// Reset prosthetic selection when the lab changes so we never carry the
// previous lab's catalog ID over.
@@ -134,8 +141,19 @@ export function NewJobForm({
};
}, [prostheticId, teeth.length]);
if (step === "files" && createdJobId) {
return (
<FilesStep
jobId={createdJobId}
onDone={() => router.push(`/jobs/${createdJobId}`)}
onSkip={() => router.push("/jobs/outbound")}
/>
);
}
return (
<form action={action} className="grid gap-5">
<StepIndicator step="details" />
<div className="grid gap-3 md:grid-cols-2">
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="labTenantId">Laboratuvar *</Label>
@@ -298,12 +316,12 @@ export function NewJobForm({
{pending ? (
<>
<Loader2 className="size-4 animate-spin" />
Gönderiliyor...
Kaydediliyor...
</>
) : (
<>
<Send className="size-4" />
İşi Yayınla
<ArrowRight className="size-4" />
Devam Et Dosyalar
</>
)}
</Button>
@@ -312,6 +330,246 @@ export function NewJobForm({
);
}
function StepIndicator({ step }: { step: "details" | "files" }) {
const items: { id: "details" | "files"; label: string }[] = [
{ id: "details", label: "İş Bilgileri" },
{ id: "files", label: "Dosyalar" },
];
return (
<ol className="flex items-center gap-3 text-xs">
{items.map((it, idx) => {
const active = it.id === step;
const done = items.findIndex((x) => x.id === step) > idx;
return (
<li key={it.id} className="flex items-center gap-3">
<span
className={`flex size-6 items-center justify-center rounded-full text-[11px] font-semibold ${
active
? "bg-primary text-primary-foreground"
: done
? "bg-emerald-600 text-white"
: "bg-muted text-muted-foreground"
}`}
>
{done ? <CheckCircle2 className="size-3.5" /> : idx + 1}
</span>
<span className={active ? "font-medium" : "text-muted-foreground"}>{it.label}</span>
{idx < items.length - 1 && (
<span className="bg-border h-px w-6" aria-hidden />
)}
</li>
);
})}
</ol>
);
}
type PendingUpload = {
id: string;
file: File;
kind: "scan" | "image" | "document";
status: "queued" | "uploading" | "processing" | "done" | "error";
progress: number;
error?: string;
};
function inferKind(file: File): PendingUpload["kind"] {
const lower = file.name.toLowerCase();
if (
lower.endsWith(".stl") ||
lower.endsWith(".ply") ||
lower.endsWith(".obj") ||
lower.endsWith(".dcm")
)
return "scan";
if (file.type.startsWith("image/")) return "image";
return "document";
}
function FilesStep({
jobId,
onDone,
onSkip,
}: {
jobId: string;
onDone: () => void;
onSkip: () => void;
}) {
const [items, setItems] = useState<PendingUpload[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const allDone = items.length > 0 && items.every((i) => i.status === "done");
const anyBusy = items.some(
(i) => i.status === "uploading" || i.status === "processing" || i.status === "queued",
);
function addFiles(files: FileList | null) {
if (!files || files.length === 0) return;
const additions: PendingUpload[] = Array.from(files).map((file) => ({
id: `${file.name}-${file.size}-${Math.random().toString(36).slice(2, 8)}`,
file,
kind: inferKind(file),
status: "queued",
progress: 0,
}));
setItems((prev) => [...prev, ...additions]);
additions.forEach(uploadOne);
}
function uploadOne(item: PendingUpload) {
const fd = new FormData();
fd.append("file", item.file);
fd.append("kind", item.kind);
setItems((prev) =>
prev.map((i) => (i.id === item.id ? { ...i, status: "uploading" } : i)),
);
const xhr = new XMLHttpRequest();
xhr.open("POST", `/api/jobs/${jobId}/files`);
xhr.upload.onprogress = (e) => {
if (!e.lengthComputable) return;
const pct = Math.round((e.loaded / e.total) * 100);
setItems((prev) =>
prev.map((i) => (i.id === item.id ? { ...i, progress: pct } : i)),
);
};
xhr.upload.onload = () => {
// Bytes are up — server is now writing to Appwrite (can take a while
// for big STL scans). Switch the row to a 'processing' state so the
// user doesn't think we hung.
setItems((prev) =>
prev.map((i) =>
i.id === item.id ? { ...i, status: "processing", progress: 100 } : i,
),
);
};
xhr.onerror = () => {
setItems((prev) =>
prev.map((i) =>
i.id === item.id
? { ...i, status: "error", error: "Ağ hatası" }
: i,
),
);
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
setItems((prev) =>
prev.map((i) => (i.id === item.id ? { ...i, status: "done" } : i)),
);
} else {
let msg = `HTTP ${xhr.status}`;
try {
const d = JSON.parse(xhr.responseText);
if (d?.error) msg = d.error;
} catch {}
setItems((prev) =>
prev.map((i) =>
i.id === item.id ? { ...i, status: "error", error: msg } : i,
),
);
}
};
xhr.send(fd);
}
return (
<div className="grid gap-5">
<StepIndicator step="files" />
<div className="bg-muted/30 grid gap-2 rounded-md border p-4 text-sm">
<div className="flex items-center gap-2 font-medium">
<FileUp className="size-4" />
Tarama, görsel veya doküman ekleyin
</div>
<p className="text-muted-foreground text-xs">
STL/PLY/OBJ/DCM dosyaları tarama olarak; JPG/PNG görsel olarak;
diğerleri doküman olarak kaydedilir. Her dosya 200 MB&apos;a kadar olabilir.
</p>
<div>
<input
ref={inputRef}
type="file"
multiple
className="hidden"
onChange={(e) => {
addFiles(e.target.files);
if (inputRef.current) inputRef.current.value = "";
}}
/>
<Button
type="button"
variant="outline"
onClick={() => inputRef.current?.click()}
disabled={anyBusy}
>
<Upload className="size-4" />
Dosya seç
</Button>
</div>
</div>
{items.length > 0 && (
<ul className="divide-y rounded-md border">
{items.map((i) => (
<li key={i.id} className="grid gap-1.5 px-3 py-2.5">
<div className="flex items-center justify-between gap-3">
<span className="truncate text-sm">{i.file.name}</span>
<span className="text-muted-foreground shrink-0 text-xs">
{(i.file.size / (1024 * 1024)).toFixed(1)} MB
</span>
</div>
{i.status === "done" && (
<p className="flex items-center gap-1.5 text-xs text-emerald-600 dark:text-emerald-400">
<CheckCircle2 className="size-3.5" />
Yüklendi
</p>
)}
{i.status === "error" && (
<p className="text-destructive text-xs">Hata: {i.error}</p>
)}
{(i.status === "uploading" || i.status === "processing") && (
<div className="flex items-center gap-2">
<Progress value={i.progress} className="flex-1" />
<span className="text-muted-foreground w-20 text-right text-xs">
{i.status === "processing"
? "İşleniyor..."
: `${i.progress}%`}
</span>
</div>
)}
</li>
))}
</ul>
)}
<div className="flex flex-wrap items-center justify-end gap-2">
<button
type="button"
onClick={onSkip}
className="text-muted-foreground hover:text-foreground text-sm underline-offset-4 hover:underline"
>
Şimdilik atla
</button>
<Button onClick={onDone} disabled={anyBusy || (items.length > 0 && !allDone)}>
{anyBusy ? (
<>
<Loader2 className="size-4 animate-spin" />
Bekleyin...
</>
) : (
<>
<Send className="size-4" />
İlanı Tamamla
</>
)}
</Button>
</div>
</div>
);
}
function PriceQuoteCard({
quote,
loading,