feat: ingredient product
This commit is contained in:
parent
c3780af341
commit
f3dfad4cb3
@ -0,0 +1,584 @@
|
||||
'use client'
|
||||
|
||||
// React Imports
|
||||
import type { ChangeEvent } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
// MUI Imports
|
||||
import Card from '@mui/material/Card'
|
||||
import CardContent from '@mui/material/CardContent'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import Button from '@mui/material/Button'
|
||||
import Grid from '@mui/material/Grid2'
|
||||
import MenuItem from '@mui/material/MenuItem'
|
||||
import Avatar from '@mui/material/Avatar'
|
||||
import Divider from '@mui/material/Divider'
|
||||
import Chip from '@mui/material/Chip'
|
||||
import Alert from '@mui/material/Alert'
|
||||
import CircularProgress from '@mui/material/CircularProgress'
|
||||
|
||||
// Third-party Imports
|
||||
import classnames from 'classnames'
|
||||
|
||||
// Type Imports
|
||||
import type { CustomInputHorizontalData } from '@core/components/custom-inputs/types'
|
||||
|
||||
// Component Imports
|
||||
import CustomInputHorizontal from '@core/components/custom-inputs/Horizontal'
|
||||
import DirectionalIcon from '@components/DirectionalIcon'
|
||||
import { useSettings } from '@core/hooks/useSettings'
|
||||
import CustomTextField from '@core/components/mui/TextField'
|
||||
|
||||
// Styles Imports
|
||||
import frontCommonStyles from '@views/front-pages/styles.module.css'
|
||||
import { useOrganizationsMutation } from '../../../../../services/mutations/organization'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
// Types
|
||||
export interface OrganizationRequest {
|
||||
organization_name: string
|
||||
organization_email?: string | null
|
||||
organization_phone_number?: string | null
|
||||
plan_type: 'basic' | 'premium' | 'enterprise'
|
||||
admin_name: string
|
||||
admin_email: string
|
||||
admin_password: string
|
||||
outlet_name: string
|
||||
outlet_address?: string | null
|
||||
outlet_timezone?: string | null
|
||||
outlet_currency: string
|
||||
}
|
||||
|
||||
// Data
|
||||
const planData: CustomInputHorizontalData[] = [
|
||||
{
|
||||
title: (
|
||||
<div className='flex items-center gap-4'>
|
||||
<Avatar
|
||||
variant='rounded'
|
||||
className='is-[58px] bs-[34px]'
|
||||
sx={theme => ({
|
||||
backgroundColor: 'var(--mui-palette-primary-light)',
|
||||
color: 'var(--mui-palette-primary-main)'
|
||||
})}
|
||||
>
|
||||
<i className='tabler-rocket text-2xl' />
|
||||
</Avatar>
|
||||
<div className='flex flex-col'>
|
||||
<Typography color='text.primary' className='font-medium'>
|
||||
Basic Plan
|
||||
</Typography>
|
||||
<Typography variant='body2' color='text.secondary'>
|
||||
Perfect for small businesses
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
value: 'basic',
|
||||
isSelected: true
|
||||
},
|
||||
{
|
||||
title: (
|
||||
<div className='flex items-center gap-4'>
|
||||
<Avatar
|
||||
variant='rounded'
|
||||
className='is-[58px] bs-[34px]'
|
||||
sx={theme => ({
|
||||
backgroundColor: 'var(--mui-palette-success-light)',
|
||||
color: 'var(--mui-palette-success-main)'
|
||||
})}
|
||||
>
|
||||
<i className='tabler-crown text-2xl' />
|
||||
</Avatar>
|
||||
<div className='flex flex-col'>
|
||||
<Typography color='text.primary' className='font-medium'>
|
||||
Premium Plan
|
||||
</Typography>
|
||||
<Typography variant='body2' color='text.secondary'>
|
||||
Most popular choice
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
value: 'premium'
|
||||
},
|
||||
{
|
||||
title: (
|
||||
<div className='flex items-center gap-4'>
|
||||
<Avatar
|
||||
variant='rounded'
|
||||
className='is-[58px] bs-[34px]'
|
||||
sx={theme => ({
|
||||
backgroundColor: 'var(--mui-palette-warning-light)',
|
||||
color: 'var(--mui-palette-warning-main)'
|
||||
})}
|
||||
>
|
||||
<i className='tabler-building text-2xl' />
|
||||
</Avatar>
|
||||
<div className='flex flex-col'>
|
||||
<Typography color='text.primary' className='font-medium'>
|
||||
Enterprise Plan
|
||||
</Typography>
|
||||
<Typography variant='body2' color='text.secondary'>
|
||||
Advanced features for large teams
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
value: 'enterprise'
|
||||
}
|
||||
]
|
||||
|
||||
const currencies = [
|
||||
{ code: 'USD', name: 'US Dollar' },
|
||||
{ code: 'EUR', name: 'Euro' },
|
||||
{ code: 'GBP', name: 'British Pound' },
|
||||
{ code: 'JPY', name: 'Japanese Yen' },
|
||||
{ code: 'AUD', name: 'Australian Dollar' },
|
||||
{ code: 'CAD', name: 'Canadian Dollar' },
|
||||
{ code: 'CHF', name: 'Swiss Franc' },
|
||||
{ code: 'CNY', name: 'Chinese Yuan' },
|
||||
{ code: 'IDR', name: 'Indonesian Rupiah' }
|
||||
]
|
||||
|
||||
const timezones = [
|
||||
'UTC',
|
||||
'America/New_York',
|
||||
'America/Los_Angeles',
|
||||
'Europe/London',
|
||||
'Europe/Paris',
|
||||
'Asia/Tokyo',
|
||||
'Asia/Shanghai',
|
||||
'Asia/Jakarta',
|
||||
'Australia/Sydney'
|
||||
]
|
||||
|
||||
const planPricing = {
|
||||
basic: { price: 29.99, features: ['Up to 5 users', 'Basic reporting', '24/7 support', 'Mobile app'] },
|
||||
premium: {
|
||||
price: 59.99,
|
||||
features: ['Up to 25 users', 'Advanced reporting', 'Priority support', 'API access', 'Custom integrations']
|
||||
},
|
||||
enterprise: {
|
||||
price: 129.99,
|
||||
features: [
|
||||
'Unlimited users',
|
||||
'Enterprise reporting',
|
||||
'Dedicated support',
|
||||
'White-label solution',
|
||||
'Custom development'
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const CreateOrganization = () => {
|
||||
const initialSelected: string = planData.filter(item => item.isSelected)[
|
||||
planData.filter(item => item.isSelected).length - 1
|
||||
].value
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// States
|
||||
const [formData, setFormData] = useState<OrganizationRequest>({
|
||||
organization_name: '',
|
||||
organization_email: null,
|
||||
organization_phone_number: null,
|
||||
plan_type: initialSelected as 'basic' | 'premium' | 'enterprise',
|
||||
admin_name: '',
|
||||
admin_email: '',
|
||||
admin_password: '',
|
||||
outlet_name: '',
|
||||
outlet_address: null,
|
||||
outlet_timezone: null,
|
||||
outlet_currency: 'USD'
|
||||
})
|
||||
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof OrganizationRequest, string>>>({})
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
|
||||
// Hooks
|
||||
const { updatePageSettings } = useSettings()
|
||||
const { createOrganization } = useOrganizationsMutation()
|
||||
|
||||
const handleInputChange = (field: keyof OrganizationRequest) => (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.value
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: value || null
|
||||
}))
|
||||
|
||||
// Clear error when user starts typing
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({
|
||||
...prev,
|
||||
[field]: undefined
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
const handlePlanChange = (prop: string | ChangeEvent<HTMLInputElement>) => {
|
||||
const planType = typeof prop === 'string' ? prop : prop.target.value
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
plan_type: planType as 'basic' | 'premium' | 'enterprise'
|
||||
}))
|
||||
}
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Partial<Record<keyof OrganizationRequest, string>> = {}
|
||||
|
||||
// Required fields validation
|
||||
if (!formData.organization_name.trim()) {
|
||||
newErrors.organization_name = 'Organization name is required'
|
||||
} else if (formData.organization_name.length > 255) {
|
||||
newErrors.organization_name = 'Organization name must be 255 characters or less'
|
||||
}
|
||||
|
||||
if (!formData.admin_name.trim()) {
|
||||
newErrors.admin_name = 'Admin name is required'
|
||||
} else if (formData.admin_name.length > 255) {
|
||||
newErrors.admin_name = 'Admin name must be 255 characters or less'
|
||||
}
|
||||
|
||||
if (!formData.admin_email.trim()) {
|
||||
newErrors.admin_email = 'Admin email is required'
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.admin_email)) {
|
||||
newErrors.admin_email = 'Please enter a valid email address'
|
||||
}
|
||||
|
||||
if (!formData.admin_password) {
|
||||
newErrors.admin_password = 'Password is required'
|
||||
} else if (formData.admin_password.length < 6) {
|
||||
newErrors.admin_password = 'Password must be at least 6 characters long'
|
||||
}
|
||||
|
||||
if (!formData.outlet_name.trim()) {
|
||||
newErrors.outlet_name = 'Outlet name is required'
|
||||
} else if (formData.outlet_name.length > 255) {
|
||||
newErrors.outlet_name = 'Outlet name must be 255 characters or less'
|
||||
}
|
||||
|
||||
if (!formData.outlet_currency) {
|
||||
newErrors.outlet_currency = 'Currency is required'
|
||||
} else if (formData.outlet_currency.length !== 3) {
|
||||
newErrors.outlet_currency = 'Currency must be a 3-character ISO code'
|
||||
}
|
||||
|
||||
// Optional email validation
|
||||
if (formData.organization_email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.organization_email)) {
|
||||
newErrors.organization_email = 'Please enter a valid email address'
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!validateForm()) return
|
||||
|
||||
setSubmitError(null)
|
||||
|
||||
createOrganization.mutate(formData, {
|
||||
onSuccess: () => {
|
||||
router.push('/login')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// For Page specific settings
|
||||
useEffect(() => {
|
||||
return updatePageSettings({
|
||||
skin: 'default'
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const selectedPlan = planPricing[formData.plan_type]
|
||||
|
||||
return (
|
||||
<section className={classnames('md:plb-[100px] plb-6', frontCommonStyles.layoutSpacing)}>
|
||||
<Card>
|
||||
<Grid container>
|
||||
<Grid size={{ md: 12, lg: 8 }}>
|
||||
<CardContent className='flex flex-col max-sm:gap-y-5 gap-y-8 sm:p-8 border-be lg:border-be-0 lg:border-e bs-full'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Typography variant='h4' className='flex items-center gap-2'>
|
||||
<i className='tabler-building text-primary' />
|
||||
Create Organization
|
||||
</Typography>
|
||||
<Typography>
|
||||
Set up your organization with admin account and primary outlet. Choose the plan that best fits your
|
||||
needs.
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
{submitError && (
|
||||
<Alert severity='error' className='mb-4'>
|
||||
{submitError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Plan Selection */}
|
||||
<div>
|
||||
<Typography variant='h5' className='mbe-4 flex items-center gap-2'>
|
||||
<i className='tabler-crown text-warning' />
|
||||
Choose Your Plan
|
||||
</Typography>
|
||||
<Grid container spacing={4}>
|
||||
{planData.map((item, index) => (
|
||||
<CustomInputHorizontal
|
||||
key={index}
|
||||
type='radio'
|
||||
name='plan-type'
|
||||
data={item}
|
||||
selected={formData.plan_type}
|
||||
handleChange={handlePlanChange}
|
||||
gridProps={{ size: { xs: 12, md: 4 } }}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
</div>
|
||||
|
||||
{/* Organization Details */}
|
||||
<div>
|
||||
<Typography variant='h5' className='mbe-6 flex items-center gap-2'>
|
||||
<i className='tabler-building text-info' />
|
||||
Organization Details
|
||||
</Typography>
|
||||
<Grid container spacing={5}>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<CustomTextField
|
||||
fullWidth
|
||||
required
|
||||
label='Organization Name'
|
||||
placeholder='My Company Ltd.'
|
||||
value={formData.organization_name}
|
||||
onChange={handleInputChange('organization_name')}
|
||||
error={!!errors.organization_name}
|
||||
helperText={errors.organization_name}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<CustomTextField
|
||||
fullWidth
|
||||
label='Organization Email'
|
||||
placeholder='contact@mycompany.com'
|
||||
type='email'
|
||||
value={formData.organization_email || ''}
|
||||
onChange={handleInputChange('organization_email')}
|
||||
error={!!errors.organization_email}
|
||||
helperText={errors.organization_email}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<CustomTextField
|
||||
fullWidth
|
||||
label='Phone Number'
|
||||
placeholder='+1 (555) 123-4567'
|
||||
value={formData.organization_phone_number || ''}
|
||||
onChange={handleInputChange('organization_phone_number')}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</div>
|
||||
|
||||
{/* Admin Account */}
|
||||
<div>
|
||||
<Typography variant='h5' className='mbe-6 flex items-center gap-2'>
|
||||
<i className='tabler-user-shield text-success' />
|
||||
Admin Account
|
||||
</Typography>
|
||||
<Grid container spacing={5}>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<CustomTextField
|
||||
fullWidth
|
||||
required
|
||||
label='Admin Name'
|
||||
placeholder='John Doe'
|
||||
value={formData.admin_name}
|
||||
onChange={handleInputChange('admin_name')}
|
||||
error={!!errors.admin_name}
|
||||
helperText={errors.admin_name}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<CustomTextField
|
||||
fullWidth
|
||||
required
|
||||
label='Admin Email'
|
||||
placeholder='admin@mycompany.com'
|
||||
type='email'
|
||||
value={formData.admin_email}
|
||||
onChange={handleInputChange('admin_email')}
|
||||
error={!!errors.admin_email}
|
||||
helperText={errors.admin_email}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<CustomTextField
|
||||
fullWidth
|
||||
required
|
||||
type='password'
|
||||
label='Admin Password'
|
||||
placeholder='Minimum 6 characters'
|
||||
value={formData.admin_password}
|
||||
onChange={handleInputChange('admin_password')}
|
||||
error={!!errors.admin_password}
|
||||
helperText={errors.admin_password}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</div>
|
||||
|
||||
{/* Outlet Information */}
|
||||
<div>
|
||||
<Typography variant='h5' className='mbe-6 flex items-center gap-2'>
|
||||
<i className='tabler-store text-warning' />
|
||||
Primary Outlet
|
||||
</Typography>
|
||||
<Grid container spacing={5}>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<CustomTextField
|
||||
fullWidth
|
||||
required
|
||||
label='Outlet Name'
|
||||
placeholder='Main Store'
|
||||
value={formData.outlet_name}
|
||||
onChange={handleInputChange('outlet_name')}
|
||||
error={!!errors.outlet_name}
|
||||
helperText={errors.outlet_name}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<CustomTextField
|
||||
select
|
||||
fullWidth
|
||||
required
|
||||
label='Currency'
|
||||
value={formData.outlet_currency}
|
||||
onChange={handleInputChange('outlet_currency')}
|
||||
error={!!errors.outlet_currency}
|
||||
helperText={errors.outlet_currency}
|
||||
>
|
||||
{currencies.map(currency => (
|
||||
<MenuItem key={currency.code} value={currency.code}>
|
||||
{currency.code} - {currency.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</CustomTextField>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<CustomTextField
|
||||
fullWidth
|
||||
label='Outlet Address'
|
||||
placeholder='123 Main Street, City, State, Country'
|
||||
value={formData.outlet_address || ''}
|
||||
onChange={handleInputChange('outlet_address')}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<CustomTextField
|
||||
select
|
||||
fullWidth
|
||||
label='Timezone'
|
||||
value={formData.outlet_timezone || ''}
|
||||
onChange={handleInputChange('outlet_timezone')}
|
||||
>
|
||||
{timezones.map(tz => (
|
||||
<MenuItem key={tz} value={tz}>
|
||||
{tz}
|
||||
</MenuItem>
|
||||
))}
|
||||
</CustomTextField>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ md: 12, lg: 4 }}>
|
||||
<CardContent className='flex flex-col gap-8 sm:p-8'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Typography variant='h4' className='flex items-center gap-2'>
|
||||
<i className='tabler-receipt text-primary' />
|
||||
Plan Summary
|
||||
</Typography>
|
||||
<Typography>Review your selected plan and get started with your organization.</Typography>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-5'>
|
||||
<div className='flex flex-col gap-4 p-6 bg-actionHover rounded'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Typography className='font-medium capitalize'>{formData.plan_type} Plan</Typography>
|
||||
<Chip
|
||||
label={formData.plan_type === 'premium' ? 'Most Popular' : 'Selected'}
|
||||
color={formData.plan_type === 'premium' ? 'success' : 'primary'}
|
||||
size='small'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-baseline'>
|
||||
<Typography variant='h1'>${selectedPlan.price}</Typography>
|
||||
<Typography component='sub'>/month</Typography>
|
||||
</div>
|
||||
<div className='flex flex-col gap-2'>
|
||||
{selectedPlan.features.map((feature, index) => (
|
||||
<div key={index} className='flex items-center gap-2'>
|
||||
<i className='tabler-check text-success text-sm' />
|
||||
<Typography variant='body2'>{feature}</Typography>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className='flex gap-2 items-center justify-between mbe-2'>
|
||||
<Typography>Plan Cost</Typography>
|
||||
<Typography color='text.primary' className='font-medium'>
|
||||
${selectedPlan.price}
|
||||
</Typography>
|
||||
</div>
|
||||
<div className='flex gap-2 items-center justify-between'>
|
||||
<Typography>Setup Fee</Typography>
|
||||
<Typography color='text.primary' className='font-medium'>
|
||||
Free
|
||||
</Typography>
|
||||
</div>
|
||||
<Divider className='mlb-4' />
|
||||
<div className='flex gap-2 items-center justify-between'>
|
||||
<Typography className='font-medium'>Total</Typography>
|
||||
<Typography color='text.primary' className='font-medium'>
|
||||
${selectedPlan.price}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant='contained'
|
||||
size='large'
|
||||
onClick={handleSubmit}
|
||||
disabled={createOrganization.isPending}
|
||||
endIcon={
|
||||
createOrganization.isPending ? (
|
||||
<CircularProgress size={20} color='inherit' />
|
||||
) : (
|
||||
<DirectionalIcon ltrIconClass='tabler-arrow-right' rtlIconClass='tabler-arrow-left' />
|
||||
)
|
||||
}
|
||||
>
|
||||
{createOrganization.isPending ? 'Creating Organization...' : 'Create Organization'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Typography variant='body2' className='text-center'>
|
||||
By creating an organization, you agree to our Terms of Service and Privacy Policy. You can change your
|
||||
plan anytime.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Card>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreateOrganization
|
||||
@ -23,6 +23,7 @@ const DashboardOrder = () => {
|
||||
<DistributedBarChartOrder
|
||||
isLoading={isLoading}
|
||||
title='Total Items'
|
||||
isCurrency={false}
|
||||
value={data?.summary.total_items as number}
|
||||
avatarIcon={'tabler-package'}
|
||||
avatarColor='primary'
|
||||
@ -33,6 +34,7 @@ const DashboardOrder = () => {
|
||||
<DistributedBarChartOrder
|
||||
isLoading={isLoading}
|
||||
title='Total Orders'
|
||||
isCurrency={false}
|
||||
value={data?.summary.total_orders as number}
|
||||
avatarIcon={'tabler-shopping-cart'}
|
||||
avatarColor='info'
|
||||
@ -43,6 +45,7 @@ const DashboardOrder = () => {
|
||||
<DistributedBarChartOrder
|
||||
isLoading={isLoading}
|
||||
title='Average Orders'
|
||||
isCurrency={true}
|
||||
value={data?.summary.average_order_value as number}
|
||||
avatarIcon={'tabler-trending-up'}
|
||||
avatarColor='warning'
|
||||
@ -53,6 +56,7 @@ const DashboardOrder = () => {
|
||||
<DistributedBarChartOrder
|
||||
isLoading={isLoading}
|
||||
title='Total Sales'
|
||||
isCurrency={true}
|
||||
value={data?.summary.total_sales as number}
|
||||
avatarIcon={'tabler-currency-dollar'}
|
||||
avatarColor='success'
|
||||
|
||||
@ -22,7 +22,7 @@ const DashboardOverview = () => {
|
||||
<p className='text-2xl font-bold text-gray-900 mb-1'>{value}</p>
|
||||
{subtitle && <p className='text-sm text-gray-500'>{subtitle}</p>}
|
||||
</div>
|
||||
<div className={`p-3 rounded-full ${bgColor} bg-opacity-10`}>
|
||||
<div className={`px-4 py-3 rounded-full ${bgColor} bg-opacity-10`}>
|
||||
<i className={`${iconClass} text-[32px] ${bgColor.replace('bg-', 'text-')}`}></i>
|
||||
</div>
|
||||
</div>
|
||||
@ -79,24 +79,6 @@ const DashboardOverview = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Additional Metrics */}
|
||||
<div className='grid grid-cols-1 md:grid-cols-2 gap-6 mb-8'>
|
||||
<div className='bg-white rounded-lg shadow-md p-6'>
|
||||
<div className='flex items-center mb-4'>
|
||||
<i className='tabler-x text-[24px] text-red-500 mr-2'></i>
|
||||
<h3 className='text-lg font-semibold text-gray-900'>Voided Orders</h3>
|
||||
</div>
|
||||
<p className='text-3xl font-bold text-red-600'>{salesData.overview.voided_orders}</p>
|
||||
</div>
|
||||
<div className='bg-white rounded-lg shadow-md p-6'>
|
||||
<div className='flex items-center mb-4'>
|
||||
<i className='tabler-refresh text-[24px] text-yellow-500 mr-2'></i>
|
||||
<h3 className='text-lg font-semibold text-gray-900'>Refunded Orders</h3>
|
||||
</div>
|
||||
<p className='text-3xl font-bold text-yellow-600'>{salesData.overview.refunded_orders}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8'>
|
||||
{/* Top Products */}
|
||||
<ProductSales title='Top Products' productData={salesData.top_products} />
|
||||
|
||||
@ -23,6 +23,7 @@ const DashboardPayment = () => {
|
||||
<DistributedBarChartOrder
|
||||
isLoading={isLoading}
|
||||
title='Total Orders'
|
||||
isCurrency={false}
|
||||
value={data?.summary.total_orders as number}
|
||||
avatarIcon={'tabler-shopping-cart'}
|
||||
avatarColor='primary'
|
||||
@ -33,6 +34,7 @@ const DashboardPayment = () => {
|
||||
<DistributedBarChartOrder
|
||||
isLoading={isLoading}
|
||||
title='Total Payment'
|
||||
isCurrency={false}
|
||||
value={data?.summary.total_payments as number}
|
||||
avatarIcon={'tabler-package'}
|
||||
avatarColor='info'
|
||||
@ -43,6 +45,7 @@ const DashboardPayment = () => {
|
||||
<DistributedBarChartOrder
|
||||
isLoading={isLoading}
|
||||
title='Average Orders'
|
||||
isCurrency={true}
|
||||
value={data?.summary.average_order_value as number}
|
||||
avatarIcon={'tabler-trending-up'}
|
||||
avatarColor='warning'
|
||||
@ -53,6 +56,7 @@ const DashboardPayment = () => {
|
||||
<DistributedBarChartOrder
|
||||
isLoading={isLoading}
|
||||
title='Total Amount'
|
||||
isCurrency={true}
|
||||
value={data?.summary.total_amount as number}
|
||||
avatarIcon={'tabler-currency-dollar'}
|
||||
avatarColor='success'
|
||||
|
||||
@ -2,24 +2,23 @@
|
||||
import Button from '@mui/material/Button'
|
||||
|
||||
// Type Imports
|
||||
import type { ChildrenType } from '@core/types'
|
||||
import type { Locale } from '@configs/i18n'
|
||||
import type { ChildrenType } from '@core/types'
|
||||
|
||||
// Layout Imports
|
||||
import HorizontalLayout from '@layouts/HorizontalLayout'
|
||||
import LayoutWrapper from '@layouts/LayoutWrapper'
|
||||
import VerticalLayout from '@layouts/VerticalLayout'
|
||||
import HorizontalLayout from '@layouts/HorizontalLayout'
|
||||
|
||||
// Component Imports
|
||||
import Providers from '@components/Providers'
|
||||
import Navigation from '@components/layout/vertical/Navigation'
|
||||
import Header from '@components/layout/horizontal/Header'
|
||||
import Navbar from '@components/layout/vertical/Navbar'
|
||||
import VerticalFooter from '@components/layout/vertical/Footer'
|
||||
import HorizontalFooter from '@components/layout/horizontal/Footer'
|
||||
import Customizer from '@core/components/customizer'
|
||||
import ScrollToTop from '@core/components/scroll-to-top'
|
||||
import AuthGuard from '@/hocs/AuthGuard'
|
||||
import Providers from '@components/Providers'
|
||||
import HorizontalFooter from '@components/layout/horizontal/Footer'
|
||||
import Header from '@components/layout/horizontal/Header'
|
||||
import VerticalFooter from '@components/layout/vertical/Footer'
|
||||
import Navbar from '@components/layout/vertical/Navbar'
|
||||
import Navigation from '@components/layout/vertical/Navigation'
|
||||
import ScrollToTop from '@core/components/scroll-to-top'
|
||||
|
||||
// Config Imports
|
||||
import { i18n } from '@configs/i18n'
|
||||
|
||||
75
src/app/[lang]/(sa)/(private)/layout.tsx
Normal file
75
src/app/[lang]/(sa)/(private)/layout.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
// MUI Imports
|
||||
import Button from '@mui/material/Button'
|
||||
|
||||
// Type Imports
|
||||
import type { Locale } from '@configs/i18n'
|
||||
import type { ChildrenType } from '@core/types'
|
||||
|
||||
// Layout Imports
|
||||
import HorizontalLayout from '@layouts/HorizontalLayout'
|
||||
import LayoutWrapper from '@layouts/LayoutWrapper'
|
||||
import VerticalLayout from '@layouts/VerticalLayout'
|
||||
|
||||
// Component Imports
|
||||
import Providers from '@components/Providers'
|
||||
import HorizontalFooter from '@components/layout/horizontal/Footer'
|
||||
import Header from '@components/layout/horizontal/Header'
|
||||
import VerticalFooter from '@components/layout/vertical/Footer'
|
||||
import Navbar from '@components/layout/vertical/Navbar'
|
||||
import Navigation from '@components/layout/vertical/Navigation'
|
||||
import ScrollToTop from '@core/components/scroll-to-top'
|
||||
|
||||
// Config Imports
|
||||
import { i18n } from '@configs/i18n'
|
||||
|
||||
// Util Imports
|
||||
import { getDictionary } from '@/utils/getDictionary'
|
||||
import { getMode, getSystemMode } from '@core/utils/serverHelpers'
|
||||
import RolesGuard from '../../../../hocs/RolesGuard'
|
||||
|
||||
const Layout = async (props: ChildrenType & { params: Promise<{ lang: Locale }> }) => {
|
||||
const params = await props.params
|
||||
|
||||
const { children } = props
|
||||
|
||||
// Vars
|
||||
const direction = i18n.langDirection[params.lang]
|
||||
const dictionary = await getDictionary(params.lang)
|
||||
const mode = await getMode()
|
||||
const systemMode = await getSystemMode()
|
||||
|
||||
return (
|
||||
<Providers direction={direction}>
|
||||
<RolesGuard locale={params.lang}>
|
||||
<LayoutWrapper
|
||||
systemMode={systemMode}
|
||||
verticalLayout={
|
||||
<VerticalLayout
|
||||
navigation={<Navigation dictionary={dictionary} mode={mode} />}
|
||||
navbar={<Navbar />}
|
||||
footer={<VerticalFooter />}
|
||||
>
|
||||
{children}
|
||||
</VerticalLayout>
|
||||
}
|
||||
horizontalLayout={
|
||||
<HorizontalLayout header={<Header dictionary={dictionary} />} footer={<HorizontalFooter />}>
|
||||
{children}
|
||||
</HorizontalLayout>
|
||||
}
|
||||
/>
|
||||
<ScrollToTop className='mui-fixed'>
|
||||
<Button
|
||||
variant='contained'
|
||||
className='is-10 bs-10 rounded-full p-0 min-is-0 flex items-center justify-center'
|
||||
>
|
||||
<i className='tabler-arrow-up' />
|
||||
</Button>
|
||||
</ScrollToTop>
|
||||
{/* <Customizer dir={direction} /> */}
|
||||
</RolesGuard>
|
||||
</Providers>
|
||||
)
|
||||
}
|
||||
|
||||
export default Layout
|
||||
25
src/app/[lang]/(sa)/(private)/sa/organizations/list/page.tsx
Normal file
25
src/app/[lang]/(sa)/(private)/sa/organizations/list/page.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import OrganizationListTable from '../../../../../../../views/sa/organizations/list/OrganizationListTable'
|
||||
|
||||
/**
|
||||
* ! If you need data using an API call, uncomment the below API code, update the `process.env.API_URL` variable in the
|
||||
* ! `.env` file found at root of your project and also update the API endpoints like `/apps/ecommerce` in below example.
|
||||
* ! Also, remove the above server action import and the action itself from the `src/app/server/actions.ts` file to clean up unused code
|
||||
* ! because we've used the server action for getting our static data.
|
||||
*/
|
||||
|
||||
/* const getEcommerceData = async () => {
|
||||
// Vars
|
||||
const res = await fetch(`${process.env.API_URL}/apps/ecommerce`)
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to fetch ecommerce data')
|
||||
}
|
||||
|
||||
return res.json()
|
||||
} */
|
||||
|
||||
const OrganizationsListTablePage = async () => {
|
||||
return <OrganizationListTable />
|
||||
}
|
||||
|
||||
export default OrganizationsListTablePage
|
||||
@ -31,6 +31,7 @@ import { useSettings } from '@core/hooks/useSettings'
|
||||
import { getLocalizedUrl } from '@/utils/i18n'
|
||||
import { useAuthMutation } from '../../../services/mutations/auth'
|
||||
import { useAuth } from '../../../contexts/authContext'
|
||||
import { CircularProgress } from '@mui/material'
|
||||
|
||||
// Styled component for badge content
|
||||
const BadgeContentSpan = styled('span')({
|
||||
@ -162,8 +163,9 @@ const UserDropdown = () => {
|
||||
endIcon={<i className='tabler-logout' />}
|
||||
onClick={handleUserLogout}
|
||||
sx={{ '& .MuiButton-endIcon': { marginInlineStart: 1.5 } }}
|
||||
disabled={logout.isPending}
|
||||
>
|
||||
Logout
|
||||
Logout {logout.isPending && <CircularProgress size={16} />}
|
||||
</Button>
|
||||
</div>
|
||||
</MenuList>
|
||||
|
||||
@ -29,6 +29,8 @@ import { getLocalizedUrl } from '@/utils/i18n'
|
||||
|
||||
// Style Imports
|
||||
import navigationCustomStyles from '@core/styles/vertical/navigationCustomStyles'
|
||||
import { useAuth } from '../../../contexts/authContext'
|
||||
import SuperAdminVerticalMenu from './SuperAdminVerticalMenu'
|
||||
|
||||
type Props = {
|
||||
dictionary: Awaited<ReturnType<typeof getDictionary>>
|
||||
@ -64,6 +66,8 @@ const Navigation = (props: Props) => {
|
||||
const { mode: muiMode, systemMode: muiSystemMode } = useColorScheme()
|
||||
const theme = useTheme()
|
||||
|
||||
const { currentUser } = useAuth()
|
||||
|
||||
// Refs
|
||||
const shadowRef = useRef(null)
|
||||
|
||||
@ -129,7 +133,12 @@ const Navigation = (props: Props) => {
|
||||
)}
|
||||
</NavHeader>
|
||||
<StyledBoxForShadow ref={shadowRef} />
|
||||
<VerticalMenu dictionary={dictionary} scrollMenu={scrollMenu} />
|
||||
|
||||
{currentUser?.role === 'superadmin' ? (
|
||||
<SuperAdminVerticalMenu dictionary={dictionary} scrollMenu={scrollMenu} />
|
||||
) : (
|
||||
<VerticalMenu dictionary={dictionary} scrollMenu={scrollMenu} />
|
||||
)}
|
||||
</VerticalNav>
|
||||
)
|
||||
}
|
||||
|
||||
91
src/components/layout/vertical/SuperAdminVerticalMenu.tsx
Normal file
91
src/components/layout/vertical/SuperAdminVerticalMenu.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
// Next Imports
|
||||
import { useParams } from 'next/navigation'
|
||||
|
||||
// MUI Imports
|
||||
import { useTheme } from '@mui/material/styles'
|
||||
|
||||
// Third-party Imports
|
||||
import PerfectScrollbar from 'react-perfect-scrollbar'
|
||||
|
||||
// Type Imports
|
||||
import type { getDictionary } from '@/utils/getDictionary'
|
||||
import type { VerticalMenuContextProps } from '@menu/components/vertical-menu/Menu'
|
||||
|
||||
// Component Imports
|
||||
import { Menu, MenuItem, MenuSection, SubMenu } from '@menu/vertical-menu'
|
||||
|
||||
// import { GenerateVerticalMenu } from '@components/GenerateMenu'
|
||||
|
||||
// Hook Imports
|
||||
import useVerticalNav from '@menu/hooks/useVerticalNav'
|
||||
|
||||
// Styled Component Imports
|
||||
import StyledVerticalNavExpandIcon from '@menu/styles/vertical/StyledVerticalNavExpandIcon'
|
||||
|
||||
// Style Imports
|
||||
import menuItemStyles from '@core/styles/vertical/menuItemStyles'
|
||||
import menuSectionStyles from '@core/styles/vertical/menuSectionStyles'
|
||||
|
||||
// Menu Data Imports
|
||||
// import menuData from '@/data/navigation/verticalMenuData'
|
||||
|
||||
type RenderExpandIconProps = {
|
||||
open?: boolean
|
||||
transitionDuration?: VerticalMenuContextProps['transitionDuration']
|
||||
}
|
||||
|
||||
type Props = {
|
||||
dictionary: Awaited<ReturnType<typeof getDictionary>>
|
||||
scrollMenu: (container: any, isPerfectScrollbar: boolean) => void
|
||||
}
|
||||
|
||||
const RenderExpandIcon = ({ open, transitionDuration }: RenderExpandIconProps) => (
|
||||
<StyledVerticalNavExpandIcon open={open} transitionDuration={transitionDuration}>
|
||||
<i className='tabler-chevron-right' />
|
||||
</StyledVerticalNavExpandIcon>
|
||||
)
|
||||
|
||||
const SuperAdminVerticalMenu = ({ dictionary, scrollMenu }: Props) => {
|
||||
// Hooks
|
||||
const theme = useTheme()
|
||||
const verticalNavOptions = useVerticalNav()
|
||||
const params = useParams()
|
||||
|
||||
// Vars
|
||||
const { isBreakpointReached, transitionDuration } = verticalNavOptions
|
||||
const { lang: locale } = params
|
||||
|
||||
const ScrollWrapper = isBreakpointReached ? 'div' : PerfectScrollbar
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line lines-around-comment
|
||||
/* Custom scrollbar instead of browser scroll, remove if you want browser scroll only */
|
||||
<ScrollWrapper
|
||||
{...(isBreakpointReached
|
||||
? {
|
||||
className: 'bs-full overflow-y-auto overflow-x-hidden',
|
||||
onScroll: container => scrollMenu(container, false)
|
||||
}
|
||||
: {
|
||||
options: { wheelPropagation: false, suppressScrollX: true },
|
||||
onScrollY: container => scrollMenu(container, true)
|
||||
})}
|
||||
>
|
||||
{/* Incase you also want to scroll NavHeader to scroll with Vertical Menu, remove NavHeader from above and paste it below this comment */}
|
||||
{/* Vertical Menu */}
|
||||
<Menu
|
||||
popoutMenuOffset={{ mainAxis: 23 }}
|
||||
menuItemStyles={menuItemStyles(verticalNavOptions, theme)}
|
||||
renderExpandIcon={({ open }) => <RenderExpandIcon open={open} transitionDuration={transitionDuration} />}
|
||||
renderExpandedMenuItemIcon={{ icon: <i className='tabler-circle text-xs' /> }}
|
||||
menuSectionStyles={menuSectionStyles(verticalNavOptions, theme)}
|
||||
>
|
||||
<SubMenu label={dictionary['navigation'].organization} icon={<i className='tabler-smart-home' />}>
|
||||
<MenuItem href={`/${locale}/sa/organizations/list`}>{dictionary['navigation'].list}</MenuItem>
|
||||
</SubMenu>
|
||||
</Menu>
|
||||
</ScrollWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export default SuperAdminVerticalMenu
|
||||
@ -91,7 +91,7 @@ const VerticalMenu = ({ dictionary, scrollMenu }: Props) => {
|
||||
</SubMenu>
|
||||
<MenuSection label={dictionary['navigation'].appsPages}>
|
||||
<SubMenu label={dictionary['navigation'].eCommerce} icon={<i className='tabler-shopping-cart' />}>
|
||||
<MenuItem href={`/${locale}/apps/ecommerce/dashboard`}>{dictionary['navigation'].dashboard}</MenuItem>
|
||||
{/* <MenuItem href={`/${locale}/apps/ecommerce/dashboard`}>{dictionary['navigation'].dashboard}</MenuItem> */}
|
||||
<SubMenu label={dictionary['navigation'].products}>
|
||||
<MenuItem href={`/${locale}/apps/ecommerce/products/list`}>{dictionary['navigation'].list}</MenuItem>
|
||||
<MenuItem className='hidden' href={`/${locale}/apps/ecommerce/products/${params.id}/detail`}>
|
||||
@ -122,7 +122,7 @@ const VerticalMenu = ({ dictionary, scrollMenu }: Props) => {
|
||||
{dictionary['navigation'].adjustment}
|
||||
</MenuItem>
|
||||
</SubMenu>
|
||||
<MenuItem href={`/${locale}/apps/ecommerce/settings`}>{dictionary['navigation'].settings}</MenuItem>
|
||||
{/* <MenuItem href={`/${locale}/apps/ecommerce/settings`}>{dictionary['navigation'].settings}</MenuItem> */}
|
||||
</SubMenu>
|
||||
<SubMenu label={dictionary['navigation'].organization} icon={<i className='tabler-sitemap' />}>
|
||||
<SubMenu label={dictionary['navigation'].outlet}>
|
||||
|
||||
@ -2,11 +2,12 @@
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
import Loading from '../components/layout/shared/Loading'
|
||||
import { User } from '../types/services/user'
|
||||
|
||||
type AuthContextType = {
|
||||
isAuthenticated: boolean
|
||||
token: string | null
|
||||
currentUser: any | null
|
||||
currentUser: User | null
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType>({
|
||||
@ -17,7 +18,7 @@ const AuthContext = createContext<AuthContextType>({
|
||||
|
||||
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const [token, setToken] = useState<string | null>(null)
|
||||
const [currentUser, setCurrentUser] = useState<any | null>(null)
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null)
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@ -12,13 +12,17 @@ import Loading from '../components/layout/shared/Loading'
|
||||
import { getLocalizedUrl } from '../utils/i18n'
|
||||
|
||||
export default function AuthGuard({ children, locale }: ChildrenType & { locale: Locale }) {
|
||||
const { isAuthenticated } = useAuth()
|
||||
const { isAuthenticated, currentUser } = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
redirect(getLocalizedUrl('/login', locale))
|
||||
}
|
||||
}, [isAuthenticated])
|
||||
|
||||
return <>{isAuthenticated ? children : <Loading />}</>
|
||||
if (currentUser?.role !== 'admin') {
|
||||
redirect(getLocalizedUrl('/not-found', locale))
|
||||
}
|
||||
}, [isAuthenticated, currentUser])
|
||||
|
||||
return <>{isAuthenticated && currentUser?.role === 'admin' ? children : <Loading />}</>
|
||||
}
|
||||
|
||||
28
src/hocs/RolesGuard.tsx
Normal file
28
src/hocs/RolesGuard.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
'use client'
|
||||
|
||||
// Type Imports
|
||||
import type { Locale } from '@configs/i18n'
|
||||
import type { ChildrenType } from '@core/types'
|
||||
import { useEffect } from 'react'
|
||||
import { useAuth } from '../contexts/authContext'
|
||||
|
||||
// Component Imports
|
||||
import { redirect } from 'next/navigation'
|
||||
import Loading from '../components/layout/shared/Loading'
|
||||
import { getLocalizedUrl } from '../utils/i18n'
|
||||
|
||||
export default function RolesGuard({ children, locale }: ChildrenType & { locale: Locale }) {
|
||||
const { isAuthenticated, currentUser } = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
redirect(getLocalizedUrl('/login', locale))
|
||||
}
|
||||
|
||||
if (currentUser?.role !== 'superadmin') {
|
||||
redirect(getLocalizedUrl('/not-found', locale))
|
||||
}
|
||||
}, [isAuthenticated, currentUser])
|
||||
|
||||
return <>{isAuthenticated && currentUser?.role === 'superadmin' ? children : <Loading />}</>
|
||||
}
|
||||
@ -7,6 +7,8 @@ import paymentMethodReducer from '@/redux-store/slices/paymentMethod'
|
||||
import ingredientReducer from '@/redux-store/slices/ingredient'
|
||||
import orderReducer from '@/redux-store/slices/order'
|
||||
import productRecipeReducer from '@/redux-store/slices/productRecipe'
|
||||
import organizationReducer from '@/redux-store/slices/organization'
|
||||
import userReducer from '@/redux-store/slices/user'
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
@ -15,7 +17,9 @@ export const store = configureStore({
|
||||
paymentMethodReducer,
|
||||
ingredientReducer,
|
||||
orderReducer,
|
||||
productRecipeReducer
|
||||
productRecipeReducer,
|
||||
organizationReducer,
|
||||
userReducer
|
||||
},
|
||||
middleware: getDefaultMiddleware => getDefaultMiddleware({ serializableCheck: false })
|
||||
})
|
||||
|
||||
37
src/redux-store/slices/organization.ts
Normal file
37
src/redux-store/slices/organization.ts
Normal file
@ -0,0 +1,37 @@
|
||||
// Third-party Imports
|
||||
import type { PayloadAction } from '@reduxjs/toolkit'
|
||||
import { createSlice } from '@reduxjs/toolkit'
|
||||
|
||||
// Type Imports
|
||||
|
||||
// Data Imports
|
||||
import { Organization } from '../../types/services/organization'
|
||||
|
||||
const initialState: { currentOrganization: Organization } = {
|
||||
currentOrganization: {
|
||||
id: '',
|
||||
name: '',
|
||||
email: '',
|
||||
phone_number: '',
|
||||
plan_type: 'basic',
|
||||
created_at: '',
|
||||
updated_at: ''
|
||||
}
|
||||
}
|
||||
|
||||
export const organizationSlice = createSlice({
|
||||
name: 'organization',
|
||||
initialState,
|
||||
reducers: {
|
||||
setOrganization: (state, action: PayloadAction<Organization>) => {
|
||||
state.currentOrganization = action.payload
|
||||
},
|
||||
resetOrganization: state => {
|
||||
state.currentOrganization = initialState.currentOrganization
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const { setOrganization, resetOrganization } = organizationSlice.actions
|
||||
|
||||
export default organizationSlice.reducer
|
||||
40
src/redux-store/slices/user.ts
Normal file
40
src/redux-store/slices/user.ts
Normal file
@ -0,0 +1,40 @@
|
||||
// Third-party Imports
|
||||
import type { PayloadAction } from '@reduxjs/toolkit'
|
||||
import { createSlice } from '@reduxjs/toolkit'
|
||||
|
||||
// Type Imports
|
||||
|
||||
// Data Imports
|
||||
import { User } from '../../types/services/user'
|
||||
|
||||
const initialState: { currentUser: User } = {
|
||||
currentUser: {
|
||||
id: '',
|
||||
organization_id: '',
|
||||
outlet_id: '',
|
||||
name: '',
|
||||
email: '',
|
||||
role: '',
|
||||
permissions: {},
|
||||
is_active: false,
|
||||
created_at: '',
|
||||
updated_at: ''
|
||||
}
|
||||
}
|
||||
|
||||
export const userSlice = createSlice({
|
||||
name: 'user',
|
||||
initialState,
|
||||
reducers: {
|
||||
setUser: (state, action: PayloadAction<User>) => {
|
||||
state.currentUser = action.payload
|
||||
},
|
||||
resetUser: state => {
|
||||
state.currentUser = initialState.currentUser
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const { setUser, resetUser } = userSlice.actions
|
||||
|
||||
export default userSlice.reducer
|
||||
52
src/services/mutations/organization.ts
Normal file
52
src/services/mutations/organization.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'react-toastify'
|
||||
import { OrganizationRequest } from '../../types/services/organization'
|
||||
import { api } from '../api'
|
||||
|
||||
export const useOrganizationsMutation = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const createOrganization = useMutation({
|
||||
mutationFn: async (newOrganization: OrganizationRequest) => {
|
||||
const response = await api.post('/organizations', newOrganization)
|
||||
return response.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Organization created successfully!')
|
||||
queryClient.invalidateQueries({ queryKey: ['organizations'] })
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.errors?.[0]?.cause || 'Create failed')
|
||||
}
|
||||
})
|
||||
|
||||
const updateOrganization = useMutation({
|
||||
mutationFn: async ({ id, payload }: { id: string; payload: OrganizationRequest }) => {
|
||||
const response = await api.put(`/organizations/${id}`, payload)
|
||||
return response.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Organization updated successfully!')
|
||||
queryClient.invalidateQueries({ queryKey: ['organizations'] })
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.errors?.[0]?.cause || 'Update failed')
|
||||
}
|
||||
})
|
||||
|
||||
const deleteOrganization = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const response = await api.delete(`/organizations/${id}`)
|
||||
return response.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Organization deleted successfully!')
|
||||
queryClient.invalidateQueries({ queryKey: ['organizations'] })
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.errors?.[0]?.cause || 'Delete failed')
|
||||
}
|
||||
})
|
||||
|
||||
return { createOrganization, updateOrganization, deleteOrganization }
|
||||
}
|
||||
@ -1,52 +1,54 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { CustomerRequest } from '../../types/services/customer'
|
||||
import { api } from '../api'
|
||||
import { toast } from 'react-toastify'
|
||||
import { UserRequest } from '../../types/services/user'
|
||||
import { api } from '../api'
|
||||
|
||||
export const useCustomersMutation = () => {
|
||||
export const useUsersMutation = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const createCustomer = useMutation({
|
||||
mutationFn: async (newCustomer: CustomerRequest) => {
|
||||
const response = await api.post('/customers', newCustomer)
|
||||
const createUser = useMutation({
|
||||
mutationFn: async (newUser: UserRequest) => {
|
||||
const response = await api.post('/users', newUser)
|
||||
return response.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Customer created successfully!')
|
||||
queryClient.invalidateQueries({ queryKey: ['customers'] })
|
||||
toast.success('User created successfully!')
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] })
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.errors?.[0]?.cause || 'Create failed')
|
||||
}
|
||||
})
|
||||
|
||||
const updateCustomer = useMutation({
|
||||
mutationFn: async ({ id, payload }: { id: string; payload: CustomerRequest }) => {
|
||||
const response = await api.put(`/customers/${id}`, payload)
|
||||
const updateUser = useMutation({
|
||||
mutationFn: async ({ id, payload }: { id: string; payload: UserRequest }) => {
|
||||
const {password, ...rest} = payload
|
||||
|
||||
const response = await api.put(`/users/${id}`, rest)
|
||||
return response.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Customer updated successfully!')
|
||||
queryClient.invalidateQueries({ queryKey: ['customers'] })
|
||||
toast.success('User updated successfully!')
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] })
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.errors?.[0]?.cause || 'Update failed')
|
||||
}
|
||||
})
|
||||
|
||||
const deleteCustomer = useMutation({
|
||||
const deleteUser = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const response = await api.delete(`/customers/${id}`)
|
||||
const response = await api.delete(`/users/${id}`)
|
||||
return response.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Customer deleted successfully!')
|
||||
queryClient.invalidateQueries({ queryKey: ['customers'] })
|
||||
toast.success('User deleted successfully!')
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] })
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.errors?.[0]?.cause || 'Delete failed')
|
||||
}
|
||||
})
|
||||
|
||||
return { createCustomer, updateCustomer, deleteCustomer }
|
||||
return { createUser, updateUser, deleteUser }
|
||||
}
|
||||
|
||||
36
src/services/queries/organizations.ts
Normal file
36
src/services/queries/organizations.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Organizations } from '../../types/services/organization'
|
||||
import { api } from '../api'
|
||||
|
||||
interface OrganizationsQueryParams {
|
||||
page?: number
|
||||
limit?: number
|
||||
search?: string
|
||||
}
|
||||
|
||||
export function useOrganizations(params: OrganizationsQueryParams = {}) {
|
||||
const { page = 1, limit = 10, search = '', ...filters } = params
|
||||
|
||||
return useQuery<Organizations>({
|
||||
queryKey: ['organizations', { 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)
|
||||
}
|
||||
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
queryParams.append(key, value.toString())
|
||||
}
|
||||
})
|
||||
|
||||
const res = await api.get(`/organizations?${queryParams.toString()}`)
|
||||
return res.data.data
|
||||
}
|
||||
})
|
||||
}
|
||||
33
src/types/services/organization.ts
Normal file
33
src/types/services/organization.ts
Normal file
@ -0,0 +1,33 @@
|
||||
export interface OrganizationRequest {
|
||||
organization_name: string; // required, 1–255 chars
|
||||
organization_email?: string | null; // optional, must be a valid email if present
|
||||
organization_phone_number?: string | null; // optional
|
||||
plan_type: 'basic' | 'premium' | 'enterprise'; // required, enum
|
||||
|
||||
admin_name: string; // required, 1–255 chars
|
||||
admin_email: string; // required, email
|
||||
admin_password: string; // required, min length 6
|
||||
|
||||
outlet_name: string; // required, 1–255 chars
|
||||
outlet_address?: string | null; // optional
|
||||
outlet_timezone?: string | null; // optional
|
||||
outlet_currency: string; // required, exactly 3 chars (ISO currency code)
|
||||
}
|
||||
|
||||
export interface Organization {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
phone_number: string | null;
|
||||
plan_type: "basic" | "enterprise"; // can be extended if there are more plan types
|
||||
created_at: string; // ISO date string
|
||||
updated_at: string; // ISO date string
|
||||
}
|
||||
|
||||
export interface Organizations {
|
||||
organizations: Organization[];
|
||||
total_count: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
total_pages: number;
|
||||
}
|
||||
@ -5,7 +5,7 @@ export type User = {
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
permissions: Record<string, unknown>;
|
||||
permissions: Record<string, boolean>;
|
||||
is_active: boolean;
|
||||
created_at: string; // ISO date string
|
||||
updated_at: string; // ISO date string
|
||||
@ -24,12 +24,11 @@ export type Users = {
|
||||
};
|
||||
|
||||
export type UserRequest = {
|
||||
organization_id: string;
|
||||
outlet_id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
role: string;
|
||||
permissions: Record<string, unknown>;
|
||||
permissions: Record<string, boolean>;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
@ -23,7 +23,7 @@ export const formatShortCurrency = (num: number): string => {
|
||||
result = absNum.toString()
|
||||
}
|
||||
|
||||
return num < 0 ? '-' + 'Rp ' + result : 'Rp ' + result
|
||||
return num < 0 ? '-' + result : result
|
||||
}
|
||||
|
||||
export const formatDate = (dateString: any) => {
|
||||
|
||||
@ -243,7 +243,7 @@ const Login = ({ mode }: { mode: SystemMode }) => {
|
||||
</Button>
|
||||
<div className='flex justify-center items-center flex-wrap gap-2'>
|
||||
<Typography>New on our platform?</Typography>
|
||||
<Typography component={Link} href={getLocalizedUrl('/register', locale as Locale)} color='primary.main'>
|
||||
<Typography component={Link} href={getLocalizedUrl('/organization', locale as Locale)} color='primary.main'>
|
||||
Create an account
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
@ -221,7 +221,6 @@ const CustomerListTable = () => {
|
||||
}
|
||||
}
|
||||
},
|
||||
{ text: 'Duplicate', icon: 'tabler-copy' }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -1,160 +0,0 @@
|
||||
'use client'
|
||||
|
||||
// MUI Imports
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import DialogContent from '@mui/material/DialogContent'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
|
||||
// Third-party Imports
|
||||
import { Autocomplete, Button, Grid2, MenuItem } from '@mui/material'
|
||||
import { useMemo, useState } from 'react'
|
||||
import CustomTextField from '../../../../../@core/components/mui/TextField'
|
||||
import DialogCloseButton from '../../../../../components/dialogs/DialogCloseButton'
|
||||
import { Product } from '../../../../../types/services/product'
|
||||
import { ProductRecipeRequest } from '../../../../../types/services/productRecipe'
|
||||
import { useOutlets } from '../../../../../services/queries/outlets'
|
||||
import { useDebounce } from 'use-debounce'
|
||||
import { useIngredients } from '../../../../../services/queries/ingredients'
|
||||
|
||||
// Component Imports
|
||||
|
||||
type PaymentMethodProps = {
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
product: Product
|
||||
}
|
||||
|
||||
const initialValues = {
|
||||
product_id: '',
|
||||
variant_id: '',
|
||||
ingredient_id: '',
|
||||
quantity: 0,
|
||||
outlet_id: ''
|
||||
}
|
||||
|
||||
const AddRecipeDialog = ({ open, setOpen, product }: PaymentMethodProps) => {
|
||||
const [formData, setFormData] = useState<ProductRecipeRequest>(initialValues)
|
||||
|
||||
const [outletInput, setOutletInput] = useState('')
|
||||
const [outletDebouncedInput] = useDebounce(outletInput, 500)
|
||||
const [ingredientInput, setIngredientInput] = useState('')
|
||||
const [ingredientDebouncedInput] = useDebounce(ingredientInput, 500)
|
||||
|
||||
const { data: outlets, isLoading: outletsLoading } = useOutlets({
|
||||
search: outletDebouncedInput
|
||||
})
|
||||
const { data: ingredients, isLoading: ingredientsLoading } = useIngredients({
|
||||
search: ingredientDebouncedInput
|
||||
})
|
||||
|
||||
const outletOptions = useMemo(() => outlets?.outlets || [], [outlets])
|
||||
const ingredientOptions = useMemo(() => ingredients?.data || [], [ingredients])
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
fullWidth
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
maxWidth='sm'
|
||||
scroll='body'
|
||||
closeAfterTransition={false}
|
||||
sx={{ '& .MuiDialog-paper': { overflow: 'visible' } }}
|
||||
>
|
||||
<DialogCloseButton onClick={() => setOpen(false)} disableRipple>
|
||||
<i className='tabler-x' />
|
||||
</DialogCloseButton>
|
||||
<DialogTitle variant='h4' className='flex gap-2 flex-col text-center sm:pbs-16 sm:pbe-10 sm:pli-16'>
|
||||
Create Recipe
|
||||
</DialogTitle>
|
||||
<DialogContent className='pbs-0 sm:pli-16 sm:pbe-20 space-y-4'>
|
||||
{product.variants && (
|
||||
<CustomTextField
|
||||
select
|
||||
fullWidth
|
||||
label='Variant'
|
||||
value={formData.variant_id}
|
||||
onChange={e => setFormData({ ...formData, variant_id: e.target.value })}
|
||||
>
|
||||
{product.variants.map((variant, index) => (
|
||||
<MenuItem value={variant.id} key={index}>
|
||||
{variant.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</CustomTextField>
|
||||
)}
|
||||
<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: <>{params.InputProps.endAdornment}</>
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Grid2 container spacing={2}>
|
||||
<Grid2 size={{ xs: 6 }}>
|
||||
<Autocomplete
|
||||
options={ingredientOptions || []}
|
||||
loading={ingredientsLoading}
|
||||
getOptionLabel={option => option.name}
|
||||
value={ingredientOptions?.find(p => p.id === formData.ingredient_id) || null}
|
||||
onInputChange={(event, newIngredientInput) => {
|
||||
setIngredientInput(newIngredientInput)
|
||||
}}
|
||||
onChange={(event, newValue) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
ingredient_id: newValue?.id || ''
|
||||
})
|
||||
}}
|
||||
renderInput={params => (
|
||||
<CustomTextField
|
||||
{...params}
|
||||
className=''
|
||||
label='Ingredient'
|
||||
fullWidth
|
||||
InputProps={{
|
||||
...params.InputProps,
|
||||
endAdornment: <>{params.InputProps.endAdornment}</>
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid2>
|
||||
<Grid2 size={{ xs: 4 }}>
|
||||
<CustomTextField
|
||||
type='number'
|
||||
label='Quantity'
|
||||
fullWidth
|
||||
value={formData.quantity}
|
||||
onChange={e => setFormData({ ...formData, quantity: Number(e.target.value) })}
|
||||
/>
|
||||
</Grid2>
|
||||
<Grid2 size={{ xs: 2 }}>
|
||||
<Button variant='contained' color='primary' className='rounded-full' startIcon={<i className='tabler-plus' />} />
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddRecipeDialog
|
||||
@ -72,25 +72,14 @@ const AddRecipeDrawer = (props: Props) => {
|
||||
const handleSubmit = (e: any) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (currentProductRecipe.id) {
|
||||
updateProductRecipe.mutate(
|
||||
{ id: currentProductRecipe.id, payload: formData },
|
||||
{
|
||||
onSuccess: () => {
|
||||
handleReset()
|
||||
}
|
||||
createProductRecipe.mutate(
|
||||
{ ...formData, product_id: product.id, variant_id: currentProductRecipe.id || '' },
|
||||
{
|
||||
onSuccess: () => {
|
||||
handleReset()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
createProductRecipe.mutate(
|
||||
{ ...formData, product_id: product.id, variant_id: currentProductRecipe.variant?.ID || '' },
|
||||
{
|
||||
onSuccess: () => {
|
||||
handleReset()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
@ -109,8 +98,8 @@ const AddRecipeDrawer = (props: Props) => {
|
||||
const setTitleDrawer = (recipe: any) => {
|
||||
let title = 'Original'
|
||||
|
||||
if (recipe?.variant?.Name) {
|
||||
title = recipe?.variant?.Name
|
||||
if (recipe?.name) {
|
||||
title = recipe?.name
|
||||
}
|
||||
|
||||
return title
|
||||
@ -205,11 +194,7 @@ const AddRecipeDrawer = (props: Props) => {
|
||||
type='submit'
|
||||
disabled={createProductRecipe.isPending || updateProductRecipe.isPending}
|
||||
>
|
||||
{currentProductRecipe?.id
|
||||
? updateProductRecipe.isPending
|
||||
? 'Updating...'
|
||||
: 'Update'
|
||||
: createProductRecipe.isPending
|
||||
{createProductRecipe.isPending
|
||||
? 'Adding...'
|
||||
: 'Add'}
|
||||
</Button>
|
||||
|
||||
@ -25,6 +25,7 @@ import Loading from '../../../../../components/layout/shared/Loading'
|
||||
import { setProductRecipe } from '../../../../../redux-store/slices/productRecipe'
|
||||
import { useProductRecipesByProduct } from '../../../../../services/queries/productRecipes'
|
||||
import { useProductById } from '../../../../../services/queries/products'
|
||||
import { ProductVariant } from '../../../../../types/services/product'
|
||||
import { formatCurrency } from '../../../../../utils/transform'
|
||||
import AddRecipeDrawer from './AddRecipeDrawer'
|
||||
|
||||
@ -37,19 +38,6 @@ const ProductDetail = () => {
|
||||
const { data: product, isLoading, error } = useProductById(params?.id as string)
|
||||
const { data: productRecipe, isLoading: isLoadingProductRecipe } = useProductRecipesByProduct(params?.id as string)
|
||||
|
||||
const groupedByVariant = productRecipe?.reduce((acc: any, item: any) => {
|
||||
const variantId = item.variant_id
|
||||
if (!acc[variantId]) {
|
||||
acc[variantId] = {
|
||||
variant: item.product_variant,
|
||||
product: item.product,
|
||||
ingredients: []
|
||||
}
|
||||
}
|
||||
acc[variantId].ingredients.push(item)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const handleOpenProductRecipe = (recipe: any) => {
|
||||
setOpenProductRecipe(true)
|
||||
dispatch(setProductRecipe(recipe))
|
||||
@ -94,174 +82,37 @@ const ProductDetail = () => {
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{productRecipe && (
|
||||
<div className='space-y-6'>
|
||||
{/* Recipe Details by Variant */}
|
||||
<div className='space-y-4'>
|
||||
<div className='flex items-center gap-2 mb-4'>
|
||||
<i className='tabler-chef-hat text-textPrimary text-xl' />
|
||||
<Typography variant='h5' component='h2' className='font-semibold'>
|
||||
Recipe Details
|
||||
</Typography>
|
||||
</div>
|
||||
{/* {productRecipe && ( */}
|
||||
<div className='space-y-6'>
|
||||
{/* Recipe Details by Variant */}
|
||||
<div className='space-y-4'>
|
||||
<div className='flex items-center gap-2 mb-4'>
|
||||
<i className='tabler-chef-hat text-textPrimary text-xl' />
|
||||
<Typography variant='h5' component='h2' className='font-semibold'>
|
||||
Recipe Details
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
{Object.keys(groupedByVariant).length > 0 ? (
|
||||
Object.entries(groupedByVariant).map(([variantId, variantData]: any) => (
|
||||
<Card key={variantId} className=''>
|
||||
<CardHeader
|
||||
title={
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<i className='tabler-variant text-blue-600 text-lg' />
|
||||
<Typography variant='h6' className='font-semibold'>
|
||||
{variantData?.variant?.Name || 'Original'} Variant
|
||||
</Typography>
|
||||
</div>
|
||||
<div className='flex gap-4 text-sm'>
|
||||
<Chip
|
||||
label={`Cost: ${formatCurrency(variantData?.variant?.Cost || variantData.product?.Cost)}`}
|
||||
variant='outlined'
|
||||
color='primary'
|
||||
/>
|
||||
<Chip
|
||||
label={`Price Modifier: ${formatCurrency(variantData?.variant?.PriceModifier || 0)}`}
|
||||
variant='outlined'
|
||||
color='secondary'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<CardContent>
|
||||
<TableContainer component={Paper} variant='outlined'>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow className='bg-gray-50'>
|
||||
<TableCell className='font-semibold'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<i className='tabler-ingredients text-green-600' />
|
||||
Ingredient
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='font-semibold text-center'>
|
||||
<div className='flex items-center justify-center gap-2'>
|
||||
<i className='tabler-scale text-orange-600' />
|
||||
Quantity
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='font-semibold text-center'>
|
||||
<div className='flex items-center justify-center gap-2'>
|
||||
<i className='tabler-currency-dollar text-purple-600' />
|
||||
Unit Cost
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='font-semibold text-center'>
|
||||
<div className='flex items-center justify-center gap-2'>
|
||||
<i className='tabler-package text-blue-600' />
|
||||
Stock Available
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='font-semibold text-right'>
|
||||
<div className='flex items-center justify-end gap-2'>
|
||||
<i className='tabler-calculator text-red-600' />
|
||||
Total Cost
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{variantData.ingredients.map((item: any) => (
|
||||
<TableRow key={item.id} className='hover:bg-gray-50'>
|
||||
<TableCell>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='w-2 h-2 rounded-full bg-green-500' />
|
||||
<div>
|
||||
<Typography variant='body2' className='font-medium capitalize'>
|
||||
{item.ingredient.name}
|
||||
</Typography>
|
||||
<Typography variant='caption' color='textSecondary'>
|
||||
{item.ingredient.is_semi_finished ? 'Semi-finished' : 'Raw ingredient'}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='text-center'>
|
||||
<Chip label={item.quantity} size='small' variant='outlined' color='primary' />
|
||||
</TableCell>
|
||||
<TableCell className='text-center'>{formatCurrency(item.ingredient.cost)}</TableCell>
|
||||
<TableCell className='text-center'>
|
||||
<Chip
|
||||
label={item.ingredient.stock}
|
||||
size='small'
|
||||
color={item.ingredient.stock > 5 ? 'success' : 'warning'}
|
||||
variant='outlined'
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className='text-right font-medium'>
|
||||
{formatCurrency(item.ingredient.cost * item.quantity)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{/* Variant Summary */}
|
||||
<Box className='mt-4 p-4 bg-blue-50 rounded-lg'>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant='body2' className='flex items-center gap-2'>
|
||||
<i className='tabler-list-numbers text-blue-600' />
|
||||
<span className='font-semibold'>Total Ingredients:</span>
|
||||
{variantData.ingredients.length}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant='body2' className='flex items-center gap-2'>
|
||||
<i className='tabler-sum text-green-600' />
|
||||
<span className='font-semibold'>Total Recipe Cost:</span>
|
||||
{formatCurrency(
|
||||
variantData.ingredients.reduce(
|
||||
(sum: any, item: any) => sum + item.ingredient.cost * item.quantity,
|
||||
0
|
||||
)
|
||||
)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
variant='outlined'
|
||||
fullWidth
|
||||
className='mt-4'
|
||||
startIcon={<i className='tabler-plus' />}
|
||||
onClick={() => handleOpenProductRecipe(variantData)}
|
||||
>
|
||||
Add Ingredient
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
<Card className=''>
|
||||
{product?.variants?.length &&
|
||||
product.variants.map((variantData: ProductVariant, index: number) => (
|
||||
<Card key={index}>
|
||||
<CardHeader
|
||||
title={
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<i className='tabler-variant text-blue-600 text-lg' />
|
||||
<Typography variant='h6' className='font-semibold'>
|
||||
Original Variant
|
||||
{variantData?.name || 'Original'} Variant
|
||||
</Typography>
|
||||
</div>
|
||||
<div className='flex gap-4 text-sm'>
|
||||
<Chip
|
||||
label={`Cost: ${formatCurrency(product?.cost || 0)}`}
|
||||
label={`Cost: ${formatCurrency(variantData?.cost || 0)}`}
|
||||
variant='outlined'
|
||||
color='primary'
|
||||
/>
|
||||
<Chip
|
||||
label={`Price Modifier: ${formatCurrency(product?.price || 0)}`}
|
||||
label={`Price Modifier: ${formatCurrency(variantData?.price_modifier || 0)}`}
|
||||
variant='outlined'
|
||||
color='secondary'
|
||||
/>
|
||||
@ -306,25 +157,86 @@ const ProductDetail = () => {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody></TableBody>
|
||||
<TableBody>
|
||||
{productRecipe?.length &&
|
||||
productRecipe
|
||||
.filter((item: any) => item.variant_id === variantData.id)
|
||||
.map((item: any, index: number) => (
|
||||
<TableRow key={index} className='hover:bg-gray-50'>
|
||||
<TableCell>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='w-2 h-2 rounded-full bg-green-500' />
|
||||
<div>
|
||||
<Typography variant='body2' className='font-medium capitalize'>
|
||||
{item.ingredient.name}
|
||||
</Typography>
|
||||
<Typography variant='caption' color='textSecondary'>
|
||||
{item.ingredient.is_semi_finished ? 'Semi-finished' : 'Raw ingredient'}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='text-center'>
|
||||
<Chip label={item.quantity} size='small' variant='outlined' color='primary' />
|
||||
</TableCell>
|
||||
<TableCell className='text-center'>{formatCurrency(item.ingredient.cost)}</TableCell>
|
||||
<TableCell className='text-center'>
|
||||
<Chip
|
||||
label={item.ingredient.stock}
|
||||
size='small'
|
||||
color={item.ingredient.stock > 5 ? 'success' : 'warning'}
|
||||
variant='outlined'
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className='text-right font-medium'>
|
||||
{formatCurrency(item.ingredient.cost * item.quantity)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{/* Variant Summary */}
|
||||
{productRecipe?.length && (
|
||||
<Box className='mt-4 p-4 bg-blue-50 rounded-lg'>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant='body2' className='flex items-center gap-2'>
|
||||
<i className='tabler-list-numbers text-blue-600' />
|
||||
<span className='font-semibold'>Total Ingredients:</span>
|
||||
{productRecipe.filter((item: any) => item.variant_id === variantData.id).length}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant='body2' className='flex items-center gap-2'>
|
||||
<i className='tabler-sum text-green-600' />
|
||||
<span className='font-semibold'>Total Recipe Cost:</span>
|
||||
{formatCurrency(
|
||||
productRecipe
|
||||
.filter((item: any) => item.variant_id === variantData.id)
|
||||
.reduce((sum: any, item: any) => sum + item.ingredient.cost * item.quantity, 0)
|
||||
)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant='outlined'
|
||||
fullWidth
|
||||
className='mt-4'
|
||||
startIcon={<i className='tabler-plus' />}
|
||||
onClick={() => handleOpenProductRecipe({ variant: undefined })}
|
||||
onClick={() => handleOpenProductRecipe(variantData)}
|
||||
>
|
||||
Add Ingredient
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AddRecipeDrawer open={openProductRecipe} handleClose={() => setOpenProductRecipe(false)} product={product!} />
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
// React Imports
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
// MUI Imports
|
||||
import Button from '@mui/material/Button'
|
||||
@ -10,15 +10,19 @@ import MenuItem from '@mui/material/MenuItem'
|
||||
import Typography from '@mui/material/Typography'
|
||||
|
||||
// Third-party Imports
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
|
||||
// Types Imports
|
||||
import type { UsersType } from '@/types/apps/userTypes'
|
||||
|
||||
// Component Imports
|
||||
import CustomTextField from '@core/components/mui/TextField'
|
||||
import { Autocomplete, Checkbox, FormControl, FormControlLabel, FormGroup, FormLabel, Switch } from '@mui/material'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { useDebounce } from 'use-debounce'
|
||||
import { RootState } from '../../../../redux-store'
|
||||
import { resetUser } from '../../../../redux-store/slices/user'
|
||||
import { useUsersMutation } from '../../../../services/mutations/users'
|
||||
import { useOutlets } from '../../../../services/queries/outlets'
|
||||
import { UserRequest } from '../../../../types/services/user'
|
||||
import { Switch } from '@mui/material'
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
@ -31,26 +35,73 @@ const initialData = {
|
||||
email: '',
|
||||
password: '',
|
||||
role: '',
|
||||
permissions: {},
|
||||
permissions: {
|
||||
can_create_orders: false,
|
||||
can_void_orders: false
|
||||
},
|
||||
is_active: true,
|
||||
organization_id: '',
|
||||
outlet_id: '',
|
||||
outlet_id: ''
|
||||
}
|
||||
|
||||
const AddUserDrawer = (props: Props) => {
|
||||
const dispatch = useDispatch()
|
||||
|
||||
// Props
|
||||
const { open, handleClose } = props
|
||||
|
||||
// States
|
||||
const [formData, setFormData] = useState<UserRequest>(initialData)
|
||||
const [outletInput, setOutletInput] = useState('')
|
||||
const [outletDebouncedInput] = useDebounce(outletInput, 500)
|
||||
|
||||
const onSubmit = () => {
|
||||
handleClose()
|
||||
setFormData(initialData)
|
||||
const { currentUser } = useSelector((state: RootState) => state.userReducer)
|
||||
|
||||
const { createUser, updateUser } = useUsersMutation()
|
||||
|
||||
const { data: outlets, isLoading: outletsLoading } = useOutlets({
|
||||
search: outletDebouncedInput
|
||||
})
|
||||
|
||||
const outletOptions = useMemo(() => outlets?.outlets || [], [outlets])
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser.id) {
|
||||
setFormData({
|
||||
name: currentUser.name,
|
||||
email: currentUser.email,
|
||||
role: currentUser.role,
|
||||
password: '',
|
||||
is_active: currentUser.is_active,
|
||||
outlet_id: currentUser.outlet_id,
|
||||
permissions: currentUser.permissions
|
||||
})
|
||||
}
|
||||
}, [currentUser])
|
||||
|
||||
const handleSubmit = (e: any) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (currentUser.id) {
|
||||
updateUser.mutate(
|
||||
{ id: currentUser.id, payload: formData },
|
||||
{
|
||||
onSuccess: () => {
|
||||
handleReset()
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
createUser.mutate(formData, {
|
||||
onSuccess: () => {
|
||||
handleReset()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
handleClose()
|
||||
dispatch(resetUser())
|
||||
setFormData(initialData)
|
||||
}
|
||||
|
||||
@ -61,6 +112,17 @@ const AddUserDrawer = (props: Props) => {
|
||||
})
|
||||
}
|
||||
|
||||
const handleCheckBoxChange = (e: any) => {
|
||||
const { name, checked } = e.target
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
permissions: {
|
||||
...prev.permissions,
|
||||
[name]: checked
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
@ -71,14 +133,14 @@ const AddUserDrawer = (props: Props) => {
|
||||
sx={{ '& .MuiDrawer-paper': { width: { xs: 300, sm: 400 } } }}
|
||||
>
|
||||
<div className='flex items-center justify-between plb-5 pli-6'>
|
||||
<Typography variant='h5'>Add New User</Typography>
|
||||
<Typography variant='h5'>{currentUser.id ? 'Edit' : 'Add'} User</Typography>
|
||||
<IconButton size='small' onClick={handleReset}>
|
||||
<i className='tabler-x text-2xl text-textPrimary' />
|
||||
</IconButton>
|
||||
</div>
|
||||
<Divider />
|
||||
<div>
|
||||
<form onSubmit={onSubmit} className='flex flex-col gap-6 p-6'>
|
||||
<form onSubmit={handleSubmit} className='flex flex-col gap-6 p-6'>
|
||||
<CustomTextField
|
||||
fullWidth
|
||||
label='Name'
|
||||
@ -96,15 +158,81 @@ const AddUserDrawer = (props: Props) => {
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
{currentUser.id ? null : (
|
||||
<CustomTextField
|
||||
fullWidth
|
||||
type='password'
|
||||
label='Password'
|
||||
placeholder='********'
|
||||
name='password'
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
)}
|
||||
<CustomTextField
|
||||
select
|
||||
fullWidth
|
||||
type='password'
|
||||
label='Password'
|
||||
placeholder='********'
|
||||
name='password'
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
label='Role'
|
||||
placeholder='Select Role'
|
||||
value={formData.role}
|
||||
onChange={e => setFormData({ ...formData, role: e.target.value })}
|
||||
>
|
||||
<MenuItem value={`manager`}>Manager</MenuItem>
|
||||
<MenuItem value={`cashier`}>Cashier</MenuItem>
|
||||
<MenuItem value={`waiter`}>Waiter</MenuItem>
|
||||
</CustomTextField>
|
||||
<Autocomplete
|
||||
options={outletOptions}
|
||||
loading={outletsLoading}
|
||||
getOptionLabel={option => option.name}
|
||||
value={outletOptions.find((p: any) => 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: <>{params.InputProps.endAdornment}</>
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FormControl component='fieldset' variant='outlined'>
|
||||
<FormLabel component='legend'>Assign permissions</FormLabel>
|
||||
<FormGroup>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={formData.permissions.can_create_orders}
|
||||
onChange={handleCheckBoxChange}
|
||||
name='can_create_orders'
|
||||
/>
|
||||
}
|
||||
label='Can create orders'
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={formData.permissions.can_void_orders}
|
||||
onChange={handleCheckBoxChange}
|
||||
name='can_void_orders'
|
||||
/>
|
||||
}
|
||||
label='Can void orders'
|
||||
/>
|
||||
</FormGroup>
|
||||
</FormControl>
|
||||
<div className='flex items-center'>
|
||||
<div className='flex flex-col items-start gap-1'>
|
||||
<Typography color='text.primary' className='font-medium'>
|
||||
@ -119,7 +247,7 @@ const AddUserDrawer = (props: Props) => {
|
||||
</div>
|
||||
<div className='flex items-center gap-4'>
|
||||
<Button variant='contained' type='submit'>
|
||||
Submit
|
||||
{createUser.isPending || updateUser.isPending ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
<Button variant='tonal' color='error' type='reset' onClick={() => handleReset()}>
|
||||
Cancel
|
||||
|
||||
@ -47,6 +47,10 @@ import TablePaginationComponent from '../../../../components/TablePaginationComp
|
||||
import { useUsers } from '../../../../services/queries/users'
|
||||
import { User } from '../../../../types/services/user'
|
||||
import AddUserDrawer from './AddUserDrawer'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import { setUser } from '../../../../redux-store/slices/user'
|
||||
import { useUsersMutation } from '../../../../services/mutations/users'
|
||||
import ConfirmDeleteDialog from '../../../../components/dialogs/confirm-delete'
|
||||
|
||||
declare module '@tanstack/table-core' {
|
||||
interface FilterFns {
|
||||
@ -123,13 +127,15 @@ const userRoleObj: UserRoleType = {
|
||||
const columnHelper = createColumnHelper<UsersTypeWithAction>()
|
||||
|
||||
const UserListTable = () => {
|
||||
const dispatch = useDispatch()
|
||||
|
||||
// States
|
||||
const [addUserOpen, setAddUserOpen] = useState(false)
|
||||
const [rowSelection, setRowSelection] = useState({})
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [openConfirm, setOpenConfirm] = useState(false)
|
||||
const [customerId, setCustomerId] = useState('')
|
||||
const [userId, setUserId] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
// Hooks
|
||||
@ -141,7 +147,7 @@ const UserListTable = () => {
|
||||
search
|
||||
})
|
||||
|
||||
// const { deleteCustomer } = useCustomersMutation()
|
||||
const { deleteUser } = useUsersMutation()
|
||||
|
||||
const users = data?.users ?? []
|
||||
const totalCount = data?.pagination.total_count ?? 0
|
||||
@ -157,11 +163,11 @@ const UserListTable = () => {
|
||||
setCurrentPage(1) // Reset to first page
|
||||
}, [])
|
||||
|
||||
// const handleDelete = () => {
|
||||
// deleteCustomer.mutate(customerId, {
|
||||
// onSuccess: () => setOpenConfirm(false)
|
||||
// })
|
||||
// }
|
||||
const handleDelete = () => {
|
||||
deleteUser.mutate(userId, {
|
||||
onSuccess: () => setOpenConfirm(false)
|
||||
})
|
||||
}
|
||||
|
||||
const columns = useMemo<ColumnDef<UsersTypeWithAction, any>[]>(
|
||||
() => [
|
||||
@ -236,22 +242,27 @@ const UserListTable = () => {
|
||||
header: 'Action',
|
||||
cell: ({ row }) => (
|
||||
<div className='flex items-center'>
|
||||
<IconButton onClick={() => {}}>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
setUserId(row.original.id)
|
||||
setOpenConfirm(true)
|
||||
}}
|
||||
>
|
||||
<i className='tabler-trash text-textSecondary' />
|
||||
</IconButton>
|
||||
<OptionMenu
|
||||
iconButtonProps={{ size: 'medium' }}
|
||||
iconClassName='text-textSecondary'
|
||||
options={[
|
||||
{
|
||||
text: 'Download',
|
||||
icon: 'tabler-download',
|
||||
menuItemProps: { className: 'flex items-center gap-2 text-textSecondary' }
|
||||
},
|
||||
{
|
||||
text: 'Edit',
|
||||
icon: 'tabler-edit',
|
||||
menuItemProps: { className: 'flex items-center gap-2 text-textSecondary' }
|
||||
menuItemProps: {
|
||||
onClick: () => {
|
||||
dispatch(setUser(row.original))
|
||||
setAddUserOpen(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
]}
|
||||
/>
|
||||
@ -298,7 +309,7 @@ const UserListTable = () => {
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader title='Filters' className='pbe-4' />
|
||||
{/* <CardHeader title='Filters' className='pbe-4' /> */}
|
||||
{/* <TableFilters setData={setFilteredData} tableData={data} /> */}
|
||||
<div className='flex justify-between flex-col items-start md:flex-row md:items-center p-6 border-bs gap-4'>
|
||||
<DebouncedInput
|
||||
@ -432,9 +443,15 @@ const UserListTable = () => {
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<AddUserDrawer
|
||||
open={addUserOpen}
|
||||
handleClose={() => setAddUserOpen(!addUserOpen)}
|
||||
<AddUserDrawer open={addUserOpen} handleClose={() => setAddUserOpen(!addUserOpen)} />
|
||||
|
||||
<ConfirmDeleteDialog
|
||||
open={openConfirm}
|
||||
onClose={() => setOpenConfirm(false)}
|
||||
onConfirm={handleDelete}
|
||||
isLoading={deleteUser.isPending}
|
||||
title='Delete User'
|
||||
message='Are you sure you want to delete this User? This action cannot be undone.'
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
import Grid from '@mui/material/Grid2'
|
||||
|
||||
// Type Imports
|
||||
import type { UsersType } from '@/types/apps/userTypes'
|
||||
|
||||
// Component Imports
|
||||
import UserListTable from './UserListTable'
|
||||
@ -10,9 +9,6 @@ import UserListTable from './UserListTable'
|
||||
const UserList = () => {
|
||||
return (
|
||||
<Grid container spacing={6}>
|
||||
{/* <Grid size={{ xs: 12 }}>
|
||||
<UserListCards />
|
||||
</Grid> */}
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<UserListTable />
|
||||
</Grid>
|
||||
|
||||
@ -16,6 +16,7 @@ import { formatShortCurrency } from '../../../utils/transform'
|
||||
type Props = {
|
||||
title: string
|
||||
value: number
|
||||
isCurrency: boolean
|
||||
isLoading: boolean
|
||||
avatarIcon: string
|
||||
avatarSkin?: CustomAvatarProps['skin']
|
||||
@ -26,12 +27,12 @@ type Props = {
|
||||
const DistributedBarChartOrder = ({
|
||||
title,
|
||||
value,
|
||||
isCurrency = false,
|
||||
isLoading,
|
||||
avatarIcon,
|
||||
avatarSkin,
|
||||
avatarColor
|
||||
}: Props) => {
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton sx={{ bgcolor: 'grey.100' }} variant='rectangular' width={300} height={118} />
|
||||
}
|
||||
@ -45,7 +46,7 @@ const DistributedBarChartOrder = ({
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography color='text.primary' variant='h4'>
|
||||
{formatShortCurrency(value)}
|
||||
{isCurrency ? 'Rp ' + formatShortCurrency(value) : formatShortCurrency(value)}
|
||||
</Typography>
|
||||
</div>
|
||||
<CustomAvatar variant='rounded' skin={avatarSkin} size={52} color={avatarColor}>
|
||||
|
||||
@ -33,7 +33,7 @@ const OrdersReport = ({ orderData, title }: { orderData: RecentSale[]; title: st
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className='bg-white divide-y divide-gray-200'>
|
||||
{orderData.map((sale, index) => (
|
||||
{orderData && orderData.map((sale, index) => (
|
||||
<tr key={index} className='hover:bg-gray-50'>
|
||||
<td className='px-4 py-4 whitespace-nowrap text-sm font-medium text-gray-900'>
|
||||
{formatDate(sale.date)}
|
||||
|
||||
@ -19,7 +19,7 @@ const PaymentMethodReport = ({ payments }: { payments: PaymentDataItem[] }) => {
|
||||
<h2 className='text-xl font-semibold text-gray-900'>Payment Methods</h2>
|
||||
</div>
|
||||
<div className='space-y-6'>
|
||||
{payments.map(method => (
|
||||
{payments && payments.map(method => (
|
||||
<div key={method.payment_method_id} className='border-b border-gray-200 pb-4 last:border-b-0'>
|
||||
<div className='flex justify-between items-center mb-2'>
|
||||
<span className='text-sm font-medium text-gray-900'>{method.payment_method_name}</span>
|
||||
|
||||
@ -34,7 +34,7 @@ const ProductSales = ({ productData, title }: { productData: ProductData[], titl
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className='bg-white divide-y divide-gray-200'>
|
||||
{productData.map((product, index) => (
|
||||
{productData && productData.map((product, index) => (
|
||||
<tr key={product.product_id} className='hover:bg-gray-50'>
|
||||
<td className='px-4 py-4 whitespace-nowrap'>
|
||||
<div className='flex items-center'>
|
||||
|
||||
377
src/views/sa/organizations/list/OrganizationListTable.tsx
Normal file
377
src/views/sa/organizations/list/OrganizationListTable.tsx
Normal file
@ -0,0 +1,377 @@
|
||||
'use client'
|
||||
|
||||
// React Imports
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
// Next Imports
|
||||
|
||||
// MUI Imports
|
||||
import Button from '@mui/material/Button'
|
||||
import Card from '@mui/material/Card'
|
||||
import CardContent from '@mui/material/CardContent'
|
||||
import Checkbox from '@mui/material/Checkbox'
|
||||
import MenuItem from '@mui/material/MenuItem'
|
||||
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'
|
||||
|
||||
// Type Imports
|
||||
|
||||
// Component Imports
|
||||
import CustomTextField from '@core/components/mui/TextField'
|
||||
|
||||
// Util Imports
|
||||
|
||||
// Style Imports
|
||||
import tableStyles from '@core/styles/table.module.css'
|
||||
import { Box, Chip, CircularProgress, IconButton, TablePagination } from '@mui/material'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import OptionMenu from '../../../../@core/components/option-menu'
|
||||
import ConfirmDeleteDialog from '../../../../components/dialogs/confirm-delete'
|
||||
import Loading from '../../../../components/layout/shared/Loading'
|
||||
import TablePaginationComponent from '../../../../components/TablePaginationComponent'
|
||||
import { setOrganization } from '../../../../redux-store/slices/organization'
|
||||
import { useOrganizationsMutation } from '../../../../services/mutations/organization'
|
||||
import { useOrganizations } from '../../../../services/queries/organizations'
|
||||
import { Organization } from '../../../../types/services/organization'
|
||||
|
||||
declare module '@tanstack/table-core' {
|
||||
interface FilterFns {
|
||||
fuzzy: FilterFn<unknown>
|
||||
}
|
||||
interface FilterMeta {
|
||||
itemRank: RankingInfo
|
||||
}
|
||||
}
|
||||
|
||||
type SaOrganizationsTypeWithAction = Organization & {
|
||||
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<SaOrganizationsTypeWithAction>()
|
||||
|
||||
const OrganizationListTable = () => {
|
||||
const dispatch = useDispatch()
|
||||
|
||||
// States
|
||||
const [rowSelection, setRowSelection] = useState({})
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [openConfirm, setOpenConfirm] = useState(false)
|
||||
const [organizationId, setOrganizationId] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
const { data, isLoading, error, isFetching } = useOrganizations({
|
||||
page: currentPage,
|
||||
limit: pageSize,
|
||||
search
|
||||
})
|
||||
|
||||
const { deleteOrganization } = useOrganizationsMutation()
|
||||
|
||||
const organizations = data?.organizations ?? []
|
||||
const totalCount = data?.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(1) // Reset to first page
|
||||
}, [])
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteOrganization.mutate(organizationId, {
|
||||
onSuccess: () => setOpenConfirm(false)
|
||||
})
|
||||
}
|
||||
|
||||
const columns = useMemo<ColumnDef<SaOrganizationsTypeWithAction, 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 }) => <Typography color='text.primary'>{row.original.name || '-'}</Typography>
|
||||
}),
|
||||
columnHelper.accessor('email', {
|
||||
header: 'Email',
|
||||
cell: ({ row }) => <Typography color='text.primary'>{row.original.email || '-'}</Typography>
|
||||
}),
|
||||
columnHelper.accessor('phone_number', {
|
||||
header: 'Phone',
|
||||
cell: ({ row }) => <Typography>{row.original.phone_number || '-'}</Typography>
|
||||
}),
|
||||
columnHelper.accessor('plan_type', {
|
||||
header: 'Plan Type',
|
||||
cell: ({ row }) => (
|
||||
<Chip label={row.original.plan_type} variant='tonal' color={row.original.plan_type === 'enterprise' ? 'primary' : 'info'} />
|
||||
)
|
||||
}),
|
||||
columnHelper.accessor('actions', {
|
||||
header: 'Actions',
|
||||
cell: ({ row }) => (
|
||||
<div className='flex items-center'>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
dispatch(setOrganization(row.original))
|
||||
}}
|
||||
>
|
||||
<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: () => {
|
||||
setOpenConfirm(true)
|
||||
setOrganizationId(row.original.id)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ text: 'Duplicate', icon: 'tabler-copy' }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false
|
||||
})
|
||||
],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: organizations as Organization[],
|
||||
columns,
|
||||
filterFns: {
|
||||
fuzzy: fuzzyFilter
|
||||
},
|
||||
state: {
|
||||
rowSelection,
|
||||
pagination: {
|
||||
pageIndex: currentPage,
|
||||
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>
|
||||
<CardContent className='flex justify-between flex-wrap max-sm:flex-col sm:items-center gap-4'>
|
||||
<DebouncedInput
|
||||
value={search}
|
||||
onChange={value => setSearch(value as string)}
|
||||
placeholder='Search'
|
||||
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={pageSize} onChange={handlePageSizeChange} className='is-full sm:is-[70px]'>
|
||||
<MenuItem value='10'>10</MenuItem>
|
||||
<MenuItem value='25'>25</MenuItem>
|
||||
<MenuItem value='50'>50</MenuItem>
|
||||
<MenuItem value='100'>100</MenuItem>
|
||||
</CustomTextField>
|
||||
<Button
|
||||
variant='tonal'
|
||||
className='max-sm:is-full'
|
||||
color='secondary'
|
||||
startIcon={<i className='tabler-upload' />}
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
<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>
|
||||
|
||||
<ConfirmDeleteDialog
|
||||
open={openConfirm}
|
||||
onClose={() => setOpenConfirm(false)}
|
||||
onConfirm={handleDelete}
|
||||
isLoading={deleteOrganization.isPending}
|
||||
title='Delete Organization'
|
||||
message='Are you sure you want to delete this Organization? This action cannot be undone.'
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default OrganizationListTable
|
||||
Loading…
x
Reference in New Issue
Block a user