From 54c7598e7a60015d972174bbd865525a1baa8d7b Mon Sep 17 00:00:00 2001 From: efrilm Date: Fri, 12 Sep 2025 18:49:01 +0700 Subject: [PATCH] Create Unit Conventer --- src/services/mutations/unitConventor.ts | 52 ++ src/services/queries/ingredients.ts | 11 + src/types/services/productRecipe.ts | 109 ++-- .../detail/IngedientUnitConversionDrawer.tsx | 547 +++++++++++------- .../detail/IngredientDetailInfo.tsx | 11 +- .../detail/IngredientDetailUnit.tsx | 10 +- .../products/ingredient/detail/index.tsx | 11 +- 7 files changed, 491 insertions(+), 260 deletions(-) create mode 100644 src/services/mutations/unitConventor.ts diff --git a/src/services/mutations/unitConventor.ts b/src/services/mutations/unitConventor.ts new file mode 100644 index 0000000..7fb0e4c --- /dev/null +++ b/src/services/mutations/unitConventor.ts @@ -0,0 +1,52 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'react-toastify' +import { api } from '../api' +import { IngredientUnitConverterRequest } from '@/types/services/productRecipe' + +export const useUnitConventorMutation = () => { + const queryClient = useQueryClient() + + const createUnitConventer = useMutation({ + mutationFn: async (newUnitConventer: IngredientUnitConverterRequest) => { + const response = await api.post('/unit-converters', newUnitConventer) + return response.data + }, + onSuccess: () => { + toast.success('UnitConventer created successfully!') + queryClient.invalidateQueries({ queryKey: ['unitConventers'] }) + }, + onError: (error: any) => { + toast.error(error.response?.data?.errors?.[0]?.cause || 'Create failed') + } + }) + + const updateUnitConventer = useMutation({ + mutationFn: async ({ id, payload }: { id: string; payload: IngredientUnitConverterRequest }) => { + const response = await api.put(`/unit-converters/${id}`, payload) + return response.data + }, + onSuccess: () => { + toast.success('UnitConventer updated successfully!') + queryClient.invalidateQueries({ queryKey: ['unit-converters'] }) + }, + onError: (error: any) => { + toast.error(error.response?.data?.errors?.[0]?.cause || 'Update failed') + } + }) + + const deleteUnitConventer = useMutation({ + mutationFn: async (id: string) => { + const response = await api.delete(`/unit-converters/${id}`) + return response.data + }, + onSuccess: () => { + toast.success('UnitConventer deleted successfully!') + queryClient.invalidateQueries({ queryKey: ['unitConventers'] }) + }, + onError: (error: any) => { + toast.error(error.response?.data?.errors?.[0]?.cause || 'Delete failed') + } + }) + + return { createUnitConventer, updateUnitConventer, deleteUnitConventer } +} diff --git a/src/services/queries/ingredients.ts b/src/services/queries/ingredients.ts index f355566..1885f6b 100644 --- a/src/services/queries/ingredients.ts +++ b/src/services/queries/ingredients.ts @@ -1,6 +1,7 @@ import { useQuery } from '@tanstack/react-query' import { Ingredients } from '../../types/services/ingredient' import { api } from '../api' +import { Ingredient } from '@/types/services/productRecipe' interface IngredientsQueryParams { page?: number @@ -34,3 +35,13 @@ export function useIngredients(params: IngredientsQueryParams = {}) { } }) } + +export function useIngredientById(id: string) { + return useQuery({ + queryKey: ['ingredients', id], + queryFn: async () => { + const res = await api.get(`/ingredients/${id}`) + return res.data.data + } + }) +} diff --git a/src/types/services/productRecipe.ts b/src/types/services/productRecipe.ts index 6a43cc2..fe1733f 100644 --- a/src/types/services/productRecipe.ts +++ b/src/types/services/productRecipe.ts @@ -1,56 +1,75 @@ export interface Product { - ID: string; - OrganizationID: string; - CategoryID: string; - SKU: string; - Name: string; - Description: string | null; - Price: number; - Cost: number; - BusinessType: string; - ImageURL: string; - PrinterType: string; - UnitID: string | null; - HasIngredients: boolean; - Metadata: Record; - IsActive: boolean; - CreatedAt: string; // ISO date string - UpdatedAt: string; // ISO date string + ID: string + OrganizationID: string + CategoryID: string + SKU: string + Name: string + Description: string | null + Price: number + Cost: number + BusinessType: string + ImageURL: string + PrinterType: string + UnitID: string | null + HasIngredients: boolean + Metadata: Record + IsActive: boolean + CreatedAt: string // ISO date string + UpdatedAt: string // ISO date string } export interface Ingredient { - id: string; - organization_id: string; - outlet_id: string | null; - name: string; - unit_id: string; - cost: number; - stock: number; - is_semi_finished: boolean; - is_active: boolean; - metadata: Record; - created_at: string; - updated_at: string; + id: string + organization_id: string + outlet_id: string | null + name: string + unit_id: string + cost: number + stock: number + is_semi_finished: boolean + is_active: boolean + metadata: Record + created_at: string + updated_at: string + unit: IngredientUnit } export interface ProductRecipe { - id: string; - organization_id: string; - outlet_id: string | null; - product_id: string; - variant_id: string | null; - ingredient_id: string; - quantity: number; - created_at: string; - updated_at: string; - product: Product; - ingredient: Ingredient; + id: string + organization_id: string + outlet_id: string | null + product_id: string + variant_id: string | null + ingredient_id: string + quantity: number + created_at: string + updated_at: string + product: Product + ingredient: Ingredient } export interface ProductRecipeRequest { - product_id: string; - variant_id: string | null; - ingredient_id: string; - quantity: number; - outlet_id: string | null; + product_id: string + variant_id: string | null + ingredient_id: string + quantity: number + outlet_id: string | null +} + +export interface IngredientUnit { + id: string + organization_id: string + outlet_id: string + name: string + abbreviation: string + is_active: boolean + created_at: string + updated_at: string +} + +export interface IngredientUnitConverterRequest { + ingredient_id: string + from_unit_id: string + to_unit_id: string + conversion_factor: number } diff --git a/src/views/apps/ecommerce/products/ingredient/detail/IngedientUnitConversionDrawer.tsx b/src/views/apps/ecommerce/products/ingredient/detail/IngedientUnitConversionDrawer.tsx index 2d23725..1caeac2 100644 --- a/src/views/apps/ecommerce/products/ingredient/detail/IngedientUnitConversionDrawer.tsx +++ b/src/views/apps/ecommerce/products/ingredient/detail/IngedientUnitConversionDrawer.tsx @@ -1,6 +1,6 @@ 'use client' // React Imports -import { useState } from 'react' +import { useState, useEffect } from 'react' // MUI Imports import Button from '@mui/material/Button' @@ -17,101 +17,121 @@ import { useForm, Controller } from 'react-hook-form' // Component Imports import CustomTextField from '@core/components/mui/TextField' +import { Ingredient } from '@/types/services/productRecipe' +import { useUnits } from '@/services/queries/units' +import { useUnitConventorMutation } from '@/services/mutations/unitConventor' + +// Interface Integration +export interface IngredientUnitConverterRequest { + ingredient_id: string + from_unit_id: string + to_unit_id: string + conversion_factor: number +} type Props = { open: boolean handleClose: () => void - setData?: (data: any) => void + setData?: (data: IngredientUnitConverterRequest) => void + data?: Ingredient // Contains ingredientId, unit info, and cost } type UnitConversionType = { - satuan: string + satuan: string // This will be from_unit_id quantity: number - unit: string - hargaBeli: number + unit: string // This will be to_unit_id (from data) + hargaBeli: number // Calculated as factor * ingredientCost hargaJual: number isDefault: boolean } -type FormValidateType = { - conversions: UnitConversionType[] -} - -// Vars -const initialConversion: UnitConversionType = { - satuan: 'Box', - quantity: 12, - unit: 'Pcs', - hargaBeli: 3588000, - hargaJual: 5988000, - isDefault: false -} - const IngedientUnitConversionDrawer = (props: Props) => { // Props - const { open, handleClose, setData } = props + const { open, handleClose, setData, data } = props + + // Extract values from data prop with safe defaults + const ingredientId = data?.id || '' + const toUnitId = data?.unit_id || data?.unit?.id || '' + const ingredientCost = data?.cost || 0 + + const { + data: units, + isLoading, + error, + isFetching + } = useUnits({ + page: 1, + limit: 20 + }) + + // Vars - initial state with values from data + const getInitialConversion = () => ({ + satuan: '', + quantity: 1, + unit: toUnitId, // Set from data + hargaBeli: ingredientCost, // Will be calculated as factor * ingredientCost + hargaJual: 0, + isDefault: true + }) // States - const [conversions, setConversions] = useState([initialConversion]) + const [conversion, setConversion] = useState(getInitialConversion()) + const { createUnitConventer } = useUnitConventorMutation() // Hooks const { control, reset: resetForm, handleSubmit, + setValue, formState: { errors } - } = useForm({ - defaultValues: { - conversions: [initialConversion] - } + } = useForm({ + defaultValues: getInitialConversion() }) + // Update form when data changes + useEffect(() => { + if (toUnitId || ingredientCost) { + const updatedConversion = getInitialConversion() + setConversion(updatedConversion) + resetForm(updatedConversion) + } + }, [toUnitId, ingredientCost, resetForm]) + // Functions untuk konversi unit - const handleTambahBaris = () => { - const newConversion: UnitConversionType = { - satuan: '', - quantity: 0, - unit: '', - hargaBeli: 0, - hargaJual: 0, - isDefault: false + const handleChangeConversion = (field: keyof UnitConversionType, value: any) => { + const newConversion = { ...conversion, [field]: value } + setConversion(newConversion) + setValue(field, value) + } + + const onSubmit = (data: UnitConversionType) => { + // Transform form data to IngredientUnitConverterRequest + const converterRequest: IngredientUnitConverterRequest = { + ingredient_id: ingredientId, + from_unit_id: conversion.satuan, + to_unit_id: toUnitId, // Use toUnitId from data prop + conversion_factor: conversion.quantity } - setConversions([...conversions, newConversion]) - } - const handleHapusBaris = (index: number) => { - if (conversions.length > 1) { - const newConversions = conversions.filter((_, i) => i !== index) - setConversions(newConversions) - } - } + console.log('Unit conversion request:', converterRequest) - const handleChangeConversion = (index: number, field: keyof UnitConversionType, value: any) => { - const newConversions = [...conversions] - newConversions[index] = { ...newConversions[index], [field]: value } - setConversions(newConversions) - } - - const handleToggleDefault = (index: number) => { - const newConversions = conversions.map((conversion, i) => ({ - ...conversion, - isDefault: i === index - })) - setConversions(newConversions) - } - - const onSubmit = (data: FormValidateType) => { - console.log('Unit conversions:', conversions) - if (setData) { - setData(conversions) - } - handleClose() + // if (setData) { + // setData(converterRequest) + // } + createUnitConventer.mutate(converterRequest, { + onSuccess: () => { + handleClose() + resetForm(getInitialConversion()) + } + }) } const handleReset = () => { handleClose() - setConversions([initialConversion]) - resetForm({ conversions: [initialConversion] }) + const resetData = getInitialConversion() + setConversion(resetData) + resetForm(resetData) } const formatNumber = (value: number) => { @@ -122,6 +142,12 @@ const IngedientUnitConversionDrawer = (props: Props) => { return parseInt(value.replace(/\./g, '')) || 0 } + // Calculate total purchase price: factor * ingredientCost + const totalPurchasePrice = conversion.quantity * ingredientCost + + // Validation to ensure all required fields are provided + const isValidForSubmit = ingredientId && conversion.satuan && toUnitId && conversion.quantity > 0 + return ( { + {!ingredientId && ( + + + Warning: Ingredient data is required for conversion + + + )} + {ingredientId && ( + + + Converting for: {data?.name || `Ingredient ${ingredientId}`} + + {ingredientCost > 0 && ( + + Base cost per {units?.data.find(u => u.id === toUnitId)?.name || 'unit'}: Rp{' '} + {formatNumber(ingredientCost)} + + )} + + )} {/* Scrollable Content */} -
onSubmit(data))}> +
{/* Header Kolom */} - Satuan + From Unit @@ -175,20 +221,20 @@ const IngedientUnitConversionDrawer = (props: Props) => { - Jumlah + Factor - Unit + To Unit - + Harga Beli - + Harga Jual @@ -198,146 +244,223 @@ const IngedientUnitConversionDrawer = (props: Props) => { Default - - - Action - + + + {/* Form Input Row */} + + {/* From Unit (Satuan) */} + +
+ + 1 + + ( + { + field.onChange(e.target.value) + handleChangeConversion('satuan', e.target.value) + }} + > + {units?.data + .filter(unit => unit.id !== toUnitId) // Prevent selecting same unit as target + .map(unit => ( + + {unit.name} + + )) ?? []} + + )} + /> +
+ {errors.satuan && ( + + {errors.satuan.message} + + )} +
+ + {/* Tanda sama dengan */} + + = + + + {/* Conversion Factor (Quantity) */} + + ( + { + const value = parseFloat(e.target.value) || 0 + field.onChange(value) + handleChangeConversion('quantity', value) + }} + /> + )} + /> + {errors.quantity && ( + + {errors.quantity.message} + + )} + + + {/* To Unit - Disabled because it comes from data */} + + + {units?.data.map(unit => ( + + {unit.name} + + )) ?? []} + + + + {/* Harga Beli - Calculated as factor * ingredientCost */} + + + + + {/* Harga Jual */} + + ( + { + const value = parseNumber(e.target.value) + field.onChange(value) + handleChangeConversion('hargaJual', value) + }} + placeholder='Optional' + /> + )} + /> + {errors.hargaJual && ( + + {errors.hargaJual.message} + + )} + + + {/* Default Star */} + + handleChangeConversion('isDefault', !conversion.isDefault)} + sx={{ + color: conversion.isDefault ? 'warning.main' : 'grey.400' + }} + > + +
- {/* Baris Konversi */} - {conversions.map((conversion, index) => ( - - - - {index + 1} + {/* Conversion Preview */} + {conversion.quantity > 0 && conversion.satuan && toUnitId && ( + + + Conversion Preview: + + + 1 {units?.data.find(u => u.id === conversion.satuan)?.name || 'Unit'} ={' '} + + {conversion.quantity} {units?.data.find(u => u.id === toUnitId)?.name || 'Unit'} + + + + Conversion Factor: {conversion.quantity} + + + )} + + {/* Price Summary */} + {conversion.quantity > 0 && (ingredientCost > 0 || conversion.hargaJual > 0) && ( + + + Price Summary: + + {ingredientCost > 0 && ( + <> + + Total Purchase Price (1 {units?.data.find(u => u.id === conversion.satuan)?.name || 'From Unit'}): + Rp {formatNumber(totalPurchasePrice)} + + + Unit Cost per {units?.data.find(u => u.id === toUnitId)?.name || 'To Unit'}: Rp{' '} + {formatNumber(ingredientCost)} + + + )} + {conversion.hargaJual > 0 && ( + <> + + Total Selling Price (1 {units?.data.find(u => u.id === conversion.satuan)?.name || 'From Unit'}): + Rp {formatNumber(conversion.hargaJual)} + + + Unit Selling Price per {units?.data.find(u => u.id === toUnitId)?.name || 'To Unit'}: Rp{' '} + {formatNumber(Math.round(conversion.hargaJual / conversion.quantity))} + + + )} + {ingredientCost > 0 && conversion.hargaJual > 0 && ( + + Total Margin: Rp {formatNumber(conversion.hargaJual - totalPurchasePrice)} ( + {totalPurchasePrice > 0 + ? (((conversion.hargaJual - totalPurchasePrice) / totalPurchasePrice) * 100).toFixed(1) + : 0} + %) - - - {/* Satuan */} - - handleChangeConversion(index, 'satuan', e.target.value)} - > - Box - Kg - Liter - Pack - Pcs - - - - {/* Tanda sama dengan */} - - = - - - {/* Quantity */} - - handleChangeConversion(index, 'quantity', parseInt(e.target.value) || 0)} - /> - - - {/* Unit */} - - handleChangeConversion(index, 'unit', e.target.value)} - > - Pcs - Kg - Gram - Liter - ML - - - - {/* Harga Beli */} - - handleChangeConversion(index, 'hargaBeli', parseNumber(e.target.value))} - /> - - - {/* Harga Jual */} - - handleChangeConversion(index, 'hargaJual', parseNumber(e.target.value))} - /> - - - {/* Default Star */} - - handleToggleDefault(index)} - sx={{ - color: conversion.isDefault ? 'warning.main' : 'grey.400' - }} - > - - - - - {/* Delete Button */} - - {conversions.length > 1 && ( - handleHapusBaris(index)} - sx={{ - color: 'error.main', - border: 1, - borderColor: 'error.main', - '&:hover': { - backgroundColor: 'error.light', - borderColor: 'error.main' - } - }} - > - - - )} - - - ))} - - {/* Tambah Baris Button */} -
- -
+ )} + + )}
@@ -355,13 +478,21 @@ const IngedientUnitConversionDrawer = (props: Props) => { }} >
- -
+ {!isValidForSubmit && ( + + Please fill in all required fields: {!ingredientId && 'Ingredient Data, '} + {!conversion.satuan && 'From Unit, '} + {!toUnitId && 'To Unit (from ingredient data), '} + {conversion.quantity <= 0 && 'Conversion Factor'} + + )}
) diff --git a/src/views/apps/ecommerce/products/ingredient/detail/IngredientDetailInfo.tsx b/src/views/apps/ecommerce/products/ingredient/detail/IngredientDetailInfo.tsx index 1774198..355a092 100644 --- a/src/views/apps/ecommerce/products/ingredient/detail/IngredientDetailInfo.tsx +++ b/src/views/apps/ecommerce/products/ingredient/detail/IngredientDetailInfo.tsx @@ -1,14 +1,19 @@ +import { Ingredient } from '@/types/services/productRecipe' import { formatCurrency } from '@/utils/transform' import { Card, CardHeader, Chip, Typography } from '@mui/material' -const IngredientDetailInfo = () => { +interface Props { + data: Ingredient | undefined +} + +const IngredientDetailInfo = ({ data }: Props) => { return ( - Tepung Terigu + {data?.name ?? '-'} @@ -17,7 +22,7 @@ const IngredientDetailInfo = () => {
- Cost: {formatCurrency(5000)} + Cost: {formatCurrency(data?.cost ?? 0)}
diff --git a/src/views/apps/ecommerce/products/ingredient/detail/IngredientDetailUnit.tsx b/src/views/apps/ecommerce/products/ingredient/detail/IngredientDetailUnit.tsx index 05f4784..d5d1bf5 100644 --- a/src/views/apps/ecommerce/products/ingredient/detail/IngredientDetailUnit.tsx +++ b/src/views/apps/ecommerce/products/ingredient/detail/IngredientDetailUnit.tsx @@ -2,8 +2,13 @@ import React, { useState } from 'react' import { Card, CardContent, CardHeader, Typography, Button, Box, Stack } from '@mui/material' import IngedientUnitConversionDrawer from './IngedientUnitConversionDrawer' // Sesuaikan dengan path file Anda +import { Ingredient } from '@/types/services/productRecipe' -const IngredientDetailUnit = () => { +interface Props { + data: Ingredient | undefined +} + +const IngredientDetailUnit = ({ data }: Props) => { // State untuk mengontrol drawer const [openConversionDrawer, setOpenConversionDrawer] = useState(false) @@ -34,7 +39,7 @@ const IngredientDetailUnit = () => { Satuan Dasar - : Pcs + : {data?.unit.name ?? '-'} @@ -61,6 +66,7 @@ const IngredientDetailUnit = () => { open={openConversionDrawer} handleClose={handleCloseConversionDrawer} setData={handleSetConversionData} + data={data} /> ) diff --git a/src/views/apps/ecommerce/products/ingredient/detail/index.tsx b/src/views/apps/ecommerce/products/ingredient/detail/index.tsx index d3b4ccf..c9dd062 100644 --- a/src/views/apps/ecommerce/products/ingredient/detail/index.tsx +++ b/src/views/apps/ecommerce/products/ingredient/detail/index.tsx @@ -6,11 +6,18 @@ import IngredientDetailInfo from './IngredientDetailInfo' import IngredientDetailUnit from './IngredientDetailUnit' import IngredientDetailStockAdjustmentDrawer from './IngredientDetailStockAdjustmentDrawer' // Sesuaikan dengan path file Anda import { Button } from '@mui/material' +import { useParams } from 'next/navigation' +import { useIngredientById } from '@/services/queries/ingredients' const IngredientDetail = () => { // State untuk mengontrol stock adjustment drawer const [openStockAdjustmentDrawer, setOpenStockAdjustmentDrawer] = useState(false) + const params = useParams() + const id = params?.id + + const { data, isLoading } = useIngredientById(id as string) + // Function untuk membuka stock adjustment drawer const handleOpenStockAdjustmentDrawer = () => { setOpenStockAdjustmentDrawer(true) @@ -32,7 +39,7 @@ const IngredientDetail = () => { <> - + - +