Erweiterung der Produktpflege
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user