Erweiterung der Produktpflege

This commit is contained in:
2026-08-27 15:31:50 +02:00
parent b2ce536723
commit cdf7a538c1
240 changed files with 8264 additions and 6420 deletions
@@ -37,6 +37,18 @@ export async function getValuesFromPropertyId(propertyId: number): Promise<Value
return response.data ?? []
}
export async function createValueForPropertyId(propertyId: number, value: Value): Promise<Value> {
const response = await ky
.post(`${API_BASE_URL}/createValueForPropertyId/${propertyId}`, { json: value })
.json<ApiResponse<Value>>()
return response.data
}
export async function updateValue(value: Value): Promise<void> {
await ky.put(`${API_BASE_URL}/updateValue`, { json: value })
}
export async function deleteValueFromPropertyId(valueId: number, propertyId: number): Promise<void> {
await ky.delete(`${API_BASE_URL}/deleteValueFromPropertyId/${valueId}/${propertyId}`)
}
@@ -52,3 +64,7 @@ export async function getValue(id: number): Promise<Value> {
export async function setTerminalValueForProperty(propertyId: number, value: string): Promise<void> {
await ky.post(`${API_BASE_URL}/SetTerminalValueForProperty/${propertyId}/${value}`)
}
export async function createTerminalPropertyForProductId(productId: number, propertyTypeId: number, value: string): Promise<void> {
await ky.post(`${API_BASE_URL}/createTerminalPropertyForProductId/${productId}/${propertyTypeId}/${encodeURIComponent(value)}`)
}
@@ -0,0 +1,427 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import Chip from '@mui/material/Chip';
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 Paper from '@mui/material/Paper';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import FormControl from '@mui/material/FormControl';
import InputLabel from '@mui/material/InputLabel';
import Select from '@mui/material/Select';
import type { SelectChangeEvent } from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
import Checkbox from '@mui/material/Checkbox';
import ListItemText from '@mui/material/ListItemText';
import AddIcon from '@mui/icons-material/Add';
import DeleteIcon from '@mui/icons-material/Delete';
import type { Product } from '../../data/product';
import type { Value } from '../../data/value';
import { getProduct, getPropertiesFromProductId, deletePropertyFromProductId, updateProduct } from '../../api/product.api';
import { getPropertiesFromProductGroupId } from '../../api/productGroup.api';
import {
getValuesFromPropertyId,
getValuesFromPropertyTypeId,
createValueForPropertyId,
updateValue,
deleteValueFromPropertyId,
createTerminalPropertyForProductId,
} from '../../api/value.api';
type PropertyRow = {
propertyId: number;
propertyTypeId: number;
label: string;
description: string;
values: Value[];
};
type PropertyTypeOption = {
propertyTypeId: number;
label: string;
};
async function loadPropertyRows(productId: number): Promise<PropertyRow[]> {
const properties = await getPropertiesFromProductId(productId);
return Promise.all(
properties.map(async (property) => ({
propertyId: property.id,
propertyTypeId: property.propertyTypeId,
label: property.label,
description: property.description,
values: await getValuesFromPropertyId(property.id),
})),
);
}
async function loadAvailablePropertyTypes(productGroupId: number, usedPropertyTypeIds: Set<number>): Promise<PropertyTypeOption[]> {
const groupProperties = await getPropertiesFromProductGroupId(productGroupId);
return groupProperties
.filter((property) => !property.productId && !usedPropertyTypeIds.has(property.propertyTypeId))
.map((property) => ({ propertyTypeId: property.propertyTypeId, label: property.label }));
}
async function loadTemplateValuesByProperty(rows: PropertyRow[]): Promise<Record<number, Value[]>> {
const entries = await Promise.all(
rows.map(async (row) => [row.propertyId, await getValuesFromPropertyTypeId(row.propertyTypeId)] as const),
);
return Object.fromEntries(entries);
}
type ValueChipProps = {
value: Value;
onSave: (label: string) => Promise<void>;
onDelete: () => Promise<void>;
};
const ValueChip = ({ value, onSave, onDelete }: ValueChipProps) => {
const [editing, setEditing] = useState(false);
const [label, setLabel] = useState(value.label);
useEffect(() => {
setLabel(value.label);
}, [value.label]);
const commit = async () => {
setEditing(false);
if (label.trim() && label !== value.label) {
await onSave(label.trim());
} else {
setLabel(value.label);
}
};
if (editing) {
return (
<TextField
size="small"
autoFocus
value={label}
onChange={(e) => setLabel(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') commit();
if (e.key === 'Escape') {
setLabel(value.label);
setEditing(false);
}
}}
sx={{ width: 140 }}
/>
);
}
return (
<Chip
label={value.label}
size="small"
onClick={() => setEditing(true)}
onDelete={onDelete}
deleteIcon={<DeleteIcon fontSize="small" />}
/>
);
};
type ProductDetailProps = {
productId: number;
onProductChanged?: () => void;
};
const ProductDetail = ({ productId, onProductChanged }: ProductDetailProps) => {
const [product, setProduct] = useState<Product | null>(null);
const [rows, setRows] = useState<PropertyRow[]>([]);
const [availablePropertyTypes, setAvailablePropertyTypes] = useState<PropertyTypeOption[]>([]);
const [templateValuesByProperty, setTemplateValuesByProperty] = useState<Record<number, Value[]>>({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [label, setLabel] = useState('');
const [externalId, setExternalId] = useState('');
const [description, setDescription] = useState('');
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [newPropertyTypeId, setNewPropertyTypeId] = useState<number | ''>('');
const [newValueLabel, setNewValueLabel] = useState('');
const reload = useCallback(async () => {
setLoading(true);
setError(null);
try {
const loadedProduct = await getProduct(productId);
setProduct(loadedProduct);
setLabel(loadedProduct.label);
setExternalId(loadedProduct.externalId);
setDescription(loadedProduct.description);
const propertyRows = await loadPropertyRows(productId);
setRows(propertyRows);
setTemplateValuesByProperty(await loadTemplateValuesByProperty(propertyRows));
const usedPropertyTypeIds = new Set(propertyRows.map((row) => row.propertyTypeId));
setAvailablePropertyTypes(await loadAvailablePropertyTypes(loadedProduct.productGroupId, usedPropertyTypeIds));
} catch {
setError('Failed to load product.');
} finally {
setLoading(false);
}
}, [productId]);
useEffect(() => {
reload();
}, [reload]);
const hasProductChanges = useMemo(() => {
if (!product) return false;
return label !== product.label || externalId !== product.externalId || description !== product.description;
}, [product, label, externalId, description]);
const handleSaveProduct = async () => {
if (!product || !label.trim()) return;
await updateProduct({ ...product, label: label.trim(), externalId: externalId.trim(), description: description.trim() });
await reload();
onProductChanged?.();
};
const handleOpenAddDialog = () => {
setNewPropertyTypeId('');
setNewValueLabel('');
setAddDialogOpen(true);
};
const handleAddProperty = async () => {
if (newPropertyTypeId === '' || !newValueLabel.trim()) return;
await createTerminalPropertyForProductId(productId, newPropertyTypeId, newValueLabel.trim());
setAddDialogOpen(false);
await reload();
};
const handleRemoveProperty = async (propertyId: number) => {
if (!window.confirm('Remove selected property?')) return;
await deletePropertyFromProductId(productId, propertyId);
await reload();
};
const handleSaveValue = async (value: Value, newLabel: string) => {
await updateValue({ ...value, label: newLabel });
await reload();
};
const handleDeleteValue = async (value: Value) => {
await deleteValueFromPropertyId(value.id, value.propertyId);
await reload();
};
const handleValueSelectionChange = async (row: PropertyRow, selectedLabels: string[]) => {
const templateValues = templateValuesByProperty[row.propertyId] ?? [];
const currentLabels = row.values.map((value) => value.label);
const addedLabels = selectedLabels.filter((label) => !currentLabels.includes(label));
const removedValues = row.values.filter((value) => !selectedLabels.includes(value.label));
setError(null);
try {
await Promise.all([
...addedLabels.map((label) => {
const templateValue = templateValues.find((value) => value.label === label);
return createValueForPropertyId(row.propertyId, {
id: 0,
label,
description: templateValue?.description ?? '',
propertyId: row.propertyId,
});
}),
...removedValues.map((value) => deleteValueFromPropertyId(value.id, row.propertyId)),
]);
await reload();
} catch {
setError('Failed to update values for this property.');
}
};
if (loading && !product) {
return (
<Stack sx={{ flex: 1, minWidth: 320, p: 2 }}>
<Typography variant="body2">Loading...</Typography>
</Stack>
);
}
if (!product) {
return (
<Stack sx={{ flex: 1, minWidth: 320, p: 2 }}>
{error && <Typography color="error">{error}</Typography>}
</Stack>
);
}
return (
<Stack sx={{ flex: 1, minWidth: 320, p: 1 }} spacing={2}>
<Typography variant="subtitle1">Product Details</Typography>
{error && (
<Typography color="error" variant="body2">
{error}
</Typography>
)}
<Stack spacing={1.5} sx={{ maxWidth: 480 }}>
<TextField label="ID" value={product.id} size="small" disabled fullWidth />
<TextField label="External ID" value={externalId} size="small" fullWidth onChange={(e) => setExternalId(e.target.value)} />
<TextField label="Label" value={label} size="small" fullWidth onChange={(e) => setLabel(e.target.value)} />
<TextField
label="Description"
value={description}
size="small"
fullWidth
multiline
minRows={2}
onChange={(e) => setDescription(e.target.value)}
/>
<Box>
<Button variant="contained" size="small" disabled={!hasProductChanges || !label.trim()} onClick={handleSaveProduct}>
Save
</Button>
</Box>
</Stack>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<Typography variant="subtitle2" sx={{ flexGrow: 1 }}>
Properties
</Typography>
<Tooltip title="Add property">
<span>
<IconButton size="small" onClick={handleOpenAddDialog} disabled={availablePropertyTypes.length === 0}>
<AddIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
</Stack>
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Property</TableCell>
<TableCell>Description</TableCell>
<TableCell>Values</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{rows.map((row) => (
<TableRow key={row.propertyId}>
<TableCell>{row.label}</TableCell>
<TableCell>{row.description}</TableCell>
<TableCell>
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', alignItems: 'center', gap: 0.5 }}>
{row.values.map((value) => (
<ValueChip
key={value.id}
value={value}
onSave={(newLabel) => handleSaveValue(value, newLabel)}
onDelete={() => handleDeleteValue(value)}
/>
))}
</Stack>
<FormControl size="small" sx={{ mt: 0.5, minWidth: 180 }}>
<InputLabel id={`values-select-label-${row.propertyId}`}>Select values</InputLabel>
<Select<string[]>
labelId={`values-select-label-${row.propertyId}`}
label="Select values"
multiple
value={row.values.map((value) => value.label)}
onChange={(e: SelectChangeEvent<string[]>) =>
handleValueSelectionChange(
row,
typeof e.target.value === 'string' ? e.target.value.split(',') : e.target.value,
)
}
renderValue={(selected) => selected.join(', ')}
disabled={(templateValuesByProperty[row.propertyId] ?? []).length === 0}
>
{(templateValuesByProperty[row.propertyId] ?? []).map((templateValue) => (
<MenuItem key={templateValue.id} value={templateValue.label}>
<Checkbox checked={row.values.some((value) => value.label === templateValue.label)} />
<ListItemText primary={templateValue.label} />
</MenuItem>
))}
</Select>
</FormControl>
</TableCell>
<TableCell align="right">
<Tooltip title="Remove property">
<IconButton size="small" onClick={() => handleRemoveProperty(row.propertyId)}>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))}
{rows.length === 0 && (
<TableRow>
<TableCell colSpan={4}>
<Typography variant="body2">No properties assigned to this product yet.</Typography>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
<Dialog open={addDialogOpen} onClose={() => setAddDialogOpen(false)} fullWidth maxWidth="xs">
<DialogTitle>Add Property</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<FormControl size="small" fullWidth>
<InputLabel id="property-type-select-label">Property</InputLabel>
<Select
labelId="property-type-select-label"
label="Property"
value={newPropertyTypeId}
onChange={(e: SelectChangeEvent<number | ''>) =>
setNewPropertyTypeId(e.target.value === '' ? '' : Number(e.target.value))
}
>
{availablePropertyTypes.map((option) => (
<MenuItem key={option.propertyTypeId} value={option.propertyTypeId}>
{option.label}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
label="Initial value"
value={newValueLabel}
onChange={(e) => setNewValueLabel(e.target.value)}
autoFocus
fullWidth
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setAddDialogOpen(false)}>Cancel</Button>
<Button
variant="contained"
onClick={handleAddProperty}
disabled={newPropertyTypeId === '' || !newValueLabel.trim()}
>
Add
</Button>
</DialogActions>
</Dialog>
</Stack>
);
};
export default ProductDetail;
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react';
import type { SyntheticEvent } from 'react';
import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
@@ -10,12 +10,18 @@ import InputAdornment from '@mui/material/InputAdornment';
import AddIcon from '@mui/icons-material/Add';
import DeleteIcon from '@mui/icons-material/Delete';
import SearchIcon from '@mui/icons-material/Search';
import FolderIcon from '@mui/icons-material/Folder';
import DescriptionIcon from '@mui/icons-material/Description';
import { RichTreeView } from '@mui/x-tree-view/RichTreeView';
import { TreeItem } from '@mui/x-tree-view/TreeItem';
import type { TreeItemProps } from '@mui/x-tree-view/TreeItem';
import { createProductGroup, deleteProductGroup, getProductGroupsByParentId } from '../../api/productGroup.api';
import { getProductsByGroupId } from '../../api/product.api';
import EntityFormDialog, { type EntityFormValues } from '../properties/EntityFormDialog';
const ROOT_PARENT_GROUP_ID = 0;
const PRODUCT_ID_PREFIX = 'product-';
export type GroupTreeItem = {
id: string;
@@ -23,9 +29,47 @@ export type GroupTreeItem = {
children?: GroupTreeItem[];
};
function toProductItemId(productId: number): string {
return `${PRODUCT_ID_PREFIX}${productId}`;
}
function parseProductItemId(itemId: string): number | null {
return itemId.startsWith(PRODUCT_ID_PREFIX) ? Number(itemId.slice(PRODUCT_ID_PREFIX.length)) : null;
}
// renders a folder icon for product groups and a document icon (in blue) for products
const GroupOrProductTreeItem = forwardRef<HTMLLIElement, TreeItemProps>(function GroupOrProductTreeItem(props, ref) {
const { itemId, label, ...other } = props;
const isProduct = parseProductItemId(itemId) != null;
return (
<TreeItem
{...other}
ref={ref}
itemId={itemId}
label={
<Stack direction="row" spacing={0.75} sx={{ alignItems: 'center' }}>
{isProduct ? (
<DescriptionIcon fontSize="small" sx={{ color: 'primary.main' }} />
) : (
<FolderIcon fontSize="small" />
)}
<Typography variant="body2" sx={isProduct ? { color: 'primary.main' } : undefined}>
{label}
</Typography>
</Stack>
}
/>
);
});
async function buildGroupTree(parentId: number): Promise<GroupTreeItem[]> {
const groups = await getProductGroupsByParentId(parentId);
return Promise.all(
const [groups, products] = await Promise.all([
getProductGroupsByParentId(parentId),
getProductsByGroupId(parentId),
]);
const groupItems = await Promise.all(
groups.map(async (group) => {
const children = await buildGroupTree(group.id);
return {
@@ -35,6 +79,13 @@ async function buildGroupTree(parentId: number): Promise<GroupTreeItem[]> {
};
}),
);
const productItems = products.map((product) => ({
id: toProductItemId(product.id),
label: product.externalId ? `${product.label} (${product.externalId})` : product.label,
}));
return [...groupItems, ...productItems];
}
function filterGroupTree(items: GroupTreeItem[], query: string): GroupTreeItem[] {
@@ -70,9 +121,16 @@ function findLabel(items: GroupTreeItem[], id: string): string | null {
type ProductGroupTreeProps = {
selectedGroupId: number | null;
onSelectedGroupChange: (groupId: number | null, groupLabel: string | null) => void;
selectedProductId: number | null;
onSelectedProductChange: (productId: number | null, productLabel: string | null) => void;
};
const ProductGroupTree = ({ selectedGroupId, onSelectedGroupChange }: ProductGroupTreeProps) => {
const ProductGroupTree = ({
selectedGroupId,
onSelectedGroupChange,
selectedProductId,
onSelectedProductChange,
}: ProductGroupTreeProps) => {
const [tree, setTree] = useState<GroupTreeItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -104,7 +162,16 @@ const ProductGroupTree = ({ selectedGroupId, onSelectedGroupChange }: ProductGro
}
}, [search, filteredTree]);
const selectedItemId =
selectedProductId != null ? toProductItemId(selectedProductId) : selectedGroupId != null ? String(selectedGroupId) : null;
const handleSelectedItemsChange = (_event: SyntheticEvent | null, itemId: string | null) => {
const productId = itemId != null ? parseProductItemId(itemId) : null;
if (productId != null) {
onSelectedProductChange(productId, findLabel(tree, itemId!));
return;
}
onSelectedProductChange(null, null);
onSelectedGroupChange(itemId ? Number(itemId) : null, itemId ? findLabel(tree, itemId) : null);
};
@@ -167,10 +234,11 @@ const ProductGroupTree = ({ selectedGroupId, onSelectedGroupChange }: ProductGro
<Box sx={{ flexGrow: 1, overflowY: 'auto', minHeight: 300 }}>
<RichTreeView
items={filteredTree}
selectedItems={selectedGroupId != null ? String(selectedGroupId) : null}
selectedItems={selectedItemId}
onSelectedItemsChange={handleSelectedItemsChange}
expandedItems={expandedItems}
onExpandedItemsChange={(_event, itemIds) => setExpandedItems(itemIds)}
slots={{ item: GroupOrProductTreeItem }}
/>
</Box>
{loading && <Typography variant="body2">Loading...</Typography>}
@@ -4,16 +4,22 @@ import Typography from '@mui/material/Typography';
import ProductGroupTree from './ProductGroupTree';
import ProductGrid from './ProductGrid';
import ProductDetail from './ProductDetail';
const Products: React.FC = () => {
const [selectedGroupId, setSelectedGroupId] = useState<number | null>(null);
const [selectedGroupLabel, setSelectedGroupLabel] = useState<string | null>(null);
const [selectedProductId, setSelectedProductId] = useState<number | null>(null);
const handleSelectedGroupChange = useCallback((groupId: number | null, groupLabel: string | null) => {
setSelectedGroupId(groupId);
setSelectedGroupLabel(groupLabel);
}, []);
const handleSelectedProductChange = useCallback((productId: number | null) => {
setSelectedProductId(productId);
}, []);
return (
<Box component="section" sx={{ p: 2, border: '1px dashed grey' }}>
<Typography variant="body1" gutterBottom>
@@ -28,8 +34,17 @@ const Products: React.FC = () => {
bgcolor: '#f5f5f5',
}}
>
<ProductGroupTree selectedGroupId={selectedGroupId} onSelectedGroupChange={handleSelectedGroupChange} />
<ProductGrid groupId={selectedGroupId} groupLabel={selectedGroupLabel} />
<ProductGroupTree
selectedGroupId={selectedGroupId}
onSelectedGroupChange={handleSelectedGroupChange}
selectedProductId={selectedProductId}
onSelectedProductChange={handleSelectedProductChange}
/>
{selectedProductId != null ? (
<ProductDetail productId={selectedProductId} />
) : (
<ProductGrid groupId={selectedGroupId} groupLabel={selectedGroupLabel} />
)}
</Box>
</Box>
);