From 47ce75baf7c05e41cac5f869f5b98162cf249a09 Mon Sep 17 00:00:00 2001 From: arcadio77 <66469918+arcadio77@users.noreply.github.com> Date: Sat, 10 Jan 2026 15:04:29 +0100 Subject: [PATCH 1/2] add products --- src/api/product-service.ts | 152 +++++- src/api/role-service.ts | 9 +- src/api/utils.ts | 2 - src/hooks/useProductPage.ts | 60 ++- src/pages/Login.tsx | 1 + src/pages/ProductPage.tsx | 5 +- src/pages/admin/AddProductPage.tsx | 750 ++++++++++++++++++++++++++++- src/types/products.ts | 50 +- 8 files changed, 982 insertions(+), 47 deletions(-) diff --git a/src/api/product-service.ts b/src/api/product-service.ts index e01c0c6..e7fc30b 100644 --- a/src/api/product-service.ts +++ b/src/api/product-service.ts @@ -8,23 +8,34 @@ import type { GetProductsRequestDTO, GetProductsResponseDTO, GetVariantResponseDTO, - ProductCreateRequestDTO, ProductCreateResponseDTO, + ProductCreateRequestDTO, + VariantCreateGrpcRequestDTO, + VariantCreateResponseDTO, + CategoryPropertyGroupResponse, VariantPropertyResponseDTO, } from "../types/products"; -import { apiCall } from "./utils"; +import { API_BASE_URL, apiCall } from "./utils"; const PRODUCT_SERVICE_URL = "http://localhost:8400"; const PRODUCT_API = `${PRODUCT_SERVICE_URL}/api/products`; const VARIANT_API = `${PRODUCT_SERVICE_URL}/api/variant`; const CATEGORY_API = `${PRODUCT_SERVICE_URL}/api/categories`; +const PROPERTIES_API = `${PRODUCT_SERVICE_URL}/api/properties`; +const PRODUCT_GRPC_API = `${API_BASE_URL}/api/products/grpc`; +const VARIANT_GRPC_API = `${API_BASE_URL}/api/variants/grpc`; export const createProduct = async ( - productData: ProductCreateRequestDTO, + productData: ProductCreateRequestDTO, ): Promise => { + const jwtToken = localStorage.getItem("token"); try { - const response = await apiCall(PRODUCT_API, { + const response = await apiCall(PRODUCT_GRPC_API, { method: "POST", + headers: { + Authorization: `Bearer ${jwtToken}`, + "Content-Type": "application/json", + }, body: JSON.stringify(productData), }); @@ -116,7 +127,7 @@ export const getCategoryById = async (categoryId: string): Promise => { try { const response = await apiCall(CATEGORY_API, { @@ -138,8 +149,8 @@ export const createCategory = async ( // Update category (name and/or parent) // Note: This endpoint is not yet implemented in CategoryController, only in service export const updateCategory = async ( - categoryId: string, - categoryData: CategoryUpdateRequestDTO, + categoryId: string, + categoryData: CategoryUpdateRequestDTO, ): Promise => { try { const response = await apiCall(`${CATEGORY_API}/${categoryId}`, { @@ -177,8 +188,8 @@ export const deleteCategory = async (categoryId: string): Promise => { }; export const getProductsByCategory = async ( - categoryId: string, - request: GetProductsRequestDTO, + categoryId: string, + request: GetProductsRequestDTO, ): Promise => { try { const url = new URL(PRODUCT_API); @@ -201,8 +212,8 @@ export const getProductsByCategory = async ( }; export const searchProducts = async ( - query: string, - request: GetProductsRequestDTO, + query: string, + request: GetProductsRequestDTO, ): Promise => { try { const url = new URL(`${PRODUCT_API}/search`); @@ -217,6 +228,8 @@ export const searchProducts = async ( throw new Error(`Failed to search products: ${response.status} ${response.statusText}`); } + console.log(response.json()); + return await response.json(); } catch (error) { console.error("API Error searching products:", error); @@ -232,11 +245,15 @@ export const getAllVariantDetails = async (variantId: string): Promise> => { try { const response = await apiCall(`${VARIANT_API}/${variantId}/properties`, { @@ -270,13 +291,110 @@ export const getVariantProperties = async ( if (!response.ok) { throw new Error( - `Failed to get variant properties: ${response.status} ${response.statusText}`, + `Failed to get variant properties: ${response.status} ${response.statusText}`, ); } - return await response.json(); + const res = await response.json(); + + console.log("bajojajo", res); + + return res; } catch (error) { console.error("API Error getting variant properties:", error); throw error; } }; + +export const getCategoryProperties = async ( + categoryId: string, +): Promise => { + try { + const url = new URL(PROPERTIES_API); + url.searchParams.set("categoryId", categoryId); + + const response = await apiCall(url.toString(), { + method: "GET", + }); + + if (!response.ok) { + throw new Error( + `Failed to get properties for category: ${response.status} ${response.statusText}`, + ); + } + + return await response.json(); + } catch (error) { + console.error("API Error fetching category properties:", error); + throw error; + } +}; + +export interface CreatePropertyGrpcRequestDTO { + categoryId: string; + name: string; + unit: string; + dataType: string; + role: "SELECTABLE" | "REQUIRED" | "INFO"; + defaultPropertyOptionValues: string[]; +} + +export interface CreatePropertyGrpcResponseDTO { + id: string; +} + +const PROPERTIES_GRPC_API = `${API_BASE_URL}/api/properties/grpc`; + +export const createPropertyGrpc = async ( + propertyData: CreatePropertyGrpcRequestDTO, +): Promise => { + const jwtToken = localStorage.getItem("token"); + + try { + const response = await apiCall(PROPERTIES_GRPC_API, { + method: "POST", + headers: { + Authorization: `Bearer ${jwtToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(propertyData), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to create property: ${response.status} ${response.statusText} - ${errorText}`); + } + + return await response.json(); + } catch (error) { + console.error("API Error creating property:", error); + throw error; + } +}; + +export const createVariantGrpc = async ( + variantData: VariantCreateGrpcRequestDTO, +): Promise => { + const jwtToken = localStorage.getItem("token"); + console.log("createVariant", variantData); + + try { + const response = await apiCall(VARIANT_GRPC_API, { + method: "POST", + headers: { + Authorization: `Bearer ${jwtToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(variantData), + }); + + if (!response.ok) { + throw new Error(`Failed to create variant: ${response.status} ${response.statusText}`); + } + + return await response.json(); + } catch (error) { + console.error("API Error creating variant:", error); + throw error; + } +}; \ No newline at end of file diff --git a/src/api/role-service.ts b/src/api/role-service.ts index 8cb9ae5..3545e17 100644 --- a/src/api/role-service.ts +++ b/src/api/role-service.ts @@ -1,7 +1,8 @@ import type { Role } from "../types/users.ts"; -import { API_BASE_URL, apiCall, jwtToken } from "./utils.ts"; +import { API_BASE_URL, apiCall } from "./utils.ts"; export const fetchRoles = async (): Promise => { + const jwtToken = localStorage.getItem("token"); if (!jwtToken) { throw new Error("Authorization token not found. Please log in."); } @@ -27,6 +28,7 @@ export const fetchRoles = async (): Promise => { }; export const addRole = async (role: Role): Promise => { + const jwtToken = localStorage.getItem("token"); if (!jwtToken) { throw new Error("Authorization token not found. Please log in."); } @@ -51,6 +53,7 @@ export const addRole = async (role: Role): Promise => { }; export const updateRole = async (role: Role): Promise => { + const jwtToken = localStorage.getItem("token"); if (!jwtToken) { throw new Error("Authorization token not found. Please log in."); } @@ -75,6 +78,7 @@ export const updateRole = async (role: Role): Promise => { }; export const deleteRole = async (roleName: string): Promise => { + const jwtToken = localStorage.getItem("token"); if (!jwtToken) { throw new Error("Authorization token not found. Please log in."); } @@ -98,6 +102,7 @@ export const deleteRole = async (roleName: string): Promise => { }; export const addUsersToRole = async (roleName: string, userIds: string[]): Promise => { + const jwtToken = localStorage.getItem("token"); if (!jwtToken) { throw new Error("Authorization token not found. Please log in."); } @@ -123,6 +128,7 @@ export const addUsersToRole = async (roleName: string, userIds: string[]): Promi }; export const deleteUsersFromRole = async (roleName: string, userIds: string[]): Promise => { + const jwtToken = localStorage.getItem("token"); if (!jwtToken) { throw new Error("Authorization token not found. Please log in."); } @@ -150,6 +156,7 @@ export const deleteUsersFromRole = async (roleName: string, userIds: string[]): }; export const fetchRolesPermissions = async (): Promise => { + const jwtToken = localStorage.getItem("token"); if (!jwtToken) { throw new Error("Authorization token not found. Please log in."); } diff --git a/src/api/utils.ts b/src/api/utils.ts index 09d9888..351dc52 100644 --- a/src/api/utils.ts +++ b/src/api/utils.ts @@ -1,7 +1,5 @@ const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8600"; -export const jwtToken = localStorage.getItem("token"); - const apiCall = async (url: string, options: RequestInit = {}) => { const defaultOptions: RequestInit = { credentials: "include", diff --git a/src/hooks/useProductPage.ts b/src/hooks/useProductPage.ts index 3c3dbed..b885152 100644 --- a/src/hooks/useProductPage.ts +++ b/src/hooks/useProductPage.ts @@ -33,23 +33,18 @@ export const useProductPage = () => { (propertyName: string): string[] => { if (!allVariants.length) return []; - const currentSelections = { ...selectedProperties }; - delete currentSelections[propertyName]; - + // Zwracamy WSZYSTKIE możliwe wartości dla danej właściwości ze WSZYSTKICH wariantów + // niezależnie od aktualnych wyborów, aby umożliwić przejście między wszystkimi wariantami const availableValues = new Set(); allVariants.forEach((variant) => { - const matchesCurrentSelection = Object.keys(currentSelections).every( - (key) => variant[key] === currentSelections[key], - ); - - if (matchesCurrentSelection && variant[propertyName]) { + if (variant[propertyName]) { availableValues.add(variant[propertyName]); } }); return Array.from(availableValues); }, - [allVariants, selectedProperties], + [allVariants], ); const findVariantId = useCallback( @@ -140,19 +135,33 @@ export const useProductPage = () => { [propertyName]: value, }; - selectablePropertyNames.forEach((propName) => { - if (propName !== propertyName) { - const availableValues = getAvailableValues(propName); - if (availableValues.length > 0 && !availableValues.includes(newSelections[propName])) { - delete newSelections[propName]; + // Najpierw próbujemy znaleźć dokładne dopasowanie + let newVariantId = findVariantId(newSelections); + + // Jeśli nie ma dokładnego dopasowania, szukamy wariantu który ma wybraną wartość dla zmienionej właściwości + // i zachowuje inne wybrane wartości (jeśli są dostępne) + if (!newVariantId) { + const matchingVariant = allVariants.find((variant) => { + // Sprawdzamy czy wariant ma wybraną wartość dla zmienionej właściwości + if (variant[propertyName] !== value) { + return false; } - } - }); + // Sprawdzamy czy wariant ma wszystkie inne wybrane wartości (jeśli są) + return Object.keys(newSelections).every( + (key) => !newSelections[key] || variant[key] === newSelections[key], + ); + }); + newVariantId = matchingVariant?.variantId || null; + } - setSelectedProperties(newSelections); + // Jeśli nadal nie znaleźliśmy wariantu, szukamy dowolnego wariantu z wybraną wartością dla zmienionej właściwości + if (!newVariantId) { + const matchingVariant = allVariants.find((variant) => variant[propertyName] === value); + newVariantId = matchingVariant?.variantId || null; + } - const newVariantId = findVariantId(newSelections); - if (!(newVariantId && newVariantId !== variantId)) { + // Jeśli nie znaleźliśmy wariantu, nie aktualizujemy stanu + if (!newVariantId || newVariantId === variantId) { return; } @@ -167,6 +176,18 @@ export const useProductPage = () => { }); } + // Aktualizujemy wybrane właściwości na podstawie znalezionego wariantu + // To zapewnia, że wyświetlane wartości są zawsze zgodne z aktualnym wariantem + if (newVariantData) { + const updatedSelections: SelectedProperties = {}; + selectablePropertyNames.forEach((propName) => { + if (newVariantData[propName]) { + updatedSelections[propName] = newVariantData[propName]; + } + }); + setSelectedProperties(updatedSelections); + } + navigate(`/product/${newVariantId}`, { replace: true, preventScrollReset: true }); loadVariantDetails(newVariantId, true); @@ -175,7 +196,6 @@ export const useProductPage = () => { [ selectedProperties, selectablePropertyNames, - getAvailableValues, findVariantId, variantId, allVariants, diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index eabf45a..4ea50dc 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -35,6 +35,7 @@ const Login: React.FC = () => { try { const loginData = await login(email, password); localStorage.setItem("token", loginData.token); + console.log('login', localStorage.getItem("token")); await refreshCurrentUser(); navigate("/"); } catch (error) { diff --git a/src/pages/ProductPage.tsx b/src/pages/ProductPage.tsx index e3be3cb..129e9a0 100644 --- a/src/pages/ProductPage.tsx +++ b/src/pages/ProductPage.tsx @@ -100,9 +100,10 @@ const ProductPage: React.FC = () => { }) : ["https://via.placeholder.com/600x600?text=No+Image"]; + // Zawsze pokazuj REQUIRED i INFO, SELECTABLE tylko gdy showMoreParams jest true const displayedParams = showMoreParams ? [...selectableProperties, ...requiredProperties, ...infoProperties] - : [...requiredProperties, ...selectableProperties]; + : [...requiredProperties, ...infoProperties]; const descriptionPoints = variant?.description ? variant.description.split("\n").filter((line) => line.trim()) @@ -230,7 +231,7 @@ const ProductPage: React.FC = () => { ); })} - {(selectableProperties.length > 0 || infoProperties.length > 0) && ( + {selectableProperties.length > 0 && ( + + + + + {createdProductId && ( + <> + + + + {loadingProperties && ( + + + Loading properties... + + )} + {!loadingProperties && properties && ( + + {renderPropertyGroup("Selectable (always required)", groupedProperties.selectable, true)} + {renderPropertyGroup("Required", groupedProperties.required, true)} + {renderPropertyGroup("Informational", groupedProperties.info, false)} + + )} + + + + + + + + {variantsCreated.map((variant) => { + const firstImage = variant.variantImages[0]; + const propertyLabels = getFirstTwoPropertyValues(variant); + return ( + + + + {firstImage ? ( + Variant { + const target = e.target as HTMLImageElement; + target.style.display = "none"; + if (target.parentElement) { + target.parentElement.innerHTML = 'No image'; + } + }} + /> + ) : ( + + No image + + )} + + + + {propertyLabels.map((label, idx) => ( + + {label} + + ))} + + {variant.price} PLN + + + + + + ); + })} + + setVariantModalOpen(true)} + > + + + + Add variant + + + + + + + + + )} + + + { + setVariantModalOpen(false); + resetVariantForm(); + }} + maxWidth="md" + fullWidth + > + Add New Variant + + + + + + setVariantForm((prev) => ({ ...prev, price: Number(e.target.value) })) + } + /> + + + + setVariantForm((prev) => ({ ...prev, stockQuantity: Number(e.target.value) })) + } + /> + + + + setVariantForm((prev) => ({ ...prev, description: e.target.value })) + } + /> + + + setImageInput(e.target.value)} + placeholder="https://example.com/image1.jpg https://example.com/image2.jpg or https://example.com/image1.jpg, https://example.com/image2.jpg" + helperText="You can paste multiple URLs at once - each on a new line or separated by comma" + /> + + {variantForm.variantImages.length > 0 && ( + + + {variantForm.variantImages.map((img, idx) => ( + 30 ? `${img.substring(0, 30)}...` : img} + onDelete={() => + setVariantForm((prev) => ({ + ...prev, + variantImages: prev.variantImages.filter((_, i) => i !== idx), + })) + } + /> + ))} + + + )} + + + {properties && ( + + {renderPropertyGroup("Selectable (always required)", groupedProperties.selectable, true)} + {renderPropertyGroup("Required", groupedProperties.required, true)} + {renderPropertyGroup("Informational", groupedProperties.info, false)} + + )} + + + + + + Additional Properties (INFO) + + + + {customProperties.map((prop) => ( + + + + + + handleUpdateCustomProperty(prop.id, "name", e.target.value) + } + placeholder="e.g. Material, Country of Origin" + /> + + + + handleUpdateCustomProperty(prop.id, "value", e.target.value) + } + placeholder="Enter value" + /> + + + handleRemoveCustomProperty(prop.id)} + aria-label="Remove property" + > + + + + + + + ))} + {customProperties.length === 0 && ( + + No additional properties. Click "Add Property" to add a new one. - Not implemented yet + )} + + + + + + + + + ); diff --git a/src/types/products.ts b/src/types/products.ts index fc83a73..8d968d7 100644 --- a/src/types/products.ts +++ b/src/types/products.ts @@ -14,7 +14,6 @@ export interface ProductCreateRequestDTO { approximatePrice: number; deliveryPrice: number; description: string; - info: Record; } export interface ProductCreateResponseDTO { @@ -30,6 +29,20 @@ export interface VariantCreateRequestDTO { description: string; } +export interface VariantPropertyValueRequestDTO { + propertyId: string; + displayText: string; +} + +export interface VariantCreateGrpcRequestDTO { + productId: string; + price: number; + stockQuantity: number; + description: string; + variantImages: string[]; + variantPropertyValues: VariantPropertyValueRequestDTO[]; +} + export interface VariantCreateResponseDTO { id: string; } @@ -89,3 +102,38 @@ export interface VariantPropertyResponseDTO { } export type PropertyDataType = "TEXT" | "NUMBER" | "BOOLEAN" | "DATE"; + +export interface PropertyOptionDTO { + id: string; + displayText: string; + isDefaultPropertyOption?: boolean; +} + +export interface DefaultPropertyOptionDTO { + id: string; + propertyId: string; + propertyDataType: PropertyDataType; + valueText: string | null; + valueDecimal: number | null; + valueBoolean: boolean | null; + valueDate: string | null; + displayText: string; +} + +export interface CategoryPropertyDTO { + id: string; + categoryId?: string; + name: string; + dataType: PropertyDataType; + description?: string; + hasDefaultOptions?: boolean; + role?: "SELECTABLE" | "REQUIRED" | "INFO"; + propertyOptions?: PropertyOptionDTO[]; + defaultPropertyOptions?: DefaultPropertyOptionDTO[]; +} + +export interface CategoryPropertyGroupResponse { + selectable?: CategoryPropertyDTO[]; + required?: CategoryPropertyDTO[]; + info?: CategoryPropertyDTO[]; +} From ba0aebc7242cbff33e88f430538cac946f64978f Mon Sep 17 00:00:00 2001 From: arcadio77 <66469918+arcadio77@users.noreply.github.com> Date: Sat, 10 Jan 2026 15:20:02 +0100 Subject: [PATCH 2/2] fix --- src/api/product-service.ts | 36 +- src/components/forms/CreateProductForm.tsx | 21 - src/pages/admin/AddProductPage.tsx | 1477 ++++++++++---------- 3 files changed, 776 insertions(+), 758 deletions(-) diff --git a/src/api/product-service.ts b/src/api/product-service.ts index e7fc30b..3b9a820 100644 --- a/src/api/product-service.ts +++ b/src/api/product-service.ts @@ -26,7 +26,7 @@ const PRODUCT_GRPC_API = `${API_BASE_URL}/api/products/grpc`; const VARIANT_GRPC_API = `${API_BASE_URL}/api/variants/grpc`; export const createProduct = async ( - productData: ProductCreateRequestDTO, + productData: ProductCreateRequestDTO, ): Promise => { const jwtToken = localStorage.getItem("token"); try { @@ -127,7 +127,7 @@ export const getCategoryById = async (categoryId: string): Promise => { try { const response = await apiCall(CATEGORY_API, { @@ -149,8 +149,8 @@ export const createCategory = async ( // Update category (name and/or parent) // Note: This endpoint is not yet implemented in CategoryController, only in service export const updateCategory = async ( - categoryId: string, - categoryData: CategoryUpdateRequestDTO, + categoryId: string, + categoryData: CategoryUpdateRequestDTO, ): Promise => { try { const response = await apiCall(`${CATEGORY_API}/${categoryId}`, { @@ -188,8 +188,8 @@ export const deleteCategory = async (categoryId: string): Promise => { }; export const getProductsByCategory = async ( - categoryId: string, - request: GetProductsRequestDTO, + categoryId: string, + request: GetProductsRequestDTO, ): Promise => { try { const url = new URL(PRODUCT_API); @@ -212,8 +212,8 @@ export const getProductsByCategory = async ( }; export const searchProducts = async ( - query: string, - request: GetProductsRequestDTO, + query: string, + request: GetProductsRequestDTO, ): Promise => { try { const url = new URL(`${PRODUCT_API}/search`); @@ -245,7 +245,7 @@ export const getAllVariantDetails = async (variantId: string): Promise> => { try { const response = await apiCall(`${VARIANT_API}/${variantId}/properties`, { @@ -291,7 +291,7 @@ export const getVariantProperties = async ( if (!response.ok) { throw new Error( - `Failed to get variant properties: ${response.status} ${response.statusText}`, + `Failed to get variant properties: ${response.status} ${response.statusText}`, ); } @@ -307,7 +307,7 @@ export const getVariantProperties = async ( }; export const getCategoryProperties = async ( - categoryId: string, + categoryId: string, ): Promise => { try { const url = new URL(PROPERTIES_API); @@ -319,7 +319,7 @@ export const getCategoryProperties = async ( if (!response.ok) { throw new Error( - `Failed to get properties for category: ${response.status} ${response.statusText}`, + `Failed to get properties for category: ${response.status} ${response.statusText}`, ); } @@ -349,7 +349,7 @@ export const createPropertyGrpc = async ( propertyData: CreatePropertyGrpcRequestDTO, ): Promise => { const jwtToken = localStorage.getItem("token"); - + try { const response = await apiCall(PROPERTIES_GRPC_API, { method: "POST", @@ -362,7 +362,9 @@ export const createPropertyGrpc = async ( if (!response.ok) { const errorText = await response.text(); - throw new Error(`Failed to create property: ${response.status} ${response.statusText} - ${errorText}`); + throw new Error( + `Failed to create property: ${response.status} ${response.statusText} - ${errorText}`, + ); } return await response.json(); @@ -373,7 +375,7 @@ export const createPropertyGrpc = async ( }; export const createVariantGrpc = async ( - variantData: VariantCreateGrpcRequestDTO, + variantData: VariantCreateGrpcRequestDTO, ): Promise => { const jwtToken = localStorage.getItem("token"); console.log("createVariant", variantData); @@ -397,4 +399,4 @@ export const createVariantGrpc = async ( console.error("API Error creating variant:", error); throw error; } -}; \ No newline at end of file +}; diff --git a/src/components/forms/CreateProductForm.tsx b/src/components/forms/CreateProductForm.tsx index 493c3fe..0e82a6b 100644 --- a/src/components/forms/CreateProductForm.tsx +++ b/src/components/forms/CreateProductForm.tsx @@ -12,7 +12,6 @@ const initialProductValues: ProductFormValues = { approximatePrice: 0, deliveryPrice: 0, description: "", - info: {}, }; const productValidationSchema = Yup.object({ @@ -122,26 +121,6 @@ const CreateProductForm: React.FC = ({ onSubmit }) => { placeholder="Detailed product description" /> - - - Dodatkowe Info (JSON) - - - - Enter additional product attributes as a valid JSON object. - - diff --git a/src/pages/admin/AddProductPage.tsx b/src/pages/admin/AddProductPage.tsx index 35b1b9d..9411b3e 100644 --- a/src/pages/admin/AddProductPage.tsx +++ b/src/pages/admin/AddProductPage.tsx @@ -1,758 +1,795 @@ import { useMemo, useState, type ChangeEvent } from "react"; import { - Alert, - Box, - Button, - Card, - CardContent, - CardHeader, - Chip, - CircularProgress, - Container, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - Divider, - Grid, - IconButton, - MenuItem, - Stack, - TextField, - Typography, -} from "@mui/material"; -import AddIcon from "@mui/icons-material/Add"; -import DeleteIcon from "@mui/icons-material/Delete"; + createProduct, + createVariantGrpc, + getCategoryProperties, + createPropertyGrpc, + type CreatePropertyGrpcRequestDTO, +} from "../../api/product-service"; import Breadcrumbs from "../../components/common/Breadcrumbs"; import MainLayout from "../../components/layout/MainLayout"; import { useProductContext } from "../../contexts/ProductContext"; -import { - createProduct, - createVariantGrpc, - getCategoryProperties, - createPropertyGrpc, - type CreatePropertyGrpcRequestDTO, -} from "../../api/product-service"; import type { - CategoryPropertyDTO, - CategoryPropertyGroupResponse, - ProductCreateRequestDTO, - VariantCreateGrpcRequestDTO, + CategoryPropertyDTO, + CategoryPropertyGroupResponse, + ProductCreateRequestDTO, + VariantCreateGrpcRequestDTO, } from "../../types/products"; +import AddIcon from "@mui/icons-material/Add"; +import DeleteIcon from "@mui/icons-material/Delete"; +import { + Alert, + Box, + Button, + Card, + CardContent, + CardHeader, + Chip, + CircularProgress, + Container, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + Grid, + IconButton, + MenuItem, + Stack, + TextField, + Typography, +} from "@mui/material"; type VariantFormState = Pick< - VariantCreateGrpcRequestDTO, - "price" | "stockQuantity" | "description" | "variantImages" + VariantCreateGrpcRequestDTO, + "price" | "stockQuantity" | "description" | "variantImages" >; interface VariantData { - id: string; - price: number; - stockQuantity: number; - description: string; - variantImages: string[]; - propertyValues: Record; + id: string; + price: number; + stockQuantity: number; + description: string; + variantImages: string[]; + propertyValues: Record; } const initialProductForm: ProductCreateRequestDTO = { - categoryId: "", - name: "", - approximatePrice: 0, - deliveryPrice: 0, - description: "", + categoryId: "", + name: "", + approximatePrice: 0, + deliveryPrice: 0, + description: "", }; const initialVariantForm: VariantFormState = { - price: 0, - stockQuantity: 0, - description: "", - variantImages: [], + price: 0, + stockQuantity: 0, + description: "", + variantImages: [], }; const AddProductPage: React.FC = () => { - const { categories, loading: categoriesLoading, error: categoriesError } = useProductContext(); - - const [productForm, setProductForm] = useState(initialProductForm); - const [variantForm, setVariantForm] = useState(initialVariantForm); - const [imageInput, setImageInput] = useState(""); - const [createdProductId, setCreatedProductId] = useState(null); - const [properties, setProperties] = useState(null); - const [propertyValues, setPropertyValues] = useState>({}); - const [variantsCreated, setVariantsCreated] = useState([]); - const [status, setStatus] = useState(null); - const [error, setError] = useState(null); - const [creatingProduct, setCreatingProduct] = useState(false); - const [creatingVariant, setCreatingVariant] = useState(false); - const [loadingProperties, setLoadingProperties] = useState(false); - const [variantModalOpen, setVariantModalOpen] = useState(false); - const [customProperties, setCustomProperties] = useState>([]); - - const categoryOptions = useMemo( - () => categories.map((c) => ({ value: c.id, label: c.name })), - [categories], - ); - - const groupedProperties = useMemo( - () => ({ - selectable: properties?.selectable || [], - required: properties?.required || [], - info: properties?.info || [], - }), - [properties], - ); - - const initPropertyValues = (props: CategoryPropertyGroupResponse) => { - const defaults: Record = {}; - const groups = [...(props.selectable || []), ...(props.required || []), ...(props.info || [])]; - - groups.forEach((prop) => { - if (prop.hasDefaultOptions && prop.defaultPropertyOptions && prop.defaultPropertyOptions.length > 0) { - defaults[prop.id] = prop.defaultPropertyOptions[0].displayText; - } else { - defaults[prop.id] = ""; - } - }); - - setPropertyValues(defaults); - }; - - const resetVariantForm = () => { - setVariantForm(initialVariantForm); - setImageInput(""); - setCustomProperties([]); - if (properties) { - initPropertyValues(properties); - } - }; - - const handleAddCustomProperty = () => { - const newId = `custom-${Date.now()}`; - setCustomProperties((prev) => [...prev, { id: newId, name: "", value: "" }]); - }; - - const handleRemoveCustomProperty = (id: string) => { - setCustomProperties((prev) => prev.filter((prop) => prop.id !== id)); - }; - - const handleUpdateCustomProperty = (id: string, field: "name" | "value", newValue: string) => { - setCustomProperties((prev) => - prev.map((prop) => (prop.id === id ? { ...prop, [field]: newValue } : prop)) - ); - }; - - const handleProductSubmit = async () => { - if (!productForm.categoryId) { - setError("Select a category before creating the product."); - return; - } - - setError(null); - setStatus(null); - setCreatingProduct(true); - try { - const payload: ProductCreateRequestDTO = { - ...productForm, - approximatePrice: Number(productForm.approximatePrice), - deliveryPrice: Number(productForm.deliveryPrice), - }; - - const response = await createProduct(payload); - setCreatedProductId(response.id); - setStatus("Product created. Now add variants."); - - setLoadingProperties(true); - const fetchedProps = await getCategoryProperties(productForm.categoryId); - setProperties(fetchedProps); - initPropertyValues(fetchedProps); - } catch (err) { - console.error(err); - setError("Failed to create product. Please try again."); - } finally { - setCreatingProduct(false); - setLoadingProperties(false); - } - }; - - const handleVariantSubmit = async () => { - if (!createdProductId) { - setError("First create a product."); - return; - } - - if (!properties) { - setError("First fetch category properties."); - return; - } - - const requiredPropertyIds = [ - ...(groupedProperties.selectable || []), - ...(groupedProperties.required || []), - ].map((p) => p.id); + const { categories, loading: categoriesLoading, error: categoriesError } = useProductContext(); + + const [productForm, setProductForm] = useState(initialProductForm); + const [variantForm, setVariantForm] = useState(initialVariantForm); + const [imageInput, setImageInput] = useState(""); + const [createdProductId, setCreatedProductId] = useState(null); + const [properties, setProperties] = useState(null); + const [propertyValues, setPropertyValues] = useState>({}); + const [variantsCreated, setVariantsCreated] = useState([]); + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + const [creatingProduct, setCreatingProduct] = useState(false); + const [creatingVariant, setCreatingVariant] = useState(false); + const [loadingProperties, setLoadingProperties] = useState(false); + const [variantModalOpen, setVariantModalOpen] = useState(false); + const [customProperties, setCustomProperties] = useState< + Array<{ id: string; name: string; value: string }> + >([]); + + const categoryOptions = useMemo( + () => categories.map((c) => ({ value: c.id, label: c.name })), + [categories], + ); - const missingRequired = requiredPropertyIds.filter((id) => !propertyValues[id]); - if (missingRequired.length) { - setError("Fill in required properties before adding variant."); - return; - } + const groupedProperties = useMemo( + () => ({ + selectable: properties?.selectable || [], + required: properties?.required || [], + info: properties?.info || [], + }), + [properties], + ); - setError(null); - setStatus(null); - setCreatingVariant(true); - - try { - // Automatycznie dodaj zdjęcia z textarea jeśli są tam jakieś URL-e - let allImages = [...variantForm.variantImages]; - if (imageInput.trim()) { - const urlsFromInput = imageInput - .split(/[\n,]/) - .map((url) => url.trim()) - .filter((url) => url.length > 0); - allImages = [...allImages, ...urlsFromInput]; - } - - // Filtrujemy puste stringi i upewniamy się że mamy tablicę stringów - const images = allImages.filter((img) => img && img.trim().length > 0); - - // Przygotuj standardowe właściwości - const standardPropertyValues = Object.entries(propertyValues) - .filter(([_, value]) => value !== undefined && value !== "") - .map(([propertyId, displayText]) => ({ propertyId, displayText })); - - // Utwórz custom properties i pobierz ich ID - const customPropertyValues: Array<{ propertyId: string; displayText: string }> = []; - - if (customProperties.length > 0 && productForm.categoryId) { - for (const customProp of customProperties) { - if (customProp.name.trim() && customProp.value.trim()) { - try { - // Utwórz property w backendzie - const propertyPayload: CreatePropertyGrpcRequestDTO = { - categoryId: productForm.categoryId, - name: customProp.name.trim(), - unit: "", // Dla INFO properties unit może być pusty - dataType: "TEXT", - role: "INFO", - defaultPropertyOptionValues: [], // Dla INFO properties pusta tablica - }; - - const createdProperty = await createPropertyGrpc(propertyPayload); - - // Dodaj do listy z właściwym ID - customPropertyValues.push({ - propertyId: createdProperty.id, - displayText: customProp.value.trim(), - }); - } catch (err) { - console.error(`Error creating property "${customProp.name}":`, err); - const errorMessage = err instanceof Error ? err.message : String(err); - setError(`Failed to create property "${customProp.name}": ${errorMessage}`); - return; - } - } - } + const initPropertyValues = (props: CategoryPropertyGroupResponse) => { + const defaults: Record = {}; + const groups = [...(props.selectable || []), ...(props.required || []), ...(props.info || [])]; + + groups.forEach((prop) => { + if ( + prop.hasDefaultOptions && + prop.defaultPropertyOptions && + prop.defaultPropertyOptions.length > 0 + ) { + defaults[prop.id] = prop.defaultPropertyOptions[0].displayText; + } else { + defaults[prop.id] = ""; + } + }); + + setPropertyValues(defaults); + }; + + const resetVariantForm = () => { + setVariantForm(initialVariantForm); + setImageInput(""); + setCustomProperties([]); + if (properties) { + initPropertyValues(properties); + } + }; + + const handleAddCustomProperty = () => { + const newId = `custom-${Date.now()}`; + setCustomProperties((prev) => [...prev, { id: newId, name: "", value: "" }]); + }; + + const handleRemoveCustomProperty = (id: string) => { + setCustomProperties((prev) => prev.filter((prop) => prop.id !== id)); + }; + + const handleUpdateCustomProperty = (id: string, field: "name" | "value", newValue: string) => { + setCustomProperties((prev) => + prev.map((prop) => (prop.id === id ? { ...prop, [field]: newValue } : prop)), + ); + }; + + const handleProductSubmit = async () => { + if (!productForm.categoryId) { + setError("Select a category before creating the product."); + return; + } + + setError(null); + setStatus(null); + setCreatingProduct(true); + try { + const payload: ProductCreateRequestDTO = { + ...productForm, + approximatePrice: Number(productForm.approximatePrice), + deliveryPrice: Number(productForm.deliveryPrice), + }; + + const response = await createProduct(payload); + setCreatedProductId(response.id); + setStatus("Product created. Now add variants."); + + setLoadingProperties(true); + const fetchedProps = await getCategoryProperties(productForm.categoryId); + setProperties(fetchedProps); + initPropertyValues(fetchedProps); + } catch (err) { + console.error(err); + setError("Failed to create product. Please try again."); + } finally { + setCreatingProduct(false); + setLoadingProperties(false); + } + }; + + const handleVariantSubmit = async () => { + if (!createdProductId) { + setError("First create a product."); + return; + } + + if (!properties) { + setError("First fetch category properties."); + return; + } + + const requiredPropertyIds = [ + ...(groupedProperties.selectable || []), + ...(groupedProperties.required || []), + ].map((p) => p.id); + + const missingRequired = requiredPropertyIds.filter((id) => !propertyValues[id]); + if (missingRequired.length) { + setError("Fill in required properties before adding variant."); + return; + } + + setError(null); + setStatus(null); + setCreatingVariant(true); + + try { + // Automatycznie dodaj zdjęcia z textarea jeśli są tam jakieś URL-e + let allImages = [...variantForm.variantImages]; + if (imageInput.trim()) { + const urlsFromInput = imageInput + .split(/[\n,]/) + .map((url) => url.trim()) + .filter((url) => url.length > 0); + allImages = [...allImages, ...urlsFromInput]; + } + + // Filtrujemy puste stringi i upewniamy się że mamy tablicę stringów + const images = allImages.filter((img) => img && img.trim().length > 0); + + // Przygotuj standardowe właściwości + const standardPropertyValues = Object.entries(propertyValues) + .filter(([, value]) => value !== undefined && value !== "") + .map(([propertyId, displayText]) => ({ propertyId, displayText })); + + // Utwórz custom properties i pobierz ich ID + const customPropertyValues: Array<{ propertyId: string; displayText: string }> = []; + + if (customProperties.length > 0 && productForm.categoryId) { + for (const customProp of customProperties) { + if (customProp.name.trim() && customProp.value.trim()) { + try { + // Utwórz property w backendzie + const propertyPayload: CreatePropertyGrpcRequestDTO = { + categoryId: productForm.categoryId, + name: customProp.name.trim(), + unit: "", // Dla INFO properties unit może być pusty + dataType: "TEXT", + role: "INFO", + defaultPropertyOptionValues: [], // Dla INFO properties pusta tablica + }; + + const createdProperty = await createPropertyGrpc(propertyPayload); + + // Dodaj do listy z właściwym ID + customPropertyValues.push({ + propertyId: createdProperty.id, + displayText: customProp.value.trim(), + }); + } catch (err) { + console.error(`Error creating property "${customProp.name}":`, err); + const errorMessage = err instanceof Error ? err.message : String(err); + setError(`Failed to create property "${customProp.name}": ${errorMessage}`); + return; } - - const variantPayload: VariantCreateGrpcRequestDTO = { - productId: createdProductId, - price: Number(variantForm.price), - stockQuantity: Number(variantForm.stockQuantity), - description: variantForm.description, - variantImages: images, - variantPropertyValues: [...standardPropertyValues, ...customPropertyValues], - }; - - console.log("createVariant - variantPayload:", variantPayload); - - const response = await createVariantGrpc(variantPayload); - const newVariant: VariantData = { - id: response.id, - price: variantForm.price, - stockQuantity: variantForm.stockQuantity, - description: variantForm.description, - variantImages: images, // Używamy przefiltrowanych zdjęć - propertyValues: { ...propertyValues }, - }; - setVariantsCreated((prev) => [...prev, newVariant]); - setStatus("Variant added."); - resetVariantForm(); - setVariantModalOpen(false); - } catch (err) { - console.error(err); - setError("Failed to add variant. Please try again."); - } finally { - setCreatingVariant(false); + } } - }; - - - const getFirstTwoPropertyValues = (variant: VariantData): string[] => { - const values = Object.entries(variant.propertyValues) - .map(([propertyId, displayText]) => { - const allProps = [ - ...(groupedProperties.selectable || []), - ...(groupedProperties.required || []), - ...(groupedProperties.info || []), - ]; - const prop = allProps.find((p) => p.id === propertyId); - return prop ? `${prop.name}: ${displayText}` : null; - }) - .filter((v): v is string => v !== null) - .slice(0, 2); - return values; - }; - - const renderPropertyGroup = (title: string, props: CategoryPropertyDTO[], required = false) => { - if (!props.length) return null; - return ( - - - - - {props.map((prop) => { - const hasDefaultOptions = prop.hasDefaultOptions && prop.defaultPropertyOptions && prop.defaultPropertyOptions.length > 0; - const value = propertyValues[prop.id] ?? ""; - - const commonProps = { - fullWidth: true, - name: prop.id, - label: prop.name, - value, - onChange: (e: ChangeEvent) => - setPropertyValues((prev) => ({ ...prev, [prop.id]: e.target.value })), - required: required || groupedProperties.selectable.some((p) => p.id === prop.id), - }; - - return ( - - {hasDefaultOptions ? ( - - {prop.defaultPropertyOptions?.map((opt) => ( - - {opt.displayText} - - ))} - - ) : ( - - )} - - ); - })} - - - - ); - }; + } + + const variantPayload: VariantCreateGrpcRequestDTO = { + productId: createdProductId, + price: Number(variantForm.price), + stockQuantity: Number(variantForm.stockQuantity), + description: variantForm.description, + variantImages: images, + variantPropertyValues: [...standardPropertyValues, ...customPropertyValues], + }; + + console.log("createVariant - variantPayload:", variantPayload); + + const response = await createVariantGrpc(variantPayload); + const newVariant: VariantData = { + id: response.id, + price: variantForm.price, + stockQuantity: variantForm.stockQuantity, + description: variantForm.description, + variantImages: images, // Używamy przefiltrowanych zdjęć + propertyValues: { ...propertyValues }, + }; + setVariantsCreated((prev) => [...prev, newVariant]); + setStatus("Variant added."); + resetVariantForm(); + setVariantModalOpen(false); + } catch (err) { + console.error(err); + setError("Failed to add variant. Please try again."); + } finally { + setCreatingVariant(false); + } + }; + + const getFirstTwoPropertyValues = (variant: VariantData): string[] => { + const values = Object.entries(variant.propertyValues) + .map(([propertyId, displayText]) => { + const allProps = [ + ...(groupedProperties.selectable || []), + ...(groupedProperties.required || []), + ...(groupedProperties.info || []), + ]; + const prop = allProps.find((p) => p.id === propertyId); + return prop ? `${prop.name}: ${displayText}` : null; + }) + .filter((v): v is string => v !== null) + .slice(0, 2); + return values; + }; + + const renderPropertyGroup = (title: string, props: CategoryPropertyDTO[], required = false) => { + if (!props.length) return null; + return ( + + + + + {props.map((prop) => { + const hasDefaultOptions = + prop.hasDefaultOptions && + prop.defaultPropertyOptions && + prop.defaultPropertyOptions.length > 0; + const value = propertyValues[prop.id] ?? ""; + + const commonProps = { + fullWidth: true, + name: prop.id, + label: prop.name, + value, + onChange: (e: ChangeEvent) => + setPropertyValues((prev) => ({ ...prev, [prop.id]: e.target.value })), + required: required || groupedProperties.selectable.some((p) => p.id === prop.id), + }; + + return ( + + {hasDefaultOptions ? ( + + {prop.defaultPropertyOptions?.map((opt) => ( + + {opt.displayText} + + ))} + + ) : ( + + )} + + ); + })} + + + + ); + }; return ( - - Add Product and Variants - - - {categoriesError && {categoriesError}} - {error && ( - - {error} - - )} - {status && ( - - {status} - - )} - - - - - - - - - setProductForm((prev: ProductCreateRequestDTO) => ({ ...prev, categoryId: e.target.value })) - } - > - {categoryOptions.map((option) => ( - - {option.label} - - ))} - - - - - - - - - - - - setProductForm((prev: ProductCreateRequestDTO) => ({ ...prev, name: e.target.value }))} - /> - - - - setProductForm((prev: ProductCreateRequestDTO) => ({ - ...prev, - approximatePrice: Number(e.target.value), - })) - } - /> - - - - setProductForm((prev: ProductCreateRequestDTO) => ({ - ...prev, - deliveryPrice: Number(e.target.value), - })) - } - /> - - - - setProductForm((prev: ProductCreateRequestDTO) => ({ ...prev, description: e.target.value })) - } - /> - - - - - - - - - - {createdProductId && ( - <> - - - - {loadingProperties && ( - - - Loading properties... - - )} - {!loadingProperties && properties && ( - - {renderPropertyGroup("Selectable (always required)", groupedProperties.selectable, true)} - {renderPropertyGroup("Required", groupedProperties.required, true)} - {renderPropertyGroup("Informational", groupedProperties.info, false)} - - )} - - - - - - - - {variantsCreated.map((variant) => { - const firstImage = variant.variantImages[0]; - const propertyLabels = getFirstTwoPropertyValues(variant); - return ( - - - - {firstImage ? ( - Variant { - const target = e.target as HTMLImageElement; - target.style.display = "none"; - if (target.parentElement) { - target.parentElement.innerHTML = 'No image'; - } - }} - /> - ) : ( - - No image - - )} - - - - {propertyLabels.map((label, idx) => ( - - {label} - - ))} - - {variant.price} PLN - - - - - - ); - })} - - setVariantModalOpen(true)} - > - - - - Add variant - - - - - - - - - )} - + + Add Product and Variants + - { - setVariantModalOpen(false); - resetVariantForm(); - }} - maxWidth="md" + {categoriesError && {categoriesError}} + {error && ( + + {error} + + )} + {status && ( + + {status} + + )} + + + + + + + + + setProductForm((prev: ProductCreateRequestDTO) => ({ + ...prev, + categoryId: e.target.value, + })) + } + > + {categoryOptions.map((option) => ( + + {option.label} + + ))} + + + + + + + + + + + + + setProductForm((prev: ProductCreateRequestDTO) => ({ + ...prev, + name: e.target.value, + })) + } + /> + + + + setProductForm((prev: ProductCreateRequestDTO) => ({ + ...prev, + approximatePrice: Number(e.target.value), + })) + } + /> + + + + setProductForm((prev: ProductCreateRequestDTO) => ({ + ...prev, + deliveryPrice: Number(e.target.value), + })) + } + /> + + + + setProductForm((prev: ProductCreateRequestDTO) => ({ + ...prev, + description: e.target.value, + })) + } + /> + + + + + - - - {customProperties.map((prop) => ( - - - - - - handleUpdateCustomProperty(prop.id, "name", e.target.value) - } - placeholder="e.g. Material, Country of Origin" - /> - - - - handleUpdateCustomProperty(prop.id, "value", e.target.value) - } - placeholder="Enter value" - /> - - - handleRemoveCustomProperty(prop.id)} - aria-label="Remove property" - > - - - - - - - ))} - {customProperties.length === 0 && ( - - No additional properties. Click "Add Property" to add a new one. - - )} - + Create Product + + + + + + {createdProductId && ( + <> + + + + {loadingProperties && ( + + + Loading properties... + + )} + {!loadingProperties && properties && ( + + {renderPropertyGroup( + "Selectable (always required)", + groupedProperties.selectable, + true, + )} + {renderPropertyGroup("Required", groupedProperties.required, true)} + {renderPropertyGroup("Informational", groupedProperties.info, false)} + + )} + + + + + + + + {variantsCreated.map((variant) => { + const firstImage = variant.variantImages[0]; + const propertyLabels = getFirstTwoPropertyValues(variant); + return ( + + + + {firstImage ? ( + Variant { + const target = e.target as HTMLImageElement; + target.style.display = "none"; + if (target.parentElement) { + target.parentElement.innerHTML = + 'No image'; + } + }} + /> + ) : ( + + No image + + )} + + + {propertyLabels.map((label, idx) => ( + + {label} + + ))} + + {variant.price} PLN + + + + + + ); + })} + + setVariantModalOpen(true)} + > + + + + Add variant + - - - - - - + + + + + + + )} + + + { + setVariantModalOpen(false); + resetVariantForm(); + }} + maxWidth="md" + fullWidth + > + Add New Variant + + + + + + setVariantForm((prev) => ({ ...prev, price: Number(e.target.value) })) + } + /> + + + + setVariantForm((prev) => ({ ...prev, stockQuantity: Number(e.target.value) })) + } + /> + + + + setVariantForm((prev) => ({ ...prev, description: e.target.value })) + } + /> + + + setImageInput(e.target.value)} + placeholder="https://example.com/image1.jpg https://example.com/image2.jpg or https://example.com/image1.jpg, https://example.com/image2.jpg" + helperText="You can paste multiple URLs at once - each on a new line or separated by comma" + /> + + {variantForm.variantImages.length > 0 && ( + + + {variantForm.variantImages.map((img, idx) => ( + 30 ? `${img.substring(0, 30)}...` : img} + onDelete={() => + setVariantForm((prev) => ({ + ...prev, + variantImages: prev.variantImages.filter((_, i) => i !== idx), + })) + } + /> + ))} + + + )} + + + {properties && ( + + {renderPropertyGroup( + "Selectable (always required)", + groupedProperties.selectable, + true, + )} + {renderPropertyGroup("Required", groupedProperties.required, true)} + {renderPropertyGroup("Informational", groupedProperties.info, false)} + + )} + + + + + + Additional Properties (INFO) + + + + {customProperties.map((prop) => ( + + + + + + handleUpdateCustomProperty(prop.id, "name", e.target.value) + } + placeholder="e.g. Material, Country of Origin" + /> + + + + handleUpdateCustomProperty(prop.id, "value", e.target.value) + } + placeholder="Enter value" + /> + + + handleRemoveCustomProperty(prop.id)} + aria-label="Remove property" + > + + + + + + + ))} + {customProperties.length === 0 && ( + + No additional properties. Click "Add Property" to add a new one. + + )} + + + + + + + + + );