diff --git a/src/app/[lang]/(blank-layout-pages)/(guest-only)/organization/page.tsx b/src/app/[lang]/(blank-layout-pages)/(guest-only)/organization/page.tsx new file mode 100644 index 0000000..8821739 --- /dev/null +++ b/src/app/[lang]/(blank-layout-pages)/(guest-only)/organization/page.tsx @@ -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: ( +
+ ({ + backgroundColor: 'var(--mui-palette-primary-light)', + color: 'var(--mui-palette-primary-main)' + })} + > + + +
+ + Basic Plan + + + Perfect for small businesses + +
+
+ ), + value: 'basic', + isSelected: true + }, + { + title: ( +
+ ({ + backgroundColor: 'var(--mui-palette-success-light)', + color: 'var(--mui-palette-success-main)' + })} + > + + +
+ + Premium Plan + + + Most popular choice + +
+
+ ), + value: 'premium' + }, + { + title: ( +
+ ({ + backgroundColor: 'var(--mui-palette-warning-light)', + color: 'var(--mui-palette-warning-main)' + })} + > + + +
+ + Enterprise Plan + + + Advanced features for large teams + +
+
+ ), + 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({ + 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>>({}) + const [submitError, setSubmitError] = useState(null) + + // Hooks + const { updatePageSettings } = useSettings() + const { createOrganization } = useOrganizationsMutation() + + const handleInputChange = (field: keyof OrganizationRequest) => (event: ChangeEvent) => { + 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) => { + 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> = {} + + // 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 ( +
+ + + + +
+ + + Create Organization + + + Set up your organization with admin account and primary outlet. Choose the plan that best fits your + needs. + +
+ + {submitError && ( + + {submitError} + + )} + + {/* Plan Selection */} +
+ + + Choose Your Plan + + + {planData.map((item, index) => ( + + ))} + +
+ + {/* Organization Details */} +
+ + + Organization Details + + + + + + + + + + + + +
+ + {/* Admin Account */} +
+ + + Admin Account + + + + + + + + + + + + +
+ + {/* Outlet Information */} +
+ + + Primary Outlet + + + + + + + + {currencies.map(currency => ( + + {currency.code} - {currency.name} + + ))} + + + + + + + + {timezones.map(tz => ( + + {tz} + + ))} + + + +
+
+
+ + + +
+ + + Plan Summary + + Review your selected plan and get started with your organization. +
+ +
+
+
+ {formData.plan_type} Plan + +
+
+ ${selectedPlan.price} + /month +
+
+ {selectedPlan.features.map((feature, index) => ( +
+ + {feature} +
+ ))} +
+
+ +
+
+ Plan Cost + + ${selectedPlan.price} + +
+
+ Setup Fee + + Free + +
+ +
+ Total + + ${selectedPlan.price} + +
+
+ + +
+ + + By creating an organization, you agree to our Terms of Service and Privacy Policy. You can change your + plan anytime. + +
+
+
+
+
+ ) +} + +export default CreateOrganization diff --git a/src/app/[lang]/(dashboard)/(private)/dashboards/orders/page.tsx b/src/app/[lang]/(dashboard)/(private)/dashboards/orders/page.tsx index 54936b3..1ef2213 100644 --- a/src/app/[lang]/(dashboard)/(private)/dashboards/orders/page.tsx +++ b/src/app/[lang]/(dashboard)/(private)/dashboards/orders/page.tsx @@ -23,6 +23,7 @@ const DashboardOrder = () => { { { { {

{value}

{subtitle &&

{subtitle}

} -
+
@@ -55,7 +55,7 @@ const DashboardOverview = () => { { { /> - {/* Additional Metrics */} -
-
-
- -

Voided Orders

-
-

{salesData.overview.voided_orders}

-
-
-
- -

Refunded Orders

-
-

{salesData.overview.refunded_orders}

-
-
-
{/* Top Products */} diff --git a/src/app/[lang]/(dashboard)/(private)/dashboards/payment-methods/page.tsx b/src/app/[lang]/(dashboard)/(private)/dashboards/payment-methods/page.tsx index bd5a706..568ea25 100644 --- a/src/app/[lang]/(dashboard)/(private)/dashboards/payment-methods/page.tsx +++ b/src/app/[lang]/(dashboard)/(private)/dashboards/payment-methods/page.tsx @@ -23,6 +23,7 @@ const DashboardPayment = () => { { { { { + // Sample data - replace with your actual data + const { data: profitData, isLoading } = useProfitLossAnalytics() + + const formatCurrency = (amount: any) => { + return new Intl.NumberFormat('id-ID', { + style: 'currency', + currency: 'IDR', + minimumFractionDigits: 0 + }).format(amount) } - return nameMap[metric] || metric.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()) -} - -const DashboardProfitLoss = () => { - const { data, isLoading } = useProfitLossAnalytics() + const formatPercentage = (value: any) => { + return `${value.toFixed(2)}%` + } const formatDate = (dateString: any) => { return new Date(dateString).toLocaleDateString('id-ID', { + day: 'numeric', month: 'short', - day: 'numeric' + year: 'numeric' }) } - const metrics = ['cost', 'revenue', 'gross_profit', 'net_profit'] - - const transformSalesData = (data: ProfitLossReport) => { - return [ - { - type: 'products', - avatarIcon: 'tabler-package', - date: data.product_data.map((d: ProductDataReport) => d.product_name), - series: [{ data: data.product_data.map((d: ProductDataReport) => d.revenue) }] - } - // { - // type: 'profits', - // avatarIcon: 'tabler-currency-dollar', - // date: data.data.map((d: DailyData) => formatDate(d.date)), - // series: metrics.map(metric => ({ - // name: formatMetricName(metric as string), - // data: data.data.map((item: any) => item[metric] as number) - // })) - // } - ] + const getProfitabilityColor = (margin: any) => { + if (margin > 50) return 'text-green-600 bg-green-100' + if (margin > 0) return 'text-yellow-600 bg-yellow-100' + return 'text-red-600 bg-red-100' } + function formatMetricName(metric: string): string { + const nameMap: { [key: string]: string } = { + revenue: 'Revenue', + net_profit: 'Net Profit', + } + + return nameMap[metric] || metric.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()) + } + + const metrics = ['revenue', 'net_profit'] + const transformMultipleData = (data: ProfitLossReport) => { return [ { @@ -75,58 +61,285 @@ const DashboardProfitLoss = () => { ] } - if (isLoading) return + const MetricCard = ({ iconClass, title, value, subtitle, bgColor = 'bg-blue-500', isNegative = false }: any) => ( +
+
+
+

{title}

+

{value}

+ {subtitle &&

{subtitle}

} +
+
+ +
+
+
+ ) return ( - - - - - - - - - - - - - - - - - - - - + <> + {profitData && ( +
+ {/* Header */} +
+

Profit Analysis Dashboard

+

+ {formatDate(profitData.date_from)} - {formatDate(profitData.date_to)} +

+
+ + {/* Summary Metrics */} +
+ + + + +
+ + {/* Additional Summary Metrics */} +
+
+
+ +

Net Profit

+
+

+ {formatShortCurrency(profitData.summary.net_profit)} +

+

Margin: {formatPercentage(profitData.summary.net_profit_margin)}

+
+
+
+ +

Total Orders

+
+

{profitData.summary.total_orders}

+
+
+
+ +

Tax & Discount

+
+

+ {formatShortCurrency(profitData.summary.total_tax + profitData.summary.total_discount)} +

+

+ Tax: {formatShortCurrency(profitData.summary.total_tax)} | Discount:{' '} + {formatShortCurrency(profitData.summary.total_discount)} +

+
+
+ + {/* Profit Chart */} +
+ +
+ +
+ {/* Daily Breakdown */} +
+
+
+ +

Daily Breakdown

+
+
+ + + + + + + + + + + + + {profitData.data.map((day, index) => ( + + + + + + + + + ))} + +
DateRevenueCostProfitMarginOrders
+ {formatDate(day.date)} + + {formatCurrency(day.revenue)} + + {formatCurrency(day.cost)} + = 0 ? 'text-green-600' : 'text-red-600' + }`} + > + {formatCurrency(day.gross_profit)} + + + {formatPercentage(day.gross_profit_margin)} + + {day.orders}
+
+
+
+ + {/* Top Performing Products */} +
+
+
+ +

Top Performers

+
+
+ {profitData.product_data + .sort((a, b) => b.gross_profit - a.gross_profit) + .slice(0, 5) + .map((product, index) => ( +
+
+ + {index + 1} + +
+

{product.product_name}

+

{product.category_name}

+
+
+
+

= 0 ? 'text-green-600' : 'text-red-600'}`}> + {formatCurrency(product.gross_profit)} +

+

{formatPercentage(product.gross_profit_margin)}

+
+
+ ))} +
+
+
+
+ + {/* Product Analysis Table */} +
+
+
+ +

Product Analysis

+
+
+ + + + + + + + + + + + + + + {profitData.product_data + .sort((a, b) => b.gross_profit - a.gross_profit) + .map(product => ( + + + + + + + + + + + ))} + +
ProductCategoryQtyRevenueCostProfitMarginPer Unit
+
{product.product_name}
+
+ + {product.category_name} + + + {product.quantity_sold} + + {formatCurrency(product.revenue)} + + {formatCurrency(product.cost)} + = 0 ? 'text-green-600' : 'text-red-600' + }`} + > + {formatCurrency(product.gross_profit)} + + + {formatPercentage(product.gross_profit_margin)} + + = 0 ? 'text-green-600' : 'text-red-600' + }`} + > + {formatCurrency(product.profit_per_unit)} +
+
+
+
+
+ )} + ) } -export default DashboardProfitLoss +export default DashboardProfitloss diff --git a/src/app/[lang]/(dashboard)/(private)/layout.tsx b/src/app/[lang]/(dashboard)/(private)/layout.tsx index 22bbca4..4e21f84 100644 --- a/src/app/[lang]/(dashboard)/(private)/layout.tsx +++ b/src/app/[lang]/(dashboard)/(private)/layout.tsx @@ -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' diff --git a/src/app/[lang]/(sa)/(private)/layout.tsx b/src/app/[lang]/(sa)/(private)/layout.tsx new file mode 100644 index 0000000..7cf7719 --- /dev/null +++ b/src/app/[lang]/(sa)/(private)/layout.tsx @@ -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 ( + + + } + navbar={} + footer={} + > + {children} + + } + horizontalLayout={ + } footer={}> + {children} + + } + /> + + + + {/* */} + + + ) +} + +export default Layout diff --git a/src/app/[lang]/(sa)/(private)/sa/organizations/list/page.tsx b/src/app/[lang]/(sa)/(private)/sa/organizations/list/page.tsx new file mode 100644 index 0000000..c40ab0f --- /dev/null +++ b/src/app/[lang]/(sa)/(private)/sa/organizations/list/page.tsx @@ -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 +} + +export default OrganizationsListTablePage diff --git a/src/components/layout/shared/UserDropdown.tsx b/src/components/layout/shared/UserDropdown.tsx index 95e6708..97e880c 100644 --- a/src/components/layout/shared/UserDropdown.tsx +++ b/src/components/layout/shared/UserDropdown.tsx @@ -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={} onClick={handleUserLogout} sx={{ '& .MuiButton-endIcon': { marginInlineStart: 1.5 } }} + disabled={logout.isPending} > - Logout + Logout {logout.isPending && }
diff --git a/src/components/layout/vertical/Navigation.tsx b/src/components/layout/vertical/Navigation.tsx index 650288a..3b5d3a0 100644 --- a/src/components/layout/vertical/Navigation.tsx +++ b/src/components/layout/vertical/Navigation.tsx @@ -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> @@ -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) => { )} - + + {currentUser?.role === 'superadmin' ? ( + + ) : ( + + )} ) } diff --git a/src/components/layout/vertical/SuperAdminVerticalMenu.tsx b/src/components/layout/vertical/SuperAdminVerticalMenu.tsx new file mode 100644 index 0000000..12a07f3 --- /dev/null +++ b/src/components/layout/vertical/SuperAdminVerticalMenu.tsx @@ -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> + scrollMenu: (container: any, isPerfectScrollbar: boolean) => void +} + +const RenderExpandIcon = ({ open, transitionDuration }: RenderExpandIconProps) => ( + + + +) + +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 */ + 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 */} + } + renderExpandedMenuItemIcon={{ icon: }} + menuSectionStyles={menuSectionStyles(verticalNavOptions, theme)} + > + }> + {dictionary['navigation'].list} + + + + ) +} + +export default SuperAdminVerticalMenu diff --git a/src/components/layout/vertical/VerticalMenu.tsx b/src/components/layout/vertical/VerticalMenu.tsx index 8d72d1d..82a4143 100644 --- a/src/components/layout/vertical/VerticalMenu.tsx +++ b/src/components/layout/vertical/VerticalMenu.tsx @@ -92,7 +92,7 @@ const VerticalMenu = ({ dictionary, scrollMenu }: Props) => { }> - {dictionary['navigation'].dashboard} + {/* {dictionary['navigation'].dashboard} */} {dictionary['navigation'].list} @@ -125,7 +125,7 @@ const VerticalMenu = ({ dictionary, scrollMenu }: Props) => { {dictionary['navigation'].adjustment} - {dictionary['navigation'].settings} + {/* {dictionary['navigation'].settings} */} }> diff --git a/src/contexts/authContext.tsx b/src/contexts/authContext.tsx index 70db8a8..6f70ea5 100644 --- a/src/contexts/authContext.tsx +++ b/src/contexts/authContext.tsx @@ -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({ @@ -17,7 +18,7 @@ const AuthContext = createContext({ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { const [token, setToken] = useState(null) - const [currentUser, setCurrentUser] = useState(null) + const [currentUser, setCurrentUser] = useState(null) const [isInitialized, setIsInitialized] = useState(false) useEffect(() => { diff --git a/src/hocs/AuthGuard.tsx b/src/hocs/AuthGuard.tsx index 2d65722..81613f3 100644 --- a/src/hocs/AuthGuard.tsx +++ b/src/hocs/AuthGuard.tsx @@ -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 : } + if (currentUser?.role !== 'admin') { + redirect(getLocalizedUrl('/not-found', locale)) + } + }, [isAuthenticated, currentUser]) + + return <>{isAuthenticated && currentUser?.role === 'admin' ? children : } } diff --git a/src/hocs/RolesGuard.tsx b/src/hocs/RolesGuard.tsx new file mode 100644 index 0000000..d3e5fda --- /dev/null +++ b/src/hocs/RolesGuard.tsx @@ -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 : } +} diff --git a/src/redux-store/index.ts b/src/redux-store/index.ts index 37c577c..d29167b 100644 --- a/src/redux-store/index.ts +++ b/src/redux-store/index.ts @@ -6,6 +6,9 @@ import customerReducer from '@/redux-store/slices/customer' 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: { @@ -13,7 +16,10 @@ export const store = configureStore({ customerReducer, paymentMethodReducer, ingredientReducer, - orderReducer + orderReducer, + productRecipeReducer, + organizationReducer, + userReducer }, middleware: getDefaultMiddleware => getDefaultMiddleware({ serializableCheck: false }) }) diff --git a/src/redux-store/slices/organization.ts b/src/redux-store/slices/organization.ts new file mode 100644 index 0000000..543fc74 --- /dev/null +++ b/src/redux-store/slices/organization.ts @@ -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) => { + state.currentOrganization = action.payload + }, + resetOrganization: state => { + state.currentOrganization = initialState.currentOrganization + } + } +}) + +export const { setOrganization, resetOrganization } = organizationSlice.actions + +export default organizationSlice.reducer diff --git a/src/redux-store/slices/productRecipe.ts b/src/redux-store/slices/productRecipe.ts new file mode 100644 index 0000000..5dc1c7a --- /dev/null +++ b/src/redux-store/slices/productRecipe.ts @@ -0,0 +1,28 @@ +// Third-party Imports +import type { PayloadAction } from '@reduxjs/toolkit' +import { createSlice } from '@reduxjs/toolkit' + +// Type Imports + +// Data Imports + +const initialState: { currentProductRecipe: any } = { + currentProductRecipe: {} +} + +export const productRecipeSlice = createSlice({ + name: 'productRecipe', + initialState, + reducers: { + setProductRecipe: (state, action: PayloadAction) => { + state.currentProductRecipe = action.payload + }, + resetProductRecipe: state => { + state.currentProductRecipe = initialState.currentProductRecipe + } + } +}) + +export const { setProductRecipe, resetProductRecipe } = productRecipeSlice.actions + +export default productRecipeSlice.reducer diff --git a/src/redux-store/slices/user.ts b/src/redux-store/slices/user.ts new file mode 100644 index 0000000..8f3e77e --- /dev/null +++ b/src/redux-store/slices/user.ts @@ -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) => { + state.currentUser = action.payload + }, + resetUser: state => { + state.currentUser = initialState.currentUser + } + } +}) + +export const { setUser, resetUser } = userSlice.actions + +export default userSlice.reducer diff --git a/src/services/mutations/organization.ts b/src/services/mutations/organization.ts new file mode 100644 index 0000000..0fc9953 --- /dev/null +++ b/src/services/mutations/organization.ts @@ -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 } +} diff --git a/src/services/mutations/productRecipes.ts b/src/services/mutations/productRecipes.ts new file mode 100644 index 0000000..4ccf3de --- /dev/null +++ b/src/services/mutations/productRecipes.ts @@ -0,0 +1,45 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'react-toastify' +import { ProductRecipeRequest } from '../../types/services/productRecipe' +import { api } from '../api' + +export const useProductRecipesMutation = () => { + const queryClient = useQueryClient() + + const createProductRecipe = useMutation({ + mutationFn: async (newProductRecipe: ProductRecipeRequest) => { + const { variant_id, ...rest } = newProductRecipe + + const cleanRequest = variant_id ? newProductRecipe : rest + + const response = await api.post('/product-recipes', cleanRequest) + return response.data + }, + onSuccess: () => { + toast.success('Product Recipe created successfully!') + queryClient.invalidateQueries({ queryKey: ['product-recipes'] }) + }, + onError: (error: any) => { + toast.error(error.response?.data?.errors?.[0]?.cause || 'Create failed') + } + }) + + const updateProductRecipe = useMutation({ + mutationFn: async ({ id, payload }: { id: string; payload: ProductRecipeRequest }) => { + const response = await api.put(`/product-recipes/${id}`, payload) + return response.data + }, + onSuccess: () => { + toast.success('Product Recipe updated successfully!') + queryClient.invalidateQueries({ queryKey: ['product-recipes'] }) + }, + onError: (error: any) => { + toast.error(error.response?.data?.errors?.[0]?.cause || 'Update failed') + } + }) + + return { + createProductRecipe, + updateProductRecipe + } +} diff --git a/src/services/mutations/users.ts b/src/services/mutations/users.ts index ca790bb..d4f7ace 100644 --- a/src/services/mutations/users.ts +++ b/src/services/mutations/users.ts @@ -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 } } diff --git a/src/services/queries/organizations.ts b/src/services/queries/organizations.ts new file mode 100644 index 0000000..594a4b6 --- /dev/null +++ b/src/services/queries/organizations.ts @@ -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({ + 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 + } + }) +} diff --git a/src/services/queries/productRecipes.ts b/src/services/queries/productRecipes.ts new file mode 100644 index 0000000..aa2f8da --- /dev/null +++ b/src/services/queries/productRecipes.ts @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query' +import { ProductRecipe } from '../../types/services/productRecipe' +import { api } from '../api' + +export function useProductRecipesByProduct(productId: string) { + return useQuery({ + queryKey: ['product-recipes', productId], + queryFn: async () => { + const res = await api.get(`/product-recipes/product/${productId}`) + return res.data.data + } + }) +} diff --git a/src/services/queries/products.ts b/src/services/queries/products.ts index cc895f1..c2cf7c8 100644 --- a/src/services/queries/products.ts +++ b/src/services/queries/products.ts @@ -1,6 +1,7 @@ import { useQuery } from '@tanstack/react-query' -import { Products } from '../../types/services/product' +import { Product, Products } from '../../types/services/product' import { api } from '../api' +import { ProductRecipe } from '../../types/services/productRecipe' interface ProductsQueryParams { page?: number @@ -39,7 +40,7 @@ export function useProducts(params: ProductsQueryParams = {}) { } export function useProductById(id: string) { - return useQuery({ + return useQuery({ queryKey: ['product', id], queryFn: async () => { const res = await api.get(`/products/${id}`) diff --git a/src/types/services/organization.ts b/src/types/services/organization.ts new file mode 100644 index 0000000..3da2746 --- /dev/null +++ b/src/types/services/organization.ts @@ -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; +} diff --git a/src/types/services/productRecipe.ts b/src/types/services/productRecipe.ts new file mode 100644 index 0000000..6a43cc2 --- /dev/null +++ b/src/types/services/productRecipe.ts @@ -0,0 +1,56 @@ +export interface Product { + ID: string; + OrganizationID: string; + CategoryID: string; + SKU: string; + Name: string; + Description: string | null; + Price: number; + Cost: number; + BusinessType: string; + ImageURL: string; + PrinterType: string; + UnitID: string | null; + HasIngredients: boolean; + Metadata: Record; + IsActive: boolean; + CreatedAt: string; // ISO date string + UpdatedAt: string; // ISO date string +} + +export interface Ingredient { + id: string; + organization_id: string; + outlet_id: string | null; + name: string; + unit_id: string; + cost: number; + stock: number; + is_semi_finished: boolean; + is_active: boolean; + metadata: Record; + created_at: string; + updated_at: string; +} + +export interface ProductRecipe { + id: string; + organization_id: string; + outlet_id: string | null; + product_id: string; + variant_id: string | null; + ingredient_id: string; + quantity: number; + created_at: string; + updated_at: string; + product: Product; + ingredient: Ingredient; +} + +export interface ProductRecipeRequest { + product_id: string; + variant_id: string | null; + ingredient_id: string; + quantity: number; + outlet_id: string | null; +} diff --git a/src/types/services/user.ts b/src/types/services/user.ts index 4d0e2fe..94bdb25 100644 --- a/src/types/services/user.ts +++ b/src/types/services/user.ts @@ -5,7 +5,7 @@ export type User = { name: string; email: string; role: string; - permissions: Record; + permissions: Record; 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; + permissions: Record; is_active: boolean; } diff --git a/src/utils/transform.ts b/src/utils/transform.ts index 5df90b2..a33ecfc 100644 --- a/src/utils/transform.ts +++ b/src/utils/transform.ts @@ -7,12 +7,23 @@ export const formatCurrency = (amount: number) => { } export const formatShortCurrency = (num: number): string => { - if (num >= 1_000_000) { - return (num / 1_000_000).toFixed(2) + 'M' - } else if (num >= 1_000) { - return (num / 1_000).toFixed(2) + 'k' + const formatNumber = (value: number, suffix: string) => { + const str = value.toFixed(2).replace(/\.00$/, '') + return str + suffix } - return num.toString() + + const absNum = Math.abs(num) + let result: string + + if (absNum >= 1_000_000) { + result = formatNumber(absNum / 1_000_000, 'M') + } else if (absNum >= 1_000) { + result = formatNumber(absNum / 1_000, 'k') + } else { + result = absNum.toString() + } + + return num < 0 ? '-' + result : result } export const formatDate = (dateString: any) => { diff --git a/src/views/Login.tsx b/src/views/Login.tsx index d1934e3..9be34de 100644 --- a/src/views/Login.tsx +++ b/src/views/Login.tsx @@ -243,7 +243,7 @@ const Login = ({ mode }: { mode: SystemMode }) => {
New on our platform? - + Create an account
diff --git a/src/views/apps/ecommerce/customers/list/CustomerListTable.tsx b/src/views/apps/ecommerce/customers/list/CustomerListTable.tsx index 3a75941..e21c5f2 100644 --- a/src/views/apps/ecommerce/customers/list/CustomerListTable.tsx +++ b/src/views/apps/ecommerce/customers/list/CustomerListTable.tsx @@ -221,7 +221,6 @@ const CustomerListTable = () => { } } }, - { text: 'Duplicate', icon: 'tabler-copy' } ]} /> diff --git a/src/views/apps/ecommerce/products/detail/AddRecipeDrawer.tsx b/src/views/apps/ecommerce/products/detail/AddRecipeDrawer.tsx new file mode 100644 index 0000000..e0b2372 --- /dev/null +++ b/src/views/apps/ecommerce/products/detail/AddRecipeDrawer.tsx @@ -0,0 +1,210 @@ +// React Imports +import { useMemo, useState } from 'react' + +// MUI Imports +import Button from '@mui/material/Button' +import Divider from '@mui/material/Divider' +import Drawer from '@mui/material/Drawer' +import IconButton from '@mui/material/IconButton' +import Typography from '@mui/material/Typography' + +// Third-party Imports +import PerfectScrollbar from 'react-perfect-scrollbar' + +// Type Imports + +// Component Imports +import CustomTextField from '@core/components/mui/TextField' +import { Autocomplete } from '@mui/material' +import { useDispatch, useSelector } from 'react-redux' +import { useDebounce } from 'use-debounce' +import { RootState } from '../../../../../redux-store' +import { resetProductRecipe } from '../../../../../redux-store/slices/productRecipe' +import { useProductRecipesMutation } from '../../../../../services/mutations/productRecipes' +import { useIngredients } from '../../../../../services/queries/ingredients' +import { useOutlets } from '../../../../../services/queries/outlets' +import { Product } from '../../../../../types/services/product' +import { ProductRecipeRequest } from '../../../../../types/services/productRecipe' + +type Props = { + open: boolean + handleClose: () => void + product: Product +} + +// Vars +const initialData = { + outlet_id: '', + product_id: '', + variant_id: '', + ingredient_id: '', + quantity: 0 +} + +const AddRecipeDrawer = (props: Props) => { + const dispatch = useDispatch() + + // Props + const { open, handleClose, product } = props + + const { currentProductRecipe } = useSelector((state: RootState) => state.productRecipeReducer) + + const [outletInput, setOutletInput] = useState('') + const [outletDebouncedInput] = useDebounce(outletInput, 500) + const [ingredientInput, setIngredientInput] = useState('') + const [ingredientDebouncedInput] = useDebounce(ingredientInput, 500) + const [formData, setFormData] = useState(initialData) + + 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]) + + const { createProductRecipe, updateProductRecipe } = useProductRecipesMutation() + + const handleSubmit = (e: any) => { + e.preventDefault() + + createProductRecipe.mutate( + { ...formData, product_id: product.id, variant_id: currentProductRecipe.id || '' }, + { + onSuccess: () => { + handleReset() + } + } + ) + } + + const handleReset = () => { + handleClose() + dispatch(resetProductRecipe()) + setFormData(initialData) + } + + const handleInputChange = (e: any) => { + setFormData({ + ...formData, + [e.target.name]: e.target.value + }) + } + + const setTitleDrawer = (recipe: any) => { + let title = 'Original' + + if (recipe?.name) { + title = recipe?.name + } + + return title + } + + return ( + +
+ {setTitleDrawer(currentProductRecipe)} Variant Ingredient + + + +
+ + +
+
+ + Basic Information + + 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 => ( + {params.InputProps.endAdornment} + }} + /> + )} + /> + 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 => ( + {params.InputProps.endAdornment} + }} + /> + )} + /> + setFormData({ ...formData, quantity: Number(e.target.value) })} + /> +
+ + +
+ +
+
+
+ ) +} + +export default AddRecipeDrawer diff --git a/src/views/apps/ecommerce/products/detail/ProductDetail.tsx b/src/views/apps/ecommerce/products/detail/ProductDetail.tsx index fb5dfeb..69273ac 100644 --- a/src/views/apps/ecommerce/products/detail/ProductDetail.tsx +++ b/src/views/apps/ecommerce/products/detail/ProductDetail.tsx @@ -2,319 +2,381 @@ import { Avatar, - Badge, + Box, + Button, Card, CardContent, - CardMedia, + CardHeader, Chip, - Divider, Grid, - List, - ListItem, - ListItemIcon, - ListItemText, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, Typography } from '@mui/material' import { useParams } from 'next/navigation' -import React, { useEffect } from 'react' +import { useState } from 'react' import { useDispatch } from 'react-redux' import Loading from '../../../../../components/layout/shared/Loading' -import { setProduct } from '../../../../../redux-store/slices/product' +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, formatDate } from '../../../../../utils/transform' -// Tabler icons (using class names) -const TablerIcon = ({ name, className = '' }: { name: string; className?: string }) => ( - -) +import { formatCurrency } from '../../../../../utils/transform' +import AddRecipeDrawer from './AddRecipeDrawer' const ProductDetail = () => { const dispatch = useDispatch() const params = useParams() + const [openProductRecipe, setOpenProductRecipe] = useState(false) + const { data: product, isLoading, error } = useProductById(params?.id as string) + const { data: productRecipe, isLoading: isLoadingProductRecipe } = useProductRecipesByProduct(params?.id as string) - useEffect(() => { - if (product) { - dispatch(setProduct(product)) - } - }, [product, dispatch]) - - const getBusinessTypeColor = (type: string) => { - switch (type.toLowerCase()) { - case 'restaurant': - return 'primary' - case 'retail': - return 'secondary' - case 'cafe': - return 'info' - default: - return 'default' - } + const handleOpenProductRecipe = (recipe: any) => { + setOpenProductRecipe(true) + dispatch(setProductRecipe(recipe)) } - const getPrinterTypeColor = (type: string) => { - switch (type.toLowerCase()) { - case 'kitchen': - return 'warning' - case 'bar': - return 'info' - case 'receipt': - return 'success' - default: - return 'default' - } - } - - const getPlainText = (html: string) => { - const doc = new DOMParser().parseFromString(html, 'text/html') - return doc.body.textContent || '' - } - - if (isLoading) return + if (isLoading || isLoadingProductRecipe) return return ( -
- {/* Header Card */} - - - - - - - -
-
-
- - {product.name} - -
+ <> +
+ {/* Header Card */} + + } + title={ +
+ + {product?.name} + + +
+ } + subheader={ +
+ + SKU: {product?.sku} • Category: {product?.business_type} + +
+ + Price: {formatCurrency(product?.price || 0)} + + + Base Cost: {formatCurrency(product?.cost || 0)} + +
+
+ } + /> +
+ + {/* {productRecipe && ( */} +
+ {/* Recipe Details by Variant */} +
+
+ + + Recipe Details + +
+ + + +
+ + + Original Variant + +
+
+ } - label={product.sku} - size='small' + label={`Price Modifier: ${formatCurrency(product?.price || 0)}`} variant='outlined' - /> - } - label={product.is_active ? 'Active' : 'Inactive'} - color={product.is_active ? 'success' : 'error'} - size='small' + color='secondary' />
-
+ } + /> + + + + + + +
+ + Ingredient +
+
+ +
+ + Quantity +
+
+ +
+ + Unit Cost +
+
+ +
+ + Stock Available +
+
+ +
+ + Total Cost +
+
+
+
+ + {productRecipe?.length && + productRecipe + .filter((item: any) => item.variant_id === null) + .map((item: any, index: number) => ( + + +
+
+
+ + {item.ingredient.name} + + + {item.ingredient.is_semi_finished ? 'Semi-finished' : 'Raw ingredient'} + +
+
+ + + + + {formatCurrency(item.ingredient.cost)} + + 5 ? 'success' : 'warning'} + variant='outlined' + /> + + + {formatCurrency(item.ingredient.cost * item.quantity)} + + + ))} + +
+
- {product.description && ( - - {getPlainText(product.description)} - + {/* Variant Summary */} + {productRecipe?.length && ( + + + + + + Total Ingredients: + {productRecipe.filter((item: any) => item.variant_id === null).length} + + + + + + Total Recipe Cost: + {formatCurrency( + productRecipe + .filter((item: any) => item.variant_id === null) + .reduce((sum: any, item: any) => sum + item.ingredient.cost * item.quantity, 0) + )} + + + + )} -
-
- -
- - Price - - - {formatCurrency(product.price)} - -
-
-
- -
- - Cost - - - {formatCurrency(product.cost)} - -
-
-
- -
- } - label={product.business_type} - color={getBusinessTypeColor(product.business_type)} - size='small' - /> - } - label={product.printer_type} - color={getPrinterTypeColor(product.printer_type)} - size='small' - /> -
-
- - - - - - - {/* Product Information */} - - - - - - Product Information - -
-
- - Product ID - - - {product.id} - -
-
- - Category ID - - - {product.category_id} - -
-
- - Organization ID - - - {product.organization_id} - -
-
- - Profit Margin - - - {formatCurrency(product.price - product.cost)} - - ({(((product.price - product.cost) / product.cost) * 100).toFixed(1)}%) - - -
-
-
-
- - {/* Variants Section */} - {product.variants && product.variants.length > 0 && ( - - - - - Product Variants - - - - {product.variants.map((variant: ProductVariant, index: number) => ( - - - - - {variant.name.charAt(0)} - - - - - {variant.name} - -
- - +{formatCurrency(variant.price_modifier)} - - - Cost: {formatCurrency(variant.cost)} - -
-
- } - secondary={ - - Total Price: {formatCurrency(product.price + variant.price_modifier)} - - } - /> - - {index < product.variants.length - 1 && } - - ))} - + - )} - - {/* Metadata & Timestamps */} - - - - - - Timestamps - -
-
- - Created - - - {formatDate(product.created_at)} - -
- -
- - Last Updated - - - {formatDate(product.updated_at)} - -
-
- - {Object.keys(product.metadata).length > 0 && ( - <> - - - Metadata - -
- {Object.entries(product.metadata).map(([key, value]) => ( -
- - {key.replace(/_/g, ' ')} - - - {typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value)} - + {product?.variants?.length && + product.variants.map((variantData: ProductVariant, index: number) => ( + + +
+ + + {variantData?.name || 'Original'} Variant + +
+
+ + +
- ))} -
- - )} -
-
-
- -
+ } + /> + + + + + + +
+ + Ingredient +
+
+ +
+ + Quantity +
+
+ +
+ + Unit Cost +
+
+ +
+ + Stock Available +
+
+ +
+ + Total Cost +
+
+
+
+ + {productRecipe?.length && + productRecipe + .filter((item: any) => item.variant_id === variantData.id) + .map((item: any, index: number) => ( + + +
+
+
+ + {item.ingredient.name} + + + {item.ingredient.is_semi_finished ? 'Semi-finished' : 'Raw ingredient'} + +
+
+ + + + + {formatCurrency(item.ingredient.cost)} + + 5 ? 'success' : 'warning'} + variant='outlined' + /> + + + {formatCurrency(item.ingredient.cost * item.quantity)} + + + ))} + +
+
+ + {/* Variant Summary */} + {productRecipe?.length && ( + + + + + + Total Ingredients: + {productRecipe.filter((item: any) => item.variant_id === variantData.id).length} + + + + + + Total Recipe Cost: + {formatCurrency( + productRecipe + .filter((item: any) => item.variant_id === variantData.id) + .reduce((sum: any, item: any) => sum + item.ingredient.cost * item.quantity, 0) + )} + + + + + )} + + +
+ + ))} +
+
+
+ + setOpenProductRecipe(false)} product={product!} /> + ) } diff --git a/src/views/apps/user/list/AddUserDrawer.tsx b/src/views/apps/user/list/AddUserDrawer.tsx index 4d909cc..e0e7863 100644 --- a/src/views/apps/user/list/AddUserDrawer.tsx +++ b/src/views/apps/user/list/AddUserDrawer.tsx @@ -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(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 ( { sx={{ '& .MuiDrawer-paper': { width: { xs: 300, sm: 400 } } }} >
- Add New User + {currentUser.id ? 'Edit' : 'Add'} User
-
+ { value={formData.email} onChange={handleInputChange} /> + {currentUser.id ? null : ( + + )} setFormData({ ...formData, role: e.target.value })} + > + Manager + Cashier + Waiter + + 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 => ( + {params.InputProps.endAdornment} + }} + /> + )} /> + + Assign permissions + + + } + label='Can create orders' + /> + + } + label='Can void orders' + /> + +
@@ -119,7 +247,7 @@ const AddUserDrawer = (props: Props) => {
+
+ +
+ {isLoading ? ( + + ) : ( + + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => ( + + ))} + + ))} + + {table.getFilteredRowModel().rows.length === 0 ? ( + + + + + + ) : ( + + {table + .getRowModel() + .rows.slice(0, table.getState().pagination.pageSize) + .map(row => { + return ( + + {row.getVisibleCells().map(cell => ( + + ))} + + ) + })} + + )} +
+ {header.isPlaceholder ? null : ( + <> +
+ {flexRender(header.column.columnDef.header, header.getContext())} + {{ + asc: , + desc: + }[header.column.getIsSorted() as 'asc' | 'desc'] ?? null} +
+ + )} +
+ No data available +
{flexRender(cell.column.columnDef.cell, cell.getContext())}
+ )} + + {isFetching && !isLoading && ( + + + + )} +
+ + ( + + )} + count={totalCount} + rowsPerPage={pageSize} + page={currentPage} + onPageChange={handlePageChange} + onRowsPerPageChange={handlePageSizeChange} + rowsPerPageOptions={[10, 25, 50]} + disabled={isLoading} + /> + + + 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