Initial scaffold: React/Vite/TS PWA + Express/Prisma/Postgres backend
Materialschein & Kabelrechner für BergVOLT Elektrotechnik, portiert von der vorherigen Single-File-HTML-Version. Microsoft-Entra-ID-Login (MSAL), Projekt- Dashboard statt einer Datei pro Kunde, Retour-Buchen-Zusammenführung, 111 Kabeltypen aus den offiziellen Meinhart-Datenblättern. gitops/ nach PROX/deploy-standard: git-backed Portainer-Stack, versionierte Docker-Images, CI-Commit-back auf gitops/stack.yml. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../lib/api';
|
||||
import type { Projekt, ProjektStatus } from '../lib/types';
|
||||
|
||||
function dd(s: string | null) {
|
||||
if (!s) return '–';
|
||||
const p = s.split('T')[0];
|
||||
const [y, m, d] = p.split('-');
|
||||
return `${d}.${m}.${y}`;
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const navigate = useNavigate();
|
||||
const [projekte, setProjekte] = useState<Projekt[] | null>(null);
|
||||
const [filter, setFilter] = useState<ProjektStatus | 'Alle'>('Aktiv');
|
||||
const [search, setSearch] = useState('');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
const [form, setForm] = useState({ titel: '', auftraggeber: '', datum: new Date().toISOString().slice(0, 10), monteur: '', notiz: '' });
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
setProjekte(await api.projekte.list());
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
const gefiltert = useMemo(() => {
|
||||
if (!projekte) return [];
|
||||
let items = projekte;
|
||||
if (filter !== 'Alle') items = items.filter(p => p.status === filter);
|
||||
const q = search.trim().toLowerCase();
|
||||
if (q) items = items.filter(p => (p.titel + ' ' + (p.auftraggeber ?? '') + ' ' + (p.monteur ?? '')).toLowerCase().includes(q));
|
||||
return items;
|
||||
}, [projekte, filter, search]);
|
||||
|
||||
async function createProjekt() {
|
||||
if (!form.titel.trim()) { setErr('Bitte Projekt/Baustelle eingeben.'); return; }
|
||||
setBusy(true);
|
||||
setErr('');
|
||||
try {
|
||||
const projekt = await api.projekte.create(form);
|
||||
setShowForm(false);
|
||||
navigate(`/projekte/${projekt.id}`);
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="dash-toolbar">
|
||||
<input className="dash-search" placeholder="Projekt suchen…" value={search} onChange={e => setSearch(e.target.value)} />
|
||||
<div className="dash-filters">
|
||||
{(['Aktiv', 'Abgeschlossen', 'Alle'] as const).map(f => (
|
||||
<button key={f} className={'dfilter' + (filter === f ? ' active' : '')} onClick={() => setFilter(f)}>{f}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proj-list">
|
||||
{projekte === null && <div className="empty-msg"><div className="spin" /></div>}
|
||||
{projekte !== null && gefiltert.length === 0 && <div className="empty-msg">Keine Projekte gefunden.</div>}
|
||||
{gefiltert.map(p => (
|
||||
<div key={p.id} className="proj-card" onClick={() => navigate(`/projekte/${p.id}`)}>
|
||||
<div className="proj-main">
|
||||
<div className="proj-name">{p.titel || '(ohne Namen)'}</div>
|
||||
<div className="proj-meta">
|
||||
{p.auftraggeber && <span>{p.auftraggeber}</span>}
|
||||
{p.datum && <span>{dd(p.datum)}</span>}
|
||||
{p.monteur && <span>{p.monteur}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className={'proj-status ' + (p.status === 'Abgeschlossen' ? 'status-abgeschlossen' : 'status-aktiv')}>{p.status}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!showForm && <div className="new-proj-card" style={{ marginTop: 14 }} onClick={() => setShowForm(true)}>+ Neues Projekt anlegen</div>}
|
||||
|
||||
{showForm && (
|
||||
<div className="card" style={{ marginTop: 14 }}>
|
||||
<div className="card-title">Neues Projekt</div>
|
||||
<div className="grid-2">
|
||||
<label className="field">Projekt / Baustelle
|
||||
<input value={form.titel} onChange={e => setForm({ ...form, titel: e.target.value })} placeholder="z. B. EFH Müller, Dornbirn" />
|
||||
</label>
|
||||
<label className="field">Auftraggeber
|
||||
<input value={form.auftraggeber} onChange={e => setForm({ ...form, auftraggeber: e.target.value })} placeholder="Kundenname" />
|
||||
</label>
|
||||
<label className="field">Datum
|
||||
<input type="date" value={form.datum} onChange={e => setForm({ ...form, datum: e.target.value })} />
|
||||
</label>
|
||||
<label className="field">Monteur
|
||||
<input value={form.monteur} onChange={e => setForm({ ...form, monteur: e.target.value })} placeholder="Name" />
|
||||
</label>
|
||||
<label className="field" style={{ gridColumn: 'span 2' }}>Notiz / Bereich
|
||||
<textarea value={form.notiz} onChange={e => setForm({ ...form, notiz: e.target.value })} placeholder="Stockwerk, Bereich, Bemerkung …" />
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 16 }}>
|
||||
<button className="btn btn-cyan" disabled={busy} onClick={createProjekt}>✓ Anlegen & öffnen</button>
|
||||
<button className="btn btn-ghost" onClick={() => setShowForm(false)}>Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{err && <div className="lerr" style={{ marginTop: 12 }}>{err}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useState } from 'react';
|
||||
import { KAB_DATA } from '../data/kabelData';
|
||||
import KabelOptions from '../components/KabelOptions';
|
||||
|
||||
export default function Kabelrechner() {
|
||||
const [typSel, setTypSel] = useState('');
|
||||
const [kgpm, setKgpm] = useState('');
|
||||
const [kgpmReadOnly, setKgpmReadOnly] = useState(true);
|
||||
const [gewicht, setGewicht] = useState('');
|
||||
const [useTara, setUseTara] = useState(false);
|
||||
const [tara, setTara] = useState('');
|
||||
const [typName, setTypName] = useState('Kabel');
|
||||
|
||||
function onTypChange(v: string) {
|
||||
setTypSel(v);
|
||||
if (v === 'custom') { setKgpm(''); setKgpmReadOnly(false); setTypName('Eigenes kg/m'); }
|
||||
else { setKgpm(v); setKgpmReadOnly(true); const opt = KAB_DATA.find(c => String(c.kgpm) === v); setTypName(opt?.name ?? 'Kabel'); }
|
||||
}
|
||||
|
||||
const kgpmNum = parseFloat(kgpm);
|
||||
const rawKg = parseFloat(gewicht);
|
||||
const taraKg = useTara ? (parseFloat(tara) || 0) : 0;
|
||||
const netto = rawKg - taraKg;
|
||||
const visible = kgpmNum > 0 && rawKg > 0 && netto > 0;
|
||||
const laenge = visible ? netto / kgpmNum : 0;
|
||||
|
||||
function pickFromRef(c: { kgpm: number; name: string }) {
|
||||
setTypSel(String(c.kgpm));
|
||||
setKgpm(String(c.kgpm));
|
||||
setKgpmReadOnly(true);
|
||||
setTypName(c.name);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="card">
|
||||
<div className="card-title">Gewicht → Kabellänge (Meinhart Daten)</div>
|
||||
<div className="grid-2">
|
||||
<label className="field">Kabeltyp
|
||||
<select value={typSel} onChange={e => onTypChange(e.target.value)}>
|
||||
<option value="" disabled>— bitte wählen —</option>
|
||||
<KabelOptions mode="kgpm" />
|
||||
<optgroup label="Manuell"><option value="custom">Eigenes kg/m …</option></optgroup>
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">kg pro Meter
|
||||
<input type="number" min={0.001} step={0.001} placeholder="z. B. 0.170" value={kgpm} readOnly={kgpmReadOnly} onChange={e => setKgpm(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<label className="field" style={{ maxWidth: 280 }}>Gemessenes Gewicht (kg)
|
||||
<input type="number" min={0} step={0.01} placeholder="z. B. 12.50" value={gewicht} onChange={e => setGewicht(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="tare-row">
|
||||
<label className="toggle"><input type="checkbox" checked={useTara} onChange={e => setUseTara(e.target.checked)} /><span className="slider" /></label>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--muted)' }}>Tara abziehen (Ring / Trommel)</span>
|
||||
</div>
|
||||
{useTara && (
|
||||
<div className="grid-2">
|
||||
<label className="field" style={{ marginTop: 12, maxWidth: 220 }}>Tara-Gewicht (kg)
|
||||
<input type="number" min={0} step={0.01} placeholder="z. B. 1.20" value={tara} onChange={e => setTara(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{visible && (
|
||||
<div className="kc-result-box">
|
||||
<div style={{ fontSize: 10, letterSpacing: '.08em', color: 'var(--muted)', textTransform: 'uppercase', marginBottom: 6 }}>Berechnete Länge</div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
|
||||
<div className="kc-main">{laenge.toFixed(1)}</div>
|
||||
<div style={{ fontSize: 18, opacity: .7 }}>m</div>
|
||||
</div>
|
||||
<div className="kc-detail">
|
||||
Kabel: <strong style={{ color: 'var(--cyan)' }}>{typName}</strong><br />
|
||||
Gewicht: <strong>{rawKg.toFixed(2)} kg</strong>
|
||||
{useTara && taraKg > 0 ? <> − Tara <strong>{taraKg.toFixed(2)} kg</strong> = Netto <strong>{netto.toFixed(2)} kg</strong></> : null}
|
||||
<br />Faktor: <strong>{kgpmNum.toFixed(3)} kg/m</strong>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-title">Referenztabelle – Meinhart Kabel Österreich GmbH</div>
|
||||
<div className="ref-wrap">
|
||||
<table className="reftab">
|
||||
<thead><tr><th>Typ</th><th>Bezeichnung</th><th>kg/m</th><th>bei 100 kg</th></tr></thead>
|
||||
<tbody>
|
||||
{KAB_DATA.map(c => (
|
||||
<tr key={c.name} title={`Klick: ${c.name} übernehmen`} onClick={() => pickFromRef(c)}>
|
||||
<td><span className={`badge badge-${c.badge}`}>{c.typ}</span></td>
|
||||
<td>{c.name}</td>
|
||||
<td><strong style={{ color: 'var(--cyan)' }}>{c.kgpm.toFixed(3)}</strong></td>
|
||||
<td>{(100 / c.kgpm).toFixed(1)} m</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useState } from 'react';
|
||||
import { useMsal } from '@azure/msal-react';
|
||||
|
||||
export default function Login() {
|
||||
const { instance } = useMsal();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
async function login() {
|
||||
setBusy(true);
|
||||
setErr('');
|
||||
try {
|
||||
await instance.loginPopup({ scopes: ['openid', 'profile'] });
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-screen">
|
||||
<div className="lcard">
|
||||
<div className="llogo">Berg<span>VOLT</span></div>
|
||||
<div className="lsub">Elektrotechnik · Materialschein</div>
|
||||
<div className="ltitle">Willkommen</div>
|
||||
<div className="ldesc">Melde dich mit deinem BergVOLT<br />Office 365 Konto an.</div>
|
||||
<button className="btn-ms" onClick={login} disabled={busy}>
|
||||
<svg viewBox="0 0 21 21" width="18" height="18">
|
||||
<rect x="0" y="0" width="10" height="10" fill="#f25022" />
|
||||
<rect x="11" y="0" width="10" height="10" fill="#7fba00" />
|
||||
<rect x="0" y="11" width="10" height="10" fill="#00a4ef" />
|
||||
<rect x="11" y="11" width="10" height="10" fill="#ffb900" />
|
||||
</svg>
|
||||
Mit Microsoft anmelden
|
||||
</button>
|
||||
{err && <div className="lerr">{err}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
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;
|
||||
|
||||
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-header">
|
||||
<h2 style={{ fontSize: 18, marginBottom: 4 }}>Materialschein – BergVOLT Elektrotechnik – {titel}</h2>
|
||||
<p style={{ fontSize: 12, color: '#555' }}>Datum: {datum || '—'} | Monteur: {monteur || '—'} | Auftraggeber: {auftraggeber || '—'}</p>
|
||||
<hr style={{ margin: '8px 0', borderColor: '#ddd' }} />
|
||||
</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">
|
||||
<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">
|
||||
<input type="checkbox" className="row-check" checked={selected.has(r.id)} onChange={e => toggleRowSelect(r.id, e.target.checked)} />
|
||||
</td>
|
||||
<td className="pos-nr">{i + 1}</td>
|
||||
<td>
|
||||
<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>{r.einheit}</td>
|
||||
<td className="art-cell">{r.artNr || '—'}</td>
|
||||
<td>{r.bezeichnung}</td>
|
||||
<td>
|
||||
<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><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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="print-bar">
|
||||
<button className="btn btn-cyan" onClick={() => window.print()}>🖨 DRUCKEN / PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user