Löschfunktion für abgeschlossene Projekte + VenDoc-CSV-Export
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:
@@ -28,7 +28,8 @@ export const api = {
|
|||||||
projekte: {
|
projekte: {
|
||||||
list: () => req<Projekt[]>('/api/projekte'),
|
list: () => req<Projekt[]>('/api/projekte'),
|
||||||
create: (data: Partial<Projekt>) => req<Projekt>('/api/projekte', { method: 'POST', body: JSON.stringify(data) }),
|
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: {
|
material: {
|
||||||
list: (projektId: string) => req<MaterialItem[]>(`/api/material?projektId=${encodeURIComponent(projektId)}`),
|
list: (projektId: string) => req<MaterialItem[]>(`/api/material?projektId=${encodeURIComponent(projektId)}`),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export interface Projekt {
|
|||||||
id: string;
|
id: string;
|
||||||
titel: string;
|
titel: string;
|
||||||
auftraggeber: string | null;
|
auftraggeber: string | null;
|
||||||
|
auftragsNr: string | null;
|
||||||
datum: string | null;
|
datum: string | null;
|
||||||
monteur: string | null;
|
monteur: string | null;
|
||||||
notiz: string | null;
|
notiz: string | null;
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export default function Dashboard() {
|
|||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [err, setErr] = useState('');
|
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(); }, []);
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ export default function Dashboard() {
|
|||||||
let items = projekte;
|
let items = projekte;
|
||||||
if (filter !== 'Alle') items = items.filter(p => p.status === filter);
|
if (filter !== 'Alle') items = items.filter(p => p.status === filter);
|
||||||
const q = search.trim().toLowerCase();
|
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;
|
return items;
|
||||||
}, [projekte, filter, search]);
|
}, [projekte, filter, search]);
|
||||||
|
|
||||||
@@ -72,6 +72,7 @@ export default function Dashboard() {
|
|||||||
<div className="proj-main">
|
<div className="proj-main">
|
||||||
<div className="proj-name">{p.titel || '(ohne Namen)'}</div>
|
<div className="proj-name">{p.titel || '(ohne Namen)'}</div>
|
||||||
<div className="proj-meta">
|
<div className="proj-meta">
|
||||||
|
{p.auftragsNr && <span>Auftrag {p.auftragsNr}</span>}
|
||||||
{p.auftraggeber && <span>{p.auftraggeber}</span>}
|
{p.auftraggeber && <span>{p.auftraggeber}</span>}
|
||||||
{p.datum && <span>{dd(p.datum)}</span>}
|
{p.datum && <span>{dd(p.datum)}</span>}
|
||||||
{p.monteur && <span>{p.monteur}</span>}
|
{p.monteur && <span>{p.monteur}</span>}
|
||||||
@@ -94,6 +95,9 @@ export default function Dashboard() {
|
|||||||
<label className="field">Auftraggeber
|
<label className="field">Auftraggeber
|
||||||
<input value={form.auftraggeber} onChange={e => setForm({ ...form, auftraggeber: e.target.value })} placeholder="Kundenname" />
|
<input value={form.auftraggeber} onChange={e => setForm({ ...form, auftraggeber: e.target.value })} placeholder="Kundenname" />
|
||||||
</label>
|
</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
|
<label className="field">Datum
|
||||||
<input type="date" value={form.datum} onChange={e => setForm({ ...form, datum: e.target.value })} />
|
<input type="date" value={form.datum} onChange={e => setForm({ ...form, datum: e.target.value })} />
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
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 { api } from '../lib/api';
|
||||||
import type { MaterialItem, Projekt } from '../lib/types';
|
import type { MaterialItem, Projekt } from '../lib/types';
|
||||||
import { kabelInfoForBez } from '../data/kabelData';
|
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() {
|
export default function ProjektDetail() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [projekt, setProjekt] = useState<Projekt | null>(null);
|
const [projekt, setProjekt] = useState<Projekt | null>(null);
|
||||||
const [matList, setMatList] = useState<MaterialItem[]>([]);
|
const [matList, setMatList] = useState<MaterialItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [deleteBusy, setDeleteBusy] = useState(false);
|
||||||
|
|
||||||
// Baustelle-Formular
|
// Baustelle-Formular
|
||||||
const [titel, setTitel] = useState('');
|
const [titel, setTitel] = useState('');
|
||||||
const [auftraggeber, setAuftraggeber] = useState('');
|
const [auftraggeber, setAuftraggeber] = useState('');
|
||||||
|
const [auftragsNr, setAuftragsNr] = useState('');
|
||||||
const [datum, setDatum] = useState('');
|
const [datum, setDatum] = useState('');
|
||||||
const [monteur, setMonteur] = useState('');
|
const [monteur, setMonteur] = useState('');
|
||||||
const [notiz, setNotiz] = useState('');
|
const [notiz, setNotiz] = useState('');
|
||||||
@@ -54,7 +57,7 @@ export default function ProjektDetail() {
|
|||||||
setProjekt(p);
|
setProjekt(p);
|
||||||
setMatList(list);
|
setMatList(list);
|
||||||
if (p) {
|
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 ?? '');
|
setMonteur(p.monteur ?? ''); setNotiz(p.notiz ?? '');
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -64,7 +67,7 @@ export default function ProjektDetail() {
|
|||||||
if (id) setMatList(await api.material.list(id));
|
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;
|
if (!id) return;
|
||||||
await api.projekte.update(id, { [feld]: value } as Partial<Projekt>);
|
await api.projekte.update(id, { [feld]: value } as Partial<Projekt>);
|
||||||
}
|
}
|
||||||
@@ -76,6 +79,19 @@ export default function ProjektDetail() {
|
|||||||
setProjekt(updated);
|
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 ──
|
// ── Kabel-Rechner ──
|
||||||
const kabpm = kabTyp ? parseFloat(kabTyp.split('|')[1]) : 0;
|
const kabpm = kabTyp ? parseFloat(kabTyp.split('|')[1]) : 0;
|
||||||
const kabNetto = (parseFloat(kabKg) || 0) - (parseFloat(kabTara) || 0);
|
const kabNetto = (parseFloat(kabKg) || 0) - (parseFloat(kabTara) || 0);
|
||||||
@@ -200,6 +216,38 @@ export default function ProjektDetail() {
|
|||||||
setRetourBusy(false);
|
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 (loading) return <div className="empty-msg"><div className="spin" /></div>;
|
||||||
if (!projekt) return <div className="empty-msg">Projekt nicht gefunden.</div>;
|
if (!projekt) return <div className="empty-msg">Projekt nicht gefunden.</div>;
|
||||||
|
|
||||||
@@ -219,6 +267,7 @@ export default function ProjektDetail() {
|
|||||||
<table className="pinfo">
|
<table className="pinfo">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td colSpan={2}>Materialschein</td></tr>
|
<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>Datum</td><td>{ddDate(datum)}</td></tr>
|
||||||
<tr><td>Monteur</td><td>{monteur || '—'}</td></tr>
|
<tr><td>Monteur</td><td>{monteur || '—'}</td></tr>
|
||||||
<tr><td>Status</td><td>{projekt.status}</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}>
|
<button className="btn btn-outline" style={{ height: 36, fontSize: 11, padding: '0 14px' }} onClick={toggleStatus}>
|
||||||
{projekt.status === 'Abgeschlossen' ? '↺ Wieder aktivieren' : '✓ Abschließen'}
|
{projekt.status === 'Abgeschlossen' ? '↺ Wieder aktivieren' : '✓ Abschließen'}
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<div className="card baustelle-card">
|
<div className="card baustelle-card">
|
||||||
@@ -245,6 +299,9 @@ export default function ProjektDetail() {
|
|||||||
<label className="field">Auftraggeber
|
<label className="field">Auftraggeber
|
||||||
<input value={auftraggeber} onChange={e => setAuftraggeber(e.target.value)} onBlur={e => saveFeld('auftraggeber', e.target.value)} />
|
<input value={auftraggeber} onChange={e => setAuftraggeber(e.target.value)} onBlur={e => saveFeld('auftraggeber', e.target.value)} />
|
||||||
</label>
|
</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
|
<label className="field">Datum
|
||||||
<input type="date" value={datum} onChange={e => setDatum(e.target.value)} onBlur={e => saveFeld('datum', e.target.value)} />
|
<input type="date" value={datum} onChange={e => setDatum(e.target.value)} onBlur={e => saveFeld('datum', e.target.value)} />
|
||||||
</label>
|
</label>
|
||||||
@@ -420,6 +477,7 @@ export default function ProjektDetail() {
|
|||||||
|
|
||||||
<div className="print-bar">
|
<div className="print-bar">
|
||||||
<button className="btn btn-cyan" onClick={() => window.print()}>🖨 DRUCKEN / PDF</button>
|
<button className="btn btn-cyan" onClick={() => window.print()}>🖨 DRUCKEN / PDF</button>
|
||||||
|
<button className="btn btn-outline" onClick={exportCSV}>⬇ CSV Export (VenDoc)</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "projekte" ADD COLUMN "auftragsNr" TEXT;
|
||||||
@@ -17,6 +17,7 @@ model Projekt {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
titel String
|
titel String
|
||||||
auftraggeber String?
|
auftraggeber String?
|
||||||
|
auftragsNr String?
|
||||||
datum DateTime? @db.Date
|
datum DateTime? @db.Date
|
||||||
monteur String?
|
monteur String?
|
||||||
notiz String?
|
notiz String?
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ projekteRouter.get('/', async (_req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
projekteRouter.post('/', async (req: AuthedRequest, 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()) {
|
if (!titel || typeof titel !== 'string' || !titel.trim()) {
|
||||||
res.status(400).json({ error: 'Titel (Projekt/Baustelle) ist erforderlich.' });
|
res.status(400).json({ error: 'Titel (Projekt/Baustelle) ist erforderlich.' });
|
||||||
return;
|
return;
|
||||||
@@ -19,6 +19,7 @@ projekteRouter.post('/', async (req: AuthedRequest, res) => {
|
|||||||
data: {
|
data: {
|
||||||
titel: titel.trim(),
|
titel: titel.trim(),
|
||||||
auftraggeber: auftraggeber || null,
|
auftraggeber: auftraggeber || null,
|
||||||
|
auftragsNr: auftragsNr || null,
|
||||||
datum: datum ? new Date(datum) : null,
|
datum: datum ? new Date(datum) : null,
|
||||||
monteur: monteur || null,
|
monteur: monteur || null,
|
||||||
notiz: notiz || null
|
notiz: notiz || null
|
||||||
@@ -29,10 +30,11 @@ projekteRouter.post('/', async (req: AuthedRequest, res) => {
|
|||||||
|
|
||||||
projekteRouter.patch('/:id', async (req, res) => {
|
projekteRouter.patch('/:id', async (req, res) => {
|
||||||
const { id } = req.params;
|
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> = {};
|
const data: Record<string, unknown> = {};
|
||||||
if (titel !== undefined) data.titel = titel;
|
if (titel !== undefined) data.titel = titel;
|
||||||
if (auftraggeber !== undefined) data.auftraggeber = auftraggeber || null;
|
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 (datum !== undefined) data.datum = datum ? new Date(datum) : null;
|
||||||
if (monteur !== undefined) data.monteur = monteur || null;
|
if (monteur !== undefined) data.monteur = monteur || null;
|
||||||
if (notiz !== undefined) data.notiz = notiz || 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.' });
|
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();
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user