import { useEffect, useMemo, useState } from 'react'; import { Link, useNavigate, useParams } from 'react-router-dom'; import * as XLSX from 'xlsx'; import { api } from '../lib/api'; import type { MaterialItem, Projekt } from '../lib/types'; import { kabelInfoForBez } from '../data/kabelData'; import KabelOptions from '../components/KabelOptions'; import MonteurField from '../components/MonteurField'; 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 navigate = useNavigate(); const [projekt, setProjekt] = useState(null); const [matList, setMatList] = useState([]); const [loading, setLoading] = useState(true); const [deleteBusy, setDeleteBusy] = useState(false); // Baustelle-Formular const [titel, setTitel] = useState(''); const [auftraggeber, setAuftraggeber] = useState(''); const [auftragsNr, setAuftragsNr] = 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>(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 ?? ''); setAuftragsNr(p.auftragsNr ?? ''); 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' | 'auftragsNr' | 'datum' | 'monteur' | 'notiz', value: string) { if (!id) return; await api.projekte.update(id, { [feld]: value } as Partial); } 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); } async function deleteProjekt() { if (!id || !projekt) return; if (!confirm(`"${projekt.titel}" endgültig löschen? Alle ${matList.length} Material-Positionen werden mitgelöscht. Das kann nicht rückgängig gemacht werden.`)) return; setDeleteBusy(true); try { await api.projekte.remove(id); navigate('/'); } catch (e) { alert('Fehler beim Löschen: ' + (e instanceof Error ? e.message : e)); setDeleteBusy(false); } } // ── 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); } // ── XLSM-Export (für Import in VenDoc als Auftragspositionen) ── // Spaltenlayout und -namen sind 1:1 aus einem echten VenDoc-Aufmaßblatt-Export des // Nutzers (Auftrag A00079/2026, Blatt "Aufmaß") übernommen: Position in Spalte A, // Aufmaß (Menge) in H, EH (Einheit) in I, Positionstext in K. VenDoc führt dort keine // eigene Artikelnummer-Spalte – die Art.-Nr. wird daher wie im Original in den // Positionstext eingebettet. Oid/PosTyp/Lohn/Material bleiben leer, da das echte // VenDoc-Aufmaßblatt bestehende Positionen per interner Oid referenziert, die wir // nicht kennen/vergeben können. const EH_MAP: Record = { Stk: 'ST', m: 'M' }; // aus VenDoc-Beispiel bestätigt function exportXlsm() { if (!projekt) return; const aoa: (string | number)[][] = [ ['Projekt:'], ['Betreff', '', '', '', '', titel || ''], ['Auftrag', '', '', '', '', auftragsNr || ''], [], ['Position ', 'PosTyp', 'Dummy2', 'Dummy3', 'Oid', 'Ausschr.', 'Zugebu.', 'Aufmaß', 'EH', '', 'Positionstext'] ]; matList.forEach((r, i) => { const row: (string | number)[] = []; row[0] = i + 1; row[7] = num1(r.menge); row[8] = EH_MAP[r.einheit] ?? r.einheit; row[10] = r.artNr ? `${r.bezeichnung} (${r.artNr})` : r.bezeichnung; aoa.push(row); }); const ws = XLSX.utils.aoa_to_sheet(aoa); ws['!cols'] = [{ wch: 10 }, { wch: 8 }, { wch: 8 }, { wch: 8 }, { wch: 8 }, { wch: 9 }, { wch: 9 }, { wch: 9 }, { wch: 7 }, { wch: 2 }, { wch: 55 }]; const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, 'Aufmaß'); const safeName = (titel || 'Materialschein').replace(/[^\p{L}\p{N}_-]+/gu, '_'); const safeAuftrag = auftragsNr ? auftragsNr.replace(/[^\p{L}\p{N}_-]+/gu, '_') + '_' : ''; XLSX.writeFile(wb, `Materialschein_${safeAuftrag}${safeName}.xlsm`, { bookType: 'xlsm' }); } if (loading) return
; if (!projekt) return
Projekt nicht gefunden.
; return (
BergVOLT Elektrotechnik
Baustelle / Projekt
{titel || '—'}
{auftraggeber &&
Auftraggeber: {auftraggeber}
} {notiz &&
{notiz}
}
{auftragsNr && }
Materialschein
Auftrags-Nr.{auftragsNr}
Datum{ddDate(datum)}
Monteur{monteur || '—'}
Status{projekt.status}
Materialverbrauch – {titel || '(ohne Namen)'}
← Projekte
{titel || '(ohne Namen)'}
{projekt.status === 'Abgeschlossen' && ( )}
Baustelle / Projekt
saveFeld('monteur', v)} />