L3 Auftragsimport: Prüfmaske, Upload, API und Lane-Bericht
- /imports: Upload per Drag & Drop/Dateiauswahl mit Fortschritt, Liste mit Status, Neu verarbeiten
- /imports/[id]: Originaldokument + Prüfmaske (Kunde, Objekt, Ansprechpartner, Auftrag, Positionen),
unsichere Felder markiert, Kunden-/Objektentscheidung, Bestätigen/Verwerfen
- Datei-Route für die Vorschau, Server Actions (moduleGuard("imports"))
- API: POST /api/v1/work-orders/import, GET /api/v1/imports/[id], POST /api/v1/imports/[id]/confirm
- Texte messages/{de,en}/imports.json, Bericht docs/craftvia/lanes/import.md
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user