Merge auftraege/w2-auftraege: Auftrags-UI (Strang W2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
b7b3e834f8
13 files changed
+2596
-21
No files matched your search
@@ -1,12 +1,275 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Button, Dialog } from '../ui'
|
||||
import { createAuftrag, getAuftragPdf, getSettings, patchAuftrag, sendAuftrag } from '../../lib/financeApi'
|
||||
import { alsPdfBlob } from '../../lib/auftragPdf'
|
||||
import {
|
||||
SCHRITTE,
|
||||
auftragBestaetigung,
|
||||
auftragFehler,
|
||||
auftragHinweise,
|
||||
auftragZuFormular,
|
||||
formularZuNutzlast,
|
||||
heuteBerlin,
|
||||
kundenartWirksam,
|
||||
neueSperre,
|
||||
neuesFormular,
|
||||
pruefeSchritt,
|
||||
sendeGate,
|
||||
zahlungsplanOptionen,
|
||||
} from '../../lib/auftragView'
|
||||
import { SchrittAuftrag, SchrittProjekt, SchrittZahlung } from './AuftragSchritte'
|
||||
import AuftragVorschau from './AuftragVorschau'
|
||||
import './auftrag.css'
|
||||
|
||||
/**
|
||||
* Platzhalter von W0: Strang W2 ersetzt diese Datei vollständig (Dateiname, Default-Export und Props bleiben).
|
||||
*
|
||||
* Props (V8): open, onClose, onSaved(auftrag) (AuftragTeam mit mailStatus; nach Speichern oder Senden, danach onClose()),
|
||||
* kunde (kommt mit aktueller kundenart: der Container gleicht sie über kundeMitKundenart ab), projektId (vorausgewähltes
|
||||
* Projekt oder null = freier Titel), auftrag (Entwurf zum Bearbeiten oder null), konten (Projektkonten für die Projektwahl),
|
||||
* onShowOverview (Weg „Zur Übersicht“).
|
||||
* Die PDF-Vorschau öffnet openInNewTab(async () => URL.createObjectURL(await getAuftragPdf(id))).
|
||||
* Dialog „Auftragsbestätigung“ in vier Schritten (Projekt, Auftrag, Zahlung und Termine, Vorschau). Beim Wechsel nach
|
||||
* Schritt 4 wird der Entwurf angelegt bzw. aktualisiert und das PDF geholt (die Vorschau gibt es nur zu einem gespeicherten
|
||||
* Entwurf; archiviert wird sie nicht). „Senden“ fragt vorher nach und ist gesperrt, solange Kundendaten fehlen; Hinweise
|
||||
* (Steuernummer, „Vor Ort“ bei Privatperson) blockieren nie. Nach Speichern oder Senden ruft der Dialog onSaved(auftrag) und
|
||||
* danach immer onClose(): der Container schließt, lädt neu und meldet. Nur Entwürfe lassen sich bearbeiten.
|
||||
*/
|
||||
export default function AuftragDialog({ open, onClose, onSaved, kunde, projektId, auftrag, konten, onShowOverview }) {
|
||||
return null
|
||||
if (!open) return null
|
||||
if (auftrag && auftrag.status !== 'entwurf') {
|
||||
return (
|
||||
<Dialog title="Auftragsbestätigung" onClose={onClose} footer={<Button type="button" onClick={onClose}>Schließen</Button>}>
|
||||
<p className="msg warn" role="alert">
|
||||
Nur Entwürfe lassen sich bearbeiten. Eine gesendete Auftragsbestätigung lässt sich nur über „Neue Fassung“ in der Liste ändern.
|
||||
</p>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<AuftragForm
|
||||
key={auftrag?.id || projektId || 'neu'}
|
||||
onClose={onClose}
|
||||
onSaved={onSaved}
|
||||
kunde={kunde}
|
||||
projektId={projektId}
|
||||
auftrag={auftrag}
|
||||
konten={konten}
|
||||
onShowOverview={onShowOverview}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Ids der Felder (Task 6): wenige Felder haben eine abweichende id
|
||||
function feldId(feld, art) {
|
||||
if (feld === 'art') return 'ab-art-festpreis'
|
||||
if (feld === 'zahlungsart') return `ab-zahlungsart-${zahlungsplanOptionen(art)[0].value}`
|
||||
return `ab-${feld}`
|
||||
}
|
||||
|
||||
function AuftragForm({ onClose, onSaved, kunde, projektId, auftrag, konten, onShowOverview }) {
|
||||
const [schritt, setSchritt] = useState(1)
|
||||
const [form, setForm] = useState(() => (auftrag ? auftragZuFormular(auftrag) : neuesFormular({ projektId: projektId || '', konten, heute: heuteBerlin() })))
|
||||
const [fehler, setFehler] = useState({})
|
||||
const [meldung, setMeldung] = useState(null) // { text, zurUebersicht }
|
||||
const [entwurf, setEntwurf] = useState(auftrag || null)
|
||||
const [signatur, setSignatur] = useState('')
|
||||
const [pdfUrl, setPdfUrl] = useState('')
|
||||
const [phase, setPhase] = useState('') // '' | 'speichern' | 'pdf' | 'senden'
|
||||
const [einstellungen, setEinstellungen] = useState(undefined) // undefined = lädt, null = nicht ladbar
|
||||
const [fokus, setFokus] = useState(null) // { id }
|
||||
const sperre = useRef(neueSperre()).current
|
||||
const aliveRef = useRef(true)
|
||||
const schmutzigRef = useRef(false) // es wurde gespeichert und noch nicht gemeldet
|
||||
const urlRef = useRef('')
|
||||
|
||||
useEffect(() => {
|
||||
aliveRef.current = true
|
||||
getSettings()
|
||||
.then((d) => {
|
||||
if (aliveRef.current) setEinstellungen(d?.einstellungen ?? null)
|
||||
})
|
||||
.catch(() => {
|
||||
if (aliveRef.current) setEinstellungen(null)
|
||||
})
|
||||
return () => {
|
||||
aliveRef.current = false
|
||||
if (urlRef.current) URL.revokeObjectURL(urlRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (fokus) document.getElementById(fokus.id)?.focus()
|
||||
}, [fokus])
|
||||
|
||||
const hinweise = auftragHinweise({ kunde, form, einstellungen })
|
||||
const gate = sendeGate(kunde, form.kundenart)
|
||||
const privat = kundenartWirksam(kunde, form.kundenart) === 'privat'
|
||||
const beschaeftigt = phase !== ''
|
||||
|
||||
function zeigeFehler(schrittNr, r, art) {
|
||||
setSchritt(schrittNr)
|
||||
setFehler(r.fehler)
|
||||
setFokus({ id: feldId(r.erstes, art) })
|
||||
}
|
||||
|
||||
function weiter() {
|
||||
const r = pruefeSchritt(schritt, form, { kunde })
|
||||
if (!r.ok) {
|
||||
setFehler(r.fehler)
|
||||
setFokus({ id: feldId(r.erstes, form.art) })
|
||||
return
|
||||
}
|
||||
setFehler({})
|
||||
if (schritt === 3) zurVorschau()
|
||||
else setSchritt(schritt + 1)
|
||||
}
|
||||
|
||||
function zurueck() {
|
||||
setMeldung(null)
|
||||
setSchritt(Math.max(1, schritt - 1))
|
||||
}
|
||||
|
||||
function zurVorschau() {
|
||||
const r = formularZuNutzlast(form, { kunde })
|
||||
if (!r.ok) {
|
||||
zeigeFehler(r.schritt, r, form.art)
|
||||
return
|
||||
}
|
||||
setSchritt(4)
|
||||
vorschauLaden(r.payload)
|
||||
}
|
||||
|
||||
async function vorschauLaden(payload) {
|
||||
const neu = JSON.stringify(payload)
|
||||
if (neu === signatur && pdfUrl) return
|
||||
if (sperre.belegt) return
|
||||
await sperre.run(async () => {
|
||||
if (urlRef.current) URL.revokeObjectURL(urlRef.current)
|
||||
urlRef.current = ''
|
||||
setPdfUrl('')
|
||||
setPhase('speichern')
|
||||
setMeldung(null)
|
||||
try {
|
||||
const data = entwurf ? await patchAuftrag(entwurf.id, payload) : await createAuftrag({ kundenId: kunde.$id, ...payload })
|
||||
if (!aliveRef.current) return
|
||||
setEntwurf(data.auftrag)
|
||||
schmutzigRef.current = true
|
||||
setSignatur(neu)
|
||||
setPhase('pdf')
|
||||
const blob = await getAuftragPdf(data.auftrag.id)
|
||||
const url = URL.createObjectURL(alsPdfBlob(blob))
|
||||
if (!aliveRef.current) {
|
||||
URL.revokeObjectURL(url)
|
||||
return
|
||||
}
|
||||
urlRef.current = url
|
||||
setPdfUrl(url)
|
||||
} catch (err) {
|
||||
if (aliveRef.current) setMeldung(auftragFehler(err)) // ein schon gespeicherter Entwurf bleibt
|
||||
} finally {
|
||||
if (aliveRef.current) setPhase('')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function speichernUndSchliessen() {
|
||||
schmutzigRef.current = false
|
||||
if (entwurf) onSaved(entwurf)
|
||||
onClose()
|
||||
}
|
||||
|
||||
function schliessen() {
|
||||
if (beschaeftigt) return
|
||||
if (schmutzigRef.current && entwurf) onSaved(entwurf)
|
||||
schmutzigRef.current = false
|
||||
onClose()
|
||||
}
|
||||
|
||||
async function senden() {
|
||||
if (sperre.belegt || !entwurf) return
|
||||
const g = sendeGate(kunde, form.kundenart)
|
||||
if (!g.erlaubt) {
|
||||
setMeldung({ text: g.grund, zurUebersicht: g.zurUebersicht })
|
||||
return
|
||||
}
|
||||
if (!window.confirm(auftragBestaetigung('senden', entwurf, { email: kunde?.email }))) return
|
||||
await sperre.run(async () => {
|
||||
setPhase('senden')
|
||||
setMeldung(null)
|
||||
try {
|
||||
const data = await sendAuftrag(entwurf.id)
|
||||
schmutzigRef.current = false
|
||||
onSaved(data.auftrag)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
if (aliveRef.current) setMeldung(auftragFehler(err))
|
||||
} finally {
|
||||
if (aliveRef.current) setPhase('')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const fuss = (
|
||||
<div className="auftrag-foot">
|
||||
{schritt > 1 && (
|
||||
<Button type="button" onClick={zurueck} disabled={beschaeftigt}>Zurück</Button>
|
||||
)}
|
||||
<span className="grow" />
|
||||
{schritt < 4 ? (
|
||||
<>
|
||||
<Button type="button" onClick={schliessen} disabled={beschaeftigt}>Abbrechen</Button>
|
||||
<Button type="submit" variant="primary" disabled={beschaeftigt}>{schritt === 3 ? 'Zur Vorschau' : 'Weiter'}</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button type="button" onClick={speichernUndSchliessen} disabled={beschaeftigt || !entwurf}>Als Entwurf speichern</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={senden}
|
||||
disabled={beschaeftigt || !entwurf || !pdfUrl || !gate.erlaubt}
|
||||
title={!gate.erlaubt ? gate.grund : undefined}
|
||||
>
|
||||
{phase === 'senden' ? 'Sende …' : 'Senden'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
as="form"
|
||||
wide
|
||||
title={auftrag ? 'Auftragsbestätigung bearbeiten' : 'Auftragsbestätigung'}
|
||||
busy={beschaeftigt}
|
||||
onClose={schliessen}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
if (schritt < 4) weiter()
|
||||
}}
|
||||
footer={fuss}
|
||||
>
|
||||
<ol className="auftrag-schritte" aria-label="Schritte">
|
||||
{SCHRITTE.map((s) => (
|
||||
<li key={s.id} aria-current={s.id === schritt ? 'step' : undefined}>
|
||||
<span className="auftrag-schritt-nr">{s.id}</span>
|
||||
<span className="auftrag-schritt-text">{s.label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{schritt === 1 && <SchrittProjekt form={form} onChange={setForm} fehler={fehler} kunde={kunde} konten={konten} neueFassung={Boolean(auftrag?.ersetztId)} />}
|
||||
{schritt === 2 && <SchrittAuftrag form={form} onChange={setForm} fehler={fehler} kunde={kunde} konten={konten} />}
|
||||
{schritt === 3 && <SchrittZahlung form={form} onChange={setForm} fehler={fehler} kunde={kunde} konten={konten} />}
|
||||
{schritt === 4 && (
|
||||
<AuftragVorschau
|
||||
phase={phase}
|
||||
pdfUrl={pdfUrl}
|
||||
meldung={meldung}
|
||||
gate={gate}
|
||||
privat={privat}
|
||||
hinweise={hinweise}
|
||||
onRetry={zurVorschau}
|
||||
onShowOverview={() => {
|
||||
schliessen()
|
||||
onShowOverview()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import { Badge } from '../ui'
|
||||
import FormField from '../finance/FormField'
|
||||
import { useWorkorders } from '../../hooks/useWorkorders'
|
||||
import { euro } from '../../lib/financeFormat'
|
||||
import { customerName, lineTotalCents } from '../../lib/financeView'
|
||||
import { KUNDENART_OPTIONEN, kundenartLabel } from '../../lib/kundenFelder'
|
||||
import {
|
||||
AUFTRAG_LIMITS,
|
||||
AUFTRAG_WEGE,
|
||||
formularSummeCents,
|
||||
kundenartWirksam,
|
||||
neuePosition,
|
||||
parseProzent,
|
||||
parseTage,
|
||||
schalteZeitquelle,
|
||||
vorOrtHinweis,
|
||||
waehleProjekt,
|
||||
wechselArt,
|
||||
zahlungsplanOptionen,
|
||||
zahlungsplanVorschau,
|
||||
zeitquelleOptionen,
|
||||
} from '../../lib/auftragView'
|
||||
import './auftrag.css'
|
||||
|
||||
/** Meldung eines Feldes ohne FormField (Art, Positionen, Zahlungsplan, Zeitquelle). */
|
||||
function FeldFehler({ text }) {
|
||||
return text ? <div className="msg err" role="alert">{text}</div> : null
|
||||
}
|
||||
|
||||
/** Schritt 1: Projekt, Titel und (falls am Kunden noch leer) die Kundenart. */
|
||||
export function SchrittProjekt({ form, onChange, fehler, kunde, konten, neueFassung = false }) {
|
||||
const name = customerName(kunde) || 'Kunde'
|
||||
return (
|
||||
<div className="auftrag-schritt">
|
||||
<p className="muted">
|
||||
Kunde: <strong>{name}</strong>
|
||||
{kunde?.email ? ` · ${kunde.email}` : ''}
|
||||
</p>
|
||||
{neueFassung && <p className="msg muted">Neue Fassung: Beim Senden ersetzt sie die bisherige Auftragsbestätigung.</p>}
|
||||
<FormField id="ab-projekt" label="Projekt">
|
||||
<select className="select" value={form.projektId} onChange={(e) => onChange(waehleProjekt(form, e.target.value, konten))}>
|
||||
<option value="">Kein Projekt (freier Titel)</option>
|
||||
{(Array.isArray(konten) ? konten : []).map((k) => (
|
||||
<option key={k.projektId} value={k.projektId}>{k.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField
|
||||
id="ab-titel"
|
||||
label="Titel"
|
||||
hint="Steht als Titel auf der Auftragsbestätigung, z. B. „Website für Praxis Müller“."
|
||||
error={fehler.titel}
|
||||
>
|
||||
<input
|
||||
className="input"
|
||||
maxLength={AUFTRAG_LIMITS.maxTitel}
|
||||
value={form.titel}
|
||||
onChange={(e) => onChange({ ...form, titel: e.target.value, titelGeaendert: true })}
|
||||
/>
|
||||
</FormField>
|
||||
{!kunde?.kundenart ? (
|
||||
<FormField
|
||||
id="ab-kundenart"
|
||||
label="Kundenart"
|
||||
hint="Wird am Kunden gespeichert. Privatpersonen bekommen die Widerrufsbelehrung in der Auftragsbestätigung."
|
||||
error={fehler.kundenart}
|
||||
>
|
||||
<select className="select" value={form.kundenart} onChange={(e) => onChange({ ...form, kundenart: e.target.value })}>
|
||||
<option value="">Bitte wählen</option>
|
||||
{KUNDENART_OPTIONEN.slice(1).map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
) : (
|
||||
<p className="muted">Kundenart: {kundenartLabel(kunde.kundenart)}. Änderbar in der Übersicht des Kunden.</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Schritt 2: Absprache (Datum, Weg), Art des Auftrags und Positionen bzw. Stundensatz. */
|
||||
export function SchrittAuftrag({ form, onChange, fehler, kunde }) {
|
||||
const vorOrt = vorOrtHinweis(form.auftragWeg, kundenartWirksam(kunde, form.kundenart))
|
||||
const positionen = form.positionen
|
||||
const setPosition = (uid, feld, wert) =>
|
||||
onChange({ ...form, positionen: positionen.map((p) => (p.uid === uid ? { ...p, [feld]: wert } : p)) })
|
||||
const entferne = (uid) => onChange({ ...form, positionen: positionen.filter((p) => p.uid !== uid) })
|
||||
const summe = formularSummeCents(form)
|
||||
|
||||
return (
|
||||
<div className="auftrag-schritt">
|
||||
<div className="field-row">
|
||||
<FormField id="ab-auftragVom" label="Datum der Absprache" error={fehler.auftragVom}>
|
||||
<input className="input" type="date" value={form.auftragVom} onChange={(e) => onChange({ ...form, auftragVom: e.target.value })} />
|
||||
</FormField>
|
||||
<FormField id="ab-auftragWeg" label="Weg der Absprache" error={fehler.auftragWeg}>
|
||||
<select className="select" value={form.auftragWeg} onChange={(e) => onChange({ ...form, auftragWeg: e.target.value })}>
|
||||
<option value="">Bitte wählen</option>
|
||||
{AUFTRAG_WEGE.map((w) => (
|
||||
<option key={w.value} value={w.value}>{w.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
{vorOrt && <p className="msg warn auftrag-hinweis">{vorOrt}</p>}
|
||||
|
||||
<fieldset className="auftrag-art">
|
||||
<legend>Art des Auftrags</legend>
|
||||
<label className="check">
|
||||
<input type="radio" name="ab-art" id="ab-art-festpreis" checked={form.art === 'festpreis'} onChange={() => onChange(wechselArt(form, 'festpreis'))} />
|
||||
<span className="auftrag-check-text">Festpreis</span>
|
||||
<span className="hint">Feste Positionen mit Preisen.</span>
|
||||
</label>
|
||||
<label className="check">
|
||||
<input type="radio" name="ab-art" id="ab-art-aufwand" checked={form.art === 'aufwand'} onChange={() => onChange(wechselArt(form, 'aufwand'))} />
|
||||
<span className="auftrag-check-text">Nach Aufwand</span>
|
||||
<span className="hint">Stundensatz, abgerechnet nach erfasster Arbeitszeit.</span>
|
||||
</label>
|
||||
<FeldFehler text={fehler.art} />
|
||||
</fieldset>
|
||||
|
||||
{form.art === 'festpreis' && (
|
||||
<fieldset id="ab-positionen" tabIndex={-1} className="auftrag-items">
|
||||
<legend>Positionen</legend>
|
||||
{positionen.map((p, i) => {
|
||||
const n = i + 1
|
||||
const zeile = lineTotalCents(p)
|
||||
return (
|
||||
<div key={p.uid} className="auftrag-item">
|
||||
<div className="auftrag-item-desc">
|
||||
<span className="auftrag-item-label">Beschreibung</span>
|
||||
<input
|
||||
className="input"
|
||||
maxLength={AUFTRAG_LIMITS.maxBeschreibung}
|
||||
aria-label={`Position ${n}: Beschreibung`}
|
||||
placeholder="z. B. Startseite"
|
||||
value={p.description}
|
||||
onChange={(e) => setPosition(p.uid, 'description', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="auftrag-item-qty">
|
||||
<span className="auftrag-item-label">Menge</span>
|
||||
<input
|
||||
className="input"
|
||||
inputMode="numeric"
|
||||
aria-label={`Position ${n}: Menge`}
|
||||
value={p.quantity}
|
||||
onChange={(e) => setPosition(p.uid, 'quantity', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="auftrag-item-price">
|
||||
<span className="auftrag-item-label">Einzelpreis in €</span>
|
||||
<input
|
||||
className="input"
|
||||
inputMode="decimal"
|
||||
aria-label={`Position ${n}: Einzelpreis in Euro`}
|
||||
placeholder="0,00"
|
||||
value={p.price}
|
||||
onChange={(e) => setPosition(p.uid, 'price', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="auftrag-item-sum">
|
||||
<span className="auftrag-item-label">Summe</span>
|
||||
<span className="auftrag-item-wert">{zeile === null ? '–' : euro(zeile)}</span>
|
||||
</div>
|
||||
<div className="auftrag-item-remove">
|
||||
<button
|
||||
type="button"
|
||||
className="link"
|
||||
aria-label={`Position ${n} entfernen`}
|
||||
disabled={positionen.length <= 1}
|
||||
onClick={() => entferne(p.uid)}
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={positionen.length >= AUFTRAG_LIMITS.maxPositionen}
|
||||
onClick={() => onChange({ ...form, positionen: [...positionen, neuePosition()] })}
|
||||
>
|
||||
+ Position
|
||||
</button>
|
||||
</div>
|
||||
<div className="auftrag-total" aria-live="polite">
|
||||
<span>Summe</span>
|
||||
<span>{euro(summe)}</span>
|
||||
</div>
|
||||
<p className="hint">Gemäß § 19 UStG ohne Umsatzsteuer.</p>
|
||||
<FeldFehler text={fehler.positionen} />
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{form.art === 'aufwand' && (
|
||||
<div className="field-row">
|
||||
<FormField id="ab-stundensatz" label="Stundensatz in €" hint="Steht mit der Aufwandsregel in der Auftragsbestätigung." error={fehler.stundensatz}>
|
||||
<input className="input" inputMode="decimal" value={form.stundensatz} onChange={(e) => onChange({ ...form, stundensatz: e.target.value })} />
|
||||
</FormField>
|
||||
<FormField
|
||||
id="ab-schaetzung"
|
||||
label="Geschätzter Umfang in Stunden (optional)"
|
||||
hint="In Schritten von 15 Minuten, z. B. 10 oder 12,5. Unverbindlich."
|
||||
error={fehler.schaetzung}
|
||||
>
|
||||
<input
|
||||
className="input"
|
||||
inputMode="decimal"
|
||||
placeholder="z. B. 10"
|
||||
value={form.schaetzung}
|
||||
onChange={(e) => onChange({ ...form, schaetzung: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Zeitquelle: Tickets des Kunden, deren Arbeitsblätter zum Auftrag zählen (höchstens 10). */
|
||||
function ZeitquelleAuswahl({ kundeId, form, onChange, projektTicketIds, fehler }) {
|
||||
const { workorders, loading, error } = useWorkorders({ customerId: kundeId, limit: 100 })
|
||||
const optionen = zeitquelleOptionen(workorders, form.zeitquelle, projektTicketIds)
|
||||
const voll = form.zeitquelle.length >= AUFTRAG_LIMITS.maxZeitquellen
|
||||
return (
|
||||
<fieldset id="ab-zeitquelle" tabIndex={-1} className="auftrag-zeitquelle">
|
||||
<legend>Zeiterfassung aus diesen Tickets</legend>
|
||||
<p className="hint">Arbeitsblätter dieser Tickets zählen zum Auftrag (Projektkonto, Aufwandsrechnung). Höchstens 10.</p>
|
||||
{loading && optionen.length === 0 && <div className="loading" role="status">Tickets werden geladen …</div>}
|
||||
{error && <p className="msg warn" role="alert">Die Tickets konnten nicht geladen werden: {error}. Die bisherige Auswahl bleibt.</p>}
|
||||
{!loading && !error && optionen.length === 0 && <p className="muted">Dieser Kunde hat noch keine Tickets.</p>}
|
||||
{optionen.length > 0 && (
|
||||
<div className="auftrag-zeitquelle-liste">
|
||||
{optionen.map((o) => (
|
||||
<label key={o.id} className="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={o.gewaehlt}
|
||||
disabled={!o.gewaehlt && voll}
|
||||
onChange={(e) => onChange({ ...form, zeitquelle: schalteZeitquelle(form.zeitquelle, o.id, e.target.checked), zeitquelleGeaendert: true })}
|
||||
/>
|
||||
<span className="auftrag-check-text">{o.label}</span>
|
||||
{o.projekt && <Badge tone="muted">Ticket des Projekts</Badge>}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<FeldFehler text={fehler.zeitquelle} />
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
|
||||
/** Schritt 3: Zahlungsplan, Zahlungsziel, Termine und Zeitquelle. */
|
||||
export function SchrittZahlung({ form, onChange, fehler, kunde, konten }) {
|
||||
const konto = (Array.isArray(konten) ? konten : []).find((k) => k.projektId && k.projektId === form.projektId)
|
||||
const projektTicketIds = konto && Array.isArray(konto.tickets) ? konto.tickets.map((t) => t.id) : []
|
||||
const vorschau = zahlungsplanVorschau({
|
||||
art: form.zahlungsart,
|
||||
prozent: parseProzent(form.prozent),
|
||||
text: form.zahlungstext,
|
||||
summeCents: formularSummeCents(form),
|
||||
zielTage: parseTage(form.zahlungsziel),
|
||||
})
|
||||
return (
|
||||
<div className="auftrag-schritt">
|
||||
<fieldset className="auftrag-art">
|
||||
<legend>Zahlungsplan</legend>
|
||||
{zahlungsplanOptionen(form.art).map((o) => (
|
||||
<label key={o.value} className="check">
|
||||
<input
|
||||
type="radio"
|
||||
name="ab-zahlungsart"
|
||||
id={`ab-zahlungsart-${o.value}`}
|
||||
checked={form.zahlungsart === o.value}
|
||||
onChange={() => onChange({ ...form, zahlungsart: o.value })}
|
||||
/>
|
||||
<span className="auftrag-check-text">{o.label}</span>
|
||||
</label>
|
||||
))}
|
||||
<FeldFehler text={fehler.zahlungsart} />
|
||||
</fieldset>
|
||||
{form.zahlungsart === 'anzahlung' && (
|
||||
<FormField id="ab-prozent" label="Anzahlung in %" error={fehler.prozent}>
|
||||
<input className="input auftrag-narrow" inputMode="numeric" value={form.prozent} onChange={(e) => onChange({ ...form, prozent: e.target.value })} />
|
||||
</FormField>
|
||||
)}
|
||||
{form.zahlungsart === 'frei' && (
|
||||
<div>
|
||||
<FormField id="ab-zahlungstext" label="Zahlungsplan als Text" error={fehler.zahlungstext}>
|
||||
<textarea
|
||||
className="textarea"
|
||||
rows={3}
|
||||
maxLength={AUFTRAG_LIMITS.maxZahlungsplanText}
|
||||
value={form.zahlungstext}
|
||||
onChange={(e) => onChange({ ...form, zahlungstext: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="hint">{form.zahlungstext.length} / {AUFTRAG_LIMITS.maxZahlungsplanText} Zeichen</div>
|
||||
</div>
|
||||
)}
|
||||
{vorschau && <p className="msg muted auftrag-plan-vorschau">Sinngemäß: {vorschau}</p>}
|
||||
<FormField id="ab-zahlungsziel" label="Zahlungsziel in Tagen" hint="0 bis 60 Tage nach Rechnung." error={fehler.zahlungsziel}>
|
||||
<input className="input auftrag-narrow" inputMode="numeric" value={form.zahlungsziel} onChange={(e) => onChange({ ...form, zahlungsziel: e.target.value })} />
|
||||
</FormField>
|
||||
<div className="field-row">
|
||||
<FormField id="ab-startAm" label="Start (optional)" error={fehler.startAm}>
|
||||
<input className="input" type="date" value={form.startAm} onChange={(e) => onChange({ ...form, startAm: e.target.value })} />
|
||||
</FormField>
|
||||
<FormField id="ab-fertigBis" label="Voraussichtliche Fertigstellung (optional)" error={fehler.fertigBis}>
|
||||
<input className="input" type="date" value={form.fertigBis} onChange={(e) => onChange({ ...form, fertigBis: e.target.value })} />
|
||||
</FormField>
|
||||
</div>
|
||||
<ZeitquelleAuswahl kundeId={kunde?.$id} form={form} onChange={onChange} projektTicketIds={projektTicketIds} fehler={fehler} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useRef } from 'react'
|
||||
import { Button } from '../ui'
|
||||
import { useScrollIntoView } from '../../hooks/useScrollIntoView'
|
||||
import './auftrag.css'
|
||||
|
||||
/**
|
||||
* Schritt 4 des Dialogs als reine Darstellung (kein eigener Zustand): Lade- und Fehlerzustand, Hinweise und die PDF-Vorschau.
|
||||
* Alles, was vor dem Senden zu lesen ist, steht über dem hohen PDF-Rahmen. Hinweise sind reine Texte und sperren nichts;
|
||||
* nur `gate` (Kundendaten) hält das Senden auf, der Entwurf lässt sich trotzdem speichern.
|
||||
*
|
||||
* phase: '' | 'speichern' | 'pdf' | 'senden'; meldung: { text, zurUebersicht? } | null; gate: { erlaubt, grund, zurUebersicht };
|
||||
* hinweise: [{ key, text }]; privat: Kundenart Privatperson.
|
||||
*/
|
||||
export default function AuftragVorschau({ phase, pdfUrl, meldung, gate, privat, hinweise = [], onRetry, onShowOverview }) {
|
||||
const errorRef = useRef(null)
|
||||
useScrollIntoView(errorRef, meldung)
|
||||
const laedt = phase === 'speichern' || phase === 'pdf'
|
||||
|
||||
return (
|
||||
<div className="auftrag-schritt">
|
||||
{phase === 'speichern' && <div className="loading" role="status">Entwurf wird gespeichert …</div>}
|
||||
{phase === 'pdf' && <div className="loading" role="status">Vorschau wird erstellt …</div>}
|
||||
|
||||
{meldung && (
|
||||
<div className="msg err" role="alert" ref={errorRef}>
|
||||
{meldung.text}
|
||||
{meldung.zurUebersicht && (
|
||||
<>
|
||||
{' '}
|
||||
<button type="button" className="link" onClick={onShowOverview}>
|
||||
Zur Übersicht
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{meldung && !pdfUrl && !laedt && phase !== 'senden' && (
|
||||
<div>
|
||||
<Button type="button" onClick={onRetry}>Erneut versuchen</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pdfUrl && <p className="msg ok">Entwurf gespeichert. Er bleibt in der Liste, bis er gesendet oder gelöscht wird.</p>}
|
||||
|
||||
{gate && !gate.erlaubt && (
|
||||
<div className="msg warn" role="alert">
|
||||
<span>{gate.grund}</span>
|
||||
{gate.zurUebersicht && (
|
||||
<>
|
||||
{' '}
|
||||
<button type="button" className="link" onClick={onShowOverview}>
|
||||
Zur Übersicht
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hinweise.map((h) => (
|
||||
<p key={h.key} className="msg warn auftrag-hinweis">{h.text}</p>
|
||||
))}
|
||||
|
||||
{privat && (
|
||||
<p className="hint">
|
||||
Privatperson: Die Auftragsbestätigung enthält die Widerrufsbelehrung. Mit der Arbeit darf erst nach Ablauf der Widerrufsfrist begonnen
|
||||
werden, es sei denn, der Kunde verlangt es ausdrücklich (Häkchen auf der Bestätigungsseite).
|
||||
</p>
|
||||
)}
|
||||
|
||||
{pdfUrl && (
|
||||
<>
|
||||
<iframe className="auftrag-pdf" title="Vorschau der Auftragsbestätigung" src={pdfUrl} />
|
||||
<a className="link" href={pdfUrl} target="_blank" rel="noopener noreferrer">
|
||||
PDF in neuem Tab öffnen
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/* 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 { padding: 2px; 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-liste .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-liste .auftrag-table td, .auftrag-liste .auftrag-table td:first-child, .auftrag-liste .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-liste .auftrag-table td.auftrag-amount { grid-area: summe; }
|
||||
.auftrag-liste .auftrag-table td.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; }
|
||||
}
|
||||
|
||||
/* Dialog: Schritte 1 bis 3 */
|
||||
.auftrag-schritt { display: grid; gap: 16px; min-width: 0; }
|
||||
.auftrag-schritt .field { min-width: 0; }
|
||||
.auftrag-art { border: 0; padding: 0; margin: 0; display: grid; gap: 8px; min-width: 0; }
|
||||
.auftrag-art legend { font-weight: 500; padding: 0; margin-bottom: 4px; }
|
||||
.auftrag-art .check { flex-wrap: wrap; }
|
||||
.auftrag-check-text { flex: 1 1 0; min-width: 0; overflow-wrap: anywhere; }
|
||||
.auftrag-zeitquelle-liste .auftrag-check-text { flex: 0 1 auto; }
|
||||
.auftrag-art .check .hint { flex-basis: 100%; padding-left: 23px; font-size: 13px; color: var(--text-2); }
|
||||
.auftrag-items { border: 0; padding: 0; margin: 0; display: grid; gap: 12px; min-width: 0; }
|
||||
.auftrag-items legend { font-weight: 500; padding: 0; margin-bottom: 4px; }
|
||||
.auftrag-item {
|
||||
display: grid; grid-template-columns: minmax(0, 1fr) 80px 120px 110px auto; gap: 8px 12px; align-items: end;
|
||||
grid-template-areas: "desc qty price sum remove";
|
||||
}
|
||||
.auftrag-item .input { width: 100%; }
|
||||
.auftrag-item > div { display: grid; gap: 4px; min-width: 0; }
|
||||
.auftrag-item-desc { grid-area: desc; }
|
||||
.auftrag-item-qty { grid-area: qty; }
|
||||
.auftrag-item-price { grid-area: price; }
|
||||
.auftrag-item-sum { grid-area: sum; }
|
||||
.auftrag-item-remove { grid-area: remove; }
|
||||
.auftrag-item-qty .input, .auftrag-item-price .input { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.auftrag-item-wert { min-height: 36px; display: flex; align-items: center; justify-content: flex-end; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.auftrag-item-remove { min-height: 36px; align-content: center; }
|
||||
.auftrag-item-label { font-size: 12px; color: var(--text-2); }
|
||||
.auftrag-item + .auftrag-item .auftrag-item-label { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
||||
.auftrag-total { display: flex; justify-content: space-between; gap: 16px; font-weight: 600; }
|
||||
.auftrag-narrow { max-width: 120px; }
|
||||
.auftrag-plan-vorschau { margin: 0; overflow-wrap: anywhere; }
|
||||
.auftrag-hinweis { margin: 0; overflow-wrap: anywhere; }
|
||||
.auftrag-zeitquelle { border: 0; padding: 0; margin: 0; display: grid; gap: 6px; min-width: 0; }
|
||||
.auftrag-zeitquelle legend { font-weight: 500; padding: 0; margin-bottom: 4px; }
|
||||
.auftrag-zeitquelle-liste { display: grid; gap: 6px; max-height: 220px; overflow-y: auto; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); padding: 6px 0; }
|
||||
.auftrag-zeitquelle-liste .check { flex-wrap: wrap; overflow-wrap: anywhere; }
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.auftrag-item {
|
||||
grid-template-columns: minmax(0, .7fr) minmax(0, 1fr) minmax(0, 1fr) auto;
|
||||
grid-template-areas: "desc desc desc desc" "qty price sum remove";
|
||||
}
|
||||
.auftrag-item + .auftrag-item .auftrag-item-label, .auftrag-item-label { position: static; width: auto; height: auto; margin: 0; overflow: visible; clip: auto; white-space: normal; }
|
||||
.auftrag-narrow { max-width: none; }
|
||||
}
|
||||
|
||||
/* Dialog: Schrittleiste, Vorschau, Fuss */
|
||||
.auftrag-schritte { list-style: none; margin: 0 0 16px; padding: 0; display: flex; flex-wrap: wrap; gap: 8px 20px; font-size: 13px; color: var(--text-2); }
|
||||
.auftrag-schritte li[aria-current="step"] { color: var(--text); font-weight: 600; }
|
||||
.auftrag-schritt-nr { margin-right: 6px; }
|
||||
.auftrag-pdf { width: 100%; height: min(70vh, 640px); border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-subtle); }
|
||||
.auftrag-foot { display: flex; flex: 1; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
.auftrag-foot .grow { flex: 1; }
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.auftrag-schritt-text { display: none; }
|
||||
.auftrag-schritte li[aria-current="step"] .auftrag-schritt-text { display: inline; }
|
||||
.auftrag-pdf { height: 50vh; }
|
||||
.auftrag-foot .btn { flex: 1 1 auto; }
|
||||
}
|
||||
Reference in new issue
Block a user