Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,4 @@ public/tts-cache/*.wav
# Generated TTS cache (key-generated audio — bundle ships it, git doesn't)
apps/tischler/public/tts-cache/
.vercel
apps/tischler/public/werkstoff-bundle/modell.glb
103 changes: 81 additions & 22 deletions apps/tischler/app/[locale]/werkstoff/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,14 @@ import {
type Komponentenzeile,
type Materialkarte,
} from "../../../lib/werkstoff/karte";

const WERKSTUECKE = [
{ id: "seite-links-890x555", kurz: "Seite links", laenge: 890, breite: 555 },
{ id: "boden-562x555", kurz: "Boden", laenge: 562, breite: 555 },
];
import {
karteFuer,
ladeAuftrag,
luecke,
schluesselKurz,
type Auftrag,
type AuftragLuecke,
} from "../../../lib/werkstoff/auftrag";

function Ampel({ freigabe }: { freigabe: Materialkarte["freigabe"] }) {
return (
Expand Down Expand Up @@ -120,7 +123,13 @@ function Zeile({ karte, z }: { karte: Materialkarte; z: Komponentenzeile }) {
}

export default function WerkstoffPage() {
// Der Auftrag bestimmt, welche Bretter es gibt — nicht eine Liste im Code. Fehlt er, gibt es
// kein Brett und den Satz dazu; ein Katalog-Beispiel an seiner Stelle wäre das falsche Material.
const [auftrag, setAuftrag] = useState<Auftrag | null>(null);
const [auftragFehler, setAuftragFehler] = useState<string | null>(null);
const [gewaehlt, setGewaehlt] = useState<string | null>(null);
// Ein angetipptes Brett ohne Karte: kein Fehler, sondern der Grund — Material noch offen.
const [lueckeGewaehlt, setLueckeGewaehlt] = useState<AuftragLuecke | null>(null);
const [karte, setKarte] = useState<Materialkarte | null>(null);
const [stand, setStand] = useState<Bundlestand | null>(null);
const [grenze, setGrenze] = useState<Datengrenze | null>(null);
Expand All @@ -131,6 +140,9 @@ export default function WerkstoffPage() {
// Fehlt die Grenzaussage, bleibt es still — kein Fehler, aber auch kein Satz, den
// niemand belegen kann.
ladeDatengrenze().then(setGrenze);
ladeAuftrag()
.then(setAuftrag)
.catch((e) => setAuftragFehler(String(e.message ?? e)));
}, []);

useEffect(() => {
Expand All @@ -153,8 +165,32 @@ export default function WerkstoffPage() {
}, [gewaehlt]);

const gruppen = useMemo(() => (karte ? gruppiere(karte.komponenten) : []), [karte]);
// Ein Tap führt über den Schlüssel: zur Karte, wenn es eine gibt — sonst zur benannten Lücke.
const tippeTeil = (schluessel: string) => {
if (!auftrag) return;
const id = karteFuer(auftrag, schluessel);
if (id) {
setLueckeGewaehlt(null);
setGewaehlt(gewaehlt === id ? null : id);
return;
}
const l = auftrag.teile_ohne_karte.find((t) => t.schluessel === schluessel);
if (l) {
setGewaehlt(null);
setLueckeGewaehlt(lueckeGewaehlt?.schluessel === l.schluessel ? null : l);
}
};
const bretter = useMemo(
() =>
auftrag
? [
...auftrag.teile.map((t) => ({ ...t, luecke: undefined as AuftragLuecke | undefined })),
...auftrag.teile_ohne_karte.map((t) => ({ ...t, luecke: t })),
].sort((a, b) => a.schluessel.localeCompare(b.schluessel))
: [],
[auftrag],
);
const schritte = useMemo(() => (karte ? arbeitsfolge(karte) : []), [karte]);
const massstab = 0.28;

return (
<main className="werkstoff">
Expand All @@ -165,6 +201,13 @@ export default function WerkstoffPage() {
? `Katalogstand ${stand.stand} · ${stand.dateien} Datendateien · ${stand.sha256.slice(0, 12)}…`
: "Katalogstand wird gelesen …"}
</p>
{auftrag && (
<p className="herkunft">
Möbel {auftrag.moebel_id} · Revision {auftrag.revision} · Plan{" "}
{auftrag.buildplan_sha256.slice(0, 12)}…
</p>
)}
{auftragFehler && <p className="fehler">⚠ Kein Auftrag im Bundle: {auftragFehler}</p>}
<p className="hinweis-klein">
Offline-Bundle aus dem Auftrag — Datenblätter sind verlinkt, nicht kopiert.
</p>
Expand All @@ -179,23 +222,38 @@ export default function WerkstoffPage() {
</header>

<section className="teile" aria-label="Werkstücke antippen">
{WERKSTUECKE.map((w) => (
<button
key={w.id}
type="button"
className={`teil ${gewaehlt === w.id ? "aktiv" : ""}`}
style={{ width: w.laenge * massstab, height: w.breite * massstab }}
onClick={() => setGewaehlt(gewaehlt === w.id ? null : w.id)}
aria-pressed={gewaehlt === w.id}
>
<span className="teilname">{w.kurz}</span>
<span className="teilmass">
{w.laenge} × {w.breite}
</span>
</button>
))}
{bretter.map((w) => {
const aktiv = w.luecke
? lueckeGewaehlt?.schluessel === w.schluessel
: gewaehlt === w.werkstueck_id;
return (
<button
key={w.schluessel}
type="button"
className={`teil ${w.luecke ? "luecke" : ""} ${aktiv ? "aktiv" : ""}`}
onClick={() => tippeTeil(w.schluessel)}
aria-pressed={aktiv}
>
<span className="teilname">{schluesselKurz(w.schluessel)}</span>
<span className="teilmass">{w.luecke ? "Material noch offen" : w.werkstueck_id}</span>
</button>
);
})}
</section>

{auftrag && lueckeGewaehlt && (
<section className="luecke-hinweis" aria-live="polite">
<h2>{schluesselKurz(lueckeGewaehlt.schluessel)} — Material noch offen</h2>
<p>
Aufbau {lueckeGewaehlt.aufbau}: {luecke(auftrag, lueckeGewaehlt.schluessel)?.grund}
</p>
<p className="hinweis-klein">
Der Plan verlangt dieses Brett, der Katalog kennt seinen Aufbau nicht. Welches Material
verbaut wird, entscheidet der Betrieb — nicht das Werkzeug.
</p>
</section>
)}

{fehler && <p className="fehler">⚠ {fehler}</p>}

{karte && (
Expand All @@ -204,7 +262,8 @@ export default function WerkstoffPage() {
<div>
<h2>{karte.werkstueck.bezeichnung}</h2>
<p className="aufbau">
Aufbau {karte.aufbau.bezeichnung} ({karte.aufbau.id}@{karte.aufbau.revision})
Teil {karte.werkstueck.id} · Aufbau {karte.aufbau.bezeichnung} ({karte.aufbau.id}@
{karte.aufbau.revision})
</p>
</div>
<Ampel freigabe={karte.freigabe} />
Expand Down
54 changes: 54 additions & 0 deletions apps/tischler/app/[locale]/werkstoff/panel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,57 @@ describe("Werkstoff-Panel: die Grenzaussage am Werkstück", () => {
expect(document.body.textContent ?? "").not.toMatch(/Feldname/i);
});
});

describe("Werkstoff-Panel: der Auftrag bestimmt, welche Bretter es gibt", () => {
it("zeigt Möbel und Revision aus dem Auftrag — und die vier Teile des Plans als Schaltflächen", async () => {
vi.stubGlobal("fetch", stub());
render(<Werkstoffseite />);
await waitFor(() => {
expect(screen.getByRole("button", { name: /Se:links/ })).toBeTruthy();
});
const text = document.body.textContent ?? "";
expect(text).toMatch(/moebel_beispiel0001/);
expect(text).toMatch(/Revision 3/);
expect(screen.getAllByRole("button", { name: /^(Bo|Se):/ })).toHaveLength(4);
// Die Rückwand steht als fünftes Brett da — als benannte Lücke, nicht weggeschnitten.
expect(screen.getByRole("button", { name: /^Rw/ })).toBeTruthy();
});

it("tippt der Handwerker auf die Rückwand, kommt keine Karte, sondern der Grund — Material noch offen", async () => {
vi.stubGlobal("fetch", stub());
render(<Werkstoffseite />);
const rw = await waitFor(() => screen.getByRole("button", { name: /^Rw/ }));
rw.click();
await waitFor(() => {
expect(document.body.textContent).toMatch(/Material noch offen/);
});
expect(document.body.textContent).toMatch(/rw-8@1/);
expect(document.querySelector(".karte")).toBeNull();
});

it("tippt der Handwerker auf »Se:links«, kommt die Karte des Plan-Teils, nicht eines Katalog-Bretts", async () => {
vi.stubGlobal("fetch", stub());
render(<Werkstoffseite />);
const knopf = await waitFor(() => screen.getByRole("button", { name: /Se:links/ }));
knopf.click();
await waitFor(() => {
expect(document.body.textContent).toMatch(/Teil teil_beispielse0links/);
});
// Die Bezeichnung kommt aus der Karte des Plan-Teils — der Test liest sie dort, statt sie
// zu raten; ein Katalog-Brett hieße anders.
const karte = JSON.parse(
readFileSync(join(BUNDLE, "karten/teil_beispielse0links.json"), "utf8"),
) as { werkstueck: { bezeichnung: string } };
expect(karte.werkstueck.bezeichnung.length).toBeGreaterThan(0);
expect(document.body.textContent).toContain(karte.werkstueck.bezeichnung);
});

it("ohne Auftrag im Bundle: kein Brett, sondern der Satz, dass der Auftrag fehlt", async () => {
vi.stubGlobal("fetch", stub((url, roh) => (url.endsWith("auftrag.json") ? null : roh)));
render(<Werkstoffseite />);
await waitFor(() => {
expect(document.body.textContent).toMatch(/Kein Auftrag/);
});
expect(screen.queryAllByRole("button", { name: /^(Bo|Se):/ })).toHaveLength(0);
});
});
6 changes: 6 additions & 0 deletions apps/tischler/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -1002,3 +1002,9 @@ body.cc-kiosk .cc-workbench {
border-top: 1px dashed currentColor;
border-color: color-mix(in srgb, currentColor 25%, transparent);
}

/* Werkstoff: ein Brett ohne Karte — die benannte Lücke */
.werkstoff .teil.luecke { border-style: dashed; color: #64748b; background: #f8fafc; }
.werkstoff .luecke-hinweis { border: 1px dashed #94a3b8; border-radius: 8px; padding: 1rem 1.25rem; margin: 1rem 0; background: #f8fafc; }
.werkstoff .luecke-hinweis h2 { margin: 0 0 0.5rem; font-size: 1.1rem; }
.werkstoff .luecke-hinweis p { margin: 0.25rem 0; }
Loading
Loading