diff --git a/.agents/skills/frontend-routes/SKILL.md b/.agents/skills/frontend-routes/SKILL.md index 80a3f8e..d68155a 100644 --- a/.agents/skills/frontend-routes/SKILL.md +++ b/.agents/skills/frontend-routes/SKILL.md @@ -33,6 +33,9 @@ Reference guide for the backend LLM agent when emitting route navigation actions | **Order List (Bestellungen)**| `/auftrag/bestellung` | Static | `/auftrag/bestellung` | | **New Order** | `/auftrag/bestellung/neu` | Static draft form | `/auftrag/bestellung/neu` | | **Order Detail** | `/auftrag/bestellung/:orderId` | Order document ID: `/auftrag/bestellung/${orderId}` | `/auftrag/bestellung/2005` | +| **Shipments (Ausliefern)** | `/auftrag/ausliefern` | Static (shipment manifests & parcels) | `/auftrag/ausliefern` | +| **Payments (Zahlungen)** | `/auftrag/zahlungen` | Static (order payments ledger) | `/auftrag/zahlungen` | + | **Customer List** | `/kunde` | Static | `/kunde` | | **New Customer** | `/kunde/neu` | Static form | `/kunde/neu` | | **Customer Detail** | `/kunde/:entityId` | Customer entity ID: `/kunde/${entityId}` | `/kunde/312` | @@ -68,8 +71,11 @@ Reference guide for the backend LLM agent when emitting route navigation actions - Distinction: - Quotes (Angebote) live at `/auftrag` and `/auftrag/:quoteId`. - Orders (Bestellungen) live at `/auftrag/bestellung` and `/auftrag/bestellung/:orderId`. + - Shipments (Ausliefern) live at `/auftrag/ausliefern` (connected to shipment manifests and packages). + - Payments (Zahlungen) live at `/auftrag/zahlungen` (payment bookings and payment balances). - Both IDs (`quoteId`, `orderId`) are document IDs. + ### Customers (`/kunde`) - `:entityId`: Numeric ID of the entity/customer record (e.g. `entity.id`). - Opens contact addresses, communications, and customer data tiles. diff --git a/src/api/account.js b/src/api/account.js new file mode 100644 index 0000000..c0543da --- /dev/null +++ b/src/api/account.js @@ -0,0 +1,24 @@ +import { http } from './http.js'; + +/** + * Company payment accounts. + */ +export const account = { + // #region GET /api/account — list + list() { + return http('/api/account'); + }, + // #endregion + + // #region POST /api/account — create + create(body) { + return http('/api/account', { method: 'POST', body }); + }, + // #endregion + + // #region DELETE /api/account/:id — delete + delete(id) { + return http(`/api/account/${id}`, { method: 'DELETE' }); + }, + // #endregion +}; diff --git a/src/api/index.js b/src/api/index.js index 4c579bd..ae5ffaa 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -26,3 +26,8 @@ export { vfs } from './vfs.js'; export { warehouse } from './warehouse.js'; export { stock } from './stock.js'; export { globalHistory } from './globalHistory.js'; +export { shipment } from './shipment.js'; +export { shipping } from './shipping.js'; +export { payment } from './payment.js'; +export { account } from './account.js'; + diff --git a/src/api/payment.js b/src/api/payment.js new file mode 100644 index 0000000..ccb16a9 --- /dev/null +++ b/src/api/payment.js @@ -0,0 +1,24 @@ +import { http } from './http.js'; + +/** + * Payments linked to orders (manifests). + */ +export const payment = { + // #region GET /api/order/:id/payment — listByOrder + listByOrder(orderId) { + return http(`/api/order/${orderId}/payment`); + }, + // #endregion + + // #region POST /api/order/:id/payment — create + create(orderId, body) { + return http(`/api/order/${orderId}/payment`, { method: 'POST', body }); + }, + // #endregion + + // #region DELETE /api/payment/:id — delete + delete(id) { + return http(`/api/payment/${id}`, { method: 'DELETE' }); + }, + // #endregion +}; diff --git a/src/api/shipment.js b/src/api/shipment.js new file mode 100644 index 0000000..3ff56cf --- /dev/null +++ b/src/api/shipment.js @@ -0,0 +1,31 @@ +import { http } from './http.js'; + +/** + * Outbound shipments (manifest type = shipment). + * Manifest number format: , e.g. AU-1001-L-1. + */ +export const shipment = { + // #region GET /api/order/:id/shipment — listByOrder + listByOrder(orderId) { + return http(`/api/order/${orderId}/shipment`); + }, + // #endregion + + // #region POST /api/order/:id/shipment — create + create(orderId, body) { + return http(`/api/order/${orderId}/shipment`, { method: 'POST', body }); + }, + // #endregion + + // #region GET /api/shipment/:id — get + get(id) { + return http(`/api/shipment/${id}`); + }, + // #endregion + + // #region DELETE /api/shipment/:id — delete + delete(id) { + return http(`/api/shipment/${id}`, { method: 'DELETE' }); + }, + // #endregion +}; diff --git a/src/api/shipping.js b/src/api/shipping.js new file mode 100644 index 0000000..b519c15 --- /dev/null +++ b/src/api/shipping.js @@ -0,0 +1,30 @@ +import { http } from './http.js'; + +/** + * Packages / logistics shipments attached to a shipment manifest. + */ +export const shipping = { + // #region GET /api/shipment/:id/shipping — listByShipment + listByShipment(shipmentId) { + return http(`/api/shipment/${shipmentId}/shipping`); + }, + // #endregion + + // #region POST /api/shipment/:id/shipping — create + create(shipmentId, body) { + return http(`/api/shipment/${shipmentId}/shipping`, { method: 'POST', body }); + }, + // #endregion + + // #region PATCH /api/shipping/:id — update + update(id, body) { + return http(`/api/shipping/${id}`, { method: 'PATCH', body }); + }, + // #endregion + + // #region DELETE /api/shipping/:id — delete + delete(id) { + return http(`/api/shipping/${id}`, { method: 'DELETE' }); + }, + // #endregion +}; diff --git a/src/appRouter.jsx b/src/appRouter.jsx index aa5c0fe..53febdd 100644 --- a/src/appRouter.jsx +++ b/src/appRouter.jsx @@ -21,6 +21,9 @@ import AuftragAngebotEditPage from './routes/auftrag/_quoteId'; import AuftragBestellungPage from './routes/auftrag/bestellung'; import AuftragBestellungNeuPage from './routes/auftrag/bestellung/neu'; import AuftragBestellungEditPage from './routes/auftrag/bestellung/_orderId'; +import AuftragAusliefernPage from './routes/auftrag/ausliefern'; +import AuftragZahlungenPage from './routes/auftrag/zahlungen'; + import KundeListePage from './routes/kunde'; import KundeNeuPage from './routes/kunde/neu'; import KundeGruppenPage from './routes/kunde/gruppen'; @@ -83,7 +86,10 @@ const routes = [ { path: 'bestellung', element: }, { path: 'bestellung/neu', element: }, { path: 'bestellung/:orderId', element: }, + { path: 'ausliefern', element: }, + { path: 'zahlungen', element: }, { path: ':quoteId', element: }, + ], }, { diff --git a/src/automats/accountAutomat.js b/src/automats/accountAutomat.js new file mode 100644 index 0000000..5a96369 --- /dev/null +++ b/src/automats/accountAutomat.js @@ -0,0 +1,21 @@ +/** + * @file accountAutomat.js + * @description Manages cached payment accounts (`GET /api/account`) for the selected company. + */ + +import { createListAutomat } from './createListAutomat'; +import { account as accountApi } from '../api'; + +export const accountAutomat = createListAutomat({ + name: 'account', + fetch: async () => { + const res = await accountApi.list(); + return Array.isArray(res?.accounts) ? res.accounts : []; + }, + sort: (items) => + [...items].sort((a, b) => + String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de') + ), +}); + +export default accountAutomat; diff --git a/src/automats/index.js b/src/automats/index.js index fab1ef1..7d3b467 100644 --- a/src/automats/index.js +++ b/src/automats/index.js @@ -81,8 +81,10 @@ export { export { default as entityAutomat } from './entityAutomat'; export { default as customergroupAutomat } from './customergroupAutomat'; export { default as numberAutomat } from './numberAutomat'; +export { default as accountAutomat } from './accountAutomat'; export { createListAutomat, selectListItems, selectListStatus } from './createListAutomat'; + /** Per-SKU article detail views keyed by SKU. */ export { articleHistoryAutomat, articleSupplierAutomat } from './articleDetailAutomat'; diff --git a/src/automats/orderAutomat.js b/src/automats/orderAutomat.js index 50a2fd8..64f55e9 100644 --- a/src/automats/orderAutomat.js +++ b/src/automats/orderAutomat.js @@ -74,9 +74,10 @@ export function isOrderPaid(order) { } export function isOrderDelivered(order) { - return Boolean(order.delivered ?? order.isDelivered ?? order.is_delivered ?? order.deliveredAt); + return Boolean(order.shipped ?? order.delivered ?? order.isDelivered ?? order.is_delivered ?? order.deliveredAt); } + export function getOrderDate(order) { const raw = order.date || order.createdAt; if (!raw) return null; diff --git a/src/automats/sseAutomat.js b/src/automats/sseAutomat.js index 420fa9a..4fb0827 100644 --- a/src/automats/sseAutomat.js +++ b/src/automats/sseAutomat.js @@ -32,6 +32,8 @@ import warehouseAutomat from './warehouseAutomat'; import attributeAutomat from './attributeAutomat'; import propertyAutomat from './propertyAutomat'; import supplierAutomat from './supplierAutomat'; +import orderAutomat from './orderAutomat'; + /** * @typedef {Object} SseState @@ -236,6 +238,18 @@ function connect() { }); }); + addTrackedListener(es, 'order', () => { + void orderAutomat.actions.reload().catch((err) => { + console.error('[sse] order refresh failed', err); + }); + }); + + addTrackedListener(es, 'shipment', () => { + void orderAutomat.actions.reload().catch((err) => { + console.error('[sse] shipment refresh failed', err); + }); + }); + addTrackedListener(es, 'open', () => { sseAutomat.setState({ status: 'connected' }); }); diff --git a/src/components/manifest/OrderPaymentList.jsx b/src/components/manifest/OrderPaymentList.jsx new file mode 100644 index 0000000..df7d5fa --- /dev/null +++ b/src/components/manifest/OrderPaymentList.jsx @@ -0,0 +1,739 @@ +import { PureComponent } from 'react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Chip from '@mui/material/Chip'; +import CircularProgress from '@mui/material/CircularProgress'; +import Divider from '@mui/material/Divider'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import IconButton from '@mui/material/IconButton'; +import ListItemButton from '@mui/material/ListItemButton'; +import Switch from '@mui/material/Switch'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; +import MenuItem from '@mui/material/MenuItem'; +import PaymentsIcon from '@mui/icons-material/Payments'; +import AddIcon from '@mui/icons-material/Add'; +import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; +import CloseIcon from '@mui/icons-material/Close'; +import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'; +import { enqueueSnackbar } from 'notistack'; +import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; +import FillSpace from 'dashgui/FillSpace'; +import { PanelTile, PanelTileGrid } from 'dashgui/panels'; +import BackendError from '../BackendError'; +import AppDialog from '../AppDialog'; +import orderAutomat, { + isOrderPaid, +} from '../../automats/orderAutomat'; +import accountAutomat from '../../automats/accountAutomat'; +import { payment as paymentApi } from '../../api'; +import { backendErrorFrom } from '../../utils/backendError'; +import { notifyError } from '../../utils/notify'; +import { formatPrice } from '../../utils/price'; +import withNavigate from '../withNavigate'; +import { + formatDate, + loadParties, + manifestConfig, + numberDisplay, + partyDisplayName, +} from './manifestConfig'; +import { + YELLOW_PANEL, + SURFACE_BASE, + SURFACE_RAISED, + WHITE, +} from '../../colors'; + +const cfg = manifestConfig('order'); + +const PAYMENT_TYPES = [ + { value: 'transfer', label: 'Banküberweisung' }, + { value: 'cash', label: 'Barzahlung' }, + { value: 'card', label: 'EC- / Kreditkarte' }, + { value: 'paypal', label: 'PayPal' }, + { value: 'sepa', label: 'SEPA-Lastschrift' }, + { value: 'invoice', label: 'Auf Rechnung' }, + { value: 'other', label: 'Sonstige' }, +]; + +function getOrderFinancials(order) { + const lines = Array.isArray(order?.lines) ? order.lines : []; + let total = Number(order?.orderTotal) || 0; + if (!total && lines.length > 0) { + total = lines.reduce((acc, line) => { + const net = (Number(line.amount) || 0) * (Number(line.net) || 0) * (1 - (Number(line.rebate) || 0) / 100); + return acc + net * (1 + (Number(line.taxrate) || 0) / 100); + }, 0); + } + total = Math.round(total * 100) / 100; + + const payments = Array.isArray(order?.payments) ? order.payments : []; + let paid = Number(order?.paidTotal) || 0; + if (!paid && payments.length > 0) { + paid = payments.reduce((acc, p) => acc + (Number(p.amount) || 0), 0); + } + paid = Math.round(paid * 100) / 100; + + const remaining = Math.max(0, Math.round((total - paid) * 100) / 100); + return { + total, + paid, + remaining, + }; +} + +const rowSx = { + alignItems: 'center', + height: 64, + minHeight: 64, + maxHeight: 64, + boxSizing: 'border-box', + py: 0, + gap: 1.5, +}; + +const ellipsisSx = { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }; + +const rowContext = { + partiesById: {}, + onOpen: null, +}; + +// MARK: +class OrderPaymentRow extends PureComponent { + partyName(partyId) { + if (partyId == null) return '–'; + const party = rowContext.partiesById[partyId]; + return partyDisplayName('order', party) || `Kunde #${partyId}`; + } + + render() { + const { item: order } = this.props; + + if (!order) { + return ( + +   + + ); + } + + const { onOpen } = rowContext; + const { total, paid, remaining } = getOrderFinancials(order); + const isPaid = isOrderPaid(order) || (total > 0 && Math.abs(paid - total) < 0.01); + const isPartial = paid > 0 && !isPaid; + const paymentsCount = Array.isArray(order.payments) ? order.payments.length : 0; + + return ( + onOpen?.(order)}> + + + {order.number} + + + {numberDisplay(order.externalNumber)} + + + + + + {order.date ? formatDate(order.date) : '–'} + + + + + + {this.partyName(order.entityId)} + + + + {/* Amounts */} + + + {total > 0 ? formatPrice(total) : '—'} + + + + + 0 ? 'success.main' : 'text.disabled' }}> + {paid > 0 ? formatPrice(paid) : '0,00 €'} + + + + + 0 ? 'error.main' : 'text.secondary' }}> + {remaining > 0 ? formatPrice(remaining) : '0,00 €'} + + + + {/* Status chip */} + + {isPaid ? ( + + ) : isPartial ? ( + + ) : ( + + )} + + + {/* Payments count */} + + 0 ? 'action.selected' : 'action.hover', + }} + /> + + + { + e.stopPropagation(); + onOpen?.(order); + }} + sx={{ width: 36, height: 36, flexShrink: 0 }} + > + + + + ); + } +} + +// MARK: +class AddPaymentDialog extends PureComponent { + constructor(props) { + super(props); + const { order } = props; + const { remaining } = getOrderFinancials(order); + const accounts = accountAutomat.state.items || []; + const defaultAccount = accounts[0]?.id || 1; + + this.state = { + accountId: defaultAccount, + type: 'transfer', + amount: remaining > 0 ? String(remaining) : '', + saving: false, + }; + + this.handleSubmit = this.handleSubmit.bind(this); + } + + async handleSubmit() { + const { order, onClose, onSuccess } = this.props; + const { accountId, type, amount } = this.state; + const numAmount = Number(String(amount).replace(',', '.')); + + if (!Number.isFinite(numAmount) || numAmount <= 0) { + enqueueSnackbar('Bitte einen gültigen Zahlungsbetrag größer 0 eingeben.', { variant: 'warning' }); + return; + } + + this.setState({ saving: true }); + try { + await paymentApi.create(order.id, { + accountId: Number(accountId), + type, + amount: numAmount, + }); + enqueueSnackbar('Zahlung erfolgreich verbucht!', { variant: 'success' }); + await orderAutomat.actions.reload(); + onSuccess?.(); + onClose(); + } catch (err) { + notifyError(err); + this.setState({ saving: false }); + } + } + + render() { + const { open, onClose, order } = this.props; + const { accountId, type, amount, saving } = this.state; + const accounts = accountAutomat.state.items || []; + + return ( + + + + + Zahlung erfassen + + + + + + + Auftrag: {order?.number} + + + + this.setState({ accountId: e.target.value })} + > + {accounts.length > 0 ? ( + accounts.map((acc) => ( + + {acc.name} ({acc.type}) + + )) + ) : ( + Standard-Bankkonto + )} + + + this.setState({ type: e.target.value })} + > + {PAYMENT_TYPES.map((pt) => ( + + {pt.label} + + ))} + + + this.setState({ amount: e.target.value })} + inputProps={{ min: 0.01, step: 'any' }} + /> + + + + + + + + + ); + } +} + +// MARK: +class OrderPaymentDetailDialog extends PureComponent { + constructor(props) { + super(props); + this.state = { + addPaymentOpen: false, + busyAction: false, + }; + this.handleDeletePayment = this.handleDeletePayment.bind(this); + } + + async handleDeletePayment(pay) { + if (!window.confirm(`Zahlung über ${formatPrice(pay.amount)} wirklich löschen?`)) return; + this.setState({ busyAction: true }); + try { + await paymentApi.delete(pay.id); + enqueueSnackbar('Zahlung gelöscht.', { variant: 'success' }); + await orderAutomat.actions.reload(); + } catch (err) { + notifyError(err); + } finally { + this.setState({ busyAction: false }); + } + } + + render() { + const { open, onClose, orderId, partiesById } = this.props; + const { addPaymentOpen, busyAction } = this.state; + + const order = orderAutomat.state.items?.find((o) => o.id === orderId); + if (!order) return null; + + const party = partiesById[order.entityId]; + const customerName = partyDisplayName('order', party) || `Kunde #${order.entityId}`; + const { total, paid, remaining } = getOrderFinancials(order); + const payments = Array.isArray(order.payments) ? order.payments : []; + const accounts = accountAutomat.state.items || []; + const accountsById = Object.fromEntries(accounts.map((a) => [a.id, a.name])); + + return ( + + + {/* Header */} + + + + + Zahlungen: {order.number} + + {remaining === 0 ? ( + + ) : paid > 0 ? ( + + ) : ( + + )} + + + Kunde: {customerName} • Datum: {order.date ? formatDate(order.date) : '–'} + + + + + + + + {/* Quick Metrics Bar */} + + + Auftrags-Gesamtbetrag (Brutto) + {formatPrice(total)} + + + + Bereits bezahlt + 0 ? 'success.main' : 'text.primary' }}> + {formatPrice(paid)} + + + + + Offener Saldo + 0 ? 'error.main' : 'text.secondary' }}> + {formatPrice(remaining)} + + + + + + + {/* Payments list */} + + Gebuchte Zahlungseingänge ({payments.length}) + + + {payments.length === 0 ? ( + + + Bisher keine Zahlungen für diesen Auftrag erfasst. + + + + ) : ( + + {payments.map((p) => { + const typeObj = PAYMENT_TYPES.find((pt) => pt.value === p.type); + const typeLabel = typeObj?.label || p.type; + const accountName = accountsById[p.accountId] || `Konto #${p.accountId}`; + + return ( + + + + + + {typeLabel} + + + {accountName} + + + + + + + +{formatPrice(p.amount)} + + this.handleDeletePayment(p)} + aria-label="Zahlung löschen" + > + + + + + ); + })} + + )} + + + + + + + {addPaymentOpen && ( + this.setState({ addPaymentOpen: false })} + /> + )} + + ); + } +} + +// MARK: +export class OrderPaymentList extends PureComponent { + constructor(props) { + super(props); + const s = orderAutomat.state; + this.state = { + status: s.status === 'ready' ? 'ready' : 'loading', + error: s.error ? backendErrorFrom(s.error) : null, + items: s.items ?? [], + total: s.total ?? 0, + filter: '', + onlyUnpaid: true, // Default to unpaid + partiesById: {}, + selectedOrderId: null, + }; + this._cancelled = false; + this.handleRetry = this.handleRetry.bind(this); + this.handleOpenOrder = this.handleOpenOrder.bind(this); + } + + componentDidMount() { + this._cancelled = false; + this.unsub = orderAutomat.subscribe(this, (state) => ({ + items: state.items ?? [], + total: state.total ?? 0, + status: state.status, + error: state.error ? backendErrorFrom(state.error) : null, + })); + if (orderAutomat.state.status === 'idle') { + void orderAutomat.actions.load().catch(() => {}); + } + void accountAutomat.actions.ensureLoaded().catch(() => {}); + void this.loadPartiesData(); + } + + componentWillUnmount() { + this._cancelled = true; + this.unsub?.(); + } + + async loadPartiesData() { + try { + const parties = await loadParties('order'); + if (!this._cancelled) { + this.setState({ partiesById: cfg.partiesByIdFromList(parties) }); + } + } catch { + // Non-fatal + } + } + + handleRetry() { + void orderAutomat.actions.load().catch(() => {}); + } + + handleOpenOrder(order) { + this.setState({ selectedOrderId: order.id }); + } + + getFilteredOrders() { + const { items, filter, onlyUnpaid, partiesById } = this.state; + const q = filter.trim().toLowerCase(); + + return items.filter((order) => { + const { total, paid } = getOrderFinancials(order); + const isPaid = isOrderPaid(order) || (total > 0 && Math.abs(paid - total) < 0.01); + if (onlyUnpaid && isPaid) return false; + + if (q) { + const party = partiesById[order.entityId]; + const partyName = partyDisplayName('order', party); + const matches = [order.number, order.externalNumber, partyName] + .some((val) => String(val || '').toLowerCase().includes(q)); + if (!matches) return false; + } + return true; + }); + } + + render() { + const { status, error, filter, onlyUnpaid, partiesById, selectedOrderId } = this.state; + + if (status === 'loading' || status === 'idle') { + return ( + + + + ); + } + + if (status === 'failed') { + return ; + } + + const filtered = this.getFilteredOrders(); + rowContext.partiesById = partiesById; + rowContext.onOpen = this.handleOpenOrder; + + return ( + <> + + + + this.setState({ filter: next })} + /> + + + + + this.setState({ onlyUnpaid: e.target.checked })} + /> + } + label={ + + Nur unbezahlte Aufträge + + } + sx={{ m: 0 }} + /> + + + + {filtered.length} Auftrag / Aufträge zur Zahlungsüberwachung + + + + + + + + + Auftragsnr. + Datum + Kunde + Gesamt + Bezahlt + Offen + Status + Zahlungen + + + } + /> + + + + {selectedOrderId != null && ( + this.setState({ selectedOrderId: null })} + /> + )} + + ); + } +} + +export default withNavigate(OrderPaymentList); diff --git a/src/components/manifest/ShipmentList.jsx b/src/components/manifest/ShipmentList.jsx new file mode 100644 index 0000000..d413c96 --- /dev/null +++ b/src/components/manifest/ShipmentList.jsx @@ -0,0 +1,1087 @@ +import { PureComponent } from 'react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Chip from '@mui/material/Chip'; +import CircularProgress from '@mui/material/CircularProgress'; +import Divider from '@mui/material/Divider'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import IconButton from '@mui/material/IconButton'; +import ListItemButton from '@mui/material/ListItemButton'; +import Switch from '@mui/material/Switch'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; +import Table from '@mui/material/Table'; +import TableBody from '@mui/material/TableBody'; +import TableCell from '@mui/material/TableCell'; +import TableContainer from '@mui/material/TableContainer'; +import TableHead from '@mui/material/TableHead'; +import TableRow from '@mui/material/TableRow'; +import MenuItem from '@mui/material/MenuItem'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import LocalShippingIcon from '@mui/icons-material/LocalShipping'; +import AddIcon from '@mui/icons-material/Add'; +import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; +import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined'; +import CloseIcon from '@mui/icons-material/Close'; +import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'; +import { enqueueSnackbar } from 'notistack'; +import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; +import FillSpace from 'dashgui/FillSpace'; +import { PanelTile, PanelTileGrid } from 'dashgui/panels'; +import BackendError from '../BackendError'; +import AppDialog from '../AppDialog'; +import orderAutomat, { + filterOrders, + isOrderDelivered, +} from '../../automats/orderAutomat'; +import { shipment as shipmentApi, shipping as shippingApi } from '../../api'; +import { backendErrorFrom } from '../../utils/backendError'; +import { notifyError } from '../../utils/notify'; +import withNavigate from '../withNavigate'; +import { + formatDate, + loadParties, + manifestConfig, + numberDisplay, + partyDisplayName, +} from './manifestConfig'; +import { + GREEN, + GREEN_PANEL, + ORANGE_PANEL, + PURPLE_PANEL, + SURFACE_BASE, + SURFACE_RAISED, + BORDER, + WHITE, +} from '../../colors'; + +const cfg = manifestConfig('order'); + +const CARRIERS = ['DHL', 'UPS', 'DPD', 'GLS', 'Hermes', 'Spedition', 'Selbstabholung']; + +// MARK: pure helpers +function getOrderQuantities(order) { + const lines = Array.isArray(order.lines) ? order.lines : []; + const ordered = Number(order.orderedQuantity) || lines.reduce((acc, l) => acc + (Number(l.amount) || 0), 0); + const shipments = Array.isArray(order.shipments) ? order.shipments : []; + const shipped = Number(order.shippedQuantity) || shipments.reduce((acc, s) => { + return acc + (Array.isArray(s.lines) ? s.lines.reduce((lAcc, l) => lAcc + (Number(l.amount) || 0), 0) : 0); + }, 0); + return { + ordered: Math.round(ordered * 1000) / 1000, + shipped: Math.round(shipped * 1000) / 1000, + remaining: Math.max(0, Math.round((ordered - shipped) * 1000) / 1000), + }; +} + +function getTotalPackages(order) { + const shipments = Array.isArray(order?.shipments) ? order.shipments : []; + return shipments.reduce((acc, s) => acc + (Array.isArray(s.shippings) ? s.shippings.length : 0), 0); +} + +const rowSx = { + alignItems: 'center', + height: 64, + minHeight: 64, + maxHeight: 64, + boxSizing: 'border-box', + py: 0, + gap: 1.5, +}; + +const ellipsisSx = { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }; + +const rowContext = { + partiesById: {}, + onOpen: null, +}; + +// MARK: +class ShipmentOrderRow extends PureComponent { + partyName(partyId) { + if (partyId == null) return '–'; + const party = rowContext.partiesById[partyId]; + return partyDisplayName('order', party) || `Kunde #${partyId}`; + } + + render() { + const { item: order } = this.props; + + if (!order) { + return ( + +   + + ); + } + + const { onOpen } = rowContext; + const { ordered, shipped } = getOrderQuantities(order); + const isShipped = isOrderDelivered(order) || (ordered > 0 && shipped >= ordered); + const isPartial = shipped > 0 && shipped < ordered; + const shipmentCount = Array.isArray(order.shipments) ? order.shipments.length : 0; + const packageCount = getTotalPackages(order); + + return ( + onOpen?.(order)}> + + + {order.number} + + + {numberDisplay(order.externalNumber)} + + + + + + {order.date ? formatDate(order.date) : '–'} + + + + + + {this.partyName(order.entityId)} + + + + {/* Quantities & Status */} + + + + {shipped} / {ordered} geliefert + + + {isShipped ? ( + + ) : isPartial ? ( + + ) : ( + + )} + + + {/* Shipment & Package counts */} + + 0 ? 'action.selected' : 'action.hover', + }} + /> + 0 ? 'action.selected' : 'action.hover', + }} + /> + + + { + e.stopPropagation(); + onOpen?.(order); + }} + sx={{ width: 36, height: 36, flexShrink: 0 }} + > + + + + ); + } +} + +// MARK: +class CreateShipmentDialog extends PureComponent { + constructor(props) { + super(props); + const { order } = props; + const initialLines = {}; + const lines = Array.isArray(order?.lines) ? order.lines : []; + + // Precalculate remaining amounts per sku + const shippedBySku = {}; + for (const sh of order?.shipments || []) { + for (const sl of sh.lines || []) { + if (sl.sku) shippedBySku[sl.sku] = (shippedBySku[sl.sku] || 0) + (Number(sl.amount) || 0); + } + } + + for (const line of lines) { + if (!line.sku) continue; + const orderedAmt = Number(line.amount) || 0; + const shippedAmt = shippedBySku[line.sku] || 0; + const rem = Math.max(0, orderedAmt - shippedAmt); + initialLines[line.sku] = rem > 0 ? String(rem) : '0'; + } + + this.state = { + date: new Date().toISOString().slice(0, 10), + externalNumber: '', + lineAmounts: initialLines, + saving: false, + }; + + this.handleAmountChange = this.handleAmountChange.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); + } + + handleAmountChange(sku, val) { + this.setState((prev) => ({ + lineAmounts: { ...prev.lineAmounts, [sku]: val }, + })); + } + + async handleSubmit() { + const { order, onClose, onSuccess } = this.props; + const { date, externalNumber, lineAmounts } = this.state; + const linesToShip = []; + + for (const [sku, amtStr] of Object.entries(lineAmounts)) { + const amt = Number(amtStr); + if (amt > 0) { + const orderLine = order.lines?.find((l) => l.sku === sku); + linesToShip.push({ + sku, + amount: amt, + name: orderLine?.name ?? '', + pu: orderLine?.pu ?? 1, + net: orderLine?.net ?? 0, + rebate: orderLine?.rebate ?? 0, + taxrate: orderLine?.taxrate ?? 0, + }); + } + } + + if (linesToShip.length === 0) { + enqueueSnackbar('Mindestens ein Artikel muss eine Liefermenge > 0 haben.', { variant: 'warning' }); + return; + } + + this.setState({ saving: true }); + try { + const res = await shipmentApi.create(order.id, { + date, + externalNumber: externalNumber.trim() || undefined, + lines: linesToShip, + }); + enqueueSnackbar(`Lieferschein ${res.shipment?.number || ''} erfolgreich angelegt!`, { variant: 'success' }); + await orderAutomat.actions.reload(); + onSuccess?.(); + onClose(); + } catch (err) { + notifyError(err); + this.setState({ saving: false }); + } + } + + render() { + const { open, onClose, order } = this.props; + const { date, externalNumber, lineAmounts, saving } = this.state; + const lines = Array.isArray(order?.lines) ? order.lines : []; + + const shippedBySku = {}; + for (const sh of order?.shipments || []) { + for (const sl of sh.lines || []) { + if (sl.sku) shippedBySku[sl.sku] = (shippedBySku[sl.sku] || 0) + (Number(sl.amount) || 0); + } + } + + return ( + + + + + + Neue Teillieferung / Sendung erfassen + + + Auftrag {order?.number} + + + + + + + + + this.setState({ date: e.target.value })} + InputLabelProps={{ shrink: true }} + sx={{ width: 180 }} + /> + this.setState({ externalNumber: e.target.value })} + sx={{ flex: 1 }} + /> + + + + Positionen & Liefermengen (Maximal restante Bestellmenge) + + + + + + + SKU + Artikel + Bestellt + Geliefert + Offen + Jetzt liefern + + + + {lines.map((line) => { + const orderedAmt = Number(line.amount) || 0; + const shippedAmt = shippedBySku[line.sku] || 0; + const remaining = Math.max(0, orderedAmt - shippedAmt); + const currentVal = lineAmounts[line.sku] ?? '0'; + + return ( + + {line.sku} + {line.name} + {orderedAmt} + {shippedAmt} + 0 ? 'primary.main' : 'text.disabled' }}> + {remaining} + + + this.handleAmountChange(line.sku, e.target.value)} + disabled={remaining <= 0} + inputProps={{ min: 0, max: remaining, step: 'any', style: { textAlign: 'right' } }} + sx={{ width: 100 }} + /> + + + ); + })} + +
+
+ + + + + +
+
+ ); + } +} + +// MARK: +class AddPackageDialog extends PureComponent { + constructor(props) { + super(props); + this.state = { + carrier: 'DHL', + trackingCode: '', + type: 'parcel', + state: 'draft', + saving: false, + }; + this.handleSubmit = this.handleSubmit.bind(this); + } + + async handleSubmit() { + const { shipment, onClose, onSuccess } = this.props; + const { carrier, trackingCode, type, state } = this.state; + + this.setState({ saving: true }); + try { + await shippingApi.create(shipment.id, { + carrier: carrier.trim() || undefined, + trackingCode: trackingCode.trim() || undefined, + type, + state, + }); + enqueueSnackbar('Paket angelegt.', { variant: 'success' }); + await orderAutomat.actions.reload(); + onSuccess?.(); + onClose(); + } catch (err) { + notifyError(err); + this.setState({ saving: false }); + } + } + + render() { + const { open, onClose, shipment } = this.props; + const { carrier, trackingCode, type, state, saving } = this.state; + + return ( + + + + + Paket zu Lieferschein hinzufügen + + + + + + + Lieferschein: {shipment?.number} + + + + this.setState({ carrier: e.target.value })} + > + {CARRIERS.map((c) => ( + {c} + ))} + + + this.setState({ trackingCode: e.target.value })} + placeholder="z.B. 00340434234234234" + /> + + this.setState({ type: e.target.value })} + > + Paket (Parcel) + Palette (Pallet) + Brief / Großbrief + + + this.setState({ state: e.target.value })} + helperText={state === 'shipped' ? 'Bucht bei Auswahl direkt das Lager ab.' : undefined} + > + Entwurf (draft) + Gepackt (packed) + Versendet (shipped — bucht Lager ab) + + + + + + + + + + ); + } +} + +// MARK: +class OrderShipmentDetailDialog extends PureComponent { + constructor(props) { + super(props); + this.state = { + createShipmentOpen: false, + addPackageShipment: null, + busyAction: false, + }; + this.handleDeleteShipment = this.handleDeleteShipment.bind(this); + this.handleDeletePackage = this.handleDeletePackage.bind(this); + this.handleMarkPackageShipped = this.handleMarkPackageShipped.bind(this); + } + + async handleDeleteShipment(sh) { + if (!window.confirm(`Lieferschein „${sh.number}“ wirklich löschen?`)) return; + this.setState({ busyAction: true }); + try { + await shipmentApi.delete(sh.id); + enqueueSnackbar(`Lieferschein ${sh.number} gelöscht.`, { variant: 'success' }); + await orderAutomat.actions.reload(); + } catch (err) { + notifyError(err); + } finally { + this.setState({ busyAction: false }); + } + } + + async handleDeletePackage(pkg) { + if (!window.confirm(`Paket „#${pkg.number || pkg.id}“ wirklich löschen?`)) return; + this.setState({ busyAction: true }); + try { + await shippingApi.delete(pkg.id); + enqueueSnackbar('Paket gelöscht.', { variant: 'success' }); + await orderAutomat.actions.reload(); + } catch (err) { + notifyError(err); + } finally { + this.setState({ busyAction: false }); + } + } + + async handleMarkPackageShipped(pkg) { + this.setState({ busyAction: true }); + try { + await shippingApi.update(pkg.id, { state: 'shipped' }); + enqueueSnackbar('Paket als versendet markiert (Lager abgezogen).', { variant: 'success' }); + await orderAutomat.actions.reload(); + } catch (err) { + notifyError(err); + } finally { + this.setState({ busyAction: false }); + } + } + + render() { + const { open, onClose, orderId, partiesById } = this.props; + const { createShipmentOpen, addPackageShipment, busyAction } = this.state; + + // Read latest order from orderAutomat cache + const order = orderAutomat.state.items?.find((o) => o.id === orderId); + if (!order) return null; + + const party = partiesById[order.entityId]; + const customerName = partyDisplayName('order', party) || `Kunde #${order.entityId}`; + const { ordered, shipped, remaining } = getOrderQuantities(order); + const shipments = Array.isArray(order.shipments) ? order.shipments : []; + + return ( + + + {/* Header */} + + + + + Auslieferung: {order.number} + + {remaining === 0 ? ( + + ) : shipped > 0 ? ( + + ) : ( + + )} + + + Kunde: {customerName} • Datum: {order.date ? formatDate(order.date) : '–'} {order.externalNumber ? `• Ref: ${order.externalNumber}` : ''} + + + + + + + + {/* Quick Metrics Bar */} + + + Bestellte Menge + {ordered} Stück + + + + Bereits ausgeliefert + 0 ? 'success.main' : 'text.primary' }}> + {shipped} Stück + + + + + Restmenge (Offen) + 0 ? 'warning.main' : 'text.secondary' }}> + {remaining} Stück + + + + + + + {/* Shipment Manifests List */} + + Zugeordnete Lieferscheine (Shipment Manifests) + + + {shipments.length === 0 ? ( + + + Bisher keine Lieferscheine für diesen Auftrag erstellt. + + + + ) : ( + + {shipments.map((sh) => { + const shPackages = Array.isArray(sh.shippings) ? sh.shippings : []; + const shLines = Array.isArray(sh.lines) ? sh.lines : []; + const hasShippedPackages = shPackages.some((p) => p.state === 'shipped'); + + return ( + + {/* Shipment Manifest Header */} + + + + + + {sh.number} + + + Datum: {sh.date ? formatDate(sh.date) : '–'} {sh.externalNumber ? `• Ref: ${sh.externalNumber}` : ''} + + + + + + + {!hasShippedPackages && ( + this.handleDeleteShipment(sh)} + aria-label={`Lieferschein ${sh.number} löschen`} + > + + + )} + + + + {/* Shipped Items */} + + + Enthaltene Positionen ({shLines.length}) + + + + + SKU + Artikel + Gelieferte Menge + + + + {shLines.map((line, idx) => ( + + {line.sku} + {line.name} + {line.amount} + + ))} + +
+
+ + {/* Packages / Logistics */} + + + Pakete & Sendungsverfolgung ({shPackages.length}) + + + {shPackages.length === 0 ? ( + + Noch kein Paket angelegt. Nutzen Sie „Paket erfassen“, um Tracking-Codes hinzuzufügen. + + ) : ( + + {shPackages.map((pkg) => { + const isShippedPkg = pkg.state === 'shipped'; + return ( + + + + + {pkg.carrier || 'Standard'} ({pkg.type === 'pallet' ? 'Palette' : 'Paket'}) + + {pkg.trackingCode ? ( + + + {pkg.trackingCode} + + { + navigator.clipboard.writeText(pkg.trackingCode); + enqueueSnackbar('Tracking-Code kopiert!', { variant: 'info' }); + }} + sx={{ p: 0.25 }} + > + + + + ) : ( + + Kein Tracking-Code + + )} + + + + {isShippedPkg ? ( + } + label="Versendet (Lager verbucht)" + color="success" + sx={{ fontWeight: 600 }} + /> + ) : ( + <> + + + this.handleDeletePackage(pkg)} + > + + + + )} + + + ); + })} + + )} + +
+ ); + })} +
+ )} + + + + +
+ + {createShipmentOpen && ( + this.setState({ createShipmentOpen: false })} + /> + )} + + {addPackageShipment && ( + this.setState({ addPackageShipment: null })} + /> + )} +
+ ); + } +} + +// MARK: +export class ShipmentList extends PureComponent { + constructor(props) { + super(props); + const s = orderAutomat.state; + this.state = { + status: s.status === 'ready' ? 'ready' : 'loading', + error: s.error ? backendErrorFrom(s.error) : null, + items: s.items ?? [], + total: s.total ?? 0, + filter: '', + onlyUndelivered: true, // Default to pending delivery orders + partiesById: {}, + selectedOrderId: null, + }; + this._cancelled = false; + this.handleRetry = this.handleRetry.bind(this); + this.handleOpenOrder = this.handleOpenOrder.bind(this); + } + + componentDidMount() { + this._cancelled = false; + this.unsub = orderAutomat.subscribe(this, (state) => ({ + items: state.items ?? [], + total: state.total ?? 0, + status: state.status, + error: state.error ? backendErrorFrom(state.error) : null, + })); + if (orderAutomat.state.status === 'idle') { + void orderAutomat.actions.load().catch(() => {}); + } + void this.loadPartiesData(); + } + + componentWillUnmount() { + this._cancelled = true; + this.unsub?.(); + } + + async loadPartiesData() { + try { + const parties = await loadParties('order'); + if (!this._cancelled) { + this.setState({ partiesById: cfg.partiesByIdFromList(parties) }); + } + } catch { + // Non-fatal + } + } + + handleRetry() { + void orderAutomat.actions.load().catch(() => {}); + } + + handleOpenOrder(order) { + this.setState({ selectedOrderId: order.id }); + } + + getFilteredOrders() { + const { items, filter, onlyUndelivered, partiesById } = this.state; + const q = filter.trim().toLowerCase(); + + return items.filter((order) => { + const { ordered, shipped } = getOrderQuantities(order); + const isShipped = isOrderDelivered(order) || (ordered > 0 && shipped >= ordered); + if (onlyUndelivered && isShipped) return false; + + if (q) { + const party = partiesById[order.entityId]; + const partyName = partyDisplayName('order', party); + const trackingCodes = (order.shipments || []).flatMap((s) => (s.shippings || []).map((p) => p.trackingCode)).filter(Boolean); + const matches = [order.number, order.externalNumber, partyName, ...trackingCodes] + .some((val) => String(val || '').toLowerCase().includes(q)); + if (!matches) return false; + } + return true; + }); + } + + render() { + const { status, error, filter, onlyUndelivered, partiesById, selectedOrderId } = this.state; + + if (status === 'loading' || status === 'idle') { + return ( + + + + ); + } + + if (status === 'failed') { + return ; + } + + const filtered = this.getFilteredOrders(); + rowContext.partiesById = partiesById; + rowContext.onOpen = this.handleOpenOrder; + + return ( + <> + + + + this.setState({ filter: next })} + /> + + + + + this.setState({ onlyUndelivered: e.target.checked })} + /> + } + label={ + + Nur ungelieferte Aufträge + + } + sx={{ m: 0 }} + /> + + + + {filtered.length} Auftrag / Aufträge zur Auslieferung + + + + + + + + + Auftragsnr. + Datum + Kunde + Lieferstatus + Sendungen + + + } + /> + + + + {selectedOrderId != null && ( + this.setState({ selectedOrderId: null })} + /> + )} + + ); + } +} + +export default withNavigate(ShipmentList); diff --git a/src/components/manifest/index.js b/src/components/manifest/index.js index 6ce2b55..6234983 100644 --- a/src/components/manifest/index.js +++ b/src/components/manifest/index.js @@ -1,6 +1,9 @@ export { default as ManifestList } from './ManifestList'; export { default as DeliveryList } from './DeliveryList'; export { default as OrderList } from './OrderList'; +export { default as ShipmentList } from './ShipmentList'; +export { default as OrderPaymentList } from './OrderPaymentList'; + export { default as ManifestForm } from './ManifestForm'; export { default as ManifestHeader } from './ManifestHeader'; export { default as ManifestLines } from './ManifestLines'; diff --git a/src/nav.js b/src/nav.js index c71c2ae..b6a00c0 100644 --- a/src/nav.js +++ b/src/nav.js @@ -16,7 +16,9 @@ import AddBoxIcon from '@mui/icons-material/AddBox'; import StorefrontIcon from '@mui/icons-material/Storefront'; import RequestQuoteIcon from '@mui/icons-material/RequestQuote'; import ShoppingCartIcon from '@mui/icons-material/ShoppingCart'; +import PaymentsIcon from '@mui/icons-material/Payments'; import LabelIcon from '@mui/icons-material/Label'; + import HistoryIcon from '@mui/icons-material/History'; import PublicIcon from '@mui/icons-material/Public'; import BarChartIcon from '@mui/icons-material/BarChart'; @@ -209,6 +211,20 @@ export const SUB_NAV = { icon: ShoppingCartIcon, help: 'Bestellungen prüfen, freigeben und kommissionieren', }, + { + segment: 'ausliefern', + label: 'Ausliefern', + title: 'Ausliefern', + icon: LocalShippingIcon, + help: 'Auslieferungen und Lieferscheine (Shipments) verwalten und versenden', + }, + { + segment: 'zahlungen', + label: 'Zahlungen', + title: 'Zahlungen', + icon: PaymentsIcon, + help: 'Zahlungseingänge erfassen und Auftragszahlungen überwachen', + }, ], '/kunde': [ { diff --git a/src/routes/auftrag/ausliefern.jsx b/src/routes/auftrag/ausliefern.jsx new file mode 100644 index 0000000..c2a7239 --- /dev/null +++ b/src/routes/auftrag/ausliefern.jsx @@ -0,0 +1,10 @@ +import { PureComponent } from 'react'; +import ShipmentList from '../../components/manifest/ShipmentList'; + +class AuftragAusliefernPage extends PureComponent { + render() { + return ; + } +} + +export default AuftragAusliefernPage; diff --git a/src/routes/auftrag/zahlungen.jsx b/src/routes/auftrag/zahlungen.jsx new file mode 100644 index 0000000..804d066 --- /dev/null +++ b/src/routes/auftrag/zahlungen.jsx @@ -0,0 +1,10 @@ +import { PureComponent } from 'react'; +import OrderPaymentList from '../../components/manifest/OrderPaymentList'; + +class AuftragZahlungenPage extends PureComponent { + render() { + return ; + } +} + +export default AuftragZahlungenPage;