Merge lane/import in feature/craftvia-mvp

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:22:07 +02:00
co-authored by Claude Opus 5
38 changed files with 4204 additions and 4 deletions
+15
View File
@@ -0,0 +1,15 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
/** Re-renders the server page periodically while an import is being processed. */
export function AutoRefresh({ active, intervalMs = 4000 }: { active: boolean; intervalMs?: number }) {
const router = useRouter();
useEffect(() => {
if (!active) return;
const id = setInterval(() => router.refresh(), intervalMs);
return () => clearInterval(id);
}, [active, intervalMs, router]);
return null;
}
+51
View File
@@ -0,0 +1,51 @@
"use client";
import { useActionState } from "react";
import { useTranslations } from "next-intl";
import { RotateCcw, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { SimpleState } from "@/server/actions/imports/imports";
type Action = (prev: SimpleState, formData: FormData) => Promise<SimpleState>;
function ErrorText({ state }: { state: SimpleState }) {
const t = useTranslations("imports.errors");
if (state.status !== "error") return null;
return (
<span role="alert" className="text-[12.5px] font-semibold text-[var(--risk)]">
{t.has(state.code) ? t(state.code) : t("error")}
</span>
);
}
export function RetryImportButton({ action, size = "default" }: { action: Action; size?: "default" | "sm" }) {
const t = useTranslations("imports.actions");
const [state, formAction, pending] = useActionState(action, { status: "idle" } as SimpleState);
return (
<form action={formAction} className="inline-flex flex-wrap items-center gap-2">
<Button type="submit" variant="outline" size={size} disabled={pending} className={size === "sm" ? "h-9" : "h-11 px-4"}>
<RotateCcw aria-hidden /> {pending ? t("retrying") : t("retry")}
</Button>
<ErrorText state={state} />
</form>
);
}
export function DiscardImportButton({ action }: { action: Action }) {
const t = useTranslations("imports.actions");
const [state, formAction, pending] = useActionState(action, { status: "idle" } as SimpleState);
return (
<form
action={formAction}
onSubmit={(e) => {
if (!window.confirm(t("discardConfirm"))) e.preventDefault();
}}
className="inline-flex flex-wrap items-center gap-2"
>
<Button type="submit" variant="outline" disabled={pending} className="h-11 px-4">
<Trash2 aria-hidden /> {t("discard")}
</Button>
<ErrorText state={state} />
</form>
);
}
+383
View File
@@ -0,0 +1,383 @@
"use client";
import { useActionState, useMemo, useState, useTransition } from "react";
import { useTranslations } from "next-intl";
import { AlertTriangle, Info, Loader2, Plus, Search, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import type { FieldMeta, FormFieldPath, ReviewFormInput } from "@/lib/imports/review";
import type { PlausibilityHint } from "@/lib/imports/extraction";
import type { ConfirmState } from "@/server/actions/imports/imports";
export type SiteOption = { id: string; name: string; street: string | null; houseNumber: string | null; postalCode: string | null; city: string | null };
export type CustomerOption = {
id: string;
customerNumber: string | null;
companyName: string | null;
firstName: string | null;
lastName: string | null;
postalCode: string | null;
city: string | null;
sites: SiteOption[];
};
export type CustomerCandidateView = { customerId: string; score: number; reasons: string[]; customer: CustomerOption };
export type SiteCandidateView = { siteId: string; customerId: string; score: number; reasons: string[] };
type Props = {
initial: ReviewFormInput;
meta: Record<FormFieldPath, FieldMeta>;
hints: PlausibilityHint[];
customerCandidates: CustomerCandidateView[];
siteCandidates: SiteCandidateView[];
confirmAction: (prev: ConfirmState, formData: FormData) => Promise<ConfirmState>;
searchAction: (q: string) => Promise<CustomerOption[]>;
};
type Section = "customer" | "site" | "contact" | "order";
type Position = NonNullable<ReviewFormInput["positions"]>[number];
const customerLabel = (c: CustomerOption) =>
[c.companyName || [c.firstName, c.lastName].filter(Boolean).join(" ") || "—", c.customerNumber ? `(${c.customerNumber})` : null].filter(Boolean).join(" ");
const siteLabel = (s: SiteOption) => [s.name, [s.street, s.houseNumber].filter(Boolean).join(" "), [s.postalCode, s.city].filter(Boolean).join(" ")].filter(Boolean).join(" · ");
/**
* Review mask (spec §9.6): editable form in sections, uncertain fields (< 0.8) marked with
* warning colour + icon + text, source snippet as tooltip; customer/site decisions; editable
* line items with "als Materialvorgabe übernehmen". Submits the form as JSON to the confirm action.
*/
export function ImportReviewForm({ initial, meta, hints, customerCandidates, siteCandidates, confirmAction, searchAction }: Props) {
const t = useTranslations("imports.review");
const te = useTranslations("imports.errors");
const ta = useTranslations("imports.actions");
const [form, setForm] = useState<ReviewFormInput>(initial);
const [state, formAction, pending] = useActionState(confirmAction, { status: "idle" } as ConfirmState);
const [query, setQuery] = useState("");
const [results, setResults] = useState<CustomerOption[] | null>(null);
const [searching, startSearch] = useTransition();
const issues = useMemo(() => {
const map = new Map<string, string>();
if (state.status === "error") for (const i of state.issues ?? []) if (!map.has(i.path)) map.set(i.path, i.message);
return map;
}, [state]);
const knownCustomers = useMemo(() => {
const map = new Map<string, CustomerOption>();
for (const c of customerCandidates) map.set(c.customer.id, c.customer);
for (const c of results ?? []) map.set(c.id, c);
return map;
}, [customerCandidates, results]);
const selectedCustomer = form.customerMode === "existing" && form.customerId ? knownCustomers.get(form.customerId) ?? null : null;
const set = <S extends Section>(section: S, key: keyof NonNullable<ReviewFormInput[S]>, value: string) =>
setForm((f) => ({ ...f, [section]: { ...(f[section] as object), [key]: value } }));
const setPosition = (index: number, patch: Partial<Position>) =>
setForm((f) => ({ ...f, positions: (f.positions ?? []).map((p, i) => (i === index ? { ...p, ...patch } : p)) }));
const errorText = (path: string) => {
const code = issues.get(path);
return code ? (te.has(code) ? te(code) : te("form_invalid")) : null;
};
function field(section: Section, key: string, labelKey: string, opts: { type?: string; multiline?: boolean; required?: boolean; className?: string; inputMode?: "numeric" | "tel" | "email" } = {}) {
const path = `${section}.${key}`;
const m = (meta as Record<string, FieldMeta | undefined>)[path];
const value = String(((form[section] as Record<string, unknown>)[key] as string | undefined) ?? "");
const id = `rv-${section}-${key}`;
const err = errorText(path);
const tip = m?.source ? t("source", { text: m.source }) : undefined;
const common = {
id,
value,
title: tip,
"aria-invalid": err ? true : undefined,
"aria-describedby": m?.uncertain || err ? `${id}-note` : undefined,
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => set(section, key as never, e.target.value),
className: cn("mt-1 min-h-11", m?.uncertain && "border-[var(--warn)] bg-[color-mix(in_srgb,var(--warn)_7%,transparent)]"),
};
return (
<div className={opts.className}>
<Label htmlFor={id} className="flex flex-wrap items-center gap-x-2">
<span>
{t(`fields.${labelKey}`)}
{opts.required && <span aria-label={t("required")}> *</span>}
</span>
{m?.uncertain && (
<span className="inline-flex items-center gap-1 text-[11.5px] font-bold text-[var(--warn)]" title={tip}>
<AlertTriangle className="size-3.5" aria-hidden />
{t("uncertain")} · {t("confidence", { percent: Math.round(m.confidence * 100) })}
</span>
)}
</Label>
{opts.multiline ? (
<Textarea {...common} rows={4} />
) : (
<Input {...common} type={opts.type ?? "text"} inputMode={opts.inputMode} required={opts.required} />
)}
<div id={`${id}-note`}>
{m?.uncertain && m.source && <p className="mt-0.5 truncate text-[11.5px] text-muted-foreground">{tip}</p>}
{err && (
<p role="alert" className="mt-0.5 text-[12px] font-semibold text-[var(--risk)]">
{err}
</p>
)}
</div>
</div>
);
}
const radio = (name: string, value: string, checked: boolean, onChange: () => void, label: React.ReactNode, extra?: React.ReactNode) => (
<label className={cn("flex min-h-11 cursor-pointer items-start gap-2.5 rounded-lg border p-3 text-[13.5px]", checked ? "border-[var(--ui-primary)] bg-[var(--ui-primary-soft)]" : "border-border")}>
<input type="radio" name={name} value={value} checked={checked} onChange={onChange} className="mt-1 size-4" />
<span className="min-w-0 flex-1">
<span className="font-semibold">{label}</span>
{extra}
</span>
</label>
);
const sectionClass = "shadow-card rounded-xl border bg-card p-4 sm:p-5";
const h2 = "mb-3 font-heading text-[15px] font-semibold";
const siteOptions = selectedCustomer?.sites ?? [];
const siteScore = new Map(siteCandidates.map((s) => [s.siteId, s]));
return (
<form action={formAction} className="space-y-4" noValidate>
<input type="hidden" name="payload" value={JSON.stringify(form)} />
{hints.length > 0 && (
<div className="rounded-xl border border-[var(--warn)] bg-[color-mix(in_srgb,var(--warn)_8%,transparent)] p-4" role="status">
<p className="flex items-center gap-2 font-heading text-sm font-semibold text-[var(--warn)]">
<AlertTriangle className="size-4" aria-hidden /> {t("hintsTitle")}
</p>
<ul className="mt-1.5 list-disc space-y-0.5 pl-6 text-[13px]">
{hints.map((h, i) => (
<li key={i}>{t(`hints.${h.code}`, { field: h.field ? t(`extractionFields.${h.field}`) : "" })}</li>
))}
</ul>
</div>
)}
{/* Kunde */}
<section className={sectionClass} aria-labelledby="rv-h-customer">
<h2 id="rv-h-customer" className={h2}>{t("sections.customer")}</h2>
<fieldset className="grid gap-2 sm:grid-cols-2">
<legend className="sr-only">{t("sections.customer")}</legend>
{radio("customerMode", "existing", form.customerMode === "existing", () => setForm((f) => ({ ...f, customerMode: "existing", customerId: f.customerId || customerCandidates[0]?.customerId || "" })), t("customer.existing"))}
{radio("customerMode", "new", form.customerMode === "new", () => setForm((f) => ({ ...f, customerMode: "new", siteMode: f.siteMode === "existing" ? "new" : f.siteMode })), t("customer.new"))}
</fieldset>
{form.customerMode === "existing" && (
<div className="mt-4 space-y-3">
<p className="text-[12.5px] font-semibold text-muted-foreground">{t("customer.candidates")}</p>
{customerCandidates.length === 0 && <p className="text-[13px] text-muted-foreground">{t("customer.noCandidates")}</p>}
<div className="space-y-2">
{customerCandidates.map((c) =>
radio(
"customerId",
c.customerId,
form.customerId === c.customerId,
() => setForm((f) => ({ ...f, customerId: c.customerId, siteId: "" })),
customerLabel(c.customer),
<span className="mt-0.5 block text-[12.5px] text-muted-foreground">
{[c.customer.postalCode, c.customer.city].filter(Boolean).join(" ")} · {t("customer.match", { percent: Math.round(c.score * 100) })} · {c.reasons.map((r) => (t.has(`reasons.${r}`) ? t(`reasons.${r}`) : r)).join(", ")}
{" · "}
<a href={`/customers/${c.customerId}`} target="_blank" rel="noreferrer" className="font-semibold text-[var(--ui-primary)] underline-offset-2 hover:underline">
{t("customer.merge")}
</a>
</span>,
),
)}
</div>
<div className="border-t pt-3">
<Label htmlFor="rv-search">{t("customer.search")}</Label>
<div className="mt-1 flex gap-2">
<Input
id="rv-search"
value={query}
placeholder={t("customer.searchPlaceholder")}
className="min-h-11"
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
startSearch(async () => setResults(await searchAction(query)));
}
}}
/>
<Button type="button" variant="outline" className="h-11 px-4" disabled={searching || query.trim().length < 2} onClick={() => startSearch(async () => setResults(await searchAction(query)))}>
{searching ? <Loader2 className="animate-spin" aria-hidden /> : <Search aria-hidden />} {t("customer.searchButton")}
</Button>
</div>
{results && results.length === 0 && <p className="mt-2 text-[13px] text-muted-foreground">{t("customer.searchEmpty")}</p>}
<div className="mt-2 space-y-2">
{(results ?? [])
.filter((r) => !customerCandidates.some((c) => c.customerId === r.id))
.map((r) =>
radio(
"customerId",
r.id,
form.customerId === r.id,
() => setForm((f) => ({ ...f, customerId: r.id, siteId: "" })),
customerLabel(r),
<span className="mt-0.5 block text-[12.5px] text-muted-foreground">{[r.postalCode, r.city].filter(Boolean).join(" ")}</span>,
),
)}
</div>
</div>
{errorText("customerId") && <p role="alert" className="text-[12px] font-semibold text-[var(--risk)]">{errorText("customerId")}</p>}
</div>
)}
{form.customerMode === "new" && (
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-6">
{field("customer", "companyName", "companyName", { className: "sm:col-span-2 lg:col-span-4" })}
{field("customer", "customerNumber", "customerNumber", { className: "lg:col-span-2" })}
{field("customer", "firstName", "firstName", { className: "lg:col-span-3" })}
{field("customer", "lastName", "lastName", { className: "lg:col-span-3" })}
{field("customer", "street", "street", { className: "lg:col-span-4" })}
{field("customer", "houseNumber", "houseNumber", { className: "lg:col-span-2" })}
{field("customer", "postalCode", "postalCode", { inputMode: "numeric", className: "lg:col-span-2" })}
{field("customer", "city", "city", { className: "lg:col-span-3" })}
{field("customer", "country", "country", { className: "lg:col-span-1" })}
{field("customer", "phone", "phone", { type: "tel", inputMode: "tel", className: "lg:col-span-3" })}
{field("customer", "email", "email", { type: "email", inputMode: "email", className: "lg:col-span-3" })}
</div>
)}
</section>
{/* Objekt */}
<section className={sectionClass} aria-labelledby="rv-h-site">
<h2 id="rv-h-site" className={h2}>{t("sections.site")}</h2>
<fieldset className="grid gap-2 sm:grid-cols-3">
<legend className="sr-only">{t("sections.site")}</legend>
{radio("siteMode", "none", form.siteMode === "none", () => setForm((f) => ({ ...f, siteMode: "none" })), t("site.none"))}
{radio("siteMode", "existing", form.siteMode === "existing", () => setForm((f) => ({ ...f, siteMode: "existing" })), t("site.existing"))}
{radio("siteMode", "new", form.siteMode === "new", () => setForm((f) => ({ ...f, siteMode: "new" })), t("site.new"))}
</fieldset>
{form.siteMode === "existing" && (
<div className="mt-3 space-y-2">
{!selectedCustomer && <p className="text-[13px] text-muted-foreground">{t("site.chooseCustomer")}</p>}
{selectedCustomer && siteOptions.length === 0 && <p className="text-[13px] text-muted-foreground">{t("site.noSites")}</p>}
{siteOptions.map((s) => {
const cand = siteScore.get(s.id);
return (
<div key={s.id}>
{radio(
"siteId",
s.id,
form.siteId === s.id,
() => setForm((f) => ({ ...f, siteId: s.id })),
siteLabel(s),
cand ? (
<span className="mt-0.5 flex items-center gap-1 text-[12.5px] font-semibold text-[var(--ok)]">
<Info className="size-3.5" aria-hidden /> {t("site.suggested")} · {t("customer.match", { percent: Math.round(cand.score * 100) })}
</span>
) : null,
)}
</div>
);
})}
{errorText("siteId") && <p role="alert" className="text-[12px] font-semibold text-[var(--risk)]">{errorText("siteId")}</p>}
</div>
)}
{form.siteMode === "new" && (
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-6">
{field("site", "name", "siteName", { className: "sm:col-span-2 lg:col-span-6" })}
{field("site", "street", "street", { className: "lg:col-span-4" })}
{field("site", "houseNumber", "houseNumber", { className: "lg:col-span-2" })}
{field("site", "postalCode", "postalCode", { inputMode: "numeric", className: "lg:col-span-2" })}
{field("site", "city", "city", { className: "lg:col-span-3" })}
{field("site", "country", "country", { className: "lg:col-span-1" })}
</div>
)}
</section>
{/* Ansprechpartner */}
<section className={sectionClass} aria-labelledby="rv-h-contact">
<h2 id="rv-h-contact" className={h2}>{t("sections.contact")}</h2>
<div className="grid gap-3 sm:grid-cols-3">
{field("contact", "name", "name")}
{field("contact", "phone", "phone", { type: "tel", inputMode: "tel" })}
{field("contact", "email", "email", { type: "email", inputMode: "email" })}
</div>
</section>
{/* Auftrag */}
<section className={sectionClass} aria-labelledby="rv-h-order">
<h2 id="rv-h-order" className={h2}>{t("sections.order")}</h2>
<div className="grid gap-3 sm:grid-cols-2">
{field("order", "title", "title", { required: true, className: "sm:col-span-2" })}
{field("order", "externalOrderNumber", "externalOrderNumber")}
{field("order", "offerNumber", "offerNumber")}
{field("order", "plannedStart", "plannedStart", { type: "date" })}
{field("order", "plannedEnd", "plannedEnd", { type: "date" })}
{field("order", "description", "description", { multiline: true, className: "sm:col-span-2" })}
{field("order", "notes", "notes", { multiline: true, className: "sm:col-span-2" })}
</div>
</section>
{/* Positionen */}
<section className={sectionClass} aria-labelledby="rv-h-positions">
<h2 id="rv-h-positions" className={h2}>{t("sections.positions")}</h2>
{(form.positions ?? []).length === 0 && <p className="text-[13px] text-muted-foreground">{t("positions.empty")}</p>}
<div className="space-y-3">
{(form.positions ?? []).map((p, i) => (
<div key={i} className="grid gap-2 rounded-lg border p-3 sm:grid-cols-12">
<div className="sm:col-span-5">
<Label htmlFor={`rv-pos-${i}-name`}>{t("positions.name")}</Label>
<Input id={`rv-pos-${i}-name`} className="mt-1 min-h-11" value={p.name} onChange={(e) => setPosition(i, { name: e.target.value })} />
</div>
<div className="sm:col-span-3">
<Label htmlFor={`rv-pos-${i}-art`}>{t("positions.articleNumber")}</Label>
<Input id={`rv-pos-${i}-art`} className="mt-1 min-h-11" value={p.articleNumber ?? ""} onChange={(e) => setPosition(i, { articleNumber: e.target.value })} />
</div>
<div className="sm:col-span-2">
<Label htmlFor={`rv-pos-${i}-qty`}>{t("positions.quantity")}</Label>
<Input id={`rv-pos-${i}-qty`} className="mt-1 min-h-11" inputMode="decimal" value={String(p.quantity ?? "")} onChange={(e) => setPosition(i, { quantity: e.target.value })} />
</div>
<div className="sm:col-span-2">
<Label htmlFor={`rv-pos-${i}-unit`}>{t("positions.unit")}</Label>
<Input id={`rv-pos-${i}-unit`} className="mt-1 min-h-11" value={p.unit ?? ""} onChange={(e) => setPosition(i, { unit: e.target.value })} />
</div>
<div className="flex flex-wrap items-center justify-between gap-2 sm:col-span-12">
<label className="flex min-h-11 items-center gap-2 text-[13px]">
<input type="checkbox" className="size-4" checked={Boolean(p.asMaterial)} onChange={(e) => setPosition(i, { asMaterial: e.target.checked })} />
{t("positions.asMaterial")}
</label>
<Button type="button" variant="ghost" className="h-11 px-3" aria-label={t("positions.remove")} onClick={() => setForm((f) => ({ ...f, positions: (f.positions ?? []).filter((_, j) => j !== i) }))}>
<Trash2 aria-hidden /> <span className="sm:sr-only">{t("positions.remove")}</span>
</Button>
</div>
{errorText(`positions.${i}.quantity`) && <p role="alert" className="text-[12px] font-semibold text-[var(--risk)] sm:col-span-12">{errorText(`positions.${i}.quantity`)}</p>}
</div>
))}
</div>
<Button
type="button"
variant="outline"
className="mt-3 h-11 px-4"
onClick={() => setForm((f) => ({ ...f, positions: [...(f.positions ?? []), { name: "", articleNumber: "", quantity: "", unit: "", asMaterial: false }] }))}
>
<Plus aria-hidden /> {t("positions.add")}
</Button>
</section>
<div className="flex flex-wrap items-center gap-3">
<Button type="submit" disabled={pending} className="h-11 bg-[var(--ui-accent)] px-5 text-[var(--ui-accent-foreground)]">
{pending && <Loader2 className="animate-spin" aria-hidden />}
{pending ? ta("confirming") : ta("confirm")}
</Button>
{state.status === "error" && (
<p role="alert" className="text-[13px] font-semibold text-[var(--risk)]">
{te.has(state.code) ? te(state.code) : te("error")}
</p>
)}
</div>
</form>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { AlertTriangle, Ban, CheckCircle2, Loader2, Upload, XCircle } from "lucide-react";
import { Pill } from "@/components/mockup-ui";
import { IMPORT_STATUS_TONE, type ImportStatusKey } from "@/lib/imports/status";
const ICONS: Record<ImportStatusKey, typeof Upload> = {
uploaded: Upload,
processing: Loader2,
review_required: AlertTriangle,
confirmed: CheckCircle2,
failed: XCircle,
discarded: Ban,
};
/** Import status as pill: colour + icon + text (never colour alone). */
export function ImportStatusPill({ status, label }: { status: ImportStatusKey; label: string }) {
const Icon = ICONS[status];
return (
<Pill tone={IMPORT_STATUS_TONE[status]}>
<Icon className={status === "processing" ? "size-3.5 animate-spin" : "size-3.5"} aria-hidden />
{label}
</Pill>
);
}
+126
View File
@@ -0,0 +1,126 @@
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { CheckCircle2, FileUp, XCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { IMPORT_IMAGE_MAX_BYTES, IMPORT_MAX_BYTES, IMPORT_MIME_TYPES } from "@/lib/imports/status";
type UploadState =
| { kind: "idle" }
| { kind: "uploading"; percent: number; name: string }
| { kind: "done"; name: string; id: string }
| { kind: "error"; code: string };
const KNOWN_ERRORS = new Set(["file_type_not_allowed", "file_too_large", "file_type_mismatch", "file_empty", "file_missing", "forbidden", "unauthorized", "network"]);
/**
* Drag & drop / file picker upload with progress (XHR upload events). Posts to
* POST /api/v1/work-orders/import, then refreshes the server-rendered list.
*/
export function ImportUploader() {
const t = useTranslations("imports.upload");
const router = useRouter();
const inputRef = useRef<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false);
const [state, setState] = useState<UploadState>({ kind: "idle" });
function upload(file: File) {
const type = file.type === "image/jpg" ? "image/jpeg" : file.type;
if (type && !(IMPORT_MIME_TYPES as readonly string[]).includes(type)) return setState({ kind: "error", code: "file_type_not_allowed" });
const limit = type === "application/pdf" ? IMPORT_MAX_BYTES : IMPORT_IMAGE_MAX_BYTES;
if (file.size > limit) return setState({ kind: "error", code: "file_too_large" });
if (file.size === 0) return setState({ kind: "error", code: "file_empty" });
const body = new FormData();
body.append("file", file);
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/v1/work-orders/import");
xhr.responseType = "json";
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) setState({ kind: "uploading", percent: Math.round((e.loaded / e.total) * 100), name: file.name });
};
xhr.onload = () => {
const res = (xhr.response ?? {}) as { id?: string; error?: string; message?: string };
if (xhr.status === 201 && res.id) {
setState({ kind: "done", name: file.name, id: res.id });
router.refresh();
} else {
const code = res.message && KNOWN_ERRORS.has(res.message) ? res.message : res.error && KNOWN_ERRORS.has(res.error) ? res.error : "error";
setState({ kind: "error", code });
}
if (inputRef.current) inputRef.current.value = "";
};
xhr.onerror = () => setState({ kind: "error", code: "network" });
setState({ kind: "uploading", percent: 0, name: file.name });
xhr.send(body);
}
const busy = state.kind === "uploading";
return (
<div
onDragOver={(e) => {
e.preventDefault();
if (!busy) setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
const file = e.dataTransfer.files?.[0];
if (file && !busy) upload(file);
}}
className={cn(
"shadow-card rounded-xl border-2 border-dashed bg-card p-6 text-center transition-colors",
dragging ? "border-[var(--ui-accent)] bg-[var(--ui-primary-soft)]" : "border-border",
)}
>
<FileUp className="mx-auto size-8 text-[var(--ui-accent)]" aria-hidden />
<p className="mt-2 font-heading text-sm font-semibold">{t("drop")}</p>
<p className="mt-1 text-[13px] text-muted-foreground">{t("or")}</p>
<input
ref={inputRef}
id="import-file"
type="file"
accept="application/pdf,image/jpeg,image/png,.pdf,.jpg,.jpeg,.png"
className="sr-only"
disabled={busy}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) upload(file);
}}
/>
<Button type="button" className="mt-2 h-11 px-5" disabled={busy} onClick={() => inputRef.current?.click()}>
{t("choose")}
</Button>
<p className="mt-3 text-[12.5px] text-muted-foreground">{t("limits")}</p>
<div aria-live="polite" className="mt-3 min-h-6">
{state.kind === "uploading" && (
<div className="mx-auto max-w-md text-left">
<div className="flex justify-between text-[12.5px]">
<span className="truncate">{state.name}</span>
<span>{t("uploading", { percent: state.percent })}</span>
</div>
<div className="mt-1 h-2 overflow-hidden rounded-full bg-muted" role="progressbar" aria-valuenow={state.percent} aria-valuemin={0} aria-valuemax={100}>
<div className="h-full bg-[var(--ui-accent)] transition-[width]" style={{ width: `${state.percent}%` }} />
</div>
</div>
)}
{state.kind === "done" && (
<p className="inline-flex items-center gap-1.5 text-[13px] font-semibold text-[var(--ok)]">
<CheckCircle2 className="size-4" aria-hidden /> {state.name}: {t("done")}
</p>
)}
{state.kind === "error" && (
<p role="alert" className="inline-flex items-center gap-1.5 text-[13px] font-semibold text-[var(--risk)]">
<XCircle className="size-4" aria-hidden /> {t(`errors.${state.code}`)}
</p>
)}
</div>
</div>
);
}