feat: unis and product detail

This commit is contained in:
ferdiansyah783 2025-08-06 13:18:19 +07:00
parent 0906188c12
commit 5f2bddd003
18 changed files with 1294 additions and 27 deletions

View File

@ -0,0 +1,10 @@
import ProductDetail from "../../../../../../../../../views/apps/ecommerce/products/detail/ProductDetail"
// In your page or component
const productData = {
// Your product object here
}
export default function ProductPage() {
return <ProductDetail />
}

View File

@ -0,0 +1,8 @@
// Component Imports
import ProductUnitTable from '../../../../../../../../views/apps/ecommerce/products/units/ProductUnitTable'
const eCommerceProductsIngredient = () => {
return <ProductUnitTable />
}
export default eCommerceProductsIngredient

View File

@ -0,0 +1,8 @@
// Component Imports
import ProductUnitTable from '../../../../../../../../views/apps/ecommerce/products/units/ProductUnitTable'
const eCommerceProductsUnit = () => {
return <ProductUnitTable />
}
export default eCommerceProductsUnit

View File

@ -118,6 +118,12 @@ const VerticalMenu = ({ dictionary, scrollMenu }: Props) => {
<MenuItem href={`/${locale}/apps/ecommerce/products/category`}>
{dictionary['navigation'].category}
</MenuItem>
<MenuItem href={`/${locale}/apps/ecommerce/products/units`}>
{dictionary['navigation'].units}
</MenuItem>
<MenuItem href={`/${locale}/apps/ecommerce/products/ingredients`}>
{dictionary['navigation'].ingredients}
</MenuItem>
</SubMenu>
<SubMenu label={dictionary['navigation'].orders}>
<MenuItem href={`/${locale}/apps/ecommerce/orders/list`}>{dictionary['navigation'].list}</MenuItem>
@ -139,10 +145,10 @@ const VerticalMenu = ({ dictionary, scrollMenu }: Props) => {
{dictionary['navigation'].details}
</MenuItem>
</SubMenu>
<MenuItem href={`/${locale}/apps/ecommerce/manage-reviews`}>
{/* <MenuItem href={`/${locale}/apps/ecommerce/manage-reviews`}>
{dictionary['navigation'].manageReviews}
</MenuItem>
<MenuItem href={`/${locale}/apps/ecommerce/referrals`}>{dictionary['navigation'].referrals}</MenuItem>
<MenuItem href={`/${locale}/apps/ecommerce/referrals`}>{dictionary['navigation'].referrals}</MenuItem> */}
<MenuItem href={`/${locale}/apps/ecommerce/settings`}>{dictionary['navigation'].settings}</MenuItem>
</SubMenu>
<SubMenu label={dictionary['navigation'].stock} icon={<i className='tabler-basket-down' />}>

View File

@ -21,6 +21,8 @@
"add": "يضيف",
"addjustment": "تعديل",
"category": "فئة",
"units": "وحدات",
"ingredients": "مكونات",
"orders": "أوامر",
"details": "تفاصيل",
"customers": "العملاء",

View File

@ -21,6 +21,8 @@
"add": "Add",
"addjustment": "Addjustment",
"category": "Category",
"units": "Units",
"ingredients": "Ingredients",
"orders": "Orders",
"details": "Details",
"customers": "Customers",

View File

@ -21,6 +21,8 @@
"add": "Ajouter",
"addjustment": "Ajustement",
"category": "Catégorie",
"units": "Unites",
"ingredients": "Ingrédients",
"orders": "Ordres",
"details": "Détails",
"customers": "Clientes",

View File

@ -0,0 +1,60 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'react-toastify'
import { UnitRequest } from '../../types/services/unit'
import { api } from '../api'
export const useUnitsMutation = {
createUnit: () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (newUnit: UnitRequest) => {
const response = await api.post('/units', newUnit)
return response.data
},
onSuccess: () => {
toast.success('Unit created successfully!')
queryClient.invalidateQueries({ queryKey: ['units'] })
},
onError: (error: any) => {
toast.error(error.response?.data?.errors?.[0]?.cause || 'Create failed')
}
})
},
updateUnit: () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ id, payload }: { id: string; payload: UnitRequest }) => {
const response = await api.put(`/units/${id}`, payload)
return response.data
},
onSuccess: () => {
toast.success('Unit updated successfully!')
queryClient.invalidateQueries({ queryKey: ['units'] })
},
onError: (error: any) => {
toast.error(error.response?.data?.errors?.[0]?.cause || 'Update failed')
}
})
},
deleteUnit: () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (id: string) => {
const response = await api.delete(`/units/${id}`)
return response.data
},
onSuccess: () => {
toast.success('Unit deleted successfully!')
queryClient.invalidateQueries({ queryKey: ['units'] })
},
onError: (error: any) => {
toast.error(error.response?.data?.errors?.[0]?.cause || 'Delete failed')
}
})
}
}

View File

