Merge branch 'development' into fix/project-flock

This commit is contained in:
ValdiANS
2026-01-30 10:10:01 +07:00
16 changed files with 1049 additions and 465 deletions
+6 -1
View File
@@ -22,6 +22,7 @@ export interface CardProps
onCollapsedChange?: (collapsed: boolean) => void; onCollapsedChange?: (collapsed: boolean) => void;
className?: { className?: {
wrapper?: string; wrapper?: string;
wrapperContent?: string;
image?: string; image?: string;
body?: string; body?: string;
title?: string; title?: string;
@@ -144,6 +145,10 @@ const Card = ({
return cn('border-t border-base-300 mt-4 pt-4', className?.footer); return cn('border-t border-base-300 mt-4 pt-4', className?.footer);
}; };
const getWrapperContentClasses = () => {
return cn('space-y-4', className?.wrapperContent);
};
const renderCardContent = () => { const renderCardContent = () => {
const hasContent = children || actions || footer; const hasContent = children || actions || footer;
@@ -177,7 +182,7 @@ const Card = ({
); );
const cardContent = ( const cardContent = (
<div className='space-y-4'> <div className={getWrapperContentClasses()}>
{children} {children}
{actions && <div className={getActionsClasses()}>{actions}</div>} {actions && <div className={getActionsClasses()}>{actions}</div>}
{footer && <div className={getFooterClasses()}>{footer}</div>} {footer && <div className={getFooterClasses()}>{footer}</div>}
+5 -1
View File
@@ -12,6 +12,7 @@ import PopoverContent from '@/components/popover/PopoverContent';
import { useAuth } from '@/services/hooks/useAuth'; import { useAuth } from '@/services/hooks/useAuth';
import { AuthApi } from '@/services/api/auth'; import { AuthApi } from '@/services/api/auth';
import { isResponseError } from '@/lib/api-helper'; import { isResponseError } from '@/lib/api-helper';
import { useUiStore } from '@/stores/ui/ui.store';
interface NavbarProps { interface NavbarProps {
toggleSidebar?: () => void; toggleSidebar?: () => void;
@@ -21,6 +22,7 @@ const Navbar = ({ toggleSidebar }: NavbarProps) => {
const { setUser } = useAuth(); const { setUser } = useAuth();
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const navbarActions = useUiStore((state) => state.navbarActions);
const logoutClickHandler = async () => { const logoutClickHandler = async () => {
const logoutRes = await AuthApi.logout(); const logoutRes = await AuthApi.logout();
@@ -53,7 +55,9 @@ const Navbar = ({ toggleSidebar }: NavbarProps) => {
</div> </div>
</div> </div>
<div className='flex gap-2'> <div className='flex gap-2 items-center'>
{/* Page-specific actions */}
{navbarActions && <div className='mr-2'>{navbarActions}</div>}
<PopoverButton <PopoverButton
tabIndex={0} tabIndex={0}
variant='ghost' variant='ghost'
+9 -3
View File
@@ -9,15 +9,21 @@ export type ButtonFilterProps = ButtonProps & {
onClick: () => void; onClick: () => void;
}; };
// 'bg-gradient-to-t from-blue-50 to-blue-100 border-blue-500 text-blue-600 hover:from-blue-100 hover:to-blue-200
const ButtonFilter = ({ values, onClick, ...props }: ButtonFilterProps) => { const ButtonFilter = ({ values, onClick, ...props }: ButtonFilterProps) => {
return ( return (
<Button <Button
{...props} {...props}
onClick={onClick} onClick={onClick}
variant='outline'
color='none'
className={cn( className={cn(
'padding-[12px] rounded-[8px] max-h-[40px] font-semibold text-[14px] gap-[6px]',
'text-sm text-base-content/50 border border-base-content/10 shadow-button-soft',
getFilledFormikValuesCount(values) > 0 getFilledFormikValuesCount(values) > 0
? 'bg-gradient-to-t from-blue-50 to-blue-100 border-blue-500 text-blue-600 hover:from-blue-100 hover:to-blue-200' ? 'border-primary-gradient !rounded-[8px]'
: '', : '!rounded-[8px]',
props.className props.className
)} )}
> >
@@ -31,7 +37,7 @@ const ButtonFilter = ({ values, onClick, ...props }: ButtonFilterProps) => {
/> />
Filter Filter
{getFilledFormikValuesCount(values) > 0 && ( {getFilledFormikValuesCount(values) > 0 && (
<span className='w-6 h-6 text-white bg-red-500 rounded-lg flex items-center justify-center text-xs'> <span className='w-[20px] h-[20px] text-white bg-[#FF3535] rounded-[8px] border-[1px] border-base-300 flex items-center justify-center text-xs'>
{getFilledFormikValuesCount(values)} {getFilledFormikValuesCount(values)}
</span> </span>
)} )}
+3 -1
View File
@@ -72,8 +72,10 @@ const RequireAuth = ({ children }: RequireAuthProps) => {
await AuthApi.refresh(); await AuthApi.refresh();
}; };
if (user) {
refreshUserSession(); refreshUserSession();
}, []); }
}, [user]);
if ( if (
(isLoadingUserResponse && !userResponse && !userErrorResponse) || (isLoadingUserResponse && !userResponse && !userErrorResponse) ||
@@ -4,10 +4,7 @@ import Button from '@/components/Button';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import Modal, { useModal } from '@/components/Modal'; import Modal, { useModal } from '@/components/Modal';
import DateInput from '@/components/input/DateInput'; import DateInput from '@/components/input/DateInput';
import SelectInput, { import { OptionType, useSelect } from '@/components/input/SelectInput';
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { DashboardApi } from '@/services/api/dashboard'; import { DashboardApi } from '@/services/api/dashboard';
@@ -21,9 +18,9 @@ import {
} from '@/components/pages/dashboard/filter/DashboardProductionFilter.schema'; } from '@/components/pages/dashboard/filter/DashboardProductionFilter.schema';
import DashboardLineChart from '@/components/pages/dashboard/chart/DashboardLineChart'; import DashboardLineChart from '@/components/pages/dashboard/chart/DashboardLineChart';
import DashboardLineChartSkeleton from '@/components/pages/dashboard/skeleton/DashboardLineChartSkeleton'; import DashboardLineChartSkeleton from '@/components/pages/dashboard/skeleton/DashboardLineChartSkeleton';
import DashboardAllCharts, { import DashboardExportCharts, {
DashboardAllChartsRef, DashboardExportChartsRef,
} from '@/components/pages/dashboard/chart/DashboardAllCharts'; } from '@/components/pages/dashboard/export/DashboardExportCharts';
import { RadioGroup, RadioGroupItem } from '@/components/input/RadioInput'; import { RadioGroup, RadioGroupItem } from '@/components/input/RadioInput';
import { import {
DashboardFilter, DashboardFilter,
@@ -40,6 +37,11 @@ import MenuItem from '@/components/menu/MenuItem';
import { useDashboardStore } from '@/stores/dashboard'; import { useDashboardStore } from '@/stores/dashboard';
import SelectInputRadio from '@/components/input/SelectInputRadio'; import SelectInputRadio from '@/components/input/SelectInputRadio';
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox'; import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import { useUiStore } from '@/stores/ui/ui.store';
import { cn } from '@/lib/helper';
import DashboardExportStats, {
DashboardExportStatsRef,
} from '@/components/pages/dashboard/export/DashboardExportStats';
// Helper function to normalize values to array // Helper function to normalize values to array
const normalizeToArray = ( const normalizeToArray = (
@@ -59,6 +61,10 @@ const DashboardProduction = () => {
const { filterValues, setFilterValues, resetFilterValues } = const { filterValues, setFilterValues, resetFilterValues } =
useDashboardStore(); useDashboardStore();
// ===== UI STORE (for navbar actions) =====
const setNavbarActions = useUiStore((state) => state.setNavbarActions);
const clearNavbarActions = useUiStore((state) => state.clearNavbarActions);
const [analysisMode, setAnalysisMode] = useState<'OVERVIEW' | 'COMPARISON'>( const [analysisMode, setAnalysisMode] = useState<'OVERVIEW' | 'COMPARISON'>(
(filterValues.analysisMode as 'OVERVIEW' | 'COMPARISON') || 'OVERVIEW' (filterValues.analysisMode as 'OVERVIEW' | 'COMPARISON') || 'OVERVIEW'
); );
@@ -67,9 +73,8 @@ const DashboardProduction = () => {
normalizeToArray(filterValues.location) normalizeToArray(filterValues.location)
); );
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
const statsRef = useRef<HTMLDivElement>(null); const allChartsRef = useRef<DashboardExportChartsRef>(null);
const chartRef = useRef<HTMLDivElement>(null); const allStatsRef = useRef<DashboardExportStatsRef>(null);
const allChartsRef = useRef<DashboardAllChartsRef>(null);
// ===== FETCH DATA ===== // ===== FETCH DATA =====
const { const {
@@ -194,12 +199,69 @@ const DashboardProduction = () => {
const handleExportPDF = async () => { const handleExportPDF = async () => {
await generateDashboardPDF({ await generateDashboardPDF({
filterValues: formik.values, filterValues: formik.values,
statsRef, allStatsRef,
allChartsRef, allChartsRef,
setExporting, setExporting,
}); });
}; };
// ===== Register Navbar Actions =====
const openFilterModalRef = useRef(filterModal.openModal);
openFilterModalRef.current = filterModal.openModal;
useEffect(() => {
setNavbarActions(
<div className='hidden sm:flex flex-row justify-end gap-3 '>
<ButtonFilter
values={{
...formik.values,
analysisMode: undefined,
}}
variant='outline'
onClick={() => openFilterModalRef.current()}
/>
<Dropdown
trigger={
<Button
variant='outline'
color='none'
className={cn(
'p-2 rounded-lg font-semibold text-sm gap-1.5',
'text-sm text-base-content/50 border border-base-content/10 shadow-button-soft'
)}
>
<Icon width={20} height={20} icon='heroicons:cloud-arrow-down' />
Export
<div className='w-6.5 h-5 flex items-center justify-center border-l border-base-content/10'>
<Icon width={14} height={14} icon='heroicons:chevron-down' />
</div>
</Button>
}
className={{
content: 'w-full mt-1 p-0',
}}
>
<Menu
className={`p-0 w-full shadow-button-soft border border-base-content/10 rounded-lg ${exporting ? 'hidden' : ''}`}
>
<MenuItem
className='text-sm p-3'
title='PDF'
onClick={handleExportPDF}
/>
</Menu>
</Dropdown>
</div>
);
}, [formik.values, exporting, setNavbarActions]);
// Cleanup only on unmount
useEffect(() => {
return () => {
clearNavbarActions();
};
}, [clearNavbarActions]);
if (isLoadingDashboardProductionData) { if (isLoadingDashboardProductionData) {
return ( return (
<div className='w-full min-h-screen flex items-center justify-center'> <div className='w-full min-h-screen flex items-center justify-center'>
@@ -210,48 +272,62 @@ const DashboardProduction = () => {
return ( return (
<> <>
<section className='w-full p-4 space-y-6'> <section className='w-full p-3 space-y-3'>
<div className='flex flex-col sm:flex-row items-center justify-between gap-4'> <div className='flex sm:hidden flex-row justify-end gap-3 '>
<div></div>
<div className='flex flex-row justify-end gap-2'>
<ButtonFilter <ButtonFilter
values={{ values={{
...formik.values, ...formik.values,
analysisMode: undefined, analysisMode: undefined,
}} }}
variant='outline' variant='outline'
className='min-w-28 rounded-lg' onClick={() => openFilterModalRef.current()}
onClick={() => filterModal.openModal()}
/> />
<Dropdown <Dropdown
trigger={ trigger={
<Button variant='outline' className='min-w-28 rounded-lg z-50'> <Button
<Icon icon='heroicons:arrow-down-tray' /> variant='outline'
color='none'
className={cn(
'p-2 rounded-lg font-semibold text-sm gap-1.5',
'text-sm text-base-content/50 border border-base-content/10 shadow-button-soft'
)}
>
<Icon
width={20}
height={20}
icon='heroicons:cloud-arrow-down'
/>
Export Export
<Icon icon='heroicons:chevron-down' /> <div className='w-6.5 h-5 flex items-center justify-center border-l border-base-content/10'>
<Icon width={14} height={14} icon='heroicons:chevron-down' />
</div>
</Button> </Button>
} }
className={{ className={{
content: 'w-full', content:
'w-full mt-1 p-0 shadow-button-soft border border-base-content/10 rounded-lg',
}} }}
> >
<Menu className={exporting ? 'hidden' : ''}> <Menu
<MenuItem title='PDF' onClick={handleExportPDF} /> className={`p-0 w-full shadow-button-soft border border-base-content/10 rounded-lg ${exporting ? 'hidden' : ''}`}
>
<MenuItem
className='text-sm p-3'
title='PDF'
onClick={handleExportPDF}
/>
</Menu> </Menu>
</Dropdown> </Dropdown>
</div> </div>
</div>
{/* Dashboard Stats */} {/* Dashboard Stats */}
<div ref={statsRef}> <div>
<DashboardStats <DashboardStats
data={dashboardProductionData?.statistics_data ?? []} data={dashboardProductionData?.statistics_data ?? []}
/> />
</div> </div>
{/* Use DashboardLineChart component or skeleton */} {/* Use DashboardLineChart component or skeleton */}
<div ref={chartRef}> <div>
{isLoadingDashboardProductionData ? ( {isLoadingDashboardProductionData ? (
<DashboardLineChartSkeleton /> <DashboardLineChartSkeleton />
) : dashboardProductionData && ) : dashboardProductionData &&
@@ -287,6 +363,8 @@ const DashboardProduction = () => {
{/* Hidden container for all charts (used for PDF export in OVERVIEW mode) */} {/* Hidden container for all charts (used for PDF export in OVERVIEW mode) */}
{dashboardProductionData && ( {dashboardProductionData && (
<>
{/* Export Stats Charts */}
<div <div
style={{ style={{
position: 'absolute', position: 'absolute',
@@ -295,7 +373,22 @@ const DashboardProduction = () => {
width: '1200px', // Fixed width for consistent PDF rendering width: '1200px', // Fixed width for consistent PDF rendering
}} }}
> >
<DashboardAllCharts <DashboardExportStats
ref={allStatsRef}
data={dashboardProductionData?.statistics_data ?? []}
/>
</div>
{/* Export ALL Charts */}
<div
style={{
position: 'absolute',
left: '-9999px',
top: 0,
width: '1200px', // Fixed width for consistent PDF rendering
}}
>
<DashboardExportCharts
ref={allChartsRef} ref={allChartsRef}
data={dashboardProductionData} data={dashboardProductionData}
analysisMode={ analysisMode={
@@ -309,6 +402,7 @@ const DashboardProduction = () => {
} }
/> />
</div> </div>
</>
)} )}
</section> </section>
@@ -316,58 +410,55 @@ const DashboardProduction = () => {
ref={filterModal.ref} ref={filterModal.ref}
className={{ className={{
modal: 'p-0', modal: 'p-0',
modalBox: 'p-0 rounded-xl', modalBox: 'p-0 rounded-[0.875rem]',
}} }}
> >
<div className='space-y-6'> <div className='flex flex-col'>
{/* Modal Header */} {/* Modal Header */}
<div className='flex items-center justify-between gap-2 py-3 border-b border-gray-300'> <div className='flex items-center justify-between p-4 border-b border-base-content/10'>
<div className='flex items-center gap-2 ms-4'> <div className='flex items-center gap-2 text-primary'>
<Icon icon='heroicons:funnel' width={20} height={20} /> <Icon icon='heroicons:funnel' width={20} height={20} />
<h3 className='font-semibold'>Filter Data</h3> <h3 className='font-medium text-sm'>Filter Data</h3>
</div> </div>
<Button <Button
variant='link' variant='link'
onClick={() => filterModal.closeModal()} onClick={() => filterModal.closeModal()}
className='text-gray-500 hover:text-gray-700 me-4 ' className='text-gray-500 hover:text-gray-700'
> >
<Icon icon='heroicons:x-mark' width={20} height={20} /> <Icon icon='heroicons:x-mark' width={20} height={20} />
</Button> </Button>
</div> </div>
<form <form
className='space-y-4' className='flex flex-col'
onSubmit={handleFormSubmit} onSubmit={handleFormSubmit}
onReset={handleResetFilter} onReset={handleResetFilter}
> >
<div className='flex flex-col p-4 gap-1.5'>
{/* Rentang Waktu */} {/* Rentang Waktu */}
<div className='px-4'> <div>
<label className='flex items-center gap-2 mb-3'>Tanggal</label> <label className='flex text-xs items-center gap-2 py-2 font-semibold'>
<div className='flex items-start gap-2'> Tanggal
</label>
<div className='flex items-center gap-2'>
<DateInput <DateInput
name='startDate' name='startDate'
placeholder='Tanggal Mulai' placeholder='Tanggal Mulai'
value={formik.values.startDate} value={formik.values.startDate}
errorMessage={formik.errors.startDate} errorMessage={formik.errors.startDate}
onChange={formik.handleChange} onChange={formik.handleChange}
className={{
inputWrapper: 'rounded-lg',
}}
isError={ isError={
Boolean(formik.errors.startDate) && Boolean(formik.errors.startDate) &&
Boolean(formik.touched.startDate) Boolean(formik.touched.startDate)
} }
/> />
<div className='hidden md:block mt-3 text-center'></div> <hr className='w-full max-w-3 h-px border-base-content/10'></hr>
<DateInput <DateInput
name='endDate' name='endDate'
placeholder='Tanggal Akhir' placeholder='Tanggal Akhir'
value={formik.values.endDate} value={formik.values.endDate}
errorMessage={formik.errors.endDate} errorMessage={formik.errors.endDate}
onChange={formik.handleChange} onChange={formik.handleChange}
className={{
inputWrapper: 'rounded-lg',
}}
isError={ isError={
Boolean(formik.errors.endDate) && Boolean(formik.errors.endDate) &&
Boolean(formik.touched.endDate) Boolean(formik.touched.endDate)
@@ -377,14 +468,18 @@ const DashboardProduction = () => {
</div> </div>
{/* Analysis Mode */} {/* Analysis Mode */}
<div className='px-4'> <div>
<label className='block mb-3'>Analysis Mode</label> <label className='block py-2 text-xs font-semibold'>
Analysis Mode
</label>
<RadioGroup <RadioGroup
name='analysisMode' name='analysisMode'
value={formik.values.analysisMode} value={formik.values.analysisMode}
onChange={(e) => { onChange={(e) => {
formik.handleChange(e); formik.handleChange(e);
setAnalysisMode(e.target.value as 'OVERVIEW' | 'COMPARISON'); setAnalysisMode(
e.target.value as 'OVERVIEW' | 'COMPARISON'
);
// Reset all dependent fields when analysis mode changes // Reset all dependent fields when analysis mode changes
formik.setFieldValue('location', []); formik.setFieldValue('location', []);
formik.setFieldValue('flock', []); formik.setFieldValue('flock', []);
@@ -394,24 +489,27 @@ const DashboardProduction = () => {
}} }}
color='primary' color='primary'
className={{ className={{
wrapper: 'w-full my-6 font-semibold text-neutral-500', wrapper:
'w-full flex flex-row items-center font-medium text-base-content/50',
radioWrapper: 'w-full grid grid-cols-2 gap-0 p-0',
}} }}
> >
<RadioGroupItem <RadioGroupItem
color='primary' color='primary'
value='OVERVIEW' value='OVERVIEW'
label='Performance Overview' label='Performance Overview'
className='w-full p-3'
/> />
<RadioGroupItem <RadioGroupItem
color='primary' color='primary'
value='COMPARISON' value='COMPARISON'
label='Performance Comparison' label='Performance Comparison'
className='w-full p-3'
/> />
</RadioGroup> </RadioGroup>
</div> </div>
{formik.values.analysisMode === 'COMPARISON' && ( {formik.values.analysisMode === 'COMPARISON' && (
<div className='px-4'>
<SelectInputRadio <SelectInputRadio
label='Compared By' label='Compared By'
value={comparisonTypeOptions.find( value={comparisonTypeOptions.find(
@@ -430,12 +528,13 @@ const DashboardProduction = () => {
Boolean(formik.errors.comparisonType) && Boolean(formik.errors.comparisonType) &&
Boolean(formik.touched.comparisonType) Boolean(formik.touched.comparisonType)
} }
className={{
select: 'rounded-lg text-sm border-base-content/10',
}}
/> />
</div>
)} )}
{/* Location */} {/* Location */}
<div className='px-4'>
{comparisonTypeOptions.find( {comparisonTypeOptions.find(
(option) => option.value === formik.values.comparisonType (option) => option.value === formik.values.comparisonType
)?.value === 'FARM' ? ( )?.value === 'FARM' ? (
@@ -465,6 +564,9 @@ const DashboardProduction = () => {
Boolean(formik.errors.location) && Boolean(formik.errors.location) &&
Boolean(formik.touched.location) Boolean(formik.touched.location)
} }
className={{
select: 'rounded-lg text-sm border-base-content/10',
}}
/> />
) : ( ) : (
<SelectInputRadio <SelectInputRadio
@@ -493,9 +595,11 @@ const DashboardProduction = () => {
Boolean(formik.errors.location) && Boolean(formik.errors.location) &&
Boolean(formik.touched.location) Boolean(formik.touched.location)
} }
className={{
select: 'rounded-lg text-sm border-base-content/10',
}}
/> />
)} )}
</div>
{/* Flock */} {/* Flock */}
{!( {!(
@@ -505,7 +609,7 @@ const DashboardProduction = () => {
formik.values.comparisonType === 'KANDANG' formik.values.comparisonType === 'KANDANG'
) )
) && ( ) && (
<div className='px-4'> <>
{comparisonTypeOptions.find( {comparisonTypeOptions.find(
(option) => option.value === formik.values.comparisonType (option) => option.value === formik.values.comparisonType
)?.value === 'FLOCK' ? ( )?.value === 'FLOCK' ? (
@@ -530,6 +634,9 @@ const DashboardProduction = () => {
Boolean(formik.errors.flock) && Boolean(formik.errors.flock) &&
Boolean(formik.touched.flock) Boolean(formik.touched.flock)
} }
className={{
select: 'rounded-lg text-sm border-base-content/10',
}}
/> />
) : ( ) : (
<SelectInputRadio <SelectInputRadio
@@ -553,9 +660,12 @@ const DashboardProduction = () => {
Boolean(formik.errors.flock) && Boolean(formik.errors.flock) &&
Boolean(formik.touched.flock) Boolean(formik.touched.flock)
} }
className={{
select: 'rounded-lg text-sm border-base-content/10',
}}
/> />
)} )}
</div> </>
)} )}
{/* Kandang */} {/* Kandang */}
@@ -563,7 +673,7 @@ const DashboardProduction = () => {
formik.values.analysisMode === 'COMPARISON' && formik.values.analysisMode === 'COMPARISON' &&
!(formik.values.comparisonType === 'KANDANG') !(formik.values.comparisonType === 'KANDANG')
) && ( ) && (
<div className='px-4'> <>
{comparisonTypeOptions.find( {comparisonTypeOptions.find(
(option) => option.value === formik.values.comparisonType (option) => option.value === formik.values.comparisonType
)?.value === 'KANDANG' ? ( )?.value === 'KANDANG' ? (
@@ -588,6 +698,9 @@ const DashboardProduction = () => {
Boolean(formik.errors.kandang) && Boolean(formik.errors.kandang) &&
Boolean(formik.touched.kandang) Boolean(formik.touched.kandang)
} }
className={{
select: 'rounded-lg text-sm border-base-content/10',
}}
/> />
) : ( ) : (
<SelectInputRadio <SelectInputRadio
@@ -611,26 +724,38 @@ const DashboardProduction = () => {
Boolean(formik.errors.kandang) && Boolean(formik.errors.kandang) &&
Boolean(formik.touched.kandang) Boolean(formik.touched.kandang)
} }
className={{
select: 'rounded-lg text-sm border-base-content/10',
}}
/> />
)} )}
</div> </>
)} )}
<div className='w-full p-4'> {formErrorList.length > 0 && (
<AlertErrorList formErrorList={formErrorList} onClose={close} /> <div className='w-full'>
<AlertErrorList
formErrorList={formErrorList}
onClose={close}
/>
</div>
)}
</div> </div>
{/* Action Buttons */} {/* Action Buttons */}
<div className='flex justify-between gap-4 py-4 mt-8 border-t border-gray-300 bg-gray-100'> <div className='flex justify-between gap-4 p-4 border-t border-base-content/10 bg-gray-100'>
<Button <Button
type='reset' type='reset'
variant='soft' variant='soft'
className='ms-4 min-w-36 rounded-lg' className='rounded-lg p-3 bg-gray-100 border-gray-100 text-base-content/65 hover:bg-base-content/10'
> >
Reset Filter Reset Filter
</Button> </Button>
<Button type='submit' className='me-4 min-w-36 rounded-lg'> <Button
Terapkan Filter type='submit'
className='min-w-40 text-sm p-3 text-white rounded-lg'
>
Apply Filter
</Button> </Button>
</div> </div>
</form> </form>
@@ -147,11 +147,12 @@ const DashboardLineChart = ({
return ( return (
<Card <Card
className={{ className={{
wrapper: 'w-full rounded-lg', wrapper: 'w-full rounded-lg p-0',
body: 'p-4',
}} }}
variant='bordered' variant='bordered'
> >
<div className='flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6'> <div className='flex flex-col sm:flex-row justify-between items-start gap-4 mb-3'>
<div className='text-lg font-semibold'> <div className='text-lg font-semibold'>
Performance{' '} Performance{' '}
<Icon <Icon
@@ -165,26 +166,28 @@ const DashboardLineChart = ({
<Dropdown <Dropdown
align='end' align='end'
direction='bottom' direction='bottom'
className={{
content: 'mt-1 min-w-full',
}}
trigger={ trigger={
<Button <Button
variant='outline' variant='outline'
color='none' color='none'
className='text-neutral-500 hover:text-neutral-700 rounded-lg px-4 py-2 border-neutral-300' className='py-2.5 pl-3 pr-2 text-base-content/50 rounded-lg text-sm border-base-content/10 shadow-button-soft'
onClick={() => setOpen(!open)} onClick={() => setOpen(!open)}
> >
{chartTypeLabels[chartData]}{' '} {chartTypeLabels[chartData]}{' '}
<div className='divider divider-horizontal p-0 m-0 before:bg-neutral-300 after:bg-neutral-300'></div> <div className='w-6 h-5 flex items-center justify-center border-l border-base-content/10'>
<Icon icon='heroicons:chevron-down' width={20} height={20} /> <Icon icon='heroicons:chevron-down' width={14} height={14} />
</div>
</Button> </Button>
} }
className={{
content: 'w-52 mt-3',
}}
controlled={open} controlled={open}
> >
<Menu> <Menu className='p-0 w-full shadow-button-soft border border-base-content/10 rounded-lg'>
<MenuItem <MenuItem
title='Body weight' title='Body weight'
className='text-sm padding-3 whitespace-nowrap'
onClick={() => { onClick={() => {
setChartData('body_weight'); setChartData('body_weight');
setOpen(!open); setOpen(!open);
@@ -192,6 +195,7 @@ const DashboardLineChart = ({
/> />
<MenuItem <MenuItem
title='Performance' title='Performance'
className='text-sm padding-3 whitespace-nowrap'
onClick={() => { onClick={() => {
setChartData('performance'); setChartData('performance');
setOpen(!open); setOpen(!open);
@@ -199,6 +203,7 @@ const DashboardLineChart = ({
/> />
<MenuItem <MenuItem
title='FCR' title='FCR'
className='text-sm padding-3 whitespace-nowrap'
onClick={() => { onClick={() => {
setChartData('fcr'); setChartData('fcr');
setOpen(!open); setOpen(!open);
@@ -206,6 +211,7 @@ const DashboardLineChart = ({
/> />
<MenuItem <MenuItem
title='Quality Control' title='Quality Control'
className='text-sm padding-3 whitespace-nowrap'
onClick={() => { onClick={() => {
setChartData('quality_control'); setChartData('quality_control');
setOpen(!open); setOpen(!open);
@@ -213,6 +219,7 @@ const DashboardLineChart = ({
/> />
<MenuItem <MenuItem
title='Deplesi' title='Deplesi'
className='text-sm padding-3 whitespace-nowrap'
onClick={() => { onClick={() => {
setChartData('deplesi'); setChartData('deplesi');
setOpen(!open); setOpen(!open);
@@ -248,8 +255,8 @@ const DashboardLineChart = ({
.includes('std'); .includes('std');
return ( return (
<button <Button
key={series.id} key={`${series.id}-${index}`}
onClick={() => { onClick={() => {
const newVisible = new Set(visibleSeries); const newVisible = new Set(visibleSeries);
if (isVisible) { if (isVisible) {
@@ -259,14 +266,16 @@ const DashboardLineChart = ({
} }
setVisibleSeries(newVisible); setVisibleSeries(newVisible);
}} }}
className={`flex items-center gap-2 px-3 py-2 rounded-lg border transition-colors ${ variant='outline'
color='none'
className={`flex items-center gap-2 p-3 rounded-lg border transition-colors ${
isVisible isVisible
? 'border-neutral-400 bg-neutral-50' ? 'border-base-content/10 hover:bg-base-content/4'
: 'border-neutral-300 hover:bg-neutral-50' : 'border-base-content/10 bg-base-content/4'
}`} }`}
> >
<div <div
className={`w-6 h-0.5 ${ className={`w-5 h-0.5 ${
isStandard ? 'border-t-2 border-dashed' : '' isStandard ? 'border-t-2 border-dashed' : ''
} ${!isVisible ? 'opacity-30' : ''}`} } ${!isVisible ? 'opacity-30' : ''}`}
style={{ style={{
@@ -279,17 +288,17 @@ const DashboardLineChart = ({
}} }}
></div> ></div>
<span <span
className={`text-sm ${isVisible ? 'text-neutral-900 font-medium' : 'text-neutral-700'}`} className={`font-semibold text-sm ${isVisible ? 'text-base-content/50' : 'text-base-content/50'}`}
> >
{series.label} {series.label}
</span> </span>
<Icon <Icon
icon='heroicons:information-circle' icon='heroicons:eye'
width={16} width={16}
height={16} height={16}
className='text-neutral-400' className='text-base-content/40'
/> />
</button> </Button>
); );
}); });
})()} })()}
@@ -335,20 +344,68 @@ const DashboardLineChart = ({
<CartesianGrid strokeDasharray='3 3' stroke='#e5e7eb' /> <CartesianGrid strokeDasharray='3 3' stroke='#e5e7eb' />
<XAxis <XAxis
dataKey='week' dataKey='week'
tick={{ fontSize: 11, fill: '#9ca3af' }} tick={{
fontSize: 12,
fill: '#18181B',
opacity: 0.5,
fontWeight: 600,
}}
tickLine={false} tickLine={false}
axisLine={{ stroke: '#e5e7eb' }} axisLine={{ stroke: '#C1C1C180', opacity: 0.5 }}
label={{ label={{
value: 'Weeks', value: 'Weeks',
position: 'insideBottom', position: 'insideBottom',
offset: -5, offset: -5,
style: { fontSize: 12, fill: '#9ca3af' }, style: {
fontSize: 12,
fill: '#18181B',
opacity: 0.2,
fontWeight: 600,
},
}} }}
/> />
<YAxis <YAxis
tick={{ fontSize: 11, fill: '#9ca3af' }} tick={{
fontSize: 12,
fill: '#18181B',
opacity: 0.5,
fontWeight: 600,
}}
label={
(chartData === 'body_weight' || chartData === 'performance') &&
analysisMode === 'OVERVIEW'
? {
value:
chartData === 'body_weight'
? 'Body Weight'
: 'Percentage',
position: 'insideLeft',
angle: -90,
offset: 5,
style: {
fontSize: 12,
fill: '#18181B',
opacity: 0.2,
fontWeight: 600,
},
}
: analysisMode === 'COMPARISON'
? {
value: 'Percentage',
position: 'insideLeft',
angle: -90,
offset: 5,
style: {
fontSize: 12,
fill: '#18181B',
opacity: 0.2,
fontWeight: 600,
},
}
: undefined
}
tickLine={false} tickLine={false}
axisLine={{ stroke: '#e5e7eb' }} axisLine={{ stroke: '#C1C1C180', opacity: 0.5 }}
domain={(() => { domain={(() => {
// Calculate dynamic domain based on visible data // Calculate dynamic domain based on visible data
let seriesData: DashboardChartsSeries[] = []; let seriesData: DashboardChartsSeries[] = [];
@@ -399,14 +456,12 @@ const DashboardLineChart = ({
})()} })()}
ticks={(() => { ticks={(() => {
// Calculate dynamic ticks based on domain // Calculate dynamic ticks based on domain
let seriesData: DashboardChartsSeries[] = [];
let dataset: DashboardChartsDataset[] = []; let dataset: DashboardChartsDataset[] = [];
if ( if (
analysisMode === 'OVERVIEW' && analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts) isOverviewCharts(data.charts)
) { ) {
seriesData = data.charts[chartData]?.series || [];
dataset = data.charts[chartData]?.dataset || []; dataset = data.charts[chartData]?.dataset || [];
} else if ( } else if (
analysisMode === 'COMPARISON' && analysisMode === 'COMPARISON' &&
@@ -416,7 +471,6 @@ const DashboardLineChart = ({
data.charts.farm || data.charts.farm ||
data.charts.flock || data.charts.flock ||
data.charts.kandang; data.charts.kandang;
seriesData = comparisonChart?.series || [];
dataset = comparisonChart?.dataset || []; dataset = comparisonChart?.dataset || [];
} }
@@ -436,6 +490,20 @@ const DashboardLineChart = ({
const minValue = Math.min(...allValues); const minValue = Math.min(...allValues);
const maxValue = Math.max(...allValues); const maxValue = Math.max(...allValues);
// Handle edge case where min equals max
if (minValue === maxValue) {
const value = Math.round(minValue);
const padding = Math.max(10, Math.abs(value) * 0.2);
return [
Math.floor(value - padding),
Math.floor(value - padding / 2),
value,
Math.ceil(value + padding / 2),
Math.ceil(value + padding),
];
}
const padding = (maxValue - minValue) * 0.1; const padding = (maxValue - minValue) * 0.1;
const domainMin = Math.floor(Math.max(0, minValue - padding)); const domainMin = Math.floor(Math.max(0, minValue - padding));
const domainMax = Math.ceil(maxValue + padding); const domainMax = Math.ceil(maxValue + padding);
@@ -444,21 +512,25 @@ const DashboardLineChart = ({
const range = domainMax - domainMin; const range = domainMax - domainMin;
const step = range / 4; const step = range / 4;
return [ // Use Set to ensure unique values
const tickSet = new Set([
domainMin, domainMin,
Math.round(domainMin + step), Math.round(domainMin + step),
Math.round(domainMin + step * 2), Math.round(domainMin + step * 2),
Math.round(domainMin + step * 3), Math.round(domainMin + step * 3),
domainMax, domainMax,
]; ]);
return Array.from(tickSet).sort((a, b) => a - b);
})()} })()}
tickFormatter={(value) => formatNumber(Number(value))}
/> />
<Tooltip <Tooltip
contentStyle={{ contentStyle={{
backgroundColor: '#1f2937', backgroundColor: '#1f2937',
border: 'none', border: 'none',
borderRadius: '8px', borderRadius: '8px',
padding: '8px 12px', padding: '12px 12px',
color: 'white', color: 'white',
}} }}
labelStyle={{ color: 'white', marginBottom: '4px' }} labelStyle={{ color: 'white', marginBottom: '4px' }}
@@ -466,8 +538,8 @@ const DashboardLineChart = ({
labelFormatter={(value) => `Week ${value}`} labelFormatter={(value) => `Week ${value}`}
content={(props) => { content={(props) => {
return ( return (
<div className='flex flex-col gap-2 rounded-lg bg-neutral-950 p-4 text-white'> <div className='flex flex-col gap-1.5 rounded-lg bg-neutral-950 p-4 text-white'>
<p className='text-neutral-300 text-xs font-semibold text-start'> <p className='text-white/50 text-xs font-semibold text-start'>
{analysisMode === 'OVERVIEW' {analysisMode === 'OVERVIEW'
? selectedKandang ? selectedKandang
? selectedKandang.label || 'Overview Performance' ? selectedKandang.label || 'Overview Performance'
@@ -506,12 +578,12 @@ const DashboardLineChart = ({
return ( return (
<li <li
key={item.name} key={`${item.name}-${index}`}
className='flex w-full justify-between items-center flex-row gap-6 p-0' className='flex w-full justify-between items-center flex-row gap-y-1.5 gap-x-3 p-0'
> >
<span className='flex flex-row gap-1 items-center'> <span className='flex flex-row gap-1.5 items-center'>
<div <div
className='h-4 w-4 m-0 rounded-md' className='h-5 w-5 m-0 rounded'
style={{ style={{
backgroundColor: color, backgroundColor: color,
}} }}
@@ -526,7 +598,7 @@ const DashboardLineChart = ({
); );
})} })}
</ul> </ul>
<p className='text-neutral-300 text-xs text-start'> <p className='text-white/50 text-xs text-start'>
Week {props.label} Week {props.label}
</p> </p>
</div> </div>
@@ -598,7 +670,7 @@ const DashboardLineChart = ({
return ( return (
<Line <Line
key={series.id} key={`${series.id}--${index}`}
type='monotone' type='monotone'
dataKey={dataKey} dataKey={dataKey}
name={series.label} name={series.label}
@@ -649,20 +721,22 @@ const DashboardLineChart = ({
return ( return (
<div className='absolute inset-x-0 inset-y-15 z-10 flex flex-col items-center justify-center rounded-lg'> <div className='absolute inset-x-0 inset-y-15 z-10 flex flex-col items-center justify-center rounded-lg'>
{/* Chart icon */} {/* Chart icon */}
<div className='w-12 h-12 bg-blue-500 rounded-xl flex items-center justify-center mb-4'> <div className='w-12.5 h-12.5 bg-[var(--main-color-base-100,#FFFFFF)] border border-base-content/10 rounded-[0.875rem] border border-base-content shadow-[0px_25px_50px_-12px_#00000040] flex items-center justify-center mb-2'>
<div className='w-9.5 h-9.5 bg-primary rounded-lg border border-primary flex items-center justify-center shadow-[inset_0px_4px_4px_0px_#FFFFFF80,inset_0px_2px_0px_0px_#FFFFFF80]'>
<Icon <Icon
icon='heroicons:chart-bar' icon='heroicons:chart-bar'
className='text-white' className='text-white'
width={24} width={20}
height={24} height={20}
/> />
</div> </div>
</div>
{/* Empty state text */} {/* Empty state text */}
<h3 className='text-gray-900 font-semibold text-base mb-2'> <h3 className='text-base-content/50 font-semibold text-sm mb-1'>
Data Not Yet Available Data Not Yet Available
</h3> </h3>
<p className='text-gray-500 text-sm text-center max-w-xs'> <p className='text-base-content/50 text-xs text-center max-w-xs'>
Please change your filters to get the data. Please change your filters to get the data.
</p> </p>
</div> </div>
@@ -21,7 +21,7 @@ const CARD_CONFIG = [
key: 'Avg. Selling Price', key: 'Avg. Selling Price',
icon: 'heroicons:document-currency-dollar', icon: 'heroicons:document-currency-dollar',
alertColor: 'success' as const, alertColor: 'success' as const,
suffix: ' /Kg', suffix: ' /Kg Telur',
prefix: '', prefix: '',
}, },
{ {
@@ -48,7 +48,7 @@ const DashboardStats = ({ data }: DashboardStatsProps) => {
icon: isPositive icon: isPositive
? 'heroicons:arrow-trending-up' ? 'heroicons:arrow-trending-up'
: 'heroicons:arrow-trending-down', : 'heroicons:arrow-trending-down',
color: isPositive ? 'text-success' : 'text-error', color: isPositive ? 'text-[#008000]' : 'text-[#FF3A3A]',
value: Math.abs(percent), value: Math.abs(percent),
}; };
}; };
@@ -60,14 +60,16 @@ const DashboardStats = ({ data }: DashboardStatsProps) => {
{prefix} {prefix}
{formatNumber(value)} {formatNumber(value)}
{suffix && ( {suffix && (
<span className='text-sm font-normal text-neutral-500'>{suffix}</span> <span className='text-sm font-normal text-base-content/50'>
{suffix}
</span>
)} )}
</> </>
); );
}; };
return ( return (
<div className='grid sm:grid-cols-2 xl:grid-cols-4 gap-6'> <div className='grid sm:grid-cols-2 xl:grid-cols-4 gap-3'>
{CARD_CONFIG.map((config) => { {CARD_CONFIG.map((config) => {
// Find matching data from API // Find matching data from API
const cardData = data.find((item) => item.label === config.key); const cardData = data.find((item) => item.label === config.key);
@@ -78,35 +80,41 @@ const DashboardStats = ({ data }: DashboardStatsProps) => {
<Card <Card
key={config.key} key={config.key}
className={{ className={{
wrapper: 'w-full rounded-lg', wrapper: 'w-full rounded-xl border border-base-content/10',
body: 'p-0', body: 'p-0',
wrapperContent:
'h-full flex flex-col items-between justify-between',
footer: 'mt-0!',
}} }}
variant='bordered' variant='bordered'
footer={ footer={
<div className='flex flex-row justify-between px-4 pb-4'> <div className='flex flex-row justify-between px-4 pb-4 max-h-12'>
<div className='text-neutral-400 font-semibold text-sm'> <div className='text-base-content/50 font-semibold text-xs'>
From last month From last month
</div> </div>
<div className='text-neutral-400 font-semibold text-sm'> <div className='text-base-content/50 font-semibold text-xs'>
Filter Required Filter Required
</div> </div>
</div> </div>
} }
> >
<div className='flex flex-row items-center gap-4 px-4 pt-4'> <div className='flex flex-row items-center gap-3 px-4 py-4'>
<Alert variant='soft' className='rounded-lg p-3 bg-neutral-100'> <Alert
variant='soft'
className={`rounded-lg p-0 w-12.5 h-12.5 bg-[${config.alertColor}]/12 flex items-center justify-center`}
>
<Icon <Icon
icon={config.icon} icon={config.icon}
width={32} width={24}
height={32} height={24}
className='text-neutral-400' className='text-base-content/50'
/> />
</Alert> </Alert>
<div> <div>
<h3 className='text-neutral-400 font-semibold text-sm'> <h3 className='text-base-content/50 font-semibold text-sm'>
{config.key} {config.key}
</h3> </h3>
<p className='text-2xl font-semibold text-neutral-400'> <p className='text-xl font-semibold text-base-content/50'>
******** ********
</p> </p>
</div> </div>
@@ -121,17 +129,20 @@ const DashboardStats = ({ data }: DashboardStatsProps) => {
<Card <Card
key={config.key} key={config.key}
className={{ className={{
wrapper: 'w-full rounded-lg', wrapper: 'w-full rounded-xl border border-base-content/10',
body: 'p-0', body: 'p-0',
wrapperContent:
'h-full flex flex-col items-between justify-between',
footer: 'mt-0!',
}} }}
variant='bordered' variant='bordered'
footer={ footer={
<div className='flex flex-row justify-between px-4 pb-4'> <div className='flex flex-row justify-between px-4 pb-4'>
<div className='text-neutral-500 font-semibold text-sm'> <div className='text-base-content/50 font-semibold text-xs'>
From last month From last month
</div> </div>
<div <div
className={`${trend.color} font-semibold flex flex-row items-center gap-1 text-sm`} className={`${trend.color} font-semibold flex flex-row items-center gap-2 text-xs`}
> >
<Icon icon={trend.icon} width={16} height={16} /> <Icon icon={trend.icon} width={16} height={16} />
{trend.value}% {trend.value}%
@@ -143,15 +154,15 @@ const DashboardStats = ({ data }: DashboardStatsProps) => {
<Alert <Alert
variant='soft' variant='soft'
color={config.alertColor} color={config.alertColor}
className='rounded-lg p-3' className={`rounded-lg p-3 bg-[${config.alertColor}]/12 flex items-center justify-center`}
> >
<Icon icon={config.icon} width={32} height={32} /> <Icon icon={config.icon} width={24} height={24} />
</Alert> </Alert>
<div> <div className='space-y-1'>
<h3 className='text-neutral-500 font-semibold text-sm'> <h3 className='text-base-content/50 font-semibold text-sm'>
{cardData.label} {cardData.label}
</h3> </h3>
<p className='text-2xl font-semibold'> <p className='text-xl font-semibold'>
{formatValue(cardData.value, config.prefix, config.suffix)} {formatValue(cardData.value, config.prefix, config.suffix)}
</p> </p>
</div> </div>
@@ -17,12 +17,12 @@ import {
YAxis, YAxis,
} from 'recharts'; } from 'recharts';
type DashboardAllChartsProps = { type DashboardExportChartsProps = {
data: Dashboard; data: Dashboard;
analysisMode: string; analysisMode: string;
}; };
export type DashboardAllChartsRef = { export type DashboardExportChartsRef = {
getChartRefs: () => { getChartRefs: () => {
key: string; key: string;
ref: HTMLDivElement | null; ref: HTMLDivElement | null;
@@ -99,9 +99,9 @@ const chartTypeLabels: Record<keyof DashboardOverviewCharts, string> = {
deplesi: 'Deplesi', deplesi: 'Deplesi',
}; };
const DashboardAllCharts = forwardRef< const DashboardExportCharts = forwardRef<
DashboardAllChartsRef, DashboardExportChartsRef,
DashboardAllChartsProps DashboardExportChartsProps
>(({ data, analysisMode }, ref) => { >(({ data, analysisMode }, ref) => {
// Create refs for charts - use string keys for flexibility // Create refs for charts - use string keys for flexibility
const chartRefs = useRef<{ const chartRefs = useRef<{
@@ -189,7 +189,8 @@ const DashboardAllCharts = forwardRef<
> >
<Card <Card
className={{ className={{
wrapper: 'w-full rounded-lg', wrapper: 'w-full rounded-lg p-0',
body: 'p-4',
}} }}
variant='bordered' variant='bordered'
> >
@@ -338,6 +339,6 @@ const DashboardAllCharts = forwardRef<
); );
}); });
DashboardAllCharts.displayName = 'DashboardAllCharts'; DashboardExportCharts.displayName = 'DashboardExportCharts';
export default DashboardAllCharts; export default DashboardExportCharts;
@@ -0,0 +1,197 @@
import Alert from '@/components/Alert';
import Card from '@/components/Card';
import { formatNumber } from '@/lib/helper';
import { DashboardStatisticsData } from '@/types/api/dashboard/dashboard';
import { Icon } from '@iconify/react';
import { forwardRef, useImperativeHandle, useRef } from 'react';
interface DashboardStatsProps {
data: DashboardStatisticsData[];
}
export type DashboardExportStatsRef = {
getStatsRefs: () => {
key: string;
ref: HTMLDivElement | null;
label: string;
}[];
getContainerRef: () => HTMLDivElement | null;
};
// Konfigurasi untuk setiap kartu
const CARD_CONFIG = [
{
key: 'HPP Global',
icon: 'heroicons:banknotes',
alertColor: 'warning' as const,
suffix: ' /Kg',
prefix: 'RP ',
},
{
key: 'Avg. Selling Price',
icon: 'heroicons:document-currency-dollar',
alertColor: 'success' as const,
suffix: ' /Kg',
prefix: '',
},
{
key: 'FCR',
icon: 'heroicons:clipboard-document-list',
alertColor: 'info' as const,
suffix: '',
prefix: '',
},
{
key: 'Mortality',
icon: 'heroicons:exclamation-triangle',
alertColor: 'error' as const,
suffix: ' %',
prefix: '',
},
];
const DashboardExportStats = forwardRef<
DashboardExportStatsRef,
DashboardStatsProps
>(({ data }, ref) => {
const containerRef = useRef<HTMLDivElement>(null);
// Helper to get trend icon and color
const getTrendDisplay = (percent: number) => {
const isPositive = percent >= 0;
return {
icon: isPositive
? 'heroicons:arrow-trending-up'
: 'heroicons:arrow-trending-down',
color: isPositive ? 'text-[#008000]' : 'text-[#FF3A3A]',
value: Math.abs(percent),
};
};
// Helper to format value
const formatValue = (value: number, prefix: string, suffix: string) => {
return (
<>
{prefix}
{formatNumber(value)}
{suffix && (
<span className='text-sm font-normal text-base-content/50'>
{suffix}
</span>
)}
</>
);
};
// Expose container ref through imperative handle
useImperativeHandle(ref, () => ({
getStatsRefs: () => [],
getContainerRef: () => containerRef.current,
}));
return (
<div ref={containerRef} className='grid grid-cols-4 gap-3'>
{CARD_CONFIG.map((config) => {
// Find matching data from API
const cardData = data.find((item) => item.label === config.key);
if (!cardData) {
// Show placeholder card for missing data (FCR & Mortality)
return (
<Card
key={config.key}
className={{
wrapper: 'w-full rounded-lg',
body: 'p-0',
wrapperContent:
'h-full flex flex-col items-between justify-between',
footer: 'mt-0!',
}}
variant='bordered'
footer={
<div className='flex flex-row justify-between px-4 pb-4 max-h-12'>
<div className='text-base-content/50 font-semibold text-xs'>
From last month
</div>
<div className='text-base-content/50 font-semibold text-xs'>
Filter Required
</div>
</div>
}
>
<div className='flex flex-row items-center gap-3 px-4 pt-4'>
<Alert
variant='soft'
className={`rounded-lg p-0 w-12.5 h-12.5 bg-[${config.alertColor}]/12 flex items-center justify-center`}
>
<Icon
icon={config.icon}
width={24}
height={24}
className='text-base-content/50'
/>
</Alert>
<div>
<h3 className='text-base-content/50 font-semibold text-sm'>
{config.key}
</h3>
<p className='text-xl font-semibold text-base-content/50'>
********
</p>
</div>
</div>
</Card>
);
}
const trend = getTrendDisplay(cardData.percent_last_month);
return (
<Card
key={config.key}
className={{
wrapper: 'w-full rounded-lg border border-base-content/10',
body: 'p-0',
wrapperContent:
'h-full flex flex-col items-between justify-between',
footer: 'mt-0!',
}}
variant='bordered'
footer={
<div className='flex flex-row justify-between px-4 pb-4 max-h-12'>
<div className='text-base-content/50 font-semibold text-xs'>
From last month
</div>
<div
className={`${trend.color} font-semibold flex flex-row items-center gap-2 text-xs`}
>
<Icon icon={trend.icon} width={16} height={16} />
{trend.value}%
</div>
</div>
}
>
<div className='flex flex-row items-center gap-4 px-4 pt-4'>
<Alert
variant='soft'
color={config.alertColor}
className={`rounded-lg p-0 w-12.5 h-12.5 bg-[${config.alertColor}]/12 flex items-center justify-center`}
>
<Icon icon={config.icon} width={24} height={24} />
</Alert>
<div>
<h3 className='text-base-content/50 font-semibold text-sm'>
{cardData.label}
</h3>
<p className='text-xl font-semibold'>
{formatValue(cardData.value, config.prefix, config.suffix)}
</p>
</div>
</div>
</Card>
);
})}
</div>
);
});
DashboardExportStats.displayName = 'DashboardExportStats';
export default DashboardExportStats;
@@ -3,18 +3,19 @@ import { toPng } from 'html-to-image';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { formatDate } from '@/lib/helper'; import { formatDate } from '@/lib/helper';
import { DashboardFilterType } from '@/components/pages/dashboard/filter/DashboardProductionFilter.schema'; import { DashboardFilterType } from '@/components/pages/dashboard/filter/DashboardProductionFilter.schema';
import { DashboardAllChartsRef } from '@/components/pages/dashboard/chart/DashboardAllCharts'; import { DashboardExportChartsRef } from '@/components/pages/dashboard/export/DashboardExportCharts';
import { DashboardExportStatsRef } from '@/components/pages/dashboard/export/DashboardExportStats';
interface DashboardPDFExportParams { interface DashboardPDFExportParams {
filterValues: DashboardFilterType; filterValues: DashboardFilterType;
statsRef: React.RefObject<HTMLDivElement | null>; allStatsRef: React.RefObject<DashboardExportStatsRef | null>;
allChartsRef: React.RefObject<DashboardAllChartsRef | null>; allChartsRef: React.RefObject<DashboardExportChartsRef | null>;
setExporting: (value: boolean) => void; setExporting: (value: boolean) => void;
} }
export const generateDashboardPDF = async ({ export const generateDashboardPDF = async ({
filterValues, filterValues,
statsRef, allStatsRef,
allChartsRef, allChartsRef,
setExporting, setExporting,
}: DashboardPDFExportParams): Promise<void> => { }: DashboardPDFExportParams): Promise<void> => {
@@ -168,8 +169,10 @@ export const generateDashboardPDF = async ({
yPosition += 10; yPosition += 10;
// Capture and add stats if available // Capture and add stats if available
if (statsRef.current) { if (allStatsRef.current) {
const statsImage = await toPng(statsRef.current, { const statsContainer = allStatsRef.current.getContainerRef();
if (statsContainer) {
const statsImage = await toPng(statsContainer, {
quality: 1, quality: 1,
pixelRatio: 2, pixelRatio: 2,
}); });
@@ -194,6 +197,7 @@ export const generateDashboardPDF = async ({
); );
yPosition += statsHeight + 10; yPosition += statsHeight + 10;
} }
}
if (allChartsRef.current) { if (allChartsRef.current) {
// Get all individual chart refs // Get all individual chart refs
@@ -3,53 +3,45 @@ import { DashboardMeta } from '@/types/api/dashboard/dashboard';
const DashboardLineChartSkeleton = ({ meta }: { meta?: DashboardMeta }) => { const DashboardLineChartSkeleton = ({ meta }: { meta?: DashboardMeta }) => {
return ( return (
<div className='w-full bg-white rounded-lg shadow-sm border border-gray-200 p-6 relative'> <div className='w-full bg-white rounded-xl border border-base-content/10 p-4 relative'>
{/* Header with title skeleton */} {/* Header with title skeleton */}
<div className='text-lg font-semibold'> <div className='text-base font-semibold'>
Performance{' '} Performance{' '}
<Icon <Icon
icon='heroicons:information-circle' icon='heroicons:information-circle'
width={20} width={20}
height={20} height={20}
className='inline text-neutral-500' className='inline text-base-content/50'
/> />
</div> </div>
{/* Chart area with axes skeleton */} {/* Chart area with axes skeleton */}
<div className='relative mt-6 '> <div className='relative mt-6 '>
{/* Main chart container */}
<div className='flex gap-4'>
{/* Y-axis skeleton (left side) */}
<div className='flex flex-col justify-between py-4 space-y-4'>
{[1, 2, 3, 4, 5, 6].map((item) => (
<div
key={item}
className='h-4 w-12 bg-gray-100 rounded animate-pulse'
></div>
))}
</div>
{/* Chart content area */} {/* Chart content area */}
<div className='flex-1 relative'> <div className='flex-1 relative'>
{/* Empty state centered in chart area */} {/* Empty state centered in chart area */}
<div className='absolute inset-0 flex flex-col items-center justify-center pb-12'> <div className='absolute inset-0 flex flex-col items-center justify-center pb-10'>
{!meta?.filters && ( {!meta?.filters && (
<> <>
{/* Filter icon */} {/* Filter icon */}
<div className='w-12 h-12 bg-blue-500 rounded-xl flex items-center justify-center mb-4'> <div className='mb-2'>
<div className='w-12.5 h-12.5 bg-[var(--main-color-base-100,#FFFFFF)] border border-base-content/10 rounded-[0.875rem] border border-base-content shadow-[0px_25px_50px_-12px_#00000040] flex items-center justify-center'>
<div className='w-9.5 h-9.5 bg-primary rounded-lg border border-primary flex items-center justify-center shadow-[inset_0px_4px_4px_0px_#FFFFFF80,inset_0px_2px_0px_0px_#FFFFFF80]'>
<Icon <Icon
icon='heroicons:funnel' icon='heroicons:funnel'
className='text-white' className='text-white'
width={24} width={20}
height={24} height={20}
/> />
</div> </div>
</div>
</div>
{/* Empty state text */} {/* Empty state text */}
<h3 className='text-gray-900 font-semibold text-base mb-2'> <h3 className='text-base-content/50 font-semibold text-sm mb-1'>
No Filters Selected No Filters Selected
</h3> </h3>
<p className='text-gray-500 text-sm text-center max-w-xs'> <p className='text-base-content/50 text-xs text-center max-w-xs'>
Please choose filters to narrow down your results and make Please choose filters to narrow down your results and make
your search easier. your search easier.
</p> </p>
@@ -58,38 +50,53 @@ const DashboardLineChartSkeleton = ({ meta }: { meta?: DashboardMeta }) => {
{meta?.filters && ( {meta?.filters && (
<> <>
{/* Filter icon */} {/* Filter icon */}
<div className='w-12 h-12 bg-blue-500 rounded-xl flex items-center justify-center mb-4'> <div className='w-12.5 h-12.5 bg-[var(--main-color-base-100,#FFFFFF)] border border-base-content/10 rounded-[0.875rem] border border-base-content shadow-[0px_25px_50px_-12px_#00000040] flex items-center justify-center mb-2'>
<div className='w-9.5 h-9.5 bg-primary rounded-lg border border-primary flex items-center justify-center shadow-[inset_0px_4px_4px_0px_#FFFFFF80,inset_0px_2px_0px_0px_#FFFFFF80]'>
<Icon <Icon
icon='heroicons:chart-bar' icon='heroicons:chart-bar'
className='text-white' className='text-white'
width={24} width={20}
height={24} height={20}
/> />
</div> </div>
</div>
{/* Empty state text */} {/* Empty state text */}
<h3 className='text-gray-900 font-semibold text-base mb-2'> <h3 className='text-base-content/50 font-semibold text-sm mb-1'>
Data Not Yet Available Data Not Yet Available
</h3> </h3>
<p className='text-gray-500 text-sm text-center max-w-xs'> <p className='text-base-content/50 text-xs text-center max-w-xs'>
Please change your filters to get the data. Please change your filters to get the data.
</p> </p>
</> </>
)} )}
</div> </div>
{/* Placeholder for chart height */} <div className='flex flex-row w-full items-center gap-4'>
<div className='h-64'></div> <div className='flex-1 h-full min-w-4'>
<div className='h-28.5 w-4 bg-base-content/4 rounded'></div>
</div>
<div className='w-full grid grid-cols-1 gap-y-13.25 mb-2'>
{[1, 2, 3, 4].map((item) => (
<div key={item} className='flex items-center w-full h-4 gap-4'>
<div className='h-4 w-6 bg-base-content/4 rounded'></div>
<div className='h-0.25 w-full bg-base-content/4 rounded'></div>
</div>
))}
</div>
</div>
{/* X-axis skeleton (bottom) */} {/* X-axis skeleton (bottom) */}
<div className='flex justify-between pt-4 border-t border-gray-100'> <div className='grid grid-cols-10 gap-15 mt-4 ps-13 sm:ps-26 overflow-x-hidden'>
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((item) => ( {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((item) => (
<div <div
key={item} key={item}
className='h-4 w-8 bg-gray-100 rounded animate-pulse' className='h-4 w-9.5 bg-base-content/4 rounded'
></div> ></div>
))} ))}
</div> </div>
<div className='flex justify-center pt-4 ps-13 sm:ps-26'>
<div className='h-4 w-28.5 bg-base-content/4 rounded'></div>
</div> </div>
</div> </div>
</div> </div>
@@ -123,23 +123,61 @@ const DeliveryObjectSchema: Yup.ObjectSchema<DeliverySchema> = Yup.object({
.transform((value) => .transform((value) =>
isNaN(value) || value === '' || value === null ? undefined : value isNaN(value) || value === '' || value === null ? undefined : value
) )
.when('supplier_id', {
is: (supplier_id: number | null | undefined) =>
supplier_id !== null && supplier_id !== undefined && supplier_id > 0,
then: (schema) =>
schema
.required('Biaya pengiriman wajib diisi!')
.min(1, 'Biaya minimal 1!')
.typeError('Biaya harus berupa angka!'),
otherwise: (schema) =>
schema
.optional() .optional()
.nullable() .nullable()
.min(1, 'Biaya minimal 1!') .min(1, 'Biaya minimal 1!')
.typeError('Biaya harus berupa angka!'), .typeError('Biaya harus berupa angka!'),
}),
delivery_cost_per_item: Yup.number() delivery_cost_per_item: Yup.number()
.transform((value) => .transform((value) =>
isNaN(value) || value === '' || value === null ? undefined : value isNaN(value) || value === '' || value === null ? undefined : value
) )
.when('supplier_id', {
is: (supplier_id: number | null | undefined) =>
supplier_id !== null && supplier_id !== undefined && supplier_id > 0,
then: (schema) =>
schema
.required('Biaya per item wajib diisi!')
.min(1, 'Biaya per item minimal 1!')
.typeError('Biaya per item harus berupa angka!'),
otherwise: (schema) =>
schema
.optional() .optional()
.nullable() .nullable()
.min(1, 'Biaya per item minimal 1!') .min(1, 'Biaya per item minimal 1!')
.typeError('Biaya per item harus berupa angka!'), .typeError('Biaya per item harus berupa angka!'),
}),
document_path: Yup.string().nullable().optional(), document_path: Yup.string().nullable().optional(),
document_index: Yup.number().optional(), document_index: Yup.number().optional(),
document: DeliveryDocumentSchema, document: DeliveryDocumentSchema,
driver_name: Yup.string().optional().nullable(), driver_name: Yup.string().when('supplier_id', {
vehicle_plate: Yup.string().optional().nullable(), is: (supplier_id: number | null | undefined) =>
supplier_id !== null && supplier_id !== undefined && supplier_id > 0,
then: (schema) =>
schema
.required('Nama sopir wajib diisi!')
.min(1, 'Nama sopir wajib diisi!'),
otherwise: (schema) => schema.optional().nullable(),
}),
vehicle_plate: Yup.string().when('supplier_id', {
is: (supplier_id: number | null | undefined) =>
supplier_id !== null && supplier_id !== undefined && supplier_id > 0,
then: (schema) =>
schema
.required('Plat nomor wajib diisi!')
.min(1, 'Plat nomor wajib diisi!'),
otherwise: (schema) => schema.optional().nullable(),
}),
supplier: Yup.object({ supplier: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
@@ -984,6 +984,28 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
[formik.values.deliveries, formik.values.products, type] [formik.values.deliveries, formik.values.products, type]
); );
const isSupplierSelected = useCallback(
(deliveryIdx: number) => {
const delivery = formik.values.deliveries?.[deliveryIdx];
return (
delivery &&
delivery.supplier_id !== null &&
delivery.supplier_id !== undefined &&
delivery.supplier_id > 0
);
},
[formik.values.deliveries]
);
const hasAnySupplierSelected = useMemo(() => {
return formik.values.deliveries?.some(
(delivery) =>
delivery.supplier_id !== null &&
delivery.supplier_id !== undefined &&
delivery.supplier_id > 0
);
}, [formik.values.deliveries]);
// ===== COMPUTED VALUES ===== // ===== COMPUTED VALUES =====
const invalidQtyRows = useMemo( const invalidQtyRows = useMemo(
() => () =>
@@ -1659,12 +1681,62 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<span className='text-error'>*</span> <span className='text-error'>*</span>
</span> </span>
</th> </th>
<th>Supplier</th> <th>
<th>Plat Nomor</th> Supplier
{hasAnySupplierSelected && (
<span
className='tooltip tooltip-error tooltip-bottom z-9999'
data-tip='required jika supplier dipilih'
>
<span className='text-error'>*</span>
</span>
)}
</th>
<th>
Plat Nomor
{hasAnySupplierSelected && (
<span
className='tooltip tooltip-error tooltip-bottom z-9999'
data-tip='required jika supplier dipilih'
>
<span className='text-error'>*</span>
</span>
)}
</th>
<th>Dokumen</th> <th>Dokumen</th>
<th>Biaya Pengiriman (Rp.)</th> <th>
<th>Biaya Per Item (Rp.)</th> Biaya Pengiriman (Rp.)
<th>Nama Sopir</th> {hasAnySupplierSelected && (
<span
className='tooltip tooltip-error tooltip-bottom z-9999'
data-tip='required jika supplier dipilih'
>
<span className='text-error'>*</span>
</span>
)}
</th>
<th>
Biaya Per Item (Rp.)
{hasAnySupplierSelected && (
<span
className='tooltip tooltip-error tooltip-bottom z-9999'
data-tip='required jika supplier dipilih'
>
<span className='text-error'>*</span>
</span>
)}
</th>
<th>
Nama Sopir
{hasAnySupplierSelected && (
<span
className='tooltip tooltip-error tooltip-bottom z-9999'
data-tip='required jika supplier dipilih'
>
<span className='text-error'>*</span>
</span>
)}
</th>
{type !== 'detail' && <th>Aksi</th>} {type !== 'detail' && <th>Aksi</th>}
</tr> </tr>
</thead> </thead>
@@ -1772,6 +1844,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
idx idx
)} )}
readOnly={type === 'detail'} readOnly={type === 'detail'}
required={isSupplierSelected(idx)}
className={{ className={{
wrapper: 'w-full min-w-52 md:min-w-72 lg:min-w-80', wrapper: 'w-full min-w-52 md:min-w-72 lg:min-w-80',
}} }}
@@ -1867,6 +1940,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
idx idx
)} )}
readOnly={type === 'detail'} readOnly={type === 'detail'}
required={isSupplierSelected(idx)}
className={{ className={{
wrapper: 'w-full min-w-52 md:min-w-72 lg:min-w-80', wrapper: 'w-full min-w-52 md:min-w-72 lg:min-w-80',
}} }}
@@ -1890,6 +1964,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
idx idx
)} )}
readOnly={type === 'detail'} readOnly={type === 'detail'}
required={isSupplierSelected(idx)}
className={{ className={{
wrapper: 'w-full min-w-52 md:min-w-72 lg:min-w-80', wrapper: 'w-full min-w-52 md:min-w-72 lg:min-w-80',
}} }}
@@ -1908,6 +1983,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
idx idx
)} )}
readOnly={type === 'detail'} readOnly={type === 'detail'}
required={isSupplierSelected(idx)}
className={{ className={{
wrapper: 'w-full min-w-52 md:min-w-72 lg:min-w-80', wrapper: 'w-full min-w-52 md:min-w-72 lg:min-w-80',
}} }}
+22
View File
@@ -0,0 +1,22 @@
'use client';
import { ReactNode } from 'react';
import { StateCreator } from 'zustand';
import { UIStore } from '@/types/stores';
type NavbarActionsSlice = {
navbarActions: ReactNode | null;
setNavbarActions: (actions: ReactNode) => void;
clearNavbarActions: () => void;
};
export const createNavbarActionsSlice: StateCreator<
UIStore,
[],
[],
NavbarActionsSlice
> = (set) => ({
navbarActions: null,
setNavbarActions: (actions) => set({ navbarActions: actions }),
clearNavbarActions: () => set({ navbarActions: null }),
});
+2
View File
@@ -7,6 +7,7 @@ import { UIStore } from '@/types/stores';
import { createMainUiSlice } from '@/stores/ui/slices/main.slice'; import { createMainUiSlice } from '@/stores/ui/slices/main.slice';
import { createDrawerUISlice } from '@/stores/ui/slices/drawer.slice'; import { createDrawerUISlice } from '@/stores/ui/slices/drawer.slice';
import { createTableUISlice } from '@/stores/ui/slices/table.slice'; import { createTableUISlice } from '@/stores/ui/slices/table.slice';
import { createNavbarActionsSlice } from '@/stores/ui/slices/navbar.slice';
export const useUiStore = create<UIStore>()( export const useUiStore = create<UIStore>()(
devtools( devtools(
@@ -15,6 +16,7 @@ export const useUiStore = create<UIStore>()(
...createMainUiSlice(...args), ...createMainUiSlice(...args),
...createDrawerUISlice(...args), ...createDrawerUISlice(...args),
...createTableUISlice(...args), ...createTableUISlice(...args),
...createNavbarActionsSlice(...args),
}), }),
{ {
name: 'ui-cache', name: 'ui-cache',
+11 -1
View File
@@ -32,7 +32,17 @@ type TableUISlice = {
resetSearchValue: () => void; resetSearchValue: () => void;
}; };
export type UIStore = MainUiSlice & DrawerUISlice & TableUISlice; // Navbar Actions Slice
type NavbarActionsSlice = {
navbarActions: ReactNode | null;
setNavbarActions: (actions: ReactNode) => void;
clearNavbarActions: () => void;
};
export type UIStore = MainUiSlice &
DrawerUISlice &
TableUISlice &
NavbarActionsSlice;
type ProductionStandardFormSlice = { type ProductionStandardFormSlice = {
formData: { formData: {