Files
Materialschein/client/src/pages/ProjektDetail.tsx
T
Fidelis HuberandClaude Sonnet 5 fc1e4d0e25
Build and Deploy / version (push) Successful in 1s
Build and Deploy / build (push) Successful in 9s
Build and Deploy / deploy (push) Successful in 2s
Use the real BergVOLT logo instead of a CSS-recreated text wordmark
Echtes Firmenlogo (2 WhatsApp-Dateien vom Nutzer) per Farbschlüssel-
Freistellung (PowerShell/System.Drawing) in echte transparente PNGs
umgewandelt und zugeschnitten: bergvolt-logo-dark.png (weiß/cyan, für
dunklen Hintergrund) und bergvolt-logo-light.png (schwarz/cyan, für hellen
Hintergrund - aktuell noch ungenutzt, da Header/Login/Druckbanner bewusst
immer dunkel bleiben, aber für später mit im Repo).

Ersetzt die nachgebaute Text-Version in Header, Login-Screen und
Druck-Banner durch <img> mit dem echten Logo.

Temporärer test-build-only.yml Workflow zur Verifikation ohne Registry-Push.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 10:16:58 +02:00

427 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { api } from '../lib/api';
import type { MaterialItem, Projekt } from '../lib/types';
import { kabelInfoForBez } from '../data/kabelData';
import KabelOptions from '../components/KabelOptions';
const fmt1 = (v: number | string) => (Math.round((parseFloat(String(v)) || 0) * 10) / 10).toFixed(1);
const num1 = (v: number | string) => Math.round((parseFloat(String(v)) || 0) * 10) / 10;
const ddDate = (s: string) => { if (!s) return '—'; const [y, m, d] = s.split('-'); return `${d}.${m}.${y}`; };
export default function ProjektDetail() {
const { id } = useParams<{ id: string }>();
const [projekt, setProjekt] = useState<Projekt | null>(null);
const [matList, setMatList] = useState<MaterialItem[]>([]);
const [loading, setLoading] = useState(true);
// Baustelle-Formular
const [titel, setTitel] = useState('');
const [auftraggeber, setAuftraggeber] = useState('');
const [datum, setDatum] = useState('');
const [monteur, setMonteur] = useState('');
const [notiz, setNotiz] = useState('');
// Kabel inline
const [kabTyp, setKabTyp] = useState('');
const [kabKg, setKabKg] = useState('');
const [kabTara, setKabTara] = useState('');
const [kabLaenge, setKabLaenge] = useState('');
const [kabBusy, setKabBusy] = useState(false);
// Manuell
const [mBez, setMBez] = useState('');
const [mArt, setMArt] = useState('');
const [mQty, setMQty] = useState('1');
const [mEinh, setMEinh] = useState('Stk');
const [mBusy, setMBusy] = useState(false);
// Retour
const [retourMode, setRetourMode] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [retourQty, setRetourQty] = useState('');
const [retourKg, setRetourKg] = useState('');
const [retourBusy, setRetourBusy] = useState(false);
useEffect(() => { if (id) load(id); }, [id]);
async function load(projektId: string) {
setLoading(true);
const [p, list] = await Promise.all([
api.projekte.list().then(all => all.find(x => x.id === projektId) ?? null),
api.material.list(projektId)
]);
setProjekt(p);
setMatList(list);
if (p) {
setTitel(p.titel); setAuftraggeber(p.auftraggeber ?? ''); setDatum(p.datum ? p.datum.slice(0, 10) : '');
setMonteur(p.monteur ?? ''); setNotiz(p.notiz ?? '');
}
setLoading(false);
}
async function reloadMaterial() {
if (id) setMatList(await api.material.list(id));
}
async function saveFeld(feld: 'titel' | 'auftraggeber' | 'datum' | 'monteur' | 'notiz', value: string) {
if (!id) return;
await api.projekte.update(id, { [feld]: value } as Partial<Projekt>);
}
async function toggleStatus() {
if (!id || !projekt) return;
const neu = projekt.status === 'Abgeschlossen' ? 'Aktiv' : 'Abgeschlossen';
const updated = await api.projekte.update(id, { status: neu });
setProjekt(updated);
}
// ── Kabel-Rechner ──
const kabpm = kabTyp ? parseFloat(kabTyp.split('|')[1]) : 0;
const kabNetto = (parseFloat(kabKg) || 0) - (parseFloat(kabTara) || 0);
const kabResultVisible = !!kabTyp && !!kabKg && parseFloat(kabKg) > 0 && kabNetto > 0;
const kabResultLaenge = kabResultVisible ? kabNetto / kabpm : 0;
useEffect(() => {
if (kabResultVisible) setKabLaenge(kabResultLaenge.toFixed(1));
}, [kabTyp, kabKg, kabTara]); // eslint-disable-line react-hooks/exhaustive-deps
async function addKabelToList() {
if (!id) return;
if (!kabTyp) { alert('Bitte Kabeltyp wählen.'); return; }
const laenge = parseFloat(kabLaenge);
if (!laenge || laenge <= 0) { alert('Bitte Gewicht eingeben oder Länge direkt eintragen.'); return; }
const typName = kabTyp.split('|')[0];
const kg = parseFloat(kabKg) || 0;
setKabBusy(true);
try {
await api.material.create({
projektId: id, bezeichnung: 'Kabel ' + typName, artNr: '', menge: num1(laenge), einheit: 'm',
notiz: kg > 0 ? `${kg.toFixed(2)} kg gewogen` : ''
});
setKabKg(''); setKabLaenge('');
await reloadMaterial();
} catch (e) { alert('Fehler beim Speichern: ' + (e instanceof Error ? e.message : e)); }
setKabBusy(false);
}
// ── Manuell ──
async function addManual() {
if (!id) return;
if (!mBez.trim()) { alert('Bitte Bezeichnung eingeben.'); return; }
setMBusy(true);
try {
await api.material.create({ projektId: id, bezeichnung: mBez.trim(), artNr: mArt.trim(), menge: num1(mQty) || 1, einheit: mEinh });
setMBez(''); setMArt(''); setMQty('1');
await reloadMaterial();
} catch (e) { alert('Fehler beim Speichern: ' + (e instanceof Error ? e.message : e)); }
setMBusy(false);
}
// ── Zeilen-Aktionen ──
async function updateMenge(row: MaterialItem, val: string) {
const neu = Math.max(0, num1(val)) || 0;
setMatList(prev => prev.map(r => r.id === row.id ? { ...r, menge: String(neu) } : r));
try { await api.material.update(row.id, { menge: neu }); }
catch (e) { alert('Fehler beim Speichern: ' + (e instanceof Error ? e.message : e)); await reloadMaterial(); }
}
async function updateNotiz(row: MaterialItem, val: string) {
setMatList(prev => prev.map(r => r.id === row.id ? { ...r, notiz: val } : r));
try { await api.material.update(row.id, { notiz: val }); }
catch (e) { alert('Fehler beim Speichern: ' + (e instanceof Error ? e.message : e)); await reloadMaterial(); }
}
async function deleteRow(row: MaterialItem) {
try { await api.material.remove(row.id); await reloadMaterial(); }
catch (e) { alert('Fehler beim Löschen: ' + (e instanceof Error ? e.message : e)); }
}
async function clearList() {
if (!matList.length || !confirm('Alle Positionen dieses Projekts löschen?')) return;
try { await Promise.all(matList.map(r => api.material.remove(r.id))); await reloadMaterial(); }
catch (e) { alert('Fehler beim Löschen: ' + (e instanceof Error ? e.message : e)); await reloadMaterial(); }
}
// ── Retour buchen ──
function toggleRetourMode() {
setRetourMode(v => !v);
setSelected(new Set());
}
function toggleRowSelect(rowId: string, checked: boolean) {
setSelected(prev => {
const next = new Set(prev);
if (checked) next.add(rowId); else next.delete(rowId);
return next;
});
}
const retourInfo = useMemo(() => {
const sel = matList.filter(r => selected.has(r.id));
if (!sel.length) return { mode: null as null | 'kabel' | 'material', text: 'Positionen in der Liste ankreuzen, die zusammengeführt werden sollen.', kgpm: 0, einh: '', summ: 0, error: false };
const kabelInfos = sel.map(r => kabelInfoForBez(r.bezeichnung));
const allKabel = kabelInfos.every(Boolean);
const anyKabel = kabelInfos.some(Boolean);
if (allKabel) {
const names = [...new Set(kabelInfos.map(k => k!.name))];
if (names.length > 1) return { mode: null, text: `${sel.length} Positionen ausgewählt – unterschiedliche Kabeltypen. Bitte nur denselben Typ auswählen.`, kgpm: 0, einh: '', summ: 0, error: true };
const sumM = sel.reduce((s, r) => s + parseFloat(r.menge), 0);
return { mode: 'kabel' as const, text: `${sel.length} Position(en) · ${kabelInfos[0]!.name} · Summe ${fmt1(sumM)} m`, kgpm: kabelInfos[0]!.kgpm, einh: 'm', summ: sumM, error: false };
}
if (anyKabel) return { mode: null, text: `${sel.length} Positionen ausgewählt – Kabel und normales Material lassen sich nicht gemeinsam zusammenführen.`, kgpm: 0, einh: '', summ: 0, error: true };
const einhSet = [...new Set(sel.map(r => r.einheit))];
if (einhSet.length > 1) return { mode: null, text: `${sel.length} Positionen ausgewählt – unterschiedliche Einheiten (${einhSet.join(', ')}). Bitte nur gleiche Einheit auswählen.`, kgpm: 0, einh: '', summ: 0, error: true };
const sumQ = sel.reduce((s, r) => s + parseFloat(r.menge), 0);
return { mode: 'material' as const, text: `${sel.length} Position(en) ausgewählt · Summe ${fmt1(sumQ)} ${einhSet[0]}`, kgpm: 0, einh: einhSet[0], summ: sumQ, error: false };
}, [matList, selected]);
async function applyRetour() {
const sel = matList.filter(r => selected.has(r.id));
if (!sel.length || !retourInfo.mode || !id) return;
setRetourBusy(true);
const art = sel[0].artNr ?? '';
const bez = sel[0].bezeichnung;
let neueMenge: number, einh: string, notiz: string;
if (retourInfo.mode === 'kabel') {
const rKg = parseFloat(retourKg) || 0;
const rM = rKg / retourInfo.kgpm;
neueMenge = Math.max(0, num1(retourInfo.summ - rM));
einh = 'm';
notiz = rKg > 0 ? `Retour: ${rKg.toFixed(2)} kg (≈${fmt1(rM)} m) aus ${sel.length} Positionen` : `${sel.length} Positionen zusammengeführt`;
} else {
const r = num1(retourQty) || 0;
neueMenge = Math.max(0, num1(retourInfo.summ - r));
einh = retourInfo.einh;
notiz = r > 0 ? `Retour: ${fmt1(r)} ${einh} aus ${sel.length} Positionen` : `${sel.length} Positionen zusammengeführt`;
}
try {
await api.material.create({ projektId: id, bezeichnung: bez, artNr: art, menge: neueMenge, einheit: einh, notiz });
await Promise.all(sel.map(r => api.material.remove(r.id)));
setRetourMode(false); setSelected(new Set()); setRetourQty(''); setRetourKg('');
await reloadMaterial();
} catch (e) { alert('Fehler beim Zusammenführen: ' + (e instanceof Error ? e.message : e)); }
setRetourBusy(false);
}
if (loading) return <div className="empty-msg"><div className="spin" /></div>;
if (!projekt) return <div className="empty-msg">Projekt nicht gefunden.</div>;
return (
<div>
<div className="print-doc">
<div className="pbanner">
<img src="/logo/bergvolt-logo-dark.png" alt="BergVOLT Elektrotechnik" className="plogo-img" />
</div>
<div className="ptop">
<div className="paddr">
<div className="lbl">Baustelle / Projekt</div>
<div className="name">{titel || '—'}</div>
{auftraggeber && <div>Auftraggeber: {auftraggeber}</div>}
{notiz && <div className="pnote">{notiz}</div>}
</div>
<table className="pinfo">
<tbody>
<tr><td colSpan={2}>Materialschein</td></tr>
<tr><td>Datum</td><td>{ddDate(datum)}</td></tr>
<tr><td>Monteur</td><td>{monteur || '—'}</td></tr>
<tr><td>Status</td><td>{projekt.status}</td></tr>
</tbody>
</table>
</div>
<div className="psubject">Materialverbrauch – {titel || '(ohne Namen)'}</div>
</div>
<div className="detail-back">
<Link className="btn-back" to="/">← Projekte</Link>
<div className="detail-title">{titel || '(ohne Namen)'}</div>
<button className="btn btn-outline" style={{ height: 36, fontSize: 11, padding: '0 14px' }} onClick={toggleStatus}>
{projekt.status === 'Abgeschlossen' ? '↺ Wieder aktivieren' : '✓ Abschließen'}
</button>
</div>
<div className="card baustelle-card">
<div className="card-title">Baustelle / Projekt</div>
<div className="grid-2">
<label className="field">Projekt / Baustelle
<input value={titel} onChange={e => setTitel(e.target.value)} onBlur={e => saveFeld('titel', e.target.value)} />
</label>
<label className="field">Auftraggeber
<input value={auftraggeber} onChange={e => setAuftraggeber(e.target.value)} onBlur={e => saveFeld('auftraggeber', e.target.value)} />
</label>
<label className="field">Datum
<input type="date" value={datum} onChange={e => setDatum(e.target.value)} onBlur={e => saveFeld('datum', e.target.value)} />
</label>
<label className="field">Monteur
<input value={monteur} onChange={e => setMonteur(e.target.value)} onBlur={e => saveFeld('monteur', e.target.value)} />
</label>
<label className="field" style={{ gridColumn: 'span 2' }}>Notiz / Bereich
<textarea value={notiz} onChange={e => setNotiz(e.target.value)} onBlur={e => saveFeld('notiz', e.target.value)} />
</label>
</div>
</div>
<div className="card kab-card">
<div className="card-title">Kabel hinzufügen – Gewicht direkt eingeben</div>
<div className="kab-inline">
<label className="field">Kabeltyp (Meinhart)
<select value={kabTyp} onChange={e => setKabTyp(e.target.value)}>
<option value="" disabled>— wählen —</option>
<KabelOptions />
</select>
</label>
<label className="field">Gewicht gesamt (kg)
<input type="number" min={0} step={0.01} placeholder="z. B. 12.50" value={kabKg} onChange={e => setKabKg(e.target.value)} />
</label>
<label className="field">Tara kg (optional)
<input type="number" min={0} step={0.01} placeholder="0.00" value={kabTara} onChange={e => setKabTara(e.target.value)} />
</label>
<label className="field">Länge (m)
<input type="number" min={0} step={0.1} placeholder="oder direkt" value={kabLaenge} onChange={e => { setKabLaenge(e.target.value); setKabKg(''); }} />
</label>
<label className="field"><span style={{ opacity: 0 }}>.</span>
<button className="btn btn-cyan" style={{ width: '100%' }} disabled={kabBusy} onClick={addKabelToList}>+ Liste</button>
</label>
</div>
{kabResultVisible && (
<div className="kab-result-inline">
<div>
<div style={{ fontSize: 10, letterSpacing: '.07em', color: 'var(--muted)', textTransform: 'uppercase', marginBottom: 4 }}>Berechnete Länge</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
<div className="kab-res-big">{kabResultLaenge.toFixed(1)}</div>
<div style={{ fontSize: 14, opacity: .7 }}>m</div>
</div>
</div>
<div className="kab-res-info">
{kabTyp.split('|')[0]} · {kabpm.toFixed(3)} kg/m<br />
{(parseFloat(kabKg) || 0).toFixed(2)} kg{(parseFloat(kabTara) || 0) > 0 ? ` − ${(parseFloat(kabTara) || 0).toFixed(2)} kg Tara = ${kabNetto.toFixed(2)} kg netto` : ''}
</div>
</div>
)}
</div>
<div className="card manual-card">
<div className="card-title">Material eintragen</div>
<div className="grid-4">
<label className="field">Bezeichnung
<input value={mBez} onChange={e => setMBez(e.target.value)} placeholder="z. B. LS B16 1+N, Kabelkanal 60×40 …" />
</label>
<label className="field">Art.-Nr.
<input value={mArt} onChange={e => setMArt(e.target.value)} placeholder="optional" />
</label>
<label className="field">Menge
<input type="number" inputMode="decimal" min={0.1} step={0.1} value={mQty} onChange={e => setMQty(e.target.value)} />
</label>
<label className="field">Einheit
<select value={mEinh} onChange={e => setMEinh(e.target.value)}>
<option>Stk</option><option>m</option><option>m²</option><option>Pkg</option><option>Rol</option><option>Set</option>
</select>
</label>
<label className="field"><span style={{ opacity: 0 }}>.</span>
<button className="btn btn-cyan" style={{ width: '100%' }} disabled={mBusy} onClick={addManual}>+ Hinzufügen</button>
</label>
</div>
</div>
<div className="card">
<div className="card-title" style={{ justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
<span>Materialliste</span>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button className="btn btn-ghost" style={{ height: 32, fontSize: 11, padding: '0 14px' }} onClick={reloadMaterial}>🔄 Aktualisieren</button>
<button className={'btn btn-outline btn-retour-toggle' + (retourMode ? ' active' : '')} style={{ height: 32, fontSize: 11, padding: '0 14px' }} onClick={toggleRetourMode}>↩ Retour buchen</button>
<button className="btn btn-danger" style={{ height: 32, fontSize: 11, padding: '0 14px' }} onClick={clearList}>🗑 Leeren</button>
</div>
</div>
<div className="card-hint">Wurde dieselbe Position mehrmals geholt? Mit „Retour buchen" die Positionen ankreuzen, Rückgabemenge (bzw. -gewicht bei Kabel) eingeben – sie werden zu einer Position mit dem tatsächlichen Verbrauch zusammengeführt. Die Liste ist für alle Monteure im Team sichtbar.</div>
{matList.length === 0 && (
<div className="empty-msg">Noch keine Artikel.<br /><span style={{ fontSize: 12, opacity: .6 }}>Kabel berechnen oder Material manuell eintragen.</span></div>
)}
{matList.length > 0 && (
<div className={'table-wrap' + (retourMode ? ' retour-mode' : '')}>
<table className="mat-table">
<thead>
<tr>
<th className="chk-col"></th>
<th style={{ width: 32 }}>Pos</th>
<th style={{ width: 70 }}>Menge</th>
<th style={{ width: 48 }}>Einh.</th>
<th style={{ width: 90 }}>Art.-Nr.</th>
<th>Bezeichnung</th>
<th>Notiz</th>
<th style={{ width: 30 }}></th>
</tr>
</thead>
<tbody>
{matList.map((r, i) => (
<tr key={r.id} className={selected.has(r.id) ? 'row-selected' : undefined}>
<td className="chk-col td-chk">
<input type="checkbox" className="row-check" checked={selected.has(r.id)} onChange={e => toggleRowSelect(r.id, e.target.checked)} />
</td>
<td className="pos-nr td-pos" data-label="Pos.">{i + 1}</td>
<td className="td-menge" data-label="Menge">
<input key={r.menge} className="qty-input" type="number" inputMode="decimal" min={0} step={0.1} defaultValue={r.menge} onBlur={e => updateMenge(r, e.target.value)} />
<span className="print-only">{fmt1(r.menge)}</span>
</td>
<td className="td-einh" data-label="Einheit">{r.einheit}</td>
<td className="art-cell td-art" data-label="Art.-Nr.">{r.artNr || '—'}</td>
<td className="td-bez">{r.bezeichnung}</td>
<td className="td-notiz" data-label="Notiz">
<input key={r.notiz ?? ''} className="notiz-input" type="text" placeholder="…" defaultValue={r.notiz ?? ''} onBlur={e => updateNotiz(r, e.target.value)} />
<span className="print-only">{r.notiz}</span>
</td>
<td className="td-del"><button className="del-btn" onClick={() => deleteRow(r)}>✕</button></td>
</tr>
))}
</tbody>
</table>
</div>
)}
{retourMode && (
<div className="retour-panel">
<div className="retour-panel-info" style={retourInfo.error ? { color: '#ff5555', fontWeight: 700 } : undefined}>{retourInfo.text}</div>
{retourInfo.mode === 'material' && (
<label className="field" style={{ minWidth: 150 }}>Retourmenge
<input type="number" min={0} step={0.1} placeholder="0" value={retourQty} onChange={e => setRetourQty(e.target.value)} />
</label>
)}
{retourInfo.mode === 'kabel' && (
<label className="field" style={{ minWidth: 150 }}>Retourgewicht (kg)
<input type="number" min={0} step={0.01} placeholder="0.00" value={retourKg} onChange={e => setRetourKg(e.target.value)} />
</label>
)}
<button className="btn btn-cyan" disabled={!retourInfo.mode || retourBusy} onClick={applyRetour}>✓ Zusammenführen</button>
<button className="btn btn-ghost" onClick={() => { setRetourMode(false); setSelected(new Set()); }}>Abbrechen</button>
</div>
)}
{matList.length > 0 && (
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid var(--bor)', fontSize: 11, color: 'var(--muted)' }}>
{matList.length} Position(en) eingetragen
</div>
)}
{matList.length > 0 && (
<div className="psum-line">
<table><tbody><tr><td>Positionen gesamt</td><td>{matList.length}</td></tr></tbody></table>
</div>
)}
<div className="pfoot-note">
Materialschein erstellt über die BergVOLT Materialschein-App. Bei Rückfragen wende dich bitte
an den zuständigen Monteur.
</div>
</div>
<div className="pfooter">
<div><b>BergVOLT GmbH</b>Ludescherberg 26<br />AT-6713 Ludesch</div>
<div><b>Bank</b>Raiffeisenbank im Walgau eGen<br />IBAN: AT28 3745 8000 0106 0144</div>
<div><b>Firmenbuch</b>Feldkirch<br />FN: 595938 f</div>
<div><b>Kontakt</b>Tel: +43 699 17241249<br />office@bergvolt.at</div>
</div>
<div className="print-bar">
<button className="btn btn-cyan" onClick={() => window.print()}>🖨 DRUCKEN / PDF</button>
</div>
</div>
);
}