Login-Logo war im hellen Modus weiß-auf-weiß unsichtbar (Karte wechselt mit dem Theme, Logo war hart auf die dunkle Variante codiert) - zeigt jetzt je nach Theme die passende Logo-Tinte, gleiches Problem beim "Willkommen"-Titel behoben. PDF-Export verlor den schwarzen Banner beim Drucken, da Browser Hintergrundfarben standardmäßig nicht drucken - jetzt über print-color-adjust erzwungen. Export ist jetzt eine echte .xlsm-Datei statt CSV, mit Spaltenlayout 1:1 aus einem echten VenDoc-Aufmaßblatt-Export des Nutzers übernommen (Blatt "Aufmaß", Position/Aufmaß/EH/Positionstext in den exakt gleichen Spalten). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
488 lines
24 KiB
TypeScript
488 lines
24 KiB
TypeScript
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<Projekt | null>(null);
|
||
const [matList, setMatList] = useState<MaterialItem[]>([]);
|
||
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<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 ?? ''); 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<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);
|
||
}
|
||
|
||
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<string, string> = { 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 <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>
|
||
{auftragsNr && <tr><td>Auftrags-Nr.</td><td>{auftragsNr}</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>
|
||
{projekt.status === 'Abgeschlossen' && (
|
||
<button className="btn btn-danger" style={{ height: 36, fontSize: 11, padding: '0 14px' }} disabled={deleteBusy} onClick={deleteProjekt}>
|
||
🗑 Löschen
|
||
</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">Auftrags-Nr. <span style={{ opacity: .6 }}>(VenDoc)</span>
|
||
<input value={auftragsNr} onChange={e => setAuftragsNr(e.target.value)} onBlur={e => saveFeld('auftragsNr', e.target.value)} placeholder="z. B. A00031/2026" />
|
||
</label>
|
||
<label className="field">Datum
|
||
<input type="date" value={datum} onChange={e => setDatum(e.target.value)} onBlur={e => saveFeld('datum', e.target.value)} />
|
||
</label>
|
||
<MonteurField key={id} value={monteur} onChange={setMonteur} onCommit={v => saveFeld('monteur', v)} />
|
||
<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>
|
||
<button className="btn btn-outline" onClick={exportXlsm}>⬇ Excel Export (VenDoc)</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|