import { Link as RouterLink, useNavigate } from 'react-router-dom'; import { useDispatch, useSelector } from 'react-redux'; import { useEffect, useState } from 'react'; import LayersOutlinedIcon from '@mui/icons-material/LayersOutlined'; import StraightenOutlinedIcon from '@mui/icons-material/StraightenOutlined'; import Box from '@mui/material/Box'; import Chip from '@mui/material/Chip'; import FormControl from '@mui/material/FormControl'; import IconButton from '@mui/material/IconButton'; import InputAdornment from '@mui/material/InputAdornment'; import InputLabel from '@mui/material/InputLabel'; import Link from '@mui/material/Link'; import MenuItem from '@mui/material/MenuItem'; import Select from '@mui/material/Select'; import Switch from '@mui/material/Switch'; import TextField from '@mui/material/TextField'; import Typography from '@mui/material/Typography'; import SingleLineTextEdit from '../SingleLineTextEdit'; import ArticlePhotoTile from './ArticlePhotoTile'; import ArticlePriceTiersDialog from './ArticlePriceTiersDialog'; import ArticleTileTab from './ArticleTileTab'; import ArticleUnitEditorDialog from './ArticleUnitEditorDialog'; import { article } from '../../api'; import { EditablePanelTile, PanelTile, PanelTileGrid } from '../panels'; import { requireDefined, requireObject } from '../../utils/apiShape'; import { loadUnits, selectUnits, selectUnitsStatus } from '../../store/unitSlice'; import { articleEans, brandName, buildPosPricesPutBody, companyTaxRateOptions, priceBrutto, primaryPhoto, primaryPrice, tierDraftFromArticle, } from '../../utils/articleDetail'; import { formatPrice, formatPriceAmount, netFromGross, parsePriceInput, } from '../../utils/price'; import { findUnitBySi, firstPackUnitSi, isNoPackUnit, unitLabel, } from '../../utils/units'; import { notifyError } from '../../utils/notify'; import { PANEL_TILE_KEYS } from '../../theme'; import { formatStock } from '../../pages/artikel/searchFormat'; import { artikelDetailPath } from '../../pages/artikel/articleRoute'; import { buildCategoryPath, defaultCategoryPath, } from '../../pages/artikel/categoryRoute'; import { patchArticleInLists, removeArticleBySku, selectCategoryRootId, } from '../../store/categorySlice'; import { selectCurrentCompany } from '../../store/companySlice'; function PanelValue({ label, children }) { if (children == null || children === '') return null; return ( {label ? ( {label} ) : null} {children} ); } const headerTileSx = { minHeight: 0, }; /** Second article row (Bestand / Marke / SKU / Preis). */ const SECOND_ROW_MIN_HEIGHT = 220; function CountLine({ label, count }) { if (count == null) return null; return ( {label}: {count} ); } function RelationLine({ label, active, children }) { return ( {label}:{' '} {active ? ( children ?? 'ja' ) : ( nein )} ); } function nameDraftDirty(draft, saved) { return draft.name.trim() !== String(saved.name ?? '').trim(); } function nameDraftValid(draft) { return draft.name.trim().length > 0; } function articleHasPackUnit(article) { const si = article.unit?.si; const amount = article.unitamount; if (si == null || amount == null) return false; return !isNoPackUnit(si, amount); } function priceDraftValue(article, company) { const basePrice = primaryPrice(article); const gross = priceBrutto(basePrice); const hasUnit = articleHasPackUnit(article); return { gross: gross != null ? formatPriceAmount(gross) : '', taxRate: basePrice?.taxRate != null ? basePrice.taxRate : company?.taxrate != null ? company.taxrate : '', tiers: tierDraftFromArticle(article), hasUnit, unitamount: hasUnit && article.unitamount != null ? formatPriceAmount(article.unitamount) : '', unitSi: hasUnit ? (article.unit?.si ?? '') : '', }; } function tiersEqual(a, b) { return JSON.stringify(a ?? []) === JSON.stringify(b ?? []); } function priceSectionDirty(draft, saved) { return ( parsePriceInput(draft.gross) !== parsePriceInput(saved.gross) || Number(draft.taxRate) !== Number(saved.taxRate) || !tiersEqual(draft.tiers, saved.tiers) ); } function unitSectionDirty(draft, saved) { if (Boolean(draft.hasUnit) !== Boolean(saved.hasUnit)) return true; if (!draft.hasUnit) return false; return ( parsePriceInput(draft.unitamount) !== parsePriceInput(saved.unitamount) || draft.unitSi !== saved.unitSi ); } function priceDraftDirty(draft, saved) { return priceSectionDirty(draft, saved) || unitSectionDirty(draft, saved); } function priceDraftValid(draft) { if (draft.hasUnit) { const unitamount = parsePriceInput(draft.unitamount); if (unitamount == null || unitamount <= 0) return false; if (!draft.unitSi) return false; // pc × 1 is the DB “no unit” sentinel — not a real pack unit. if (isNoPackUnit(draft.unitSi, unitamount)) return false; } const hasGross = draft.gross !== ''; if (!hasGross) { return !(draft.tiers?.length > 0); } if (parsePriceInput(draft.gross) == null) return false; if (draft.taxRate === '' || draft.taxRate == null) return false; for (const tier of draft.tiers ?? []) { if (parsePriceInput(tier.gross) == null) return false; const mult = Number(tier.minmultiplier); if (!Number.isFinite(mult) || mult <= 1) return false; } return true; } const articleNameTextSx = { fontWeight: 700, lineHeight: 1.2, }; const articleNameFieldSx = { '& .MuiInputBase-input': { fontSize: '2.125rem', ...articleNameTextSx, }, }; const articleNameFieldHiddenSx = { ...articleNameFieldSx, pointerEvents: 'none', '& .MuiOutlinedInput-notchedOutline': { borderColor: 'transparent', }, '& .MuiOutlinedInput-root:hover .MuiOutlinedInput-notchedOutline': { borderColor: 'transparent', }, '& .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: 'transparent', }, '& .MuiInputLabel-root': { visibility: 'hidden', }, }; const articlePriceTextSx = { fontWeight: 700, lineHeight: 1.1, letterSpacing: '-0.02em', }; const articlePriceFieldSx = { flexShrink: 0, '& .MuiOutlinedInput-root': { alignItems: 'baseline', flexShrink: 0, }, '& .MuiInputBase-input': { fontSize: '3.5rem', py: 0.5, height: 'auto', ...articlePriceTextSx, }, '& .MuiInputAdornment-root': { marginRight: 0.75, height: 'auto', maxHeight: 'none', alignItems: 'baseline', }, '& .MuiInputAdornment-root .MuiTypography-root': { fontSize: '2rem', fontWeight: 700, lineHeight: 1.1, }, // Keep 1px outline in all states so focus/edit doesn't nudge the value. '& .MuiOutlinedInput-notchedOutline': { borderWidth: '1px !important', }, }; const articlePriceFieldHiddenSx = { ...articlePriceFieldSx, pointerEvents: 'none', '& .MuiOutlinedInput-notchedOutline': { borderWidth: '1px !important', borderColor: 'transparent', }, '& .MuiOutlinedInput-root:hover .MuiOutlinedInput-notchedOutline': { borderWidth: '1px !important', borderColor: 'transparent', }, '& .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline': { borderWidth: '1px !important', borderColor: 'transparent', }, }; const priceBodySx = { width: '100%', display: 'flex', flexDirection: 'column', gap: 0.75, }; const priceUnitCornerSx = { position: 'absolute', top: 72, right: 4, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1.25, zIndex: 2, }; const taxPillSx = { height: 28, fontSize: 13, fontWeight: 600, '& .MuiChip-label': { px: 1 }, }; const tierPillSx = { height: 22, fontSize: 11, fontWeight: 600, }; function TaxRatePills({ rates, selected, onSelect, readOnly }) { return ( {rates.map((rate) => { const active = Number(selected) === Number(rate); return ( onSelect(rate)} sx={{ ...taxPillSx, // Keep inactive pills in layout so the active one doesn't jump. visibility: active || !readOnly ? 'visible' : 'hidden', bgcolor: active ? 'rgba(0, 0, 0, 0.14)' : 'rgba(0, 0, 0, 0.06)', ...(readOnly ? {} : { cursor: 'pointer', '&:hover': { bgcolor: 'rgba(0, 0, 0, 0.12)' }, }), }} /> ); })} ); } function PriceTaxRow({ rates, selected, onSelectTax, readOnly, onOpenTiers, }) { return ( ); } function TierPricePills({ tiers }) { return ( {(tiers ?? []).map((tier) => { const gross = parsePriceInput(tier.gross); if (gross == null) return null; return ( ); })} ); } function PriceAmountField({ value, onChange, inputRef, readOnly, error, }) { return ( { onChange?.(event.target.value); } } fullWidth error={error} sx={readOnly ? articlePriceFieldHiddenSx : articlePriceFieldSx} slotProps={{ input: { readOnly, startAdornment: ( € ), }, htmlInput: { inputMode: 'decimal', readOnly, tabIndex: readOnly ? -1 : undefined, }, }} /> ); } function PriceUnitRow({ hasUnit, unitamount, unitSi, units, displaySymbol, onChangeAmount, onChangeSi, readOnly, amountError, }) { const selected = findUnitBySi(units, unitSi); const symbol = selected?.symbol ?? displaySymbol ?? unitSi ?? ''; if (readOnly) { return ( {hasUnit && unitamount ? ( ) : null} ); } if (!hasUnit) { return ; } return ( onChangeAmount(event.target.value)} size="small" error={amountError} sx={{ width: 88, flexShrink: 0 }} slotProps={{ inputLabel: { shrink: true }, htmlInput: { inputMode: 'decimal' }, }} /> Einheit ); } function PriceUnitCorner({ hasUnit, onToggleHasUnit, onOpenEditor }) { return ( onToggleHasUnit(event.target.checked)} inputProps={{ 'aria-label': 'Einheit' }} /> ); } export default function ArticleView({ article: articleData, tab, onTabChange, onArticleUpdated, }) { const navigate = useNavigate(); const dispatch = useDispatch(); const [tiersDialogOpen, setTiersDialogOpen] = useState(false); const [unitEditorOpen, setUnitEditorOpen] = useState(false); const categories = useSelector((state) => state.category.categories); const rootCategoryId = useSelector(selectCategoryRootId); const company = useSelector(selectCurrentCompany); const units = useSelector(selectUnits); const unitsStatus = useSelector(selectUnitsStatus); const basePrice = primaryPrice(articleData); const eans = articleEans(articleData); const taxRates = companyTaxRateOptions(company, basePrice?.taxRate); const priceValue = priceDraftValue(articleData, company); const unitOptions = articleData.unit?.si && !findUnitBySi(units, articleData.unit.si) ? [...units, articleData.unit] : units; useEffect(() => { if (unitsStatus !== 'idle') return; void dispatch(loadUnits()).unwrap().catch((err) => notifyError(err)); }, [dispatch, unitsStatus]); const syncCategoryListPhoto = (next) => { const photo = primaryPhoto(next); dispatch( patchArticleInLists({ sku: next.sku, fullHash: photo?.fullHash ?? null, thumbW: photo?.thumbW ?? null, thumbH: photo?.thumbH ?? null, thumbHash: photo?.thumbHash ?? null, }), ); }; const syncCategoryListPrice = (next) => { const price = primaryPrice(next); dispatch( patchArticleInLists({ sku: next.sku, net: price?.net ?? null, taxRate: price?.taxRate ?? null, }), ); }; const handleArticleUpdated = (next) => { onArticleUpdated?.(next); syncCategoryListPhoto(next); }; const handleSaveName = async (draft) => { const name = draft.name.trim(); const res = await article.update(articleData.sku, { name }); requireObject(res, 'PATCH /api/article/:sku'); requireObject(res.article, 'article'); onArticleUpdated?.(res.article); }; const handleSavePrice = async (draft) => { const saved = priceDraftValue(articleData, company); let nextArticle = articleData; if (unitSectionDirty(draft, saved)) { if (draft.hasUnit) { const unitamount = requireDefined( parsePriceInput(draft.unitamount), 'unitamount', ); if (unitamount <= 0) { throw new Error('Ungültige Menge'); } const unitSi = requireDefined( draft.unitSi?.trim() ? draft.unitSi.trim() : null, 'unitSi', ); if (isNoPackUnit(unitSi, unitamount)) { throw new Error('Einheit pc × 1 ist keine Packungseinheit'); } const res = await article.update(articleData.sku, { unitamount, unitSi }); requireObject(res, 'PATCH /api/article/:sku'); requireObject(res.article, 'article'); nextArticle = res.article; } else { const res = await article.update(articleData.sku, { unitamount: 1, unitSi: 'pc', }); requireObject(res, 'PATCH /api/article/:sku'); requireObject(res.article, 'article'); nextArticle = res.article; } } if (priceSectionDirty(draft, saved) && draft.gross !== '') { const gross = requireDefined(parsePriceInput(draft.gross), 'price.gross'); const taxRate = requireDefined( draft.taxRate === '' ? null : Number(draft.taxRate), 'price.tax', ); if (!Number.isFinite(taxRate)) { throw new Error('Ungültiger Steuersatz'); } const net = requireDefined(netFromGross(gross, taxRate), 'price.net'); const prices = buildPosPricesPutBody({ net, taxRate, tiers: draft.tiers ?? [], }); const res = await article.setPrices(articleData.sku, prices); requireObject(res, 'PUT /api/article/:sku/prices'); requireObject(res.article, 'article'); nextArticle = res.article; syncCategoryListPrice(res.article); } onArticleUpdated?.(nextArticle); }; const handleArticleDeleted = () => { const categoryId = requireDefined( articleData.categoryIds?.[0], 'article.categoryIds[0]', ); dispatch(removeArticleBySku(articleData.sku)); if (categories && rootCategoryId != null) { navigate(buildCategoryPath(categories, rootCategoryId, categoryId)); return; } navigate(defaultCategoryPath()); }; return ( ( {}} sx={articleNameFieldHiddenSx} /> )} renderEdit={(draft, onChange, inputRef) => ( onChange({ name })} sx={articleNameFieldSx} /> )} /> {articleData.stock != null ? formatStock(articleData.stock) : null} {brandName(articleData)} {articleData.sku} {eans.map((ean) => ( {ean} ))} { if (!editing || draft == null) return null; return ( { if (hasUnit) { const unitamount = draft.unitamount || '1'; const amount = parsePriceInput(unitamount) ?? 1; let unitSi = firstPackUnitSi( unitOptions, draft.unitSi || articleData.unit?.si, ); // Only pc available: keep pc but clear amount so user must set ≠ 1. if (!unitSi && isNoPackUnit('pc', amount)) { onChange({ ...draft, hasUnit: true, unitSi: 'pc', unitamount: '', }); return; } if (!unitSi) unitSi = 'pc'; onChange({ ...draft, hasUnit: true, unitSi, unitamount, }); return; } onChange({ ...draft, hasUnit: false, unitamount: '', unitSi: '', }); }} onOpenEditor={() => setUnitEditorOpen(true)} /> ); }} renderDisplay={(saved) => ( )} renderEdit={(draft, onChange, inputRef) => { const draftGross = parsePriceInput(draft.gross); const draftAmount = parsePriceInput(draft.unitamount); return ( onChange({ ...draft, gross })} error={draft.gross !== '' && draftGross == null} /> onChange({ ...draft, taxRate })} onOpenTiers={() => setTiersDialogOpen(true)} /> onChange({ ...draft, unitamount })} onChangeSi={(unitSi) => onChange({ ...draft, unitSi })} amountError={ draft.hasUnit && draft.unitamount !== '' && (draftAmount == null || draftAmount <= 0) } /> setTiersDialogOpen(false)} onApply={(tiers) => onChange({ ...draft, tiers })} /> setUnitEditorOpen(false)} onUnitUpserted={(row) => { if (!draft.unitSi) { onChange({ ...draft, unitSi: row.si }); } }} onUnitDeleted={(si) => { if (draft.unitSi === si) { onChange({ ...draft, unitSi: '' }); } }} /> ); }} /> 0} > {articleData.childCount} {articleData.fatherSku ? ( {articleData.fatherSku} ) : null} ); }