diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index cf862e4..1695ea8 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -28,7 +28,8 @@ export const api = { projekte: { list: () => req('/api/projekte'), create: (data: Partial) => req('/api/projekte', { method: 'POST', body: JSON.stringify(data) }), - update: (id: string, data: Partial) => req(`/api/projekte/${id}`, { method: 'PATCH', body: JSON.stringify(data) }) + update: (id: string, data: Partial) => req(`/api/projekte/${id}`, { method: 'PATCH', body: JSON.stringify(data) }), + remove: (id: string) => req(`/api/projekte/${id}`, { method: 'DELETE' }) }, material: { list: (projektId: string) => req(`/api/material?projektId=${encodeURIComponent(projektId)}`), diff --git a/client/src/lib/types.ts b/client/src/lib/types.ts index 0f1cdd3..3d82750 100644 --- a/client/src/lib/types.ts +++ b/client/src/lib/types.ts @@ -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; diff --git a/client/src/pages/Dashboard.tsx b/client/src/pages/Dashboard.tsx index 8f1a256..7d5756e 100644 --- a/client/src/pages/Dashboard.tsx +++ b/client/src/pages/Dashboard.tsx @@ -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() {
{p.titel || '(ohne Namen)'}
+ {p.auftragsNr && Auftrag {p.auftragsNr}} {p.auftraggeber && {p.auftraggeber}} {p.datum && {dd(p.datum)}} {p.monteur && {p.monteur}} @@ -94,6 +95,9 @@ export default function Dashboard() { + diff --git a/client/src/pages/ProjektDetail.tsx b/client/src/pages/ProjektDetail.tsx index 6c19e0b..d3279a9 100644 --- a/client/src/pages/ProjektDetail.tsx +++ b/client/src/pages/ProjektDetail.tsx @@ -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(null); const [matList, setMatList] = useState([]); 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); } @@ -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
; if (!projekt) return
Projekt nicht gefunden.
; @@ -219,6 +267,7 @@ export default function ProjektDetail() { + {auftragsNr && } @@ -234,6 +283,11 @@ export default function ProjektDetail() { + {projekt.status === 'Abgeschlossen' && ( + + )}
@@ -245,6 +299,9 @@ export default function ProjektDetail() { + @@ -420,6 +477,7 @@ export default function ProjektDetail() {
+
); diff --git a/server/prisma/migrations/20260911090000_add_auftrags_nr/migration.sql b/server/prisma/migrations/20260911090000_add_auftrags_nr/migration.sql new file mode 100644 index 0000000..507179b --- /dev/null +++ b/server/prisma/migrations/20260911090000_add_auftrags_nr/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "projekte" ADD COLUMN "auftragsNr" TEXT; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 3253fa1..58497f7 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -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? diff --git a/server/src/routes/projekte.ts b/server/src/routes/projekte.ts index 94b6d11..ee74bd4 100644 --- a/server/src/routes/projekte.ts +++ b/server/src/routes/projekte.ts @@ -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 = {}; 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(); +});
Materialschein
Auftrags-Nr.{auftragsNr}
Datum{ddDate(datum)}
Monteur{monteur || '—'}
Status{projekt.status}