Auftragsbestaetigung: Liste mit Status, Zusatzzeilen, Aktionen und Projekt zuordnen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8b055a5d42
commit
3650573445
2 files changed
+275
-7
No files matched your search
@@ -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).
|
* Karte „Aufträge“ im Kundenreiter: Auftragsbestätigungen (AB) mit Status, Zusatzzeilen und den Aktionen je Status.
|
||||||
* Die fertige Komponente rendert <Card title="Aufträge"> selbst und ruft nach jeder Aktion onChanged().
|
* 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,
|
||||||
* Props (V8): auftraege (AuftragTeam[], V3), konten (Projektname der Zeile und Auswahl für „Projekt zuordnen“, einer Aktion für
|
* „Zur Übersicht“ in Meldungen läuft über onShowOverview. Eine Sperre fängt den Doppelklick ab (eine Anfrage, eine Rückfrage).
|
||||||
* ABs ohne Projekt über ordneAuftragProjektZu(id, projektId) aus financeApi.js), onChanged(), onBearbeiten(auftrag)
|
* Schmal (Wrapper .auftrag-body bis 860 px) wird jede Zeile ein Raster.
|
||||||
* („Entwurf bearbeiten“; nach „Neue Fassung“ mit dem neuen Entwurf) und onShowOverview (Weg „Zur Übersicht“ aus Meldungen).
|
|
||||||
*/
|
*/
|
||||||
export default function AuftragListe({ auftraege, konten, onChanged, onBearbeiten, onShowOverview }) {
|
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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
Reference in new issue
Block a user