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:
@@ -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'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,
|
||||
|
||||
Reference in New Issue
Block a user