diff --git a/buildtime/client/material-editor/src/api/language/products.api.ts b/buildtime/client/material-editor/src/api/language/products.api.ts new file mode 100644 index 000000000..e9ba11e19 --- /dev/null +++ b/buildtime/client/material-editor/src/api/language/products.api.ts @@ -0,0 +1,26 @@ +import ky from 'ky' +import type { Product } from '../../data/product' + +export const getProducts = async (): Promise => { + return ky.get(`http://localhost:8088/products`).json() +} + +export const getProductById = async (id: number): Promise => { + return ky.get(`http://localhost:8088/product/${id}`).json() +} + +export const createProduct = async (id: string): Promise => { + return ky.post(`http://localhost:8088/product`, { json: { id } }).json() +} + +export const updateProduct = async (product: Product): Promise => { + await ky.put(`http://localhost:8088/product`, { json: product }) +} + +export const deleteProduct = async (id: number): Promise => { + await ky.delete(`http://localhost:8088/product/${id}`) +} + +export const getProductsByProductGroupId = async (productGroupId: number): Promise => { + return ky.get(`http://localhost:8088/products/productGroup/${productGroupId}`).json() +} \ No newline at end of file diff --git a/buildtime/client/material-editor/src/components/LanguagesTableView.tsx b/buildtime/client/material-editor/src/components/LanguagesTableView.tsx index 8ee7184f5..71684fae9 100644 --- a/buildtime/client/material-editor/src/components/LanguagesTableView.tsx +++ b/buildtime/client/material-editor/src/components/LanguagesTableView.tsx @@ -59,7 +59,7 @@ const LanguagesTableView = ({ languages, setSelectedRow, refreshLanguages }: { l slots={{ toolbar: () => ( setIsCreateDialogOpen(true)} diff --git a/buildtime/client/material-editor/src/components/ProductCreateDialog.tsx b/buildtime/client/material-editor/src/components/ProductCreateDialog.tsx new file mode 100644 index 000000000..303170e8d --- /dev/null +++ b/buildtime/client/material-editor/src/components/ProductCreateDialog.tsx @@ -0,0 +1,74 @@ +import Stack from '@mui/material/Stack'; +import TextField from '@mui/material/TextField'; + +import CreateEntityDialog from './CreateEntityDialog'; +import type { Product } from '../data/product'; + +type ProductCreateDialogProps = { + open: boolean; + onClose: () => void; + onCreated: (product: Product) => void | Promise; +}; + +type ProductFormState = { + product_identifier: string; + label: string; + +}; + +const initialFormState: ProductFormState = { + product_identifier: '', + label: '', +}; + +const check = (formState: ProductFormState) => { + return formState.product_identifier.trim() !== '' && formState.label.trim() !== ''; +}; + +const ProductCreateDialog = ({ open, onClose, onCreated }: ProductCreateDialogProps) => { + return ( + { + if (!check(formState)) { + throw new Error('Invalid product form'); + } + + return { + id: 0, + product_identifier: formState.product_identifier, + label: formState.label, + properties: [], + products: [], + productStructureNodes: [], + isEqualPropertiesEqualValues: false, + } satisfies Product; + }} + errorMessageText="Creating the product failed." + > + {({ formState, setFormState }) => ( + + setFormState((currentState) => ({ ...currentState, product_identifier: event.target.value }))} + /> + setFormState((currentState) => ({ ...currentState, label: event.target.value }))} + /> + + )} + + ); +}; + +export default ProductCreateDialog; \ No newline at end of file diff --git a/buildtime/client/material-editor/src/components/ProductGroupTreeView.tsx b/buildtime/client/material-editor/src/components/ProductGroupTreeView.tsx index eb66302b4..d5f8e94d3 100644 --- a/buildtime/client/material-editor/src/components/ProductGroupTreeView.tsx +++ b/buildtime/client/material-editor/src/components/ProductGroupTreeView.tsx @@ -7,9 +7,10 @@ import Divider from '@mui/material/Divider'; type ProductGroupTreeViewProps = { ProductGroups?: ProductGroup[]; + setSelectedRow: (id: number) => void; }; -export default function ProductGroupTreeView({ ProductGroups = [] }: ProductGroupTreeViewProps) { +export default function ProductGroupTreeView({ ProductGroups = [], setSelectedRow }: ProductGroupTreeViewProps) { const renderTree = (node: ProductGroup) => ( @@ -20,7 +21,7 @@ export default function ProductGroupTreeView({ ProductGroups = [] }: ProductGrou ); return ( - + Product Groups: @@ -28,6 +29,7 @@ export default function ProductGroupTreeView({ ProductGroups = [] }: ProductGrou setSelectedRow(Number(itemId))} > {ProductGroups.map((productGroup) => renderTree(productGroup))} diff --git a/buildtime/client/material-editor/src/components/ProductTableView.tsx b/buildtime/client/material-editor/src/components/ProductTableView.tsx new file mode 100644 index 000000000..3e5cebf9d --- /dev/null +++ b/buildtime/client/material-editor/src/components/ProductTableView.tsx @@ -0,0 +1,150 @@ +import * as React from 'react'; +import Alert from '@mui/material/Alert'; +import CrudDataGrid from './CrudDataGrid'; +import CrudTableToolbar from './CrudTableToolbar'; +import { alternatingRowClassName, createEmptyRowSelectionModel, getSelectedNumericRowId, isDeleteDisabled } from './tableViewUtils'; +import type { GridRowSelectionModel } from '@mui/x-data-grid'; +import {getProductsByProductGroupId} from '../api/language/products.api'; +import {getProductGroupById} from '../api/language/productGroup.api'; +import type { ProductGroup } from '../data/productGroup'; +import type { Product } from '../data/product'; +import ProductCreateDialog from './ProductCreateDialog'; +import { deleteProduct } from '../api/language/products.api'; + +const ProductTableView = ({ selectedRow }: { selectedRow: number }) => { + const [products, setProducts] = React.useState([]); + const [productGroup, setProductGroup] = React.useState(null); + const [rowSelectionModel, setRowSelectionModel] = React.useState(createEmptyRowSelectionModel); + const [isCreateDialogOpen, setIsCreateDialogOpen] = React.useState(false); + const [errorMessage, setErrorMessage] = React.useState(''); + + const loadProducts = React.useCallback(() => { + if (selectedRow <= 0) { + setProducts([]); + setRowSelectionModel(createEmptyRowSelectionModel()); + return; + } + + getProductsByProductGroupId(selectedRow) + .then(setProducts) + .catch(() => setErrorMessage('Loading products failed.')); + }, [selectedRow]); + + React.useEffect(() => { + loadProducts(); + }, [loadProducts]); + + React.useEffect(() => { + if (selectedRow <= 0) { + setProductGroup(null); + return; + } + + getProductGroupById(selectedRow).then(setProductGroup); + }, [selectedRow]); + + const handleProductCreated = () => { + setIsCreateDialogOpen(false); + setErrorMessage(''); + loadProducts(); + }; + + const handleDeleteProduct = async () => { + const selectedProductId = getSelectedNumericRowId(rowSelectionModel); + if (selectedProductId == null) { + setErrorMessage('Select a product before deleting it.'); + return; + } + + try { + await deleteProduct(selectedProductId); + setRowSelectionModel(createEmptyRowSelectionModel()); + setErrorMessage(''); + loadProducts(); + } catch { + setErrorMessage('Deleting the product failed.'); + } + }; + + const rows = React.useMemo( + () => + products.map((product) => { + const propertyValues = Object.fromEntries( + (productGroup?.propertyTypes ?? []).map((propertyType) => { + const matchingProperty = (product.properties as Array> | undefined)?.find( + (property) => + Number(property?.propertyTypeID ?? property?.propertyTypeId ?? property?.propertyType_id) === propertyType.id, + ); + + return [ + `property_${propertyType.id}`, + matchingProperty?.value ?? '', + ]; + }), + ); + + return { + ...product, + ...propertyValues, + }; + }), + [products, productGroup], + ); + + const columns = React.useMemo( + () => [ + { field: 'id', headerName: 'ID', width: 100, editable: false }, + { field: 'product_identifier', headerName: 'Product Identifier', width: 200, editable: true }, + { field: 'label', headerName: 'Label', width: 200, editable: true }, + ...(productGroup?.propertyTypes?.map((propertyType) => ({ + field: `property_${propertyType.id}`, + headerName: propertyType.label, + width: 200, + editable: true, + })) ?? []), + ], + [productGroup], + ); + + return ( +
+ {errorMessage !== '' && {errorMessage}} + ( + setIsCreateDialogOpen(true)} + /> + ), + }} + processRowUpdate={async (updatedRow) => { + setErrorMessage(''); + return updatedRow; + }} + onProcessRowUpdateError={() => setErrorMessage('Saving the translation failed.')} + getRowClassName={alternatingRowClassName} + rowSelectionModel={rowSelectionModel} + onRowSelectionModelChange={(newRowSelectionModel) => setRowSelectionModel(newRowSelectionModel)} + /> + setIsCreateDialogOpen(false)} + onCreated={handleProductCreated} + /> +
+ ); +}; + +export default ProductTableView; \ No newline at end of file diff --git a/buildtime/client/material-editor/src/data/productGroup.ts b/buildtime/client/material-editor/src/data/productGroup.ts index 21192e37f..fb84bdf1d 100644 --- a/buildtime/client/material-editor/src/data/productGroup.ts +++ b/buildtime/client/material-editor/src/data/productGroup.ts @@ -8,6 +8,6 @@ export type ProductGroup = { isVisible: boolean; path: string; childProductGroups: ProductGroup[]; - PropertyTypes: PropertyType[]; - Products: Product[]; + propertyTypes: PropertyType[]; + products: Product[]; }; \ No newline at end of file diff --git a/buildtime/client/material-editor/src/pages/ProductPage.tsx b/buildtime/client/material-editor/src/pages/ProductPage.tsx index f6d56a14c..6d06064c4 100644 --- a/buildtime/client/material-editor/src/pages/ProductPage.tsx +++ b/buildtime/client/material-editor/src/pages/ProductPage.tsx @@ -4,9 +4,12 @@ import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import type { ProductGroup } from '../data/productGroup'; import { getProductGroupById } from '../api/language/productGroup.api'; +import ProductTableView from '../components/ProductTableView'; + const ProductPage: React.FC = () => { const [productGroup, setProductGroup] = useState(null); + const [selectedRow, setSelectedRow] = useState(3); const loadProductGroup = React.useCallback(async () => { const loadedProductGroup = await getProductGroupById(3); // Assuming 3 is the ID for product groups @@ -31,10 +34,12 @@ const ProductPage: React.FC = () => { bgcolor: "#f5f5f5" }} > - + + ); }; + export default ProductPage; diff --git a/buildtime/server/src/scpq-editor-server/repositories/productgroupRepository.go b/buildtime/server/src/scpq-editor-server/repositories/productgroupRepository.go index ab0490ab6..59d72f545 100644 --- a/buildtime/server/src/scpq-editor-server/repositories/productgroupRepository.go +++ b/buildtime/server/src/scpq-editor-server/repositories/productgroupRepository.go @@ -216,7 +216,7 @@ func (rr ProductgroupRepository) GetProductgroup(ProductgroupId int) (*models.Pr } // get property types of this product group - propertyTypes, err3 := rr.GetPropertyTypesByProductGroupID(pg.ID) + propertyTypes, err3 := rr.GetPropertyTypesByProductGroup(pg) if err3 != nil { return nil, &models.ResponseError{ Message: err3.Message, @@ -233,6 +233,30 @@ func (rr ProductgroupRepository) GetProductgroup(ProductgroupId int) (*models.Pr return pg, nil } +func (rr ProductgroupRepository) GetPropertyTypesByProductGroup(productGroup *models.Productgroup) ([]*models.PropertyType, *models.ResponseError) { + if productGroup.Label == "Simply CPQ" { + return rr.GetPropertyTypesByProductGroupID(productGroup.ID) + } else { + PropertyTypesParent, err := rr.GetPropertyTypesByProductGroupID(productGroup.ParentProductGroupID) + if err != nil { + return nil, &models.ResponseError{ + Message: err.Message, + Status: http.StatusInternalServerError, + } + } + PropertyTypesChild, err := rr.GetPropertyTypesByProductGroupID(productGroup.ID) + if err != nil { + return nil, &models.ResponseError{ + Message: err.Message, + Status: http.StatusInternalServerError, + } + } + + return append(PropertyTypesParent, PropertyTypesChild...), nil + } + +} + func (rr ProductgroupRepository) GetPropertyTypesByProductGroupID(productGroupID int) ([]*models.PropertyType, *models.ResponseError) { var propertyTypes []*models.PropertyType query := `SELECT property_type_id FROM product_group_property_types_list WHERE product_group_id=$1`