@ -0,0 +1,39 @@
import { useQuery } from '@tanstack/react-query'
import { Units } from '../../types/services/unit'
import { api } from '../api'
interface UnitsQueryParams {
page?: number
limit?: number
search?: string
}
export const useUnitsQuery = {
getUnits: (params: UnitsQueryParams = {}) => {
const { page = 1, limit = 10, search = '', ...filters } = params
return useQuery<Units>({
queryKey: ['units', { page, limit, search, ...filters }],
queryFn: async () => {
const queryParams = new URLSearchParams()
queryParams.append('page', page.toString())
queryParams.append('limit', limit.toString())
if (search) {
queryParams.append('search', search)
}
// Add other filters
Object.entries(filters).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
queryParams.append(key, value.toString())
}
})
const res = await api.get(`/units?${queryParams.toString()}`)
return res.data.data
},
})
}
}

View File

@ -0,0 +1,29 @@
export interface Unit {
id: string
organization_id: string
outlet_id: string | null
name: string
abbreviation: string | null
is_active: boolean
created_at: string
updated_at: string
}
export interface PaginationMeta {
page: number
limit: number
total_count: number
total_pages: number
}
export interface Units {
data: Unit[]
pagination: PaginationMeta
}
export interface UnitRequest {
name: string
abbreviation: string | null
outlet_id: string | null
is_active: boolean
}

View File

