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: {
|
||||
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)}`),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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?
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user