Auftragsbestaetigung: Liste mit Status, Zusatzzeilen, Aktionen und Projekt zuordnen

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-07 14:34:47 +00:00
1 parent 8b055a5d42
commit 3650573445
2 files changed
+275 -7

No files matched your search

+240 -7
View File
@@ -1,11 +1,244 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Badge, Button, Card, EmptyState } from '../ui'
import { noticeProps } from '../../lib/financeUi'
import {
deleteAuftrag,
erinnereAuftrag,
getAuftragPdf,
neueFassungAuftrag,
ordneAuftragProjektZu,
sendAuftrag,
storniereAuftrag,
} from '../../lib/financeApi'
import { oeffnePdfImTab } from '../../lib/auftragPdf'
import {
auftragBestaetigung,
auftragErfolg,
auftragFehler,
auftragZeilen,
neueSperre,
zuordnenOptionen,
} from '../../lib/auftragView'
import './auftrag.css'
const LAUFEND = { senden: 'Sende …', erinnern: 'Sende …', stornieren: 'Storniere …', loeschen: 'Lösche …', neueFassung: 'Lege an …', pdf: 'Öffne …' }
const AUFRUFE = {
senden: sendAuftrag,
erinnern: erinnereAuftrag,
stornieren: storniereAuftrag,
loeschen: deleteAuftrag,
neueFassung: neueFassungAuftrag,
}
/**
* Platzhalter von W0: Strang W2 ersetzt diese Datei vollständig (Dateiname, Default-Export und Props bleiben).
* Die fertige Komponente rendert <Card title="Aufträge"> selbst und ruft nach jeder Aktion onChanged().
*
* Props (V8): auftraege (AuftragTeam[], V3), konten (Projektname der Zeile und Auswahl für „Projekt zuordnen“, einer Aktion für
* ABs ohne Projekt über ordneAuftragProjektZu(id, projektId) aus financeApi.js), onChanged(), onBearbeiten(auftrag)
* („Entwurf bearbeiten“; nach „Neue Fassung“ mit dem neuen Entwurf) und onShowOverview (Weg „Zur Übersicht“ aus Meldungen).
* Karte „Aufträge“ im Kundenreiter: Auftragsbestätigungen (AB) mit Status, Zusatzzeilen und den Aktionen je Status.
* Die Regeln stecken in src/lib/auftragView.js. Nach jeder Aktion ruft die Liste onChanged() (auch nach einem Fehler:
* der Stand kann ein anderer sein). „Bearbeiten“ und „Neue Fassung“ geben den Entwurf über onBearbeiten an den Container,
* „Zur Übersicht“ in Meldungen läuft über onShowOverview. Eine Sperre fängt den Doppelklick ab (eine Anfrage, eine Rückfrage).
* Schmal (Wrapper .auftrag-body bis 860 px) wird jede Zeile ein Raster.
*/
export default function AuftragListe({ auftraege, konten, onChanged, onBearbeiten, onShowOverview }) {
return null
const zeilen = useMemo(() => auftragZeilen(auftraege, { konten }), [auftraege, konten])
const optionen = useMemo(() => zuordnenOptionen(konten), [konten])
const [busy, setBusy] = useState(null) // `${id}:${aktion}` der laufenden Anfrage
const [message, setMessage] = useState(null) // { tone, text, zurUebersicht? }
const [zuordnen, setZuordnen] = useState(null) // { id, projektId } der Zeile mit offener Auswahl
const sperre = useRef(neueSperre()).current
const aliveRef = useRef(true)
useEffect(() => {
aliveRef.current = true
return () => {
aliveRef.current = false
}
}, [])
const zeigeFehler = (err) => {
const f = auftragFehler(err)
if (aliveRef.current) setMessage({ tone: 'warn', text: f.text, zurUebersicht: f.zurUebersicht })
}
async function run(zeile, aktion) {
if (aktion.key === 'bearbeiten') {
onBearbeiten(zeile.auftrag)
return
}
if (aktion.key === 'projekt') {
setZuordnen({ id: zeile.id, projektId: '' })
setMessage(null)
return
}
if (sperre.belegt) return
const frage = auftragBestaetigung(aktion.key, zeile.auftrag)
if (frage && !window.confirm(frage)) return
await sperre.run(async () => {
setBusy(`${zeile.id}:${aktion.key}`)
setMessage(null)
try {
if (aktion.key === 'pdf') {
// kein await davor: der neue Tab muss im Klick aufgehen
await oeffnePdfImTab(() => getAuftragPdf(zeile.id))
return
}
const data = await AUFRUFE[aktion.key](zeile.id)
if (aliveRef.current) setMessage(auftragErfolg(aktion.key, data?.auftrag || zeile.auftrag, { mailFehler: data?.mailFehler }))
onChanged()
if (aktion.key === 'neueFassung' && data?.auftrag) onBearbeiten(data.auftrag)
} catch (err) {
if (aktion.key === 'pdf') {
if (aliveRef.current) setMessage({ tone: 'warn', text: auftragFehler(err).text })
return
}
zeigeFehler(err)
onChanged() // der Stand kann ein anderer sein
} finally {
if (aliveRef.current) setBusy(null)
}
})
}
async function ordneZu(zeile) {
const gewaehlt = optionen.find((o) => o.value === zuordnen?.projektId)
if (!gewaehlt || sperre.belegt) return
const frage = auftragBestaetigung('projekt', zeile.auftrag, { projektName: gewaehlt.label })
if (frage && !window.confirm(frage)) return
await sperre.run(async () => {
setBusy(`${zeile.id}:projekt`)
setMessage(null)
try {
const data = await ordneAuftragProjektZu(zeile.id, gewaehlt.value)
if (aliveRef.current) {
setMessage(auftragErfolg('projekt', data?.auftrag || zeile.auftrag, { projektName: gewaehlt.label }))
setZuordnen(null)
}
onChanged()
} catch (err) {
zeigeFehler(err)
if (aliveRef.current) setZuordnen(null)
onChanged()
} finally {
if (aliveRef.current) setBusy(null)
}
})
}
return (
<Card title="Aufträge" className="auftrag-liste">
<div className="auftrag-body">
{message && (
<p {...noticeProps(message.tone, 'auftrag-notice')}>
{message.text}
{message.zurUebersicht && (
<>
{' '}
<button type="button" className="link" onClick={onShowOverview}>
Zur Übersicht
</button>
</>
)}
</p>
)}
{zeilen.length === 0 ? (
<EmptyState title="Noch keine Aufträge." hint="Eine Auftragsbestätigung entsteht über „Auftragsbestätigung“ in der Projektliste." />
) : (
<div className="auftrag-table-wrap">
<table className="table auftrag-table" role="table">
<caption className="auftrag-sr">Auftragsbestätigungen, neueste zuerst</caption>
<thead role="rowgroup">
<tr role="row">
<th role="columnheader" scope="col">Nummer</th>
<th role="columnheader" scope="col">Projekt / Titel</th>
<th role="columnheader" scope="col">Datum</th>
<th role="columnheader" scope="col" className="auftrag-amount">Summe</th>
<th role="columnheader" scope="col">Status</th>
<th role="columnheader" scope="col">Aktionen</th>
</tr>
</thead>
<tbody role="rowgroup">
{zeilen.map((zeile) => {
const offen = zuordnen?.id === zeile.id
const wahl = offen && optionen.some((o) => o.value === zuordnen.projektId) ? zuordnen.projektId : ''
const ordnet = busy === `${zeile.id}:projekt`
return (
<tr key={zeile.key} role="row">
<td role="cell" className="auftrag-nummer">
{zeile.istEntwurf ? <span className="muted">{zeile.nummerText}</span> : zeile.nummerText}
</td>
<td role="cell" className="auftrag-titel">
<strong>{zeile.titel}</strong>
{zeile.projektText && <div className="cell-sub">{zeile.projektText}</div>}
</td>
<td role="cell" className="auftrag-datum" data-label="Datum">{zeile.datumText}</td>
<td role="cell" className="auftrag-amount" data-label="Summe">
{zeile.summeText}
{zeile.summeSub && <div className="cell-sub">{zeile.summeSub}</div>}
</td>
<td role="cell" className="auftrag-status">
<Badge tone={zeile.statusTone}>{zeile.statusLabel}</Badge>
{zeile.notizen.map((note) => (
<div key={note.key} className={note.tone === 'warn' ? 'msg warn auftrag-note' : 'muted auftrag-note'}>
{note.text}
</div>
))}
</td>
<td role="cell" className="auftrag-aktionen">
<div className="auftrag-actions">
{zeile.aktionen.map((aktion) => {
const label = `${aktion.label}: ${zeile.nummerText}, ${zeile.titel}`
const text = busy === `${zeile.id}:${aktion.key}` && LAUFEND[aktion.key] ? LAUFEND[aktion.key] : aktion.label
if (aktion.danger) {
return (
<Button key={aktion.key} type="button" variant="danger" onClick={() => run(zeile, aktion)} disabled={busy !== null} aria-label={label}>
{text}
</Button>
)
}
return (
<button
key={aktion.key}
type="button"
className="link"
onClick={() => run(zeile, aktion)}
disabled={busy !== null}
aria-label={label}
aria-expanded={aktion.key === 'projekt' ? offen : undefined}
>
{text}
</button>
)
})}
</div>
{offen && (
<div className="auftrag-zuordnen" role="group" aria-label={`Projekt zuordnen: ${zeile.nummerText}, ${zeile.titel}`}>
<select
className="select"
aria-label="Projekt wählen"
value={wahl}
onChange={(e) => setZuordnen({ id: zeile.id, projektId: e.target.value })}
>
<option value="">Projekt wählen …</option>
{optionen.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<Button type="button" onClick={() => ordneZu(zeile)} disabled={wahl === '' || busy !== null}>
{ordnet ? 'Ordne zu …' : 'Zuordnen'}
</Button>
<button type="button" className="link" disabled={busy !== null} onClick={() => setZuordnen(null)}>
Abbrechen
</button>
</div>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
</Card>
)
}
+35
View File
@@ -0,0 +1,35 @@
/* Auftragsbestaetigungen (W2): Liste, Dialog-Schritte, Vorschau. Nur Tokens, alle Klassen beginnen mit auftrag-.
Die Datei wird von der Komponente geladen, global.css kommt danach: darum nie eine globale Klasse ueberschreiben. */
/* Liste */
.auftrag-body { container-type: inline-size; }
.auftrag-notice { margin: 0 0 8px; }
.auftrag-table-wrap { overflow-x: auto; }
.auftrag-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
.auftrag-nummer { white-space: nowrap; }
.auftrag-titel { overflow-wrap: anywhere; }
.auftrag-table th.auftrag-amount { text-align: right; }
.auftrag-amount { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
.auftrag-note { margin-top: 2px; font-size: 13px; overflow-wrap: anywhere; }
.auftrag-actions { display: flex; flex-wrap: wrap; gap: 4px 14px; }
.auftrag-zuordnen { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin-top: 8px; }
.auftrag-zuordnen .select { flex: 1 1 180px; min-width: 0; max-width: 100%; }
.auftrag-liste button.link:disabled, .auftrag-liste button.link-danger:disabled { cursor: not-allowed; }
@container (max-width: 860px) {
.auftrag-table thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.auftrag-table, .auftrag-table tbody { display: block; }
.auftrag-table tr {
display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 16px; align-items: baseline;
grid-template-areas: "nummer status" "titel titel" "datum summe" "aktionen aktionen";
padding: 10px 0; border-bottom: 1px solid var(--line);
}
.auftrag-table td, .auftrag-table td:first-child, .auftrag-table td:last-child { display: block; padding: 0; border: 0; min-width: 0; }
.auftrag-nummer { grid-area: nummer; }
.auftrag-status { grid-area: status; text-align: right; }
.auftrag-titel { grid-area: titel; }
.auftrag-datum { grid-area: datum; }
.auftrag-table td.auftrag-amount { grid-area: summe; }
.auftrag-aktionen { grid-area: aktionen; padding-top: 4px; }
.auftrag-table td[data-label]::before { content: attr(data-label) " "; color: var(--text-2); font-weight: 500; }
}