@ -1,7 +1,7 @@
'use client'
// React Imports
import { useState } from 'react'
import { useEffect, useState } from 'react'
// MUI Imports
import type { BoxProps } from '@mui/material/Box'
@ -24,9 +24,10 @@ import CustomAvatar from '@core/components/mui/Avatar'
// Styled Component Imports
import AppReactDropzone from '@/libs/styles/AppReactDropzone'
import { useDispatch } from 'react-redux'
import { useDispatch, useSelector } from 'react-redux'
import { useFilesMutation } from '../../../../../services/mutations/files'
import { setProductField } from '../../../../../redux-store/slices/product'
import { RootState } from '../../../../../redux-store'
type FileProp = {
name: string
@ -52,9 +53,13 @@ const ProductImage = () => {
const dispatch = useDispatch()
const { mutate, isPending } = useFilesMutation.uploadFile()
const { image_url } = useSelector((state: RootState) => state.productReducer.productRequest)
// States
const [files, setFiles] = useState<File[]>([])
console.log(files)
const handleUpload = () => {
if (!files.length) return
@ -66,6 +71,8 @@ const ProductImage = () => {
mutate(formData, {
onSuccess: data => {
dispatch(setProductField({ field: 'image_url', value: data.file_url }))
// Clear the local files after successful upload
setFiles([])
}
})
}
@ -73,7 +80,8 @@ const ProductImage = () => {
// Hooks
const { getRootProps, getInputProps } = useDropzone({
onDrop: (acceptedFiles: File[]) => {
setFiles(acceptedFiles.map((file: File) => Object.assign(file)))
// Replace files instead of adding to them
setFiles([acceptedFiles[0]]) // Only take the first file
}
})
@ -92,6 +100,10 @@ const ProductImage = () => {
setFiles([...filtered])
}
const handleRemoveCurrentImage = () => {
dispatch(setProductField({ field: 'image_url', value: '' }))
}
const fileList = files.map((file: FileProp) => (
<ListItem key={file.name} className='pis-4 plb-3'>
<div className='file-details'>
@ -136,13 +148,45 @@ const ProductImage = () => {
<CustomAvatar variant='rounded' skin='light' color='secondary'>
<i className='tabler-upload' />
</CustomAvatar>
<Typography variant='h4'>Drag and Drop Your Image Here.</Typography>
<Typography variant='h4'>
{image_url && !files.length ? 'Drop New Image to Replace' : 'Drag and Drop Your Image Here.'}
</Typography>
<Typography color='text.disabled'>or</Typography>
<Button variant='tonal' size='small'>
Browse Image
</Button>
</div>
</div>
{/* Show current image if it exists */}
{image_url && !files.length && (
<div className='current-image mb-4'>
<Typography variant='subtitle2' className='mb-2'>
Current Image:
</Typography>
<div className='flex items-center justify-between p-3 border border-gray-200 rounded'>
<div className='flex items-center gap-3'>
<img
width={60}
height={60}
alt='Current product image'
src={image_url}
className='rounded object-cover'
/>
<div>
<Typography className='font-medium' color='text.primary'>
Current product image
</Typography>
<Typography variant='body2' color='text.secondary'>
Uploaded image
</Typography>
</div>
</div>
<IconButton onClick={handleRemoveCurrentImage} color='error'>
<i className='tabler-x text-xl' />
</IconButton>
</div>
</div>
)}
{files.length ? (
<>
<List>{fileList}</List>

View File

@ -44,7 +44,7 @@ const ProductVariants = () => {
<CardHeader title='Product Variants' />
<CardContent>
<Grid container spacing={6}>
{variants.map((variant, index) => (
{variants && variants.map((variant, index) => (
<Grid key={index} size={{ xs: 12 }} className='repeater-item'>
<Grid container spacing={6}>
<Grid size={{ xs: 12, sm: 4 }}>

View File

@ -0,0 +1,333 @@
'use client'
import {
Avatar,
Badge,
Card,
CardContent,
CardMedia,
Chip,
Divider,
Grid,
List,
ListItem,
ListItemIcon,
ListItemText,
Typography
} from '@mui/material'
import { useParams } from 'next/navigation'
import React, { useEffect } from 'react'
import { useDispatch } from 'react-redux'
import Loading from '../../../../../components/layout/shared/Loading'
import { setProduct } from '../../../../../redux-store/slices/product'
import { useProductsQuery } from '../../../../../services/queries/products'
import { ProductVariant } from '../../../../../types/services/product'
// Tabler icons (using class names)
const TablerIcon = ({ name, className = '' }: { name: string; className?: string }) => (
<i className={`tabler-${name} ${className}`} />
)
const ProductDetail = () => {
const dispatch = useDispatch()
const params = useParams()
const { data: product, isLoading, error } = useProductsQuery.getProductById(params?.id as string)
useEffect(() => {
if (product) {
dispatch(setProduct(product))
}
}, [product, dispatch])
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0
}).format(amount)
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('id-ID', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const getBusinessTypeColor = (type: string) => {
switch (type.toLowerCase()) {
case 'restaurant':
return 'primary'
case 'retail':
return 'secondary'
case 'cafe':
return 'info'
default:
return 'default'
}
}
const getPrinterTypeColor = (type: string) => {
switch (type.toLowerCase()) {
case 'kitchen':
return 'warning'
case 'bar':
return 'info'
case 'receipt':
return 'success'
default:
return 'default'
}
}
if (isLoading) return <Loading />
return (
<div className='max-w-6xl mx-auto p-4 space-y-6'>
{/* Header Card */}
<Card className='shadow-lg'>
<Grid container>
<Grid item xs={12} md={4}>
<CardMedia
component='img'
sx={{ height: 300, objectFit: 'cover' }}
image={product.image_url || '/placeholder-image.jpg'}
alt={product.name}
className='rounded-l-lg'
/>
</Grid>
<Grid item xs={12} md={8}>
<CardContent className='h-full flex flex-col justify-between'>
<div>
<div className='flex items-start justify-between mb-3'>
<div>
<Typography variant='h4' component='h1' className='font-bold text-gray-800 mb-2'>
{product.name}
</Typography>
<div className='flex items-center gap-2 mb-3'>
<Chip
icon={<TablerIcon name='barcode' className='text-sm' />}
label={product.sku}
size='small'
variant='outlined'
/>
<Chip
icon={<TablerIcon name={product.is_active ? 'check-circle' : 'x-circle'} className='text-sm' />}
label={product.is_active ? 'Active' : 'Inactive'}
color={product.is_active ? 'success' : 'error'}
size='small'
/>
</div>
</div>
</div>
{product.description && (
<Typography variant='body1' className='text-gray-600 mb-4'>
{product.description}
</Typography>
)}
<div className='grid grid-cols-2 gap-4 mb-4'>
<div className='flex items-center gap-2'>
<TablerIcon name='currency-dollar' className='text-green-600 text-xl' />
<div>
<Typography variant='body2' className='text-gray-500'>
Price
</Typography>
<Typography variant='h6' className='font-semibold text-green-600'>
{formatCurrency(product.price)}
</Typography>
</div>
</div>
<div className='flex items-center gap-2'>
<TablerIcon name='receipt' className='text-orange-600 text-xl' />
<div>
<Typography variant='body2' className='text-gray-500'>
Cost
</Typography>
<Typography variant='h6' className='font-semibold text-orange-600'>
{formatCurrency(product.cost)}
</Typography>
</div>
</div>
</div>
<div className='flex gap-2'>
<Chip
icon={<TablerIcon name='building-store' className='text-sm' />}
label={product.business_type}
color={getBusinessTypeColor(product.business_type)}
size='small'
/>
<Chip
icon={<TablerIcon name='printer' className='text-sm' />}
label={product.printer_type}
color={getPrinterTypeColor(product.printer_type)}
size='small'
/>
</div>
</div>
</CardContent>
</Grid>
</Grid>
</Card>
<Grid container spacing={3}>
{/* Product Information */}
<Grid item xs={12} md={8}>
<Card className='shadow-md'>
<CardContent>
<Typography variant='h6' className='font-semibold mb-4 flex items-center gap-2'>
<TablerIcon name='info-circle' className='text-blue-600 text-xl' />
Product Information
</Typography>
<div className='grid grid-cols-1 md:grid-cols-2 gap-4'>
<div>
<Typography variant='body2' className='text-gray-500 mb-1'>
Product ID
</Typography>
<Typography variant='body1' className='font-mono text-sm bg-gray-100 p-2 rounded'>
{product.id}
</Typography>
</div>
<div>
<Typography variant='body2' className='text-gray-500 mb-1'>
Category ID
</Typography>
<Typography variant='body1' className='font-mono text-sm bg-gray-100 p-2 rounded'>
{product.category_id}
</Typography>
</div>
<div>
<Typography variant='body2' className='text-gray-500 mb-1'>
Organization ID
</Typography>
<Typography variant='body1' className='font-mono text-sm bg-gray-100 p-2 rounded'>
{product.organization_id}
</Typography>
</div>
<div>
<Typography variant='body2' className='text-gray-500 mb-1'>
Profit Margin
</Typography>
<Typography variant='body1' className='font-semibold text-green-600'>
{formatCurrency(product.price - product.cost)}
<span className='text-sm text-gray-500 ml-1'>
({(((product.price - product.cost) / product.cost) * 100).toFixed(1)}%)
</span>
</Typography>
</div>
</div>
</CardContent>
</Card>
{/* Variants Section */}
{product.variants && product.variants.length > 0 && (
<Card className='shadow-md mt-4'>
<CardContent>
<Typography variant='h6' className='font-semibold mb-4 flex items-center gap-2'>
<TablerIcon name='versions' className='text-purple-600 text-xl' />
Product Variants
<Badge badgeContent={product.variants.length} color='primary' />
</Typography>
<List>
{product.variants.map((variant: ProductVariant, index: number) => (
<React.Fragment key={variant.id}>
<ListItem className='px-0'>
<ListItemIcon>
<Avatar className='bg-purple-100 text-purple-600 w-8 h-8 text-sm'>
{variant.name.charAt(0)}
</Avatar>
</ListItemIcon>
<ListItemText
primary={
<div className='flex items-center justify-between'>
<Typography variant='subtitle1' className='font-medium'>
{variant.name}
</Typography>
<div className='flex gap-3'>
<Typography variant='body2' className='text-green-600 font-semibold'>
+{formatCurrency(variant.price_modifier)}
</Typography>
<Typography variant='body2' className='text-orange-600'>
Cost: {formatCurrency(variant.cost)}
</Typography>
</div>
</div>
}
secondary={
<Typography variant='caption' className='text-gray-500'>
Total Price: {formatCurrency(product.price + variant.price_modifier)}
</Typography>
}
/>
</ListItem>
{index < product.variants.length - 1 && <Divider />}
</React.Fragment>
))}
</List>
</CardContent>
</Card>
)}
</Grid>
{/* Metadata & Timestamps */}
<Grid item xs={12} md={4}>
<Card className='shadow-md'>
<CardContent>
<Typography variant='h6' className='font-semibold mb-4 flex items-center gap-2'>
<TablerIcon name='clock' className='text-indigo-600 text-xl' />
Timestamps
</Typography>
<div className='space-y-3'>
<div>
<Typography variant='body2' className='text-gray-500 mb-1'>
Created
</Typography>
<Typography variant='body2' className='text-sm'>
{formatDate(product.created_at)}
</Typography>
</div>
<Divider />
<div>
<Typography variant='body2' className='text-gray-500 mb-1'>
Last Updated
</Typography>
<Typography variant='body2' className='text-sm'>
{formatDate(product.updated_at)}
</Typography>
</div>
</div>
{Object.keys(product.metadata).length > 0 && (
<>
<Divider className='my-4' />
<Typography variant='h6' className='font-semibold mb-3'>
Metadata
</Typography>
<div className='space-y-2'>
{Object.entries(product.metadata).map(([key, value]) => (
<div key={key}>
<Typography variant='body2' className='text-gray-500 mb-1 capitalize'>
{key.replace(/_/g, ' ')}
</Typography>
<Typography variant='body2' className='text-sm bg-gray-50 p-2 rounded'>
{typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value)}
</Typography>
</div>
))}
</div>
</>
)}
</CardContent>
</Card>
</Grid>
</Grid>
</div>
)
}
export default ProductDetail

View File

@ -225,6 +225,12 @@ const ProductListTable = () => {
header: 'Actions',
cell: ({ row }) => (
<div className='flex items-center'>
<IconButton
LinkComponent={Link}
href={getLocalizedUrl(`/apps/ecommerce/products/${row.original.id}/detail`, locale as Locale)}
>
<i className='tabler-eye text-textSecondary' />
</IconButton>
<IconButton
LinkComponent={Link}
href={getLocalizedUrl(`/apps/ecommerce/products/${row.original.id}/edit`, locale as Locale)}

View File

@ -0,0 +1,167 @@
// React Imports
import { useMemo, useState } from 'react'
// MUI Imports
import Button from '@mui/material/Button'
import Divider from '@mui/material/Divider'
import Drawer from '@mui/material/Drawer'
import IconButton from '@mui/material/IconButton'
import MenuItem from '@mui/material/MenuItem'
import Typography from '@mui/material/Typography'
// Third-party Imports
// Type Imports
// Components Imports
import CustomTextField from '@core/components/mui/TextField'
import { Autocomplete, CircularProgress } from '@mui/material'
import { useDebounce } from 'use-debounce'
import { useUnitsMutation } from '../../../../../services/mutations/units'
import { useOutletsQuery } from '../../../../../services/queries/outlets'
import { UnitRequest } from '../../../../../types/services/unit'
type Props = {
open: boolean
handleClose: () => void
}
const AddUnitDrawer = (props: Props) => {
// Props
const { open, handleClose } = props
// States
const [formData, setFormData] = useState<UnitRequest>({
name: '',
abbreviation: '',
outlet_id: '',
is_active: true
})
const [status, setStatus] = useState('')
const [outletInput, setOutletInput] = useState('')
const [outletDebouncedInput] = useDebounce(outletInput, 500)
const { data: outlets, isLoading: outletsLoading } = useOutletsQuery.getOutlets({
search: outletDebouncedInput
})
const { mutate: createUnit, isPending: isCreating } = useUnitsMutation.createUnit()
const outletOptions = useMemo(() => outlets?.outlets || [], [outlets])
// Handle Form Submit
const handleFormSubmit = (e: any) => {
e.preventDefault()
createUnit(
{ ...formData, is_active: status === 'active' },
{
onSuccess: () => {
handleReset()
}
}
)
}
const handleInputChange = (e: any) => {
setFormData({
...formData,
[e.target.name]: e.target.value
})
}
// Handle Form Reset
const handleReset = () => {
handleClose()
setFormData({
name: '',
abbreviation: '',
outlet_id: '',
is_active: true
})
}
return (
<Drawer
open={open}
anchor='right'
variant='temporary'
onClose={handleReset}
ModalProps={{ keepMounted: true }}
sx={{ '& .MuiDrawer-paper': { width: { xs: 300, sm: 400 } } }}
>
<div className='flex items-center justify-between pli-6 plb-5'>
<Typography variant='h5'>Add Unit</Typography>
<IconButton size='small' onClick={handleReset}>
<i className='tabler-x text-textSecondary text-2xl' />
</IconButton>
</div>
<Divider />
<div className='p-6'>
<form onSubmit={handleFormSubmit} className='flex flex-col gap-5'>
<CustomTextField
fullWidth
label='Name'
name='name'
value={formData.name}
onChange={handleInputChange}
placeholder='pcs'
/>
<CustomTextField
fullWidth
label='Abbreviation'
name='abbreviation'
value={formData.abbreviation}
onChange={handleInputChange}
placeholder='enter abbreviation'
/>
<Autocomplete
options={outletOptions}
loading={outletsLoading}
getOptionLabel={option => option.name}
value={outletOptions.find(p => p.id === formData.outlet_id) || null}
onInputChange={(event, newOutlettInput) => {
setOutletInput(newOutlettInput)
}}
onChange={(event, newValue) => {
setFormData({
...formData,
outlet_id: newValue?.id || ''
})
}}
renderInput={params => (
<CustomTextField
{...params}
className=''
label='Outlet'
fullWidth
InputProps={{
...params.InputProps,
endAdornment: (
<>
{outletsLoading && <CircularProgress size={18} />}
{params.InputProps.endAdornment}
</>
)
}}
/>
)}
/>
<CustomTextField select fullWidth label='Status' value={status} onChange={e => setStatus(e.target.value)}>
<MenuItem value={'active'}>Active</MenuItem>
<MenuItem value={'inactive'}>Inactive</MenuItem>
</CustomTextField>
<div className='flex items-center gap-4'>
<Button variant='contained' type='submit' disabled={isCreating}>
{isCreating ? 'Add...' : 'Add'}
</Button>
<Button variant='tonal' color='error' type='reset' onClick={handleReset}>
Discard
</Button>
</div>
</form>
</div>
</Drawer>
)
}
export default AddUnitDrawer

View File

@ -0,0 +1,180 @@
// React Imports
import { useEffect, useMemo, useState } from 'react'
// MUI Imports
import Button from '@mui/material/Button'
import Divider from '@mui/material/Divider'
import Drawer from '@mui/material/Drawer'
import IconButton from '@mui/material/IconButton'
import MenuItem from '@mui/material/MenuItem'
import Typography from '@mui/material/Typography'
// Third-party Imports
// Type Imports
// Components Imports
import CustomTextField from '@core/components/mui/TextField'
import { useUnitsMutation } from '../../../../../services/mutations/units'
import { Unit, UnitRequest } from '../../../../../types/services/unit'
import { Autocomplete, CircularProgress } from '@mui/material'
import { useOutletsQuery } from '../../../../../services/queries/outlets'
import { useDebounce } from 'use-debounce'
type Props = {
open: boolean
handleClose: () => void
data: Unit
}
const EditUnitDrawer = (props: Props) => {
// Props
const { open, handleClose, data } = props
// States
const [formData, setFormData] = useState<UnitRequest>({
name: '',
abbreviation: '',
outlet_id: '',
is_active: true
})
const [status, setStatus] = useState('')
const [outletInput, setOutletInput] = useState('')
const [outletDebouncedInput] = useDebounce(outletInput, 500)
const { data: outlets, isLoading: outletsLoading } = useOutletsQuery.getOutlets({
search: outletDebouncedInput
})
const { mutate: updateUnit, isPending: isCreating } = useUnitsMutation.updateUnit()
const outletOptions = useMemo(() => outlets?.outlets || [], [outlets])
useEffect(() => {
if (data) {
setFormData({
name: data.name,
abbreviation: data.abbreviation!,
outlet_id: data.outlet_id!,
is_active: data.is_active
})
setStatus(data.is_active ? 'active' : 'inactive')
}
}, [data])
// Handle Form Submit
const handleFormSubmit = (e: any) => {
e.preventDefault()
updateUnit(
{ id: data.id, payload: formData },
{
onSuccess: () => {
handleReset()
}
}
)
}
const handleInputChange = (e: any) => {
setFormData({
...formData,
[e.target.name]: e.target.value
})
}
// Handle Form Reset
const handleReset = () => {
handleClose()
setFormData({
name: '',
abbreviation: '',
outlet_id: '',
is_active: true
})
}
return (
<Drawer
open={open}
anchor='right'
variant='temporary'
onClose={handleReset}
ModalProps={{ keepMounted: true }}
sx={{ '& .MuiDrawer-paper': { width: { xs: 300, sm: 400 } } }}
>
<div className='flex items-center justify-between pli-6 plb-5'>
<Typography variant='h5'>Edit Unit</Typography>
<IconButton size='small' onClick={handleReset}>
<i className='tabler-x text-textSecondary text-2xl' />
</IconButton>
</div>
<Divider />
<div className='p-6'>
<form onSubmit={handleFormSubmit} className='flex flex-col gap-5'>
<CustomTextField
fullWidth
label='Name'
name='name'
value={formData.name || ''}
onChange={handleInputChange}
placeholder='pcs'
/>
<CustomTextField
fullWidth
label='Abbreviation'
name='abbreviation'
value={formData.abbreviation || ''}
onChange={handleInputChange}
placeholder='enter abbreviation'
/>
<Autocomplete
options={outletOptions}
loading={outletsLoading}
getOptionLabel={option => option.name}
value={outletOptions.find(p => p.id === formData.outlet_id) || null}
onInputChange={(event, newOutlettInput) => {
setOutletInput(newOutlettInput)
}}
onChange={(event, newValue) => {
setFormData({
...formData,
outlet_id: newValue?.id || ''
})
}}
renderInput={params => (
<CustomTextField
{...params}
className=''
label='Outlet'
fullWidth
InputProps={{
...params.InputProps,
endAdornment: (
<>
{outletsLoading && <CircularProgress size={18} />}
{params.InputProps.endAdornment}
</>
)
}}
/>
)}
/>
<CustomTextField select fullWidth label='Status' value={status} onChange={e => setStatus(e.target.value)}>
<MenuItem value={'active'}>Active</MenuItem>
<MenuItem value={'inactive'}>Inactive</MenuItem>
</CustomTextField>
<div className='flex items-center gap-4'>
<Button variant='contained' type='submit' disabled={isCreating}>
{isCreating ? 'Updating...' : 'Update'}
</Button>
<Button variant='tonal' color='error' type='reset' onClick={handleReset}>
Discard
</Button>
</div>
</form>
</div>
</Drawer>
)
}
export default EditUnitDrawer

View File

@ -0,0 +1,391 @@
'use client'
// React Imports
import { useCallback, useEffect, useMemo, useState } from 'react'
// MUI Imports
import Button from '@mui/material/Button'
import Card from '@mui/material/Card'
import Checkbox from '@mui/material/Checkbox'
import IconButton from '@mui/material/IconButton'
import MenuItem from '@mui/material/MenuItem'
import TablePagination from '@mui/material/TablePagination'
import type { TextFieldProps } from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
// Third-party Imports
import type { RankingInfo } from '@tanstack/match-sorter-utils'
import { rankItem } from '@tanstack/match-sorter-utils'
import type { ColumnDef, FilterFn } from '@tanstack/react-table'
import { createColumnHelper, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'
import classnames from 'classnames'
// Component Imports
import TablePaginationComponent from '@components/TablePaginationComponent'
import CustomTextField from '@core/components/mui/TextField'
import OptionMenu from '@core/components/option-menu'
// Style Imports
import tableStyles from '@core/styles/table.module.css'
import { Box, CircularProgress } from '@mui/material'
import ConfirmDeleteDialog from '../../../../../components/dialogs/confirm-delete'
import Loading from '../../../../../components/layout/shared/Loading'
import { useUnitsMutation } from '../../../../../services/mutations/units'
import { useUnitsQuery } from '../../../../../services/queries/units'
import { Unit } from '../../../../../types/services/unit'
import AddUnitDrawer from './AddUnitDrawer'
import EditUnitDrawer from './EditUnitDrawer'
declare module '@tanstack/table-core' {
interface FilterFns {
fuzzy: FilterFn<unknown>
}
interface FilterMeta {
itemRank: RankingInfo
}
}
type UnitWithActionsType = Unit & {
actions?: string
}
const fuzzyFilter: FilterFn<any> = (row, columnId, value, addMeta) => {
// Rank the item
const itemRank = rankItem(row.getValue(columnId), value)
// Store the itemRank info
addMeta({
itemRank
})
// Return if the item should be filtered in/out
return itemRank.passed
}
const DebouncedInput = ({
value: initialValue,
onChange,
debounce = 500,
...props
}: {
value: string | number
onChange: (value: string | number) => void
debounce?: number
} & Omit<TextFieldProps, 'onChange'>) => {
// States
const [value, setValue] = useState(initialValue)
useEffect(() => {
setValue(initialValue)
}, [initialValue])
useEffect(() => {
const timeout = setTimeout(() => {
onChange(value)
}, debounce)
return () => clearTimeout(timeout)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value])
return <CustomTextField {...props} value={value} onChange={e => setValue(e.target.value)} />
}
// Column Definitions
const columnHelper = createColumnHelper<UnitWithActionsType>()
const ProductUnitTable = () => {
// States
const [addUnitOpen, setAddUnitOpen] = useState(false)
const [editUnitOpen, setEditUnitOpen] = useState(false)
const [rowSelection, setRowSelection] = useState({})
const [currentPage, setCurrentPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [unitId, setUnitId] = useState('')
const [openConfirm, setOpenConfirm] = useState(false)
const [currentUnit, setCurrentUnit] = useState<Unit>()
// Fetch products with pagination and search
const { data, isLoading, error, isFetching } = useUnitsQuery.getUnits({
page: currentPage,
limit: pageSize
})
const { mutate: deleteUnit, isPending: isDeleting } = useUnitsMutation.deleteUnit()
const units = data?.data ?? []
const totalCount = data?.pagination.total_count ?? 0
const handlePageChange = useCallback((event: unknown, newPage: number) => {
setCurrentPage(newPage)
}, [])
// Handle page size change
const handlePageSizeChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const newPageSize = parseInt(event.target.value, 10)
setPageSize(newPageSize)
setCurrentPage(0) // Reset to first page
}, [])
const handleDelete = () => {
deleteUnit(unitId, {
onSuccess: () => setOpenConfirm(false)
})
}
const columns = useMemo<ColumnDef<UnitWithActionsType, any>[]>(
() => [
{
id: 'select',
header: ({ table }) => (
<Checkbox
{...{
checked: table.getIsAllRowsSelected(),
indeterminate: table.getIsSomeRowsSelected(),
onChange: table.getToggleAllRowsSelectedHandler()
}}
/>
),
cell: ({ row }) => (
<Checkbox
{...{
checked: row.getIsSelected(),
disabled: !row.getCanSelect(),
indeterminate: row.getIsSomeSelected(),
onChange: row.getToggleSelectedHandler()
}}
/>
)
},
columnHelper.accessor('name', {
header: 'Name',
cell: ({ row }) => (
<div className='flex items-center gap-3'>
{/* <img src={row.original.image} width={38} height={38} className='rounded bg-actionHover' /> */}
<div className='flex flex-col items-start'>
<Typography className='font-medium' color='text.primary'>
{row.original.name || '-'}
</Typography>
</div>
</div>
)
}),
columnHelper.accessor('abbreviation', {
header: 'Abbreviation',
cell: ({ row }) => <Typography>{row.original.abbreviation || '-'}</Typography>
}),
columnHelper.accessor('is_active', {
header: 'Status',
cell: ({ row }) => <Typography>{row.original.is_active ? 'Active' : 'Inactive'}</Typography>
}),
columnHelper.accessor('created_at', {
header: 'Created Date',
cell: ({ row }) => <Typography>{row.original.created_at}</Typography>
}),
columnHelper.accessor('actions', {
header: 'Actions',
cell: ({ row }) => (
<div className='flex items-center'>
<IconButton
onClick={() => {
setCurrentUnit(row.original)
setEditUnitOpen(!editUnitOpen)
}}
>
<i className='tabler-edit text-textSecondary' />
</IconButton>
<OptionMenu
iconButtonProps={{ size: 'medium' }}
iconClassName='text-textSecondary'
options={[
{ text: 'Download', icon: 'tabler-download' },
{
text: 'Delete',
icon: 'tabler-trash',
menuItemProps: {
onClick: () => {
setUnitId(row.original.id)
setOpenConfirm(true)
}
}
},
{ text: 'Duplicate', icon: 'tabler-copy' }
]}
/>
</div>
),
enableSorting: false
})
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[data]
)
const table = useReactTable({
data: units as Unit[],
columns,
filterFns: {
fuzzy: fuzzyFilter
},
state: {
rowSelection,
pagination: {
pageIndex: currentPage, // <= penting!
pageSize
}
},
enableRowSelection: true, //enable row selection for all rows
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
// Disable client-side pagination since we're handling it server-side
manualPagination: true,
pageCount: Math.ceil(totalCount / pageSize)
})
return (
<>
<Card>
<div className='flex flex-wrap justify-between gap-4 p-6'>
<DebouncedInput
value={'search'}
onChange={value => console.log(value)}
placeholder='Search Product'
className='max-sm:is-full'
/>
<div className='flex max-sm:flex-col items-start sm:items-center gap-4 max-sm:is-full'>
<CustomTextField
select
value={table.getState().pagination.pageSize}
onChange={e => table.setPageSize(Number(e.target.value))}
className='flex-auto max-sm:is-full sm:is-[70px]'
>
<MenuItem value='10'>10</MenuItem>
<MenuItem value='15'>15</MenuItem>
<MenuItem value='25'>25</MenuItem>
</CustomTextField>
<Button
variant='contained'
className='max-sm:is-full'
onClick={() => setAddUnitOpen(!addUnitOpen)}
startIcon={<i className='tabler-plus' />}
>
Add Unit
</Button>
</div>
</div>
<div className='overflow-x-auto'>
{isLoading ? (
<Loading />
) : (
<table className={tableStyles.table}>
<thead>
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}>
{headerGroup.headers.map(header => (
<th key={header.id}>
{header.isPlaceholder ? null : (
<>
<div
className={classnames({
'flex items-center': header.column.getIsSorted(),
'cursor-pointer select-none': header.column.getCanSort()
})}
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{{
asc: <i className='tabler-chevron-up text-xl' />,
desc: <i className='tabler-chevron-down text-xl' />
}[header.column.getIsSorted() as 'asc' | 'desc'] ?? null}
</div>
</>
)}
</th>
))}
</tr>
))}
</thead>
{table.getFilteredRowModel().rows.length === 0 ? (
<tbody>
<tr>
<td colSpan={table.getVisibleFlatColumns().length} className='text-center'>
No data available
</td>
</tr>
</tbody>
) : (
<tbody>
{table
.getRowModel()
.rows.slice(0, table.getState().pagination.pageSize)
.map(row => {
return (
<tr key={row.id} className={classnames({ selected: row.getIsSelected() })}>
{row.getVisibleCells().map(cell => (
<td key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</td>
))}
</tr>
)
})}
</tbody>
)}
</table>
)}
{isFetching && !isLoading && (
<Box
position='absolute'
top={0}
left={0}
right={0}
bottom={0}
display='flex'
alignItems='center'
justifyContent='center'
bgcolor='rgba(255,255,255,0.7)'
zIndex={1}
>
<CircularProgress size={24} />
</Box>
)}
</div>
<TablePagination
component={() => (
<TablePaginationComponent
pageIndex={currentPage}
pageSize={pageSize}
totalCount={totalCount}
onPageChange={handlePageChange}
/>
)}
count={totalCount}
rowsPerPage={pageSize}
page={currentPage}
onPageChange={handlePageChange}
onRowsPerPageChange={handlePageSizeChange}
rowsPerPageOptions={[10, 25, 50]}
disabled={isLoading}
/>
</Card>
<AddUnitDrawer open={addUnitOpen} handleClose={() => setAddUnitOpen(!addUnitOpen)} />
<EditUnitDrawer
open={editUnitOpen}
handleClose={() => setEditUnitOpen(!editUnitOpen)}
data={currentUnit!}
/>
<ConfirmDeleteDialog
open={openConfirm}
onClose={() => setOpenConfirm(false)}
onConfirm={handleDelete}
isLoading={isDeleting}
title='Delete Unit'
message='Are you sure you want to delete this Unit? This action cannot be undone.'
/>
</>
)
}
export default ProductUnitTable

View File

@ -4,7 +4,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
// Next Imports
import { useParams } from 'next/navigation'
// MUI Imports
import Button from '@mui/material/Button'
@ -30,14 +29,12 @@ import classnames from 'classnames'
// Component Imports
import TablePaginationComponent from '@components/TablePaginationComponent'
import CustomTextField from '@core/components/mui/TextField'
import OptionMenu from '@core/components/option-menu'
// Util Imports
// Style Imports
import tableStyles from '@core/styles/table.module.css'
import { Box, CircularProgress } from '@mui/material'
import ConfirmDeleteDialog from '../../../../components/dialogs/confirm-delete'
import Loading from '../../../../components/layout/shared/Loading'
import { useInventoriesMutation } from '../../../../services/mutations/inventories'
import { useInventoriesQuery } from '../../../../services/queries/inventories'
@ -106,8 +103,6 @@ const StockListTable = () => {
const [rowSelection, setRowSelection] = useState({})
const [currentPage, setCurrentPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [openConfirm, setOpenConfirm] = useState(false)
const [productId, setProductId] = useState('')
const [addInventoryOpen, setAddInventoryOpen] = useState(false)
// Fetch products with pagination and search
@ -132,12 +127,6 @@ const StockListTable = () => {
setCurrentPage(0) // Reset to first page
}, [])
const handleDelete = () => {
deleteInventory(productId, {
onSuccess: () => setOpenConfirm(false)
})
}
const columns = useMemo<ColumnDef<InventoryWithActionsType, any>[]>(
() => [
{
@ -376,15 +365,6 @@ const StockListTable = () => {
</Card>
<AdjustmentStockDrawer open={addInventoryOpen} handleClose={() => setAddInventoryOpen(!addInventoryOpen)} />
<ConfirmDeleteDialog
open={openConfirm}
onClose={() => setOpenConfirm(false)}
onConfirm={handleDelete}
isLoading={isDeleting}
title='Delete Inventory'
message='Are you sure you want to delete this inventory? This action cannot be undone.'
/>
</>
)
}