mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-25 15:55:48 +00:00
feat(FE): slicing UI dashboard and define data types
This commit is contained in:
@@ -3,61 +3,85 @@
|
||||
import Button from '@/components/Button';
|
||||
import Card from '@/components/Card';
|
||||
import { Icon } from '@iconify/react';
|
||||
import ProductionLineChart from '@/components/pages/dashboard/chart/ProductionLineChart';
|
||||
import StandardLineChart from '@/components/pages/dashboard/chart/StandardLineChart';
|
||||
import EggWeightBarChart from '@/components/pages/dashboard/chart/EggWeightBarChart';
|
||||
import FCRBarChart from '@/components/pages/dashboard/chart/FCRBarChart';
|
||||
import ProductionStat from '@/components/pages/dashboard/chart/ProductionStat';
|
||||
import Modal, { useModal } from '@/components/Modal';
|
||||
import DateInput from '@/components/input/DateInput';
|
||||
import SelectInput, {
|
||||
OptionType,
|
||||
useSelect,
|
||||
} from '@/components/input/SelectInput';
|
||||
import { RadioGroup } from '@/components/input/RadioInput';
|
||||
import { useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { DashboardApi } from '@/services/api/dashboard';
|
||||
import { useFormik } from 'formik';
|
||||
import dashboardProductionFilterSchema from '@/components/pages/dashboard/filter/DashboardProductionFilter.schema';
|
||||
import { ProjectFlockApi } from '@/services/api/production';
|
||||
import { ProductionStandardApi } from '@/services/api/master-data';
|
||||
import { KandangApi, LocationApi } from '@/services/api/master-data';
|
||||
import Alert from '@/components/Alert';
|
||||
|
||||
import {
|
||||
DashboardFilterSchema,
|
||||
DashboardFilterType,
|
||||
} from '@/components/pages/dashboard/filter/DashboardProductionFilter.schema';
|
||||
import DashboardLineChart from '@/components/pages/dashboard/chart/DashboardLineChart';
|
||||
import DashboardLineChartSkeleton from '@/components/pages/dashboard/skeleton/DashboardLineChartSkeleton';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/input/RadioInput';
|
||||
import { DashboardFilter } from '@/types/api/dashboard/dashboard';
|
||||
import DashboardStats from '@/components/pages/dashboard/chart/DashboardStats';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
|
||||
// Helper function to normalize values to array
|
||||
const normalizeToArray = (
|
||||
value: OptionType | OptionType[] | null | undefined
|
||||
): number[] => {
|
||||
if (!value) return [];
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((v) => Number(v.value));
|
||||
}
|
||||
return [Number(value.value)];
|
||||
};
|
||||
|
||||
const DashboardProduction = () => {
|
||||
const filterModal = useModal();
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('daily');
|
||||
const [selectedStandards, setSelectedStandards] = useState<string[]>([
|
||||
'hen_day',
|
||||
'hen_house',
|
||||
]);
|
||||
const [analysisMode, setAnalysisMode] = useState<'OVERVIEW' | 'COMPARISON'>(
|
||||
'OVERVIEW'
|
||||
);
|
||||
const [endpointUrl, setEndpointUrl] = useState('/dashboard');
|
||||
const [selectedLocationIds, setSelectedLocationIds] = useState<number[]>([]);
|
||||
|
||||
// ===== FETCH DATA =====
|
||||
const {
|
||||
data: dashboardProductionResponse,
|
||||
isLoading: isLoadingDashboardProductionData,
|
||||
error: dashboardProductionError,
|
||||
mutate: refreshDashboardProductionData,
|
||||
} = useSWR(endpointUrl, () =>
|
||||
DashboardApi.getDashboardProductionFetcher(endpointUrl)
|
||||
);
|
||||
|
||||
const dashboardProductionData =
|
||||
dashboardProductionResponse?.status === 'success'
|
||||
? dashboardProductionResponse.data
|
||||
: undefined;
|
||||
const dashboardProductionData = isResponseSuccess(dashboardProductionResponse)
|
||||
? dashboardProductionResponse.data
|
||||
: undefined;
|
||||
|
||||
// ===== SELECT =====
|
||||
const { options: flockOptions, isLoadingOptions: isLoadingFlockOptions } =
|
||||
useSelect(ProjectFlockApi.basePath, 'id', 'flock_name', '', {
|
||||
limit: 'limit',
|
||||
category: 'LAYING',
|
||||
location_id: selectedLocationIds ? selectedLocationIds.toString() : '',
|
||||
});
|
||||
const {
|
||||
options: standardProductionOptions,
|
||||
isLoadingOptions: isLoadingStandardProductionOptions,
|
||||
} = useSelect(ProductionStandardApi.basePath, 'id', 'name', '', {
|
||||
options: locationOptions,
|
||||
isLoadingOptions: isLoadingLocationOptions,
|
||||
} = useSelect(LocationApi.basePath, 'id', 'name', '', {
|
||||
limit: 'limit',
|
||||
});
|
||||
const { options: kandangOptions, isLoadingOptions: isLoadingKandangOptions } =
|
||||
useSelect(KandangApi.basePath, 'id', 'name', '', {
|
||||
limit: 'limit',
|
||||
location_id: selectedLocationIds ? selectedLocationIds.toString() : '',
|
||||
});
|
||||
const comparedByOptions = [
|
||||
{ value: 'LOCATION', label: 'Location' },
|
||||
{ value: 'FLOCK', label: 'Flock' },
|
||||
{ value: 'KANDANG', label: 'Kandang' },
|
||||
];
|
||||
|
||||
// ===== FORMIK =====
|
||||
const formik = useFormik({
|
||||
@@ -65,56 +89,56 @@ const DashboardProduction = () => {
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
flock: [] as OptionType[],
|
||||
standard_production_id: [] as OptionType[],
|
||||
standard_productions: [] as OptionType[],
|
||||
period: selectedPeriod,
|
||||
},
|
||||
validationSchema: dashboardProductionFilterSchema,
|
||||
location: [] as OptionType[],
|
||||
kandang: [] as OptionType[],
|
||||
analysisMode: analysisMode,
|
||||
comparedBy: '',
|
||||
lokasiIds: [],
|
||||
flockIds: [],
|
||||
kandangIds: [],
|
||||
} as DashboardFilterType,
|
||||
validationSchema: DashboardFilterSchema,
|
||||
onSubmit: (values) => {
|
||||
console.log(values);
|
||||
// Build URL with query parameters
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (values.startDate) params.set('startDate', values.startDate);
|
||||
if (values.endDate) params.set('endDate', values.endDate);
|
||||
|
||||
if (values.flock && values.flock.length > 0) {
|
||||
const flockIds = values.flock
|
||||
.map((f: OptionType) => f.value || f)
|
||||
.join(',');
|
||||
params.set('flock', flockIds);
|
||||
}
|
||||
|
||||
if (
|
||||
values.standard_production_id &&
|
||||
values.standard_production_id.length > 0
|
||||
) {
|
||||
const standardIds = values.standard_production_id
|
||||
.map((s: OptionType) => s.value || s)
|
||||
.join(',');
|
||||
params.set('standard_production_id', standardIds);
|
||||
}
|
||||
|
||||
if (selectedStandards.length > 0) {
|
||||
params.set('standards', selectedStandards.join(','));
|
||||
}
|
||||
|
||||
params.set('period', selectedPeriod);
|
||||
|
||||
const newUrl = `/dashboard?${params.toString()}`;
|
||||
setEndpointUrl(newUrl);
|
||||
|
||||
// Close modal after applying filter
|
||||
filterModal.closeModal();
|
||||
handleApplyFilter({
|
||||
start_date: values.startDate || '',
|
||||
end_date: values.endDate || '',
|
||||
analysis_mode: values.analysisMode as 'OVERVIEW' | 'COMPARISON',
|
||||
location_ids: normalizeToArray(values.location),
|
||||
flock_ids: normalizeToArray(values.flock),
|
||||
kandang_ids: normalizeToArray(values.kandang),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleResetFilter = () => {
|
||||
formik.resetForm();
|
||||
setSelectedPeriod('daily');
|
||||
setSelectedStandards(['hen_day', 'hen_house']);
|
||||
setAnalysisMode('OVERVIEW');
|
||||
setEndpointUrl('/dashboard');
|
||||
};
|
||||
const handleApplyFilter = (values: DashboardFilter) => {
|
||||
console.log(values);
|
||||
|
||||
// Build query params object, only include non-empty values
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
if (values.start_date) params.start_date = values.start_date;
|
||||
if (values.end_date) params.end_date = values.end_date;
|
||||
if (values.analysis_mode) params.analysis_mode = values.analysis_mode;
|
||||
if (values.location_ids.length > 0)
|
||||
params.location_ids = values.location_ids.toString();
|
||||
if (values.flock_ids.length > 0)
|
||||
params.flock_ids = values.flock_ids.toString();
|
||||
if (values.kandang_ids.length > 0)
|
||||
params.kandang_ids = values.kandang_ids.toString();
|
||||
|
||||
setEndpointUrl(`/dashboard?${new URLSearchParams(params).toString()}`);
|
||||
console.log(endpointUrl);
|
||||
filterModal.closeModal();
|
||||
refreshDashboardProductionData();
|
||||
formik.resetForm();
|
||||
};
|
||||
|
||||
if (isLoadingDashboardProductionData) {
|
||||
return (
|
||||
@@ -127,7 +151,7 @@ const DashboardProduction = () => {
|
||||
<>
|
||||
<section className='w-full p-4 space-y-6'>
|
||||
<div className='flex flex-col sm:flex-row items-center justify-between gap-4'>
|
||||
<h1 className='text-3xl font-bold text-primary'>Dashboard</h1>
|
||||
<div></div>
|
||||
<div className='flex flex-row justify-end gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -149,55 +173,24 @@ const DashboardProduction = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dashboard Statistics */}
|
||||
<ProductionStat data={dashboardProductionData?.statistics_data} />
|
||||
{/* Dashboard Stats */}
|
||||
<DashboardStats data={dashboardProductionData?.statistics_data ?? []} />
|
||||
|
||||
{/* Charts Grid */}
|
||||
<div className='grid grid-cols-1 gap-4'>
|
||||
{/* Production Line Chart */}
|
||||
<Card
|
||||
variant='bordered'
|
||||
className={{ wrapper: 'w-full', body: 'p-6' }}
|
||||
>
|
||||
<ProductionLineChart
|
||||
period={
|
||||
selectedPeriod as 'daily' | 'weekly' | 'monthly' | 'yearly'
|
||||
}
|
||||
data={dashboardProductionData?.production_charts}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Standard Line Chart */}
|
||||
<Card
|
||||
variant='bordered'
|
||||
className={{ wrapper: 'w-full', body: 'p-6' }}
|
||||
>
|
||||
<StandardLineChart
|
||||
selectedStandards={selectedStandards}
|
||||
data={dashboardProductionData?.standard_productions}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Bar Charts Grid - 2 columns */}
|
||||
<div className='grid grid-cols-1 lg:grid-cols-2 gap-4'>
|
||||
{/* FCR Bar Chart */}
|
||||
<Card
|
||||
variant='bordered'
|
||||
className={{ wrapper: 'w-full', body: 'p-6' }}
|
||||
>
|
||||
<FCRBarChart data={dashboardProductionData?.fcr_data} />
|
||||
</Card>
|
||||
|
||||
{/* Egg Weight Bar Chart */}
|
||||
<Card
|
||||
variant='bordered'
|
||||
className={{ wrapper: 'w-full', body: 'p-6' }}
|
||||
>
|
||||
<EggWeightBarChart data={dashboardProductionData?.egg_weights} />
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
{/* Use DashboardLineChart component or skeleton */}
|
||||
{isLoadingDashboardProductionData ? (
|
||||
<DashboardLineChartSkeleton />
|
||||
) : dashboardProductionData &&
|
||||
dashboardProductionData.charts &&
|
||||
Object.keys(dashboardProductionData.charts).length > 0 ? (
|
||||
<DashboardLineChart
|
||||
analysisMode={analysisMode}
|
||||
data={dashboardProductionData}
|
||||
/>
|
||||
) : (
|
||||
<DashboardLineChartSkeleton />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Modal
|
||||
ref={filterModal.ref}
|
||||
className={{
|
||||
@@ -224,10 +217,7 @@ const DashboardProduction = () => {
|
||||
<form className='space-y-4' onSubmit={formik.handleSubmit}>
|
||||
{/* Rentang Waktu */}
|
||||
<div className='px-4'>
|
||||
<label className='flex items-center gap-2 mb-3'>
|
||||
<Icon icon='heroicons:calendar' width={20} height={20} />
|
||||
Rentang Waktu
|
||||
</label>
|
||||
<label className='flex items-center gap-2 mb-3'>Tanggal</label>
|
||||
<div className='flex items-center gap-2'>
|
||||
<DateInput
|
||||
name='startDate'
|
||||
@@ -261,119 +251,149 @@ const DashboardProduction = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Flock */}
|
||||
{/* Analysis Mode */}
|
||||
<div className='px-4'>
|
||||
<SelectInput
|
||||
label='Flock'
|
||||
value={formik.values.flock}
|
||||
onChange={(selected) => formik.setFieldValue('flock', selected)}
|
||||
errorMessage={formik.errors.flock as string}
|
||||
options={flockOptions}
|
||||
isLoading={isLoadingFlockOptions}
|
||||
isMulti
|
||||
isError={
|
||||
Boolean(formik.errors.flock) && Boolean(formik.touched.flock)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Production */}
|
||||
<div className='px-4'>
|
||||
<SelectInput
|
||||
label='Standard Produksi'
|
||||
value={formik.values.standard_production_id}
|
||||
onChange={(selected) =>
|
||||
formik.setFieldValue('standard_production_id', selected)
|
||||
}
|
||||
errorMessage={formik.errors.standard_production_id as string}
|
||||
options={standardProductionOptions}
|
||||
isLoading={isLoadingStandardProductionOptions}
|
||||
isMulti
|
||||
isError={
|
||||
Boolean(formik.errors.standard_production_id) &&
|
||||
Boolean(formik.touched.standard_production_id)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Standard */}
|
||||
<div className='px-4'>
|
||||
<SelectInput
|
||||
label='Standard'
|
||||
value={selectedStandards.map((s) => ({
|
||||
value: s,
|
||||
label:
|
||||
s === 'hen_day'
|
||||
? 'Hen Day'
|
||||
: s === 'hen_house'
|
||||
? 'Hen House'
|
||||
: s === 'uniformity'
|
||||
? 'Uniformity'
|
||||
: s === 'egg_weight'
|
||||
? 'Egg Weight'
|
||||
: 'Egg Mass',
|
||||
}))}
|
||||
options={[
|
||||
{ value: 'hen_day', label: 'Hen Day' },
|
||||
{ value: 'hen_house', label: 'Hen House' },
|
||||
{ value: 'uniformity', label: 'Uniformity' },
|
||||
{ value: 'egg_weight', label: 'Egg Weight' },
|
||||
{ value: 'egg_mass', label: 'Egg Mass' },
|
||||
]}
|
||||
isMulti
|
||||
onChange={(selected: OptionType | OptionType[] | null) => {
|
||||
const values = Array.isArray(selected)
|
||||
? selected.map((item) => String(item.value))
|
||||
: [];
|
||||
setSelectedStandards(
|
||||
values.length > 0 ? values : ['hen_day']
|
||||
);
|
||||
<label className='block mb-3'>Analysis Mode</label>
|
||||
<RadioGroup
|
||||
name='analysisMode'
|
||||
value={formik.values.analysisMode}
|
||||
onChange={(e) => {
|
||||
formik.handleChange(e);
|
||||
// Reset all dependent fields when analysis mode changes
|
||||
formik.setFieldValue('location', []);
|
||||
formik.setFieldValue('flock', []);
|
||||
formik.setFieldValue('kandang', []);
|
||||
formik.setFieldValue('comparedBy', '');
|
||||
setSelectedLocationIds([]);
|
||||
}}
|
||||
color='primary'
|
||||
className={{
|
||||
wrapper: 'w-full my-6 font-semibold text-neutral-500',
|
||||
}}
|
||||
>
|
||||
<RadioGroupItem
|
||||
color='primary'
|
||||
value='OVERVIEW'
|
||||
label='Performance Overview'
|
||||
/>
|
||||
<RadioGroupItem
|
||||
color='primary'
|
||||
value='COMPARISON'
|
||||
label='Performance Comparison'
|
||||
/>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{formik.values.analysisMode === 'COMPARISON' && (
|
||||
<div className='px-4'>
|
||||
<SelectInput
|
||||
label='Compared By'
|
||||
value={comparedByOptions.find(
|
||||
(option) => option.value === formik.values.comparedBy
|
||||
)}
|
||||
onChange={(selected) =>
|
||||
formik.setFieldValue(
|
||||
'comparedBy',
|
||||
selected ? (selected as OptionType).value : ''
|
||||
)
|
||||
}
|
||||
errorMessage={formik.errors.comparedBy as string}
|
||||
options={comparedByOptions}
|
||||
isLoading={isLoadingLocationOptions}
|
||||
isError={
|
||||
Boolean(formik.errors.comparedBy) &&
|
||||
Boolean(formik.touched.comparedBy)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Location */}
|
||||
<div className='px-4'>
|
||||
<SelectInput
|
||||
label='Farm'
|
||||
value={formik.values.location}
|
||||
onChange={(selected) => {
|
||||
formik.setFieldValue('location', selected);
|
||||
// Update selectedLocationIds for kandang filter
|
||||
setSelectedLocationIds(normalizeToArray(selected));
|
||||
// Reset dependent fields when location changes
|
||||
formik.setFieldValue('flock', []);
|
||||
formik.setFieldValue('kandang', []);
|
||||
}}
|
||||
errorMessage={formik.errors.location as string}
|
||||
options={locationOptions}
|
||||
isLoading={isLoadingLocationOptions}
|
||||
isMulti={
|
||||
comparedByOptions.find(
|
||||
(option) => option.value === formik.values.comparedBy
|
||||
)?.value === 'LOCATION'
|
||||
}
|
||||
isError={
|
||||
Boolean(formik.errors.standard_productions) &&
|
||||
Boolean(formik.touched.standard_productions)
|
||||
Boolean(formik.errors.location) &&
|
||||
Boolean(formik.touched.location)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Periode Perbandingan */}
|
||||
<div className='px-4'>
|
||||
<label className='block mb-3'>Periode Perbandingan</label>
|
||||
<div className='grid grid-cols-4 gap-2'>
|
||||
<Button
|
||||
variant={selectedPeriod === 'daily' ? 'active' : 'soft'}
|
||||
type='button'
|
||||
className='rounded-lg'
|
||||
onClick={() => setSelectedPeriod('daily')}
|
||||
>
|
||||
Harian
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedPeriod === 'weekly' ? 'active' : 'soft'}
|
||||
type='button'
|
||||
className='rounded-lg'
|
||||
onClick={() => setSelectedPeriod('weekly')}
|
||||
>
|
||||
Mingguan
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedPeriod === 'monthly' ? 'active' : 'soft'}
|
||||
type='button'
|
||||
className='rounded-lg'
|
||||
onClick={() => setSelectedPeriod('monthly')}
|
||||
>
|
||||
Bulanan
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedPeriod === 'yearly' ? 'active' : 'soft'}
|
||||
type='button'
|
||||
className='rounded-lg'
|
||||
onClick={() => setSelectedPeriod('yearly')}
|
||||
>
|
||||
Tahunan
|
||||
</Button>
|
||||
{/* Flock */}
|
||||
{!(
|
||||
formik.values.analysisMode === 'COMPARISON' &&
|
||||
!(
|
||||
formik.values.comparedBy === 'FLOCK' ||
|
||||
formik.values.comparedBy === 'KANDANG'
|
||||
)
|
||||
) && (
|
||||
<div className='px-4'>
|
||||
<SelectInput
|
||||
label='Flock'
|
||||
value={formik.values.flock}
|
||||
onChange={(selected) =>
|
||||
formik.setFieldValue('flock', selected)
|
||||
}
|
||||
errorMessage={formik.errors.flock as string}
|
||||
options={flockOptions}
|
||||
isLoading={isLoadingFlockOptions}
|
||||
isMulti={
|
||||
comparedByOptions.find(
|
||||
(option) => option.value === formik.values.comparedBy
|
||||
)?.value === 'FLOCK'
|
||||
}
|
||||
isError={
|
||||
Boolean(formik.errors.flock) &&
|
||||
Boolean(formik.touched.flock)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Kandang */}
|
||||
{!(
|
||||
formik.values.analysisMode === 'COMPARISON' &&
|
||||
!(formik.values.comparedBy === 'KANDANG')
|
||||
) && (
|
||||
<div className='px-4'>
|
||||
<SelectInput
|
||||
label='Kandang'
|
||||
value={formik.values.kandang}
|
||||
onChange={(selected) =>
|
||||
formik.setFieldValue('kandang', selected)
|
||||
}
|
||||
errorMessage={formik.errors.kandang as string}
|
||||
options={kandangOptions}
|
||||
isLoading={isLoadingKandangOptions}
|
||||
isMulti={
|
||||
comparedByOptions.find(
|
||||
(option) => option.value === formik.values.comparedBy
|
||||
)?.value === 'KANDANG'
|
||||
}
|
||||
isError={
|
||||
Boolean(formik.errors.kandang) &&
|
||||
Boolean(formik.touched.kandang)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className='flex justify-between gap-4 py-4 mt-8 border-t border-gray-300 bg-gray-100'>
|
||||
|
||||
Reference in New Issue
Block a user