Löschfunktion für abgeschlossene Projekte + VenDoc-CSV-Export
Build and Deploy / version (push) Successful in 1s
Build and Deploy / build (push) Successful in 14s
Build and Deploy / deploy (push) Successful in 2s

Abgeschlossene Materialscheine können jetzt gelöscht werden (serverseitig
auf Status=Abgeschlossen beschränkt, mit Bestätigungsdialog). Neues Feld
Auftrags-Nr. am Projekt für die Zuordnung zum VenDoc-Auftrag. Neuer CSV-Export
(Semikolon, Komma-Dezimal, UTF-8 BOM) als Positionstabelle für den Import ins
ERP.
This commit is contained in:
Fidelis Huber
2026-09-11 08:08:19 +02:00
parent a61722bb4f
commit 36d5246187
7 changed files with 94 additions and 8 deletions
+2 -1
View File
@@ -28,7 +28,8 @@ export const api = {
projekte: {
list: () => req<Projekt[]>('/api/projekte'),
create: (data: Partial<Projekt>) => req<Projekt>('/api/projekte', { method: 'POST', body: JSON.stringify(data) }),
update: (id: string, data: Partial<Projekt>) => req<Projekt>(`/api/projekte/${id}`, { method: 'PATCH', body: JSON.stringify(data) })
update: (id: string, data: Partial<Projekt>) => req<Projekt>(`/api/projekte/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
remove: (id: string) => req<void>(`/api/projekte/${id}`, { method: 'DELETE' })
},
material: {
list: (projektId: string) => req<MaterialItem[]>(`/api/material?projektId=${encodeURIComponent(projektId)}`),
+1
View File
@@ -4,6 +4,7 @@ export interface Projekt {
id: string;
titel: string;
auftraggeber: string | null;
auftragsNr: string | null;
datum: string | null;
monteur: string | null;
notiz: string | null;
+6 -2
View File
@@ -18,7 +18,7 @@ export default function Dashboard() {
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: '' });
const [form, setForm] = useState({ titel: '', auftraggeber: '', auftragsNr: '', datum: new Date().toISOString().slice(0, 10), monteur: '', notiz: '' });
useEffect(() => { load(); }, []);
@@ -35,7 +35,7 @@ export default function Dashboard() {
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));
if (q) items = items.filter(p => (p.titel + ' ' + (p.auftraggeber ?? '') + ' ' + (p.monteur ?? '') + ' ' + (p.auftragsNr ?? '')).toLowerCase().includes(q));
return items;
}, [projekte, filter, search]);
@@ -72,6 +72,7 @@ export default function Dashboard() {
<div className="proj-main">
<div className="proj-name">{p.titel || '(ohne Namen)'}</div>
<div className="proj-meta">
{p.auftragsNr && <span>Auftrag {p.auftragsNr}</span>}
{p.auftraggeber && <span>{p.auftraggeber}</span>}
{p.datum && <span>{dd(p.datum)}</span>}
{p.monteur && <span>{p.monteur}</span>}
@@ -94,6 +95,9 @@ export default function Dashboard() {
<label className="field">Auftraggeber
<input value={form.auftraggeber} onChange={e => setForm({ ...form, auftraggeber: e.target.value })} placeholder="Kundenname" />
</label>
<label className="field">Auftrags-Nr. <span style={{ opacity: .6 }}>(VenDoc)</span>
<input value={form.auftragsNr} onChange={e => setForm({ ...form, auftragsNr: e.target.value })} placeholder="z. B. A00031/2026" />
</label>
<label className="field">Datum
<input type="date" value={form.datum} onChange={e => setForm({ ...form, datum: e.target.value })} />
</label>
+61 -3
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { api } from '../lib/api';
import type { MaterialItem, Projekt } from '../lib/types';
import { kabelInfoForBez } from '../data/kabelData';
@@ -11,13 +11,16 @@ const ddDate = (s: string) => { if (!s) return '—'; const [y, m, d] = s.split(
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('');
@@ -54,7 +57,7 @@ export default function ProjektDetail() {
setProjekt(p);
setMatList(list);
if (p) {
setTitel(p.titel); setAuftraggeber(p.auftraggeber ?? ''); setDatum(p.datum ? p.datum.slice(0, 10) : '');
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);
@@ -64,7 +67,7 @@ export default function ProjektDetail() {
if (id) setMatList(await api.material.list(id));
}
async function saveFeld(feld: 'titel' | 'auftraggeber' | 'datum' | 'monteur' | 'notiz', value: string) {
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>);
}
@@ -76,6 +79,19 @@ export default function ProjektDetail() {
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);
@@ -200,6 +216,38 @@ export default function ProjektDetail() {
setRetourBusy(false);
}
// ── CSV-Export (für Import in VenDoc o.ä. als Auftragspositionen) ──
// Semikolon-getrennt, Komma als Dezimaltrenner, UTF-8 mit BOM – Standardkonvention
// für deutschsprachiges Excel/ERP, damit die Datei ohne Umweg direkt geöffnet/
// importiert werden kann. Reine Positionstabelle ohne Kopf-Metazeilen, damit ein
// einfacher Positions-Import (z. B. VenDoc Universalimport) sie unverändert lesen kann.
function csvEscape(v: string): string {
if (/[;"\n\r]/.test(v)) return '"' + v.replace(/"/g, '""') + '"';
return v;
}
function exportCSV() {
if (!projekt) return;
const header = ['Pos', 'Artikelnummer', 'Bezeichnung', 'Menge', 'Einheit', 'Notiz'];
const rows = matList.map((r, i) => [
String(i + 1),
r.artNr ?? '',
r.bezeichnung,
fmt1(r.menge).replace('.', ','),
r.einheit,
r.notiz ?? ''
]);
const csv = [header, ...rows].map(row => row.map(csvEscape).join(';')).join('\r\n');
const safeName = (titel || 'Materialschein').replace(/[^\p{L}\p{N}_-]+/gu, '_');
const safeAuftrag = auftragsNr ? auftragsNr.replace(/[^\p{L}\p{N}_-]+/gu, '_') + '_' : '';
const filename = `Materialschein_${safeAuftrag}${safeName}.csv`;
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename;
document.body.appendChild(a); a.click(); a.remove();
URL.revokeObjectURL(url);
}
if (loading) return <div className="empty-msg"><div className="spin" /></div>;
if (!projekt) return <div className="empty-msg">Projekt nicht gefunden.</div>;
@@ -219,6 +267,7 @@ export default function ProjektDetail() {
<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>
@@ -234,6 +283,11 @@ export default function ProjektDetail() {
<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">
@@ -245,6 +299,9 @@ export default function ProjektDetail() {
<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>
@@ -420,6 +477,7 @@ export default function ProjektDetail() {
<div className="print-bar">
<button className="btn btn-cyan" onClick={() => window.print()}>🖨 DRUCKEN / PDF</button>
<button className="btn btn-outline" onClick={exportCSV}>⬇ CSV Export (VenDoc)</button>
</div>
</div>
);
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "projekte" ADD COLUMN "auftragsNr" TEXT;
+1
View File
@@ -17,6 +17,7 @@ model Projekt {
id String @id @default(cuid())
titel String
auftraggeber String?
auftragsNr String?
datum DateTime? @db.Date
monteur String?
notiz String?
+21 -2
View File
@@ -10,7 +10,7 @@ projekteRouter.get('/', async (_req, res) => {
});
projekteRouter.post('/', async (req: AuthedRequest, res) => {
const { titel, auftraggeber, datum, monteur, notiz } = req.body ?? {};
const { titel, auftraggeber, auftragsNr, datum, monteur, notiz } = req.body ?? {};
if (!titel || typeof titel !== 'string' || !titel.trim()) {
res.status(400).json({ error: 'Titel (Projekt/Baustelle) ist erforderlich.' });
return;
@@ -19,6 +19,7 @@ projekteRouter.post('/', async (req: AuthedRequest, res) => {
data: {
titel: titel.trim(),
auftraggeber: auftraggeber || null,
auftragsNr: auftragsNr || null,
datum: datum ? new Date(datum) : null,
monteur: monteur || null,
notiz: notiz || null
@@ -29,10 +30,11 @@ projekteRouter.post('/', async (req: AuthedRequest, res) => {
projekteRouter.patch('/:id', async (req, res) => {
const { id } = req.params;
const { titel, auftraggeber, datum, monteur, notiz, status } = req.body ?? {};
const { titel, auftraggeber, auftragsNr, datum, monteur, notiz, status } = req.body ?? {};
const data: Record<string, unknown> = {};
if (titel !== undefined) data.titel = titel;
if (auftraggeber !== undefined) data.auftraggeber = auftraggeber || null;
if (auftragsNr !== undefined) data.auftragsNr = auftragsNr || null;
if (datum !== undefined) data.datum = datum ? new Date(datum) : null;
if (monteur !== undefined) data.monteur = monteur || null;
if (notiz !== undefined) data.notiz = notiz || null;
@@ -44,3 +46,20 @@ projekteRouter.patch('/:id', async (req, res) => {
res.status(404).json({ error: 'Projekt nicht gefunden.' });
}
});
// Löschen ist nur für abgeschlossene Projekte erlaubt (Schutz vor versehentlichem
// Löschen laufender Baustellen) - Material-Positionen werden per Cascade mitgelöscht.
projekteRouter.delete('/:id', async (req, res) => {
const { id } = req.params;
const projekt = await prisma.projekt.findUnique({ where: { id } });
if (!projekt) {
res.status(404).json({ error: 'Projekt nicht gefunden.' });
return;
}
if (projekt.status !== 'Abgeschlossen') {
res.status(400).json({ error: 'Nur abgeschlossene Projekte können gelöscht werden.' });
return;
}
await prisma.projekt.delete({ where: { id } });
res.status(204).end();
});