import { PureComponent, createRef } from 'react'; import Autocomplete from '@mui/material/Autocomplete'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import CircularProgress from '@mui/material/CircularProgress'; import Stack from '@mui/material/Stack'; import TextField from '@mui/material/TextField'; import UploadFileIcon from '@mui/icons-material/UploadFile'; import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; import ShoppingCartIcon from '@mui/icons-material/ShoppingCart'; import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined'; import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined'; import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; import { PanelTile } from 'dashgui/panels'; import { enqueueSnackbar } from 'notistack'; import RestoreIcon from '@mui/icons-material/Restore'; import FileDropzone from '../FileDropzone'; import AttachmentList from './AttachmentList'; import { hasArticleLines, manifestConfig, partyDisplayName, getLowestSortNonSupplierCustomerGroup, formatDateTime, } from './manifestConfig'; import { notifyError, notifyErrorMessage } from '../../utils/notify'; import entityAutomat from '../../automats/entityAutomat'; import companyAutomat from '../../automats/companyAutomat'; import quoteAutomat from '../../automats/quoteAutomat'; import orderAutomat from '../../automats/orderAutomat'; import deliveryAutomat from '../../automats/deliveryAutomat'; import { getNextDocumentNumber, ensureDocumentNumber } from '../../utils/documentNumber'; import { SURFACE_RAISED, } from '../../colors'; class ManifestHeader extends PureComponent { constructor(props) { super(props); const selected = (props.parties || []).find((row) => row.id === props.partyId) ?? (props.kind === 'delivery' ? (companyAutomat.state.suppliers || []).find((row) => row.id === props.partyId) : (entityAutomat.state.items || []).find((row) => row.id === props.partyId)) ?? null; this.state = { partyInput: selected ? partyDisplayName(props.kind, selected) : '', creatingParty: false, createdParty: null, }; this.fileInputRef = createRef(); this.partyInputRef = createRef(); this.focusPartyInput = this.focusPartyInput.bind(this); this.handlePartyChange = this.handlePartyChange.bind(this); this.handlePartyInputChange = this.handlePartyInputChange.bind(this); this.handlePartyKeyDown = this.handlePartyKeyDown.bind(this); this.handleCreateParty = this.handleCreateParty.bind(this); this.handleUploadClick = this.handleUploadClick.bind(this); this.handleFileChange = this.handleFileChange.bind(this); this.handleFileDrop = this.handleFileDrop.bind(this); } componentDidUpdate(prevProps) { if (prevProps.partyId !== this.props.partyId || prevProps.parties !== this.props.parties) { const selected = (this.props.parties || []).find((row) => row.id === this.props.partyId) ?? (this.state.createdParty?.id === this.props.partyId ? this.state.createdParty : null) ?? (this.props.kind === 'delivery' ? (companyAutomat.state.suppliers || []).find((row) => row.id === this.props.partyId) : (entityAutomat.state.items || []).find((row) => row.id === this.props.partyId)); if (selected) { this.setState({ partyInput: partyDisplayName(this.props.kind, selected) }); } else if (this.props.partyId == null) { this.setState({ partyInput: '', createdParty: null }); } } if (!prevProps.partyError && this.props.partyError) { this.partyInputRef.current?.focus(); } } focusPartyInput() { this.partyInputRef.current?.focus(); } handlePartyChange(_event, option) { this.setState({ createdParty: null, partyInput: option ? partyDisplayName(this.props.kind, option) : '', }); this.props.onPartyChange(option?.id ?? null); } handlePartyInputChange(_event, value, reason) { if (reason === 'reset') { const selected = (this.props.parties || []).find((row) => row.id === this.props.partyId) ?? this.state.createdParty; this.setState({ partyInput: selected ? partyDisplayName(this.props.kind, selected) : value, }); return; } this.setState({ partyInput: value }); } handlePartyKeyDown(event) { if (event.key !== 'Enter') return; if (event.defaultPrevented) return; const activeDescendant = event.target?.getAttribute?.('aria-activedescendant'); if (activeDescendant) return; const raw = this.state.partyInput?.trim(); if (!raw || this.state.creatingParty) return; const { parties, kind } = this.props; const lower = raw.toLowerCase(); const existing = (parties || []).find((p) => { const label = partyDisplayName(kind, p).toLowerCase(); return label === lower || String(p.name ?? '').toLowerCase() === lower; }); if (existing) { event.preventDefault(); event.stopPropagation(); this.setState({ createdParty: null, partyInput: partyDisplayName(kind, existing), }); this.props.onPartyChange(existing.id); event.target?.blur?.(); } else { event.preventDefault(); event.stopPropagation(); void this.handleCreateParty(raw, event.target); } } async handleCreateParty(rawInput, targetEl) { const raw = String(rawInput ?? this.state.partyInput ?? '').trim(); if (!raw || this.state.creatingParty) return; this.setState({ creatingParty: true }); try { const { kind, onPartyChange } = this.props; if (kind === 'delivery') { const row = await companyAutomat.actions.createSupplier({ name: raw }); const displayName = row?.name || raw; enqueueSnackbar(`Lieferant „${displayName}“ angelegt.`, { variant: 'success' }); this.setState({ creatingParty: false, createdParty: row, partyInput: displayName }); onPartyChange?.(row.id); } else { await companyAutomat.actions.ensureCustomergroupsLoaded(); const groups = companyAutomat.state.customergroups ?? []; const targetGroup = getLowestSortNonSupplierCustomerGroup(groups) ?? groups[0]; if (!targetGroup) { throw new Error('Keine Kundengruppe vorhanden.'); } let name = raw; let firstName = null; if (raw.includes(',')) { const parts = raw.split(',').map((p) => p.trim()); name = parts[0]; firstName = parts.slice(1).join(' ') || null; } else if (raw.includes(' ') && !/gmbh|ag|kg|ug|inc|ltd|co\./i.test(raw)) { const parts = raw.split(' '); firstName = parts[0]; name = parts.slice(1).join(' '); } const body = { customergroupId: targetGroup.id, name, }; if (firstName) body.firstName = firstName; const row = await entityAutomat.actions.create(body); const displayName = [row.firstName, row.name].filter(Boolean).join(' ') || row.name || raw; enqueueSnackbar(`Kunde „${displayName}“ angelegt.`, { variant: 'success' }); this.setState({ creatingParty: false, createdParty: row, partyInput: displayName }); onPartyChange?.(row.id); } if (targetEl?.blur) { targetEl.blur(); } else if (document.activeElement?.blur) { document.activeElement.blur(); } } catch (err) { this.setState({ creatingParty: false }); notifyError(err); } } hasArticles() { return Boolean( this.props.hasArticles ?? (Array.isArray(this.props.lines) && hasArticleLines(this.props.lines)), ); } handleUploadClick() { if (this.hasArticles()) { notifyErrorMessage( 'Datei-Upload nicht möglich, solange Artikel vorhanden sind. Bitte entfernen Sie zuerst alle Zeilen.', ); return; } this.fileInputRef.current?.click(); } handleFileDrop(file) { if (this.hasArticles()) { notifyErrorMessage( 'Datei-Upload nicht möglich, solange Artikel vorhanden sind. Bitte entfernen Sie zuerst alle Zeilen.', ); return; } this.props.onPdfUpload?.(file); } handleFileChange(event) { const file = event.target.files?.[0]; event.target.value = ''; if (!file) return; if (this.hasArticles()) { notifyErrorMessage( 'Datei-Upload nicht möglich, solange Artikel vorhanden sind. Bitte entfernen Sie zuerst alle Zeilen.', ); return; } this.props.onPdfUpload?.(file); } subscribeListAutomat(kind) { this.unsubList?.(); this.unsubList = null; const automat = kind === 'quote' ? quoteAutomat : kind === 'order' ? orderAutomat : kind === 'delivery' ? deliveryAutomat : null; if (automat) { this.unsubList = automat.subscribe(this, () => { if (!this._cancelled) this.forceUpdate(); }); } } componentDidMount() { this._cancelled = false; this.unsubNumber = companyAutomat.subscribe( this, () => { this.forceUpdate(); }, ['numbers'], ); this.subscribeListAutomat(this.props.kind); void companyAutomat.actions.ensureNumbersLoaded().catch(() => {}); if (!this.props.number && this.props.docId == null) { void ensureDocumentNumber(this.props.kind) .then(() => { if (!this._cancelled) this.forceUpdate(); }) .catch(() => {}); } } componentDidUpdate(prevProps) { if (prevProps.kind !== this.props.kind) { this.subscribeListAutomat(this.props.kind); } if ( !this.props.number && this.props.docId == null && (prevProps.kind !== this.props.kind || prevProps.docId !== this.props.docId) ) { void ensureDocumentNumber(this.props.kind) .then(() => { if (!this._cancelled) this.forceUpdate(); }) .catch(() => {}); } } componentWillUnmount() { this._cancelled = true; this.unsubNumber?.(); this.unsubList?.(); } render() { const { kind, headerColor, dateLabel, partyLabel, parties, partiesLoading, partyId, date, createdAt, number, externalNumber, saving, pdfUploading, attachments, onPdfUpload, onPdfCreate, pdfCreating, onPdfRescan, onPdfDelete, onPdfClick, onAttachmentClick, onMentionInChat, onDateChange, onNumberChange, onExternalNumberChange, onSave, onRestore, orderCreating, onCreateOrder, actionsColor, partyError, docId, ledgerSummary, onOpenBookDialog, isDirty, dirtyFields, dirtySummary, } = this.props; const hasArticles = this.hasArticles(); const isEdit = docId != null; const previewNumber = this.props.previewNumber ?? (!number && !isEdit ? getNextDocumentNumber(kind) : null); const hasAttachments = Array.isArray(attachments) && attachments.length > 0; const hasParty = partyId != null && partyId !== ''; const partyValid = kind !== 'order' || hasParty; const canSave = partyValid && (isEdit ? Boolean(isDirty) : Boolean(hasArticles || hasAttachments)); // Delivery: number is server-allocated/read-only; the caller reference // lives in the editable externalNumber field. const cfg = manifestConfig(kind); const useExternalNumber = Boolean(cfg.externalNumber); const selected = (parties || []).find((row) => row.id === partyId) ?? (this.state.createdParty?.id === partyId ? this.state.createdParty : null); const isDateEmpty = !date || String(date).trim() === ''; const isPartyEmpty = (kind !== 'quote' || Boolean(partyError)) && (partyId == null || partyId === ''); return ( <> {/* Tile 1: 3-wide Tile */} onDateChange(e.target.value)} autoComplete="off" slotProps={{ inputLabel: { shrink: true }, }} sx={{ flex: { xs: '1 1 100%', sm: '1 1 150px' }, minWidth: { sm: 130 }, ...(dirtyFields?.date && { '& .MuiOutlinedInput-notchedOutline': { borderColor: 'warning.main', borderWidth: 2, }, }), }} /> Wird angelegt… ) : ( { e.preventDefault(); const raw = this.state.partyInput?.trim(); if (raw) void this.handleCreateParty(raw); }} sx={{ cursor: 'pointer', display: 'block', width: '100%', userSelect: 'none', fontWeight: 600, color: 'primary.main', }} > Enter zum anlegen ) } disabled={saving || pdfUploading} getOptionLabel={(option) => partyDisplayName(kind, option)} isOptionEqualToValue={(option, value) => option?.id === value?.id} onChange={this.handlePartyChange} onInputChange={this.handlePartyInputChange} onKeyDown={this.handlePartyKeyDown} sx={{ flex: { xs: '1 1 100%', sm: '2 1 200px' }, minWidth: { sm: 160 }, }} renderInput={({ inputProps: _unusedInputProps, InputProps: _unusedInputProps2, InputLabelProps: _unusedInputLabelProps, ...params }) => ( )} /> {useExternalNumber ? ( ) : null} {isEdit && createdAt ? ( ) : null} {/* Tile 2: 1-wide Action Tile (4th tile in row) with Speichern & Datei hochladen */} {(() => { const canRestore = Boolean(onRestore && isDirty && !saving && !orderCreating); return ( ); })()} {docId != null && kind === 'delivery' && onOpenBookDialog && Boolean(ledgerSummary && (ledgerSummary.totalPositions > 0 || ledgerSummary.lines?.length > 0 || ledgerSummary.hasTrackStock)) && ( )} {kind === 'quote' && docId != null && onCreateOrder && ( )} {(kind === 'quote' || kind === 'order' || kind === 'delivery') && (() => { const canCreatePdf = Boolean( onPdfCreate && docId != null && Boolean(number) && !saving && !pdfCreating && !orderCreating ); return ( ); })()} {onPdfUpload ? ( {({ isDragging }) => ( )} ) : null} {onPdfUpload ? ( ) : null} ); } } export default ManifestHeader;