diff --git a/src/app/(dashboard)/jobs/new/components/new-job-form.tsx b/src/app/(dashboard)/jobs/new/components/new-job-form.tsx index 478d0d7..2a5c845 100644 --- a/src/app/(dashboard)/jobs/new/components/new-job-form.tsx +++ b/src/app/(dashboard)/jobs/new/components/new-job-form.tsx @@ -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(""); const [quote, setQuote] = useState(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(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 ( + router.push(`/jobs/${createdJobId}`)} + onSkip={() => router.push("/jobs/outbound")} + /> + ); + } + return (
+
@@ -298,12 +316,12 @@ export function NewJobForm({ {pending ? ( <> - Gönderiliyor... + Kaydediliyor... ) : ( <> - - İşi Yayınla + + Devam Et — Dosyalar )} @@ -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 ( +
    + {items.map((it, idx) => { + const active = it.id === step; + const done = items.findIndex((x) => x.id === step) > idx; + return ( +
  1. + + {done ? : idx + 1} + + {it.label} + {idx < items.length - 1 && ( + + )} +
  2. + ); + })} +
+ ); +} + +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([]); + const inputRef = useRef(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 ( +
+ + +
+
+ + Tarama, görsel veya doküman ekleyin +
+

+ 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. +

+
+ { + addFiles(e.target.files); + if (inputRef.current) inputRef.current.value = ""; + }} + /> + +
+
+ + {items.length > 0 && ( +
    + {items.map((i) => ( +
  • +
    + {i.file.name} + + {(i.file.size / (1024 * 1024)).toFixed(1)} MB + +
    + {i.status === "done" && ( +

    + + Yüklendi +

    + )} + {i.status === "error" && ( +

    Hata: {i.error}

    + )} + {(i.status === "uploading" || i.status === "processing") && ( +
    + + + {i.status === "processing" + ? "İşleniyor..." + : `${i.progress}%`} + +
    + )} +
  • + ))} +
+ )} + +
+ + +
+
+ ); +} + function PriceQuoteCard({ quote, loading,