Produktmanagement hinzugefügt und kleinere Bug behoben
This commit is contained in:
-1
@@ -1 +0,0 @@
|
|||||||
{"root":["../../src/app.tsx","../../src/layout.tsx","../../src/keycloak.ts","../../src/main.tsx","../../src/api/propertytype.api.ts","../../src/api/propertytypegroup.api.ts","../../src/api/value.api.ts","../../src/components/footer.tsx","../../src/components/navbar.tsx","../../src/components/properties/entityformdialog.tsx","../../src/components/properties/properties.tsx","../../src/components/properties/propertytypegrid.tsx","../../src/components/properties/propertytypegrouptree.tsx","../../src/components/properties/valuegrid.tsx","../../src/data/propertytype.ts","../../src/data/propertytypegrouptable.ts","../../src/data/value.ts","../../src/pages/homepage.tsx","../../src/pages/notfoundpage.tsx"],"errors":true,"version":"6.0.3"}
|
|
||||||
-1
@@ -1 +0,0 @@
|
|||||||
{"root":["../../vite.config.ts"],"version":"6.0.3"}
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import ky from 'ky'
|
||||||
|
import type { Product } from '../data/product'
|
||||||
|
import type { Property } from '../data/property';
|
||||||
|
|
||||||
|
const API_BASE_URL = 'http://localhost:8088'
|
||||||
|
|
||||||
|
export async function createProduct(product: Product): Promise<Product> {
|
||||||
|
const response = await ky
|
||||||
|
.post(`${API_BASE_URL}/createProduct`, { json: product })
|
||||||
|
.json<{ status: string; data: Product }>()
|
||||||
|
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProduct(product: Product): Promise<void> {
|
||||||
|
await ky.put(`${API_BASE_URL}/updateProduct`, { json: product })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteProduct(id: number): Promise<void> {
|
||||||
|
await ky.delete(`${API_BASE_URL}/deleteProduct/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProduct(id: number): Promise<Product> {
|
||||||
|
const response = await ky
|
||||||
|
.get(`${API_BASE_URL}/getProduct/${id}`)
|
||||||
|
.json<{ status: string; product: Product }>()
|
||||||
|
|
||||||
|
return response.product
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProductsByGroupId(groupId: number): Promise<Product[]> {
|
||||||
|
const response = await ky
|
||||||
|
.get(`${API_BASE_URL}/getProductsByGroupId/${groupId}`)
|
||||||
|
.json<{ status: string; groupId: number; products: Product[] }>()
|
||||||
|
|
||||||
|
// the backend returns null instead of [] when there are no matching products
|
||||||
|
return response.products ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
// note: the registered route only carries :productId, propertyId is sent in the body
|
||||||
|
export async function addPropertyToProductId(productId: number, propertyId: number): Promise<void> {
|
||||||
|
await ky.post(`${API_BASE_URL}/addPropertyToProductId/${productId}`, { json: { propertyId } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// note: the registered route only carries :productId, propertyId is sent in the body
|
||||||
|
export async function deletePropertyFromProductId(productId: number, propertyId: number): Promise<void> {
|
||||||
|
await ky.delete(`${API_BASE_URL}/deletePropertyFromProductId/${productId}`, { json: { propertyId } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPropertiesFromProductId(productId: number): Promise<Property[]> {
|
||||||
|
const response = await ky
|
||||||
|
.get(`${API_BASE_URL}/getPropertiesFromProductId/${productId}`)
|
||||||
|
.json<{ status: string; productId: number; properties: Property[] }>()
|
||||||
|
return response.properties ?? []
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import ky from 'ky'
|
||||||
|
import type { ProductGroup } from '../data/productGroup'
|
||||||
|
import type { Property } from '../data/property'
|
||||||
|
|
||||||
|
type ApiResponse<T> = {
|
||||||
|
message: string;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_BASE_URL = 'http://localhost:8088'
|
||||||
|
|
||||||
|
export async function createProductGroup(productGroup: ProductGroup): Promise<ProductGroup> {
|
||||||
|
const response = await ky
|
||||||
|
.post(`${API_BASE_URL}/createProductGroup`, { json: productGroup })
|
||||||
|
.json<ApiResponse<ProductGroup>>()
|
||||||
|
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProductGroup(productGroup: ProductGroup): Promise<ProductGroup> {
|
||||||
|
const response = await ky
|
||||||
|
.put(`${API_BASE_URL}/updateProductGroup`, { json: productGroup })
|
||||||
|
.json<ApiResponse<ProductGroup>>()
|
||||||
|
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteProductGroup(id: number): Promise<void> {
|
||||||
|
await ky.delete(`${API_BASE_URL}/deleteProductGroup/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProductGroup(id: number): Promise<ProductGroup> {
|
||||||
|
const response = await ky
|
||||||
|
.get(`${API_BASE_URL}/getProductGroup/${id}`)
|
||||||
|
.json<ApiResponse<ProductGroup>>()
|
||||||
|
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProductGroupsByParentId(parentId: number): Promise<ProductGroup[]> {
|
||||||
|
const response = await ky
|
||||||
|
.get(`${API_BASE_URL}/getProductGroupsByParentId/${parentId}`)
|
||||||
|
.json<ApiResponse<ProductGroup[]>>()
|
||||||
|
|
||||||
|
// the backend returns null instead of [] when there are no matching product groups
|
||||||
|
return response.data ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteProductFromProductGroupId(productId: number): Promise<void> {
|
||||||
|
await ky.delete(`${API_BASE_URL}/deleteProductFromProductGroupId/${productId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPropertiesFromProductGroupId(productGroupId: number): Promise<Property[]> {
|
||||||
|
const response = await ky
|
||||||
|
.post(`${API_BASE_URL}/getPropertiesFromProductGroupId/${productGroupId}`)
|
||||||
|
.json<ApiResponse<Property[]>>()
|
||||||
|
|
||||||
|
// the backend returns null instead of [] when there are no matching properties
|
||||||
|
return response.data ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePropertyFromProductGroupId(propertyId: number, productGroupId: number): Promise<void> {
|
||||||
|
await ky.delete(`${API_BASE_URL}/deletePropertyFromProductGroupId/${propertyId}/${productGroupId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPropertyToProductGroupId(propertyTypeId: number, productGroupId: number): Promise<Property> {
|
||||||
|
const response = await ky
|
||||||
|
.get(`${API_BASE_URL}/createPropertyToProductGroupId/${propertyTypeId}/${productGroupId}`)
|
||||||
|
.json<ApiResponse<Property>>()
|
||||||
|
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
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 Button from '@mui/material/Button';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import Stack from '@mui/material/Stack';
|
||||||
|
|
||||||
|
export type ProductFormValues = {
|
||||||
|
label: string;
|
||||||
|
externalId: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProductFormDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (values: ProductFormValues) => Promise<void> | void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProductFormDialog = ({ open, onClose, onSubmit }: ProductFormDialogProps) => {
|
||||||
|
const [label, setLabel] = useState('');
|
||||||
|
const [externalId, setExternalId] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setLabel('');
|
||||||
|
setExternalId('');
|
||||||
|
setDescription('');
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!label.trim()) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await onSubmit({ label: label.trim(), externalId: externalId.trim(), description: description.trim() });
|
||||||
|
onClose();
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={onClose} fullWidth maxWidth="xs">
|
||||||
|
<DialogTitle>Add Product</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||||
|
<TextField label="Label" value={label} onChange={(e) => setLabel(e.target.value)} autoFocus required fullWidth />
|
||||||
|
<TextField label="External ID" value={externalId} onChange={(e) => setExternalId(e.target.value)} fullWidth />
|
||||||
|
<TextField
|
||||||
|
label="Description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose} disabled={submitting}>Cancel</Button>
|
||||||
|
<Button onClick={handleSubmit} variant="contained" disabled={submitting || !label.trim()}>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductFormDialog;
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
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 IconButton from '@mui/material/IconButton';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
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 { DataGrid } from '@mui/x-data-grid';
|
||||||
|
import type { GridColDef, GridRowSelectionModel } from '@mui/x-data-grid';
|
||||||
|
|
||||||
|
import { createProduct, deleteProduct, getProductsByGroupId } from '../../api/product.api';
|
||||||
|
import { getPropertiesFromProductGroupId } from '../../api/productGroup.api';
|
||||||
|
import { getPropertyType } from '../../api/propertyType.api';
|
||||||
|
import { getValuesFromPropertyId } from '../../api/value.api';
|
||||||
|
import ProductFormDialog, { type ProductFormValues } from './ProductFormDialog';
|
||||||
|
|
||||||
|
const EMPTY_SELECTION: GridRowSelectionModel = { type: 'include', ids: new Set() };
|
||||||
|
|
||||||
|
function toSelectionModel(id: number | null): GridRowSelectionModel {
|
||||||
|
return id == null ? EMPTY_SELECTION : { type: 'include', ids: new Set([id]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromSelectionModel(model: GridRowSelectionModel): number | null {
|
||||||
|
const [id] = Array.from(model.ids);
|
||||||
|
return id == null ? null : Number(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const propertyTypeField = (propertyTypeId: number) => `propertyType_${propertyTypeId}`;
|
||||||
|
|
||||||
|
type PropertyTypeColumn = {
|
||||||
|
propertyTypeId: number;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProductRow = {
|
||||||
|
id: number;
|
||||||
|
externalId: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
[propertyTypeField: string]: string | number;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadProductRows(groupId: number): Promise<{ rows: ProductRow[]; propertyTypeColumns: PropertyTypeColumn[] }> {
|
||||||
|
const [allProperties, products] = await Promise.all([
|
||||||
|
getPropertiesFromProductGroupId(groupId), // Assuming this function fetches properties for all products in the group
|
||||||
|
getProductsByGroupId(groupId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// properties without a productId are the group-level templates that define the assigned property types
|
||||||
|
const templatePropertyTypeIds = Array.from(
|
||||||
|
new Set(allProperties.filter((property) => !property.productId).map((property) => property.propertyTypeId)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const propertyTypeColumns = await Promise.all(
|
||||||
|
templatePropertyTypeIds.map(async (propertyTypeId) => ({
|
||||||
|
propertyTypeId,
|
||||||
|
label: (await getPropertyType(propertyTypeId)).label,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = await Promise.all(
|
||||||
|
products.map(async (product) => {
|
||||||
|
const row: ProductRow = {
|
||||||
|
id: product.id,
|
||||||
|
externalId: product.externalId,
|
||||||
|
label: product.label,
|
||||||
|
description: product.description,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ownProperties = allProperties.filter((property) => property.productId === product.id);
|
||||||
|
await Promise.all(
|
||||||
|
ownProperties.map(async (property) => {
|
||||||
|
const values = await getValuesFromPropertyId(property.id);
|
||||||
|
row[propertyTypeField(property.propertyTypeId)] = values.map((value) => value.label).join(',');
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { rows, propertyTypeColumns };
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProductGridProps = {
|
||||||
|
groupId: number | null;
|
||||||
|
groupLabel: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProductGrid = ({ groupId, groupLabel }: ProductGridProps) => {
|
||||||
|
const [rows, setRows] = useState<ProductRow[]>([]);
|
||||||
|
const [propertyTypeColumns, setPropertyTypeColumns] = useState<PropertyTypeColumn[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [selectedProductId, setSelectedProductId] = useState<number | null>(null);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
if (groupId == null) {
|
||||||
|
setRows([]);
|
||||||
|
setPropertyTypeColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const { rows: nextRows, propertyTypeColumns: nextColumns } = await loadProductRows(groupId);
|
||||||
|
setRows(nextRows);
|
||||||
|
setPropertyTypeColumns(nextColumns);
|
||||||
|
} catch {
|
||||||
|
setError('Failed to load products.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [groupId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedProductId(null);
|
||||||
|
reload();
|
||||||
|
}, [groupId, reload]);
|
||||||
|
|
||||||
|
const columns = useMemo<GridColDef<ProductRow>[]>(
|
||||||
|
() => [
|
||||||
|
{ field: 'id', headerName: 'ID', width: 80 },
|
||||||
|
{ field: 'externalId', headerName: 'External ID', width: 140 },
|
||||||
|
{ field: 'label', headerName: 'Label', flex: 1 },
|
||||||
|
{ field: 'description', headerName: 'Description', flex: 1 },
|
||||||
|
...propertyTypeColumns.map<GridColDef<ProductRow>>((column) => ({
|
||||||
|
field: propertyTypeField(column.propertyTypeId),
|
||||||
|
headerName: column.label,
|
||||||
|
flex: 1,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
[propertyTypeColumns],
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredRows = useMemo(() => {
|
||||||
|
const normalizedQuery = search.trim().toLowerCase();
|
||||||
|
if (!normalizedQuery) return rows;
|
||||||
|
return rows.filter(
|
||||||
|
(row) =>
|
||||||
|
row.label.toLowerCase().includes(normalizedQuery) ||
|
||||||
|
row.externalId.toLowerCase().includes(normalizedQuery) ||
|
||||||
|
row.description.toLowerCase().includes(normalizedQuery),
|
||||||
|
);
|
||||||
|
}, [rows, search]);
|
||||||
|
|
||||||
|
const handleAddProduct = async ({ label, externalId, description }: ProductFormValues) => {
|
||||||
|
if (groupId == null) return;
|
||||||
|
await createProduct({ id: 0, label, externalId, description, productGroupId: groupId });
|
||||||
|
await reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveProduct = async () => {
|
||||||
|
if (selectedProductId == null) return;
|
||||||
|
if (!window.confirm('Remove selected product?')) return;
|
||||||
|
await deleteProduct(selectedProductId);
|
||||||
|
setSelectedProductId(null);
|
||||||
|
await reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack sx={{ flex: 1, minWidth: 320, p: 1 }} spacing={1}>
|
||||||
|
<Typography variant="subtitle1">Products{groupLabel ? ` - ${groupLabel}` : ''}</Typography>
|
||||||
|
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
placeholder="Search products"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
disabled={groupId == null}
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<SearchIcon fontSize="small" />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tooltip title="Add product">
|
||||||
|
<span>
|
||||||
|
<IconButton size="small" onClick={() => setDialogOpen(true)} disabled={groupId == null}>
|
||||||
|
<AddIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Remove selected product">
|
||||||
|
<span>
|
||||||
|
<IconButton size="small" onClick={handleRemoveProduct} disabled={selectedProductId == null}>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
{error && (
|
||||||
|
<Typography color="error" variant="body2">
|
||||||
|
{error}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{groupId == null ? (
|
||||||
|
<Typography variant="body2" sx={{ p: 2 }}>
|
||||||
|
Select a product group to view its products.
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ flexGrow: 1, minHeight: 300 }}>
|
||||||
|
<DataGrid
|
||||||
|
rows={filteredRows}
|
||||||
|
columns={columns}
|
||||||
|
loading={loading}
|
||||||
|
density="compact"
|
||||||
|
rowSelectionModel={toSelectionModel(selectedProductId)}
|
||||||
|
onRowSelectionModelChange={(model) => setSelectedProductId(fromSelectionModel(model))}
|
||||||
|
disableMultipleRowSelection
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<ProductFormDialog open={dialogOpen} onClose={() => setDialogOpen(false)} onSubmit={handleAddProduct} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductGrid;
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import type { SyntheticEvent } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Stack from '@mui/material/Stack';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
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 { RichTreeView } from '@mui/x-tree-view/RichTreeView';
|
||||||
|
|
||||||
|
import { createProductGroup, deleteProductGroup, getProductGroupsByParentId } from '../../api/productGroup.api';
|
||||||
|
import EntityFormDialog, { type EntityFormValues } from '../properties/EntityFormDialog';
|
||||||
|
|
||||||
|
const ROOT_PARENT_GROUP_ID = 0;
|
||||||
|
|
||||||
|
export type GroupTreeItem = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
children?: GroupTreeItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
async function buildGroupTree(parentId: number): Promise<GroupTreeItem[]> {
|
||||||
|
const groups = await getProductGroupsByParentId(parentId);
|
||||||
|
return Promise.all(
|
||||||
|
groups.map(async (group) => {
|
||||||
|
const children = await buildGroupTree(group.id);
|
||||||
|
return {
|
||||||
|
id: String(group.id),
|
||||||
|
label: group.label,
|
||||||
|
children: children.length > 0 ? children : undefined,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterGroupTree(items: GroupTreeItem[], query: string): GroupTreeItem[] {
|
||||||
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
|
if (!normalizedQuery) return items;
|
||||||
|
|
||||||
|
const filterNodes = (nodes: GroupTreeItem[]): GroupTreeItem[] =>
|
||||||
|
nodes.reduce<GroupTreeItem[]>((matches, node) => {
|
||||||
|
const filteredChildren = node.children ? filterNodes(node.children) : [];
|
||||||
|
const selfMatches = node.label.toLowerCase().includes(normalizedQuery);
|
||||||
|
if (selfMatches || filteredChildren.length > 0) {
|
||||||
|
matches.push({ ...node, children: selfMatches ? node.children : filteredChildren });
|
||||||
|
}
|
||||||
|
return matches;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return filterNodes(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectIds(items: GroupTreeItem[]): string[] {
|
||||||
|
return items.flatMap((item) => [item.id, ...(item.children ? collectIds(item.children) : [])]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findLabel(items: GroupTreeItem[], id: string): string | null {
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.id === id) return item.label;
|
||||||
|
const childLabel = item.children ? findLabel(item.children, id) : null;
|
||||||
|
if (childLabel != null) return childLabel;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProductGroupTreeProps = {
|
||||||
|
selectedGroupId: number | null;
|
||||||
|
onSelectedGroupChange: (groupId: number | null, groupLabel: string | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProductGroupTree = ({ selectedGroupId, onSelectedGroupChange }: ProductGroupTreeProps) => {
|
||||||
|
const [tree, setTree] = useState<GroupTreeItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [expandedItems, setExpandedItems] = useState<string[]>([]);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
const reloadTree = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setTree(await buildGroupTree(ROOT_PARENT_GROUP_ID));
|
||||||
|
} catch {
|
||||||
|
setError('Failed to load product groups.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reloadTree();
|
||||||
|
}, [reloadTree]);
|
||||||
|
|
||||||
|
const filteredTree = useMemo(() => filterGroupTree(tree, search), [tree, search]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (search.trim()) {
|
||||||
|
setExpandedItems(collectIds(filteredTree));
|
||||||
|
}
|
||||||
|
}, [search, filteredTree]);
|
||||||
|
|
||||||
|
const handleSelectedItemsChange = (_event: SyntheticEvent | null, itemId: string | null) => {
|
||||||
|
onSelectedGroupChange(itemId ? Number(itemId) : null, itemId ? findLabel(tree, itemId) : null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddGroup = async ({ label, description }: EntityFormValues) => {
|
||||||
|
await createProductGroup({
|
||||||
|
id: 0,
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
parentGroupId: selectedGroupId ?? ROOT_PARENT_GROUP_ID,
|
||||||
|
});
|
||||||
|
await reloadTree();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveGroup = async () => {
|
||||||
|
if (selectedGroupId == null) return;
|
||||||
|
if (!window.confirm('Remove selected product group?')) return;
|
||||||
|
await deleteProductGroup(selectedGroupId);
|
||||||
|
onSelectedGroupChange(null, null);
|
||||||
|
await reloadTree();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack sx={{ width: '33%', minWidth: 280, borderRight: '1px solid #e0e0e0', p: 1 }} spacing={1}>
|
||||||
|
<Typography variant="subtitle1">Product Groups</Typography>
|
||||||
|
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
placeholder="Search groups"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<SearchIcon fontSize="small" />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tooltip title="Add group">
|
||||||
|
<IconButton size="small" onClick={() => setDialogOpen(true)}>
|
||||||
|
<AddIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Remove selected group">
|
||||||
|
<span>
|
||||||
|
<IconButton size="small" onClick={handleRemoveGroup} disabled={selectedGroupId == null}>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
{error && (
|
||||||
|
<Typography color="error" variant="body2">
|
||||||
|
{error}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Box sx={{ flexGrow: 1, overflowY: 'auto', minHeight: 300 }}>
|
||||||
|
<RichTreeView
|
||||||
|
items={filteredTree}
|
||||||
|
selectedItems={selectedGroupId != null ? String(selectedGroupId) : null}
|
||||||
|
onSelectedItemsChange={handleSelectedItemsChange}
|
||||||
|
expandedItems={expandedItems}
|
||||||
|
onExpandedItemsChange={(_event, itemIds) => setExpandedItems(itemIds)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{loading && <Typography variant="body2">Loading...</Typography>}
|
||||||
|
<EntityFormDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
title="Add Product Group"
|
||||||
|
onClose={() => setDialogOpen(false)}
|
||||||
|
onSubmit={handleAddGroup}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductGroupTree;
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import React, { useCallback, useState } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
|
||||||
|
import ProductGroupTree from './ProductGroupTree';
|
||||||
|
import ProductGrid from './ProductGrid';
|
||||||
|
|
||||||
|
const Products: React.FC = () => {
|
||||||
|
const [selectedGroupId, setSelectedGroupId] = useState<number | null>(null);
|
||||||
|
const [selectedGroupLabel, setSelectedGroupLabel] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSelectedGroupChange = useCallback((groupId: number | null, groupLabel: string | null) => {
|
||||||
|
setSelectedGroupId(groupId);
|
||||||
|
setSelectedGroupLabel(groupLabel);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box component="section" sx={{ p: 2, border: '1px dashed grey' }}>
|
||||||
|
<Typography variant="body1" gutterBottom>
|
||||||
|
Product Management
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
width: '100%',
|
||||||
|
minHeight: 500,
|
||||||
|
bgcolor: '#f5f5f5',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ProductGroupTree selectedGroupId={selectedGroupId} onSelectedGroupChange={handleSelectedGroupChange} />
|
||||||
|
<ProductGrid groupId={selectedGroupId} groupLabel={selectedGroupLabel} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Products;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export type Product = {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
externalId: string;
|
||||||
|
description: string;
|
||||||
|
productGroupId: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export type ProductGroup = {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
parentGroupId: number;
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export type Property = {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
propertyTypeId: number;
|
||||||
|
productId: number;
|
||||||
|
productGroupId: number;
|
||||||
|
};
|
||||||
@@ -7,6 +7,7 @@ import TabList from '@mui/lab/TabList';
|
|||||||
import TabPanel from '@mui/lab/TabPanel';
|
import TabPanel from '@mui/lab/TabPanel';
|
||||||
|
|
||||||
import Properties from '../components/properties/Properties';
|
import Properties from '../components/properties/Properties';
|
||||||
|
import Products from '../components/products/Products';
|
||||||
|
|
||||||
const HomePage: React.FC = () => {
|
const HomePage: React.FC = () => {
|
||||||
const [value, setValue] = React.useState('1');
|
const [value, setValue] = React.useState('1');
|
||||||
@@ -26,7 +27,7 @@ const HomePage: React.FC = () => {
|
|||||||
<Tab value="5" label="Calculations" />
|
<Tab value="5" label="Calculations" />
|
||||||
</TabList>
|
</TabList>
|
||||||
<TabPanel value="1"><Properties /></TabPanel>
|
<TabPanel value="1"><Properties /></TabPanel>
|
||||||
<TabPanel value="2">Item Two</TabPanel>
|
<TabPanel value="2"><Products /></TabPanel>
|
||||||
<TabPanel value="3">Item Three</TabPanel>
|
<TabPanel value="3">Item Three</TabPanel>
|
||||||
<TabPanel value="4">Item Three</TabPanel>
|
<TabPanel value="4">Item Three</TabPanel>
|
||||||
<TabPanel value="5">Item Three</TabPanel>
|
<TabPanel value="5">Item Three</TabPanel>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func (pr *ProductRepository) CreateProduct(product *models.Product) (*models.Pro
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, property := range properties {
|
for _, property := range properties {
|
||||||
_, err := pr.valueRepository.createPropertyFromProperty(0, product.ID, property)
|
_, err := pr.valueRepository.createPropertyFromProperty(product.ProductGroupID, product.ID, property)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ func InitHttpServer(config *viper.Viper, dbHandler *sql.DB) HttpServer {
|
|||||||
router.DELETE("/deleteProduct/:id", productController.DeleteProduct) // delete Product
|
router.DELETE("/deleteProduct/:id", productController.DeleteProduct) // delete Product
|
||||||
router.GET("/getProduct/:id", productController.GetProduct) // get Product by id
|
router.GET("/getProduct/:id", productController.GetProduct) // get Product by id
|
||||||
router.GET("/getProductsByGroupId/:groupId", productController.GetProductsByGroupId) // get all Products by group ID
|
router.GET("/getProductsByGroupId/:groupId", productController.GetProductsByGroupId) // get all Products by group ID
|
||||||
|
router.GET("/getPropertiesFromProductId/:productId", productController.GetPropertiesFromProductId) // get Properties from ProductGroup by ID
|
||||||
router.POST("/addPropertyToProductId/:productId", productController.AddPropertyToProductId) // add Property to Product by ID
|
router.POST("/addPropertyToProductId/:productId", productController.AddPropertyToProductId) // add Property to Product by ID
|
||||||
router.DELETE("/deletePropertyFromProductId/:productId", productController.DeletePropertyFromProductId) // delete Property from Product by ID
|
router.DELETE("/deletePropertyFromProductId/:productId", productController.DeletePropertyFromProductId) // delete Property from Product by ID
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user