feat(FE): slicing ui dashboard, API integration with dummy data and form validation

This commit is contained in:
randy-ar
2026-01-10 08:09:29 +07:00
parent 777b06c690
commit 126346dc52
7 changed files with 574 additions and 387 deletions
@@ -20,13 +20,19 @@ import Alert from '@/components/Alert';
import { import {
DashboardFilterSchema, DashboardFilterSchema,
DashboardFilterType, DashboardFilterType,
getDashboardFilterSchema,
} 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 { RadioGroup, RadioGroupItem } from '@/components/input/RadioInput'; import { RadioGroup, RadioGroupItem } from '@/components/input/RadioInput';
import { DashboardFilter } from '@/types/api/dashboard/dashboard'; import {
DashboardFilter,
DashboardMeta,
} from '@/types/api/dashboard/dashboard';
import DashboardStats from '@/components/pages/dashboard/chart/DashboardStats'; import DashboardStats from '@/components/pages/dashboard/chart/DashboardStats';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { getUniqueFormikErrors } from '@/lib/formik-helper';
// Helper function to normalize values to array // Helper function to normalize values to array
const normalizeToArray = ( const normalizeToArray = (
@@ -46,6 +52,7 @@ const DashboardProduction = () => {
); );
const [endpointUrl, setEndpointUrl] = useState('/dashboard'); const [endpointUrl, setEndpointUrl] = useState('/dashboard');
const [selectedLocationIds, setSelectedLocationIds] = useState<number[]>([]); const [selectedLocationIds, setSelectedLocationIds] = useState<number[]>([]);
const [formErrorList, setFormErrorList] = useState<string[]>([]);
// ===== FETCH DATA ===== // ===== FETCH DATA =====
const { const {
@@ -78,7 +85,7 @@ const DashboardProduction = () => {
location_id: selectedLocationIds ? selectedLocationIds.toString() : '', location_id: selectedLocationIds ? selectedLocationIds.toString() : '',
}); });
const comparedByOptions = [ const comparedByOptions = [
{ value: 'LOCATION', label: 'Location' }, { value: 'LOCATION', label: 'Farm' },
{ value: 'FLOCK', label: 'Flock' }, { value: 'FLOCK', label: 'Flock' },
{ value: 'KANDANG', label: 'Kandang' }, { value: 'KANDANG', label: 'Kandang' },
]; ];
@@ -97,7 +104,7 @@ const DashboardProduction = () => {
flockIds: [], flockIds: [],
kandangIds: [], kandangIds: [],
} as DashboardFilterType, } as DashboardFilterType,
validationSchema: DashboardFilterSchema, validationSchema: getDashboardFilterSchema(analysisMode),
onSubmit: (values) => { onSubmit: (values) => {
console.log(values); console.log(values);
@@ -117,6 +124,7 @@ const DashboardProduction = () => {
setAnalysisMode('OVERVIEW'); setAnalysisMode('OVERVIEW');
setEndpointUrl('/dashboard'); setEndpointUrl('/dashboard');
}; };
const handleApplyFilter = (values: DashboardFilter) => { const handleApplyFilter = (values: DashboardFilter) => {
console.log(values); console.log(values);
@@ -140,6 +148,23 @@ const DashboardProduction = () => {
formik.resetForm(); formik.resetForm();
}; };
const handleValidateForm = async () => {
const errors = await formik.validateForm();
if (Object.keys(errors).length > 0) {
// Parse and display errors
const errorMessages = getUniqueFormikErrors(errors);
setFormErrorList(errorMessages);
return; // Stop submission
}
};
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
handleValidateForm();
formik.handleSubmit();
};
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'>
@@ -155,11 +180,53 @@ const DashboardProduction = () => {
<div className='flex flex-row justify-end gap-2'> <div className='flex flex-row justify-end gap-2'>
<Button <Button
variant='outline' variant='outline'
className='min-w-28 rounded-lg' className={`min-w-28 rounded-lg ${
isResponseSuccess(dashboardProductionResponse) &&
(dashboardProductionResponse.meta as unknown as DashboardMeta)
.filters
? 'bg-gradient-to-r from-blue-50 to-blue-100 border-blue-500 text-blue-600 hover:from-blue-100 hover:to-blue-200'
: ''
}`}
onClick={() => filterModal.openModal()} onClick={() => filterModal.openModal()}
> >
<Icon icon='heroicons:funnel' width={20} height={20} /> <Icon
icon='heroicons:funnel'
width={20}
height={20}
className={
isResponseSuccess(dashboardProductionResponse) &&
(dashboardProductionResponse.meta as unknown as DashboardMeta)
.filters
? 'text-blue-600'
: ''
}
/>
Filter Filter
{isResponseSuccess(dashboardProductionResponse) &&
dashboardProductionResponse.meta &&
(dashboardProductionResponse.meta as unknown as DashboardMeta)
.filters && (
<span className='w-6 h-6 text-white bg-red-500 rounded-lg flex items-center justify-center text-xs'>
{(() => {
const meta =
dashboardProductionResponse.meta as unknown as DashboardMeta;
if (!meta.filters) return 0;
const count =
(meta.filters.location_ids.length > 1
? meta.filters.location_ids.length
: 0) +
(meta.filters.flock_ids.length > 1
? meta.filters.flock_ids.length
: 0) +
(meta.filters.kandang_ids.length > 1
? meta.filters.kandang_ids.length
: 0);
return meta.filters.analysis_mode === 'OVERVIEW'
? 1
: count;
})()}
</span>
)}
</Button> </Button>
<Button <Button
variant='outline' variant='outline'
@@ -183,7 +250,15 @@ const DashboardProduction = () => {
dashboardProductionData.charts && dashboardProductionData.charts &&
Object.keys(dashboardProductionData.charts).length > 0 ? ( Object.keys(dashboardProductionData.charts).length > 0 ? (
<DashboardLineChart <DashboardLineChart
analysisMode={analysisMode} analysisMode={
isResponseSuccess(dashboardProductionResponse)
? dashboardProductionResponse.meta
? (
dashboardProductionResponse.meta as unknown as DashboardMeta
).filters?.analysis_mode
: analysisMode
: analysisMode
}
data={dashboardProductionData} data={dashboardProductionData}
/> />
) : ( ) : (
@@ -214,7 +289,11 @@ const DashboardProduction = () => {
</Button> </Button>
</div> </div>
<form className='space-y-4' onSubmit={formik.handleSubmit}> <form
className='space-y-4'
onSubmit={handleFormSubmit}
onReset={handleResetFilter}
>
{/* Rentang Waktu */} {/* Rentang Waktu */}
<div className='px-4'> <div className='px-4'>
<label className='flex items-center gap-2 mb-3'>Tanggal</label> <label className='flex items-center gap-2 mb-3'>Tanggal</label>
@@ -259,6 +338,7 @@ const DashboardProduction = () => {
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');
// 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', []);
@@ -395,6 +475,14 @@ const DashboardProduction = () => {
</div> </div>
)} )}
{/* Error List Alert */}
{formErrorList.length > 0 && (
<AlertErrorList
formErrorList={formErrorList}
onClose={() => setFormErrorList([])}
/>
)}
{/* 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 py-4 mt-8 border-t border-gray-300 bg-gray-100'>
<Button <Button
@@ -522,7 +522,13 @@ const DashboardLineChart = ({
? false ? false
: { : {
r: 3, r: 3,
fill: getLineColor(series.id, index, analysisMode), fill: '#fff',
stroke: getLineColor(
series.id,
index,
analysisMode
),
strokeWidth: 2,
} }
} }
activeDot={isStandard ? undefined : { r: 5 }} activeDot={isStandard ? undefined : { r: 5 }}
@@ -1,5 +1,6 @@
import Alert from '@/components/Alert'; import Alert from '@/components/Alert';
import Card from '@/components/Card'; import Card from '@/components/Card';
import { formatNumber } from '@/lib/helper';
import { DashboardStatisticsData } from '@/types/api/dashboard/dashboard'; import { DashboardStatisticsData } from '@/types/api/dashboard/dashboard';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
@@ -7,7 +8,7 @@ interface DashboardStatsProps {
data: DashboardStatisticsData[]; data: DashboardStatisticsData[];
} }
// Configuration for each card's static properties // Konfigurasi untuk setiap kartu
const CARD_CONFIG = [ const CARD_CONFIG = [
{ {
key: 'HPP Global', key: 'HPP Global',
@@ -57,7 +58,7 @@ const DashboardStats = ({ data }: DashboardStatsProps) => {
return ( return (
<> <>
{prefix} {prefix}
{value.toLocaleString('id-ID')} {formatNumber(value)}
{suffix && ( {suffix && (
<span className='text-sm font-normal text-neutral-500'>{suffix}</span> <span className='text-sm font-normal text-neutral-500'>{suffix}</span>
)} )}
@@ -2,11 +2,11 @@ import { OptionType } from '@/components/input/SelectInput';
import * as yup from 'yup'; import * as yup from 'yup';
export type DashboardFilterType = { export type DashboardFilterType = {
startDate: string | undefined; startDate: string;
endDate: string | undefined; endDate: string;
analysisMode: string | undefined; analysisMode: string;
comparedBy: string | undefined; comparedBy: string | undefined;
location: OptionType | OptionType[] | undefined; location: OptionType | OptionType[];
lokasiIds: number[] | undefined; lokasiIds: number[] | undefined;
flock: OptionType | OptionType[] | undefined; flock: OptionType | OptionType[] | undefined;
flockIds: number[] | undefined; flockIds: number[] | undefined;
@@ -14,18 +14,104 @@ export type DashboardFilterType = {
kandangIds: number[] | undefined; kandangIds: number[] | undefined;
}; };
export const DashboardFilterSchema: yup.ObjectSchema<DashboardFilterType> = // Schema untuk mode OVERVIEW - semua field required
export const DashboardFilterOverviewSchema: yup.ObjectSchema<DashboardFilterType> =
yup.object({ yup.object({
startDate: yup.string().optional(), startDate: yup.string().required('Start date is required'),
endDate: yup.string().optional(), endDate: yup.string().required('End date is required'),
analysisMode: yup.string().optional(), analysisMode: yup.string().required('Analysis mode is required'),
comparedBy: yup.string().optional(), comparedBy: yup.string().when('analysisMode', {
is: 'COMPARISON',
then: (schema) => schema.required('Compared by is required'),
otherwise: (schema) => schema.optional(),
}),
lokasiIds: yup.array().optional(), lokasiIds: yup.array().optional(),
flockIds: yup.array().optional(), flockIds: yup.array().optional(),
kandangIds: yup.array().optional(), kandangIds: yup.array().optional(),
location: yup.mixed<OptionType | OptionType[]>().optional(), location: yup
flock: yup.mixed<OptionType | OptionType[]>().optional(), .mixed<OptionType | OptionType[]>()
kandang: yup.mixed<OptionType | OptionType[]>().optional(), .required('Farm is required')
.test('is-not-empty', 'Farm is required', (value) => {
if (Array.isArray(value)) {
return value.length > 0;
}
return !!value;
}),
flock: yup
.mixed<OptionType | OptionType[]>()
.required('Flock is required')
.test('is-not-empty', 'Flock is required', (value) => {
if (Array.isArray(value)) {
return value.length > 0;
}
return !!value;
}),
kandang: yup
.mixed<OptionType | OptionType[]>()
.required('Kandang is required')
.test('is-not-empty', 'Kandang is required', (value) => {
if (Array.isArray(value)) {
return value.length > 0;
}
return !!value;
}),
}); });
// Schema untuk mode COMPARISON - conditional validation
export const DashboardFilterComparisonSchema: yup.ObjectSchema<DashboardFilterType> =
yup.object({
startDate: yup.string().required('Start date is required'),
endDate: yup.string().required('End date is required'),
analysisMode: yup.string().required('Analysis mode is required'),
comparedBy: yup.string().when('analysisMode', {
is: 'COMPARISON',
then: (schema) => schema.required('Compared by is required'),
otherwise: (schema) => schema.optional(),
}),
lokasiIds: yup.array().optional(),
flockIds: yup.array().optional(),
kandangIds: yup.array().optional(),
location: yup
.mixed<OptionType | OptionType[]>()
.required('Farm is required')
.test('is-not-empty', 'Farm is required', (value) => {
if (Array.isArray(value)) {
return value.length > 0;
}
return !!value;
}),
flock: yup.mixed<OptionType | OptionType[]>().when('comparedBy', {
is: (value: string) => value === 'FLOCK' || value === 'KANDANG',
then: (schema) =>
schema.test('is-required', 'Flock is required', (value) => {
if (Array.isArray(value)) {
return value.length > 0;
}
return !!value;
}),
otherwise: (schema) => schema.optional(),
}),
kandang: yup.mixed<OptionType | OptionType[]>().when('comparedBy', {
is: 'KANDANG',
then: (schema) =>
schema.test('is-required', 'Kandang is required', (value) => {
if (Array.isArray(value)) {
return value.length > 0;
}
return !!value;
}),
otherwise: (schema) => schema.optional(),
}),
});
// Helper function untuk mendapatkan schema yang sesuai berdasarkan analysis mode
export const getDashboardFilterSchema = (analysisMode?: string) => {
return analysisMode === 'OVERVIEW'
? DashboardFilterOverviewSchema
: DashboardFilterComparisonSchema;
};
// Default schema
export const DashboardFilterSchema = DashboardFilterComparisonSchema;
export type DashboardFilterValues = yup.InferType<typeof DashboardFilterSchema>; export type DashboardFilterValues = yup.InferType<typeof DashboardFilterSchema>;
@@ -1,22 +1,4 @@
{ {
"code": 200,
"status": "success",
"message": "Get dashboard performance kandang comparison successfully",
"meta": {
"page": 1,
"limit": 10,
"total_pages": 1,
"total_results": 1,
"filters": {
"start_date": "2025-12-01",
"end_date": "2025-12-31",
"analysis_mode": "COMPARASION",
"lokasi_ids": [1],
"flock_ids": [1],
"kandang_ids": [1, 2, 3]
}
},
"data": {
"statistics_data": [ "statistics_data": [
{ {
"label": "HPP Global", "label": "HPP Global",
@@ -362,5 +344,4 @@
] ]
} }
} }
}
} }
@@ -5,10 +5,13 @@
* This file is auto-generated. Do not edit manually. * This file is auto-generated. Do not edit manually.
*/ */
import { Dashboard } from '../../types/api/dashboard/dashboard'; import { Dashboard, DashboardMeta } from '../../types/api/dashboard/dashboard';
import { BaseApiResponse } from '@/types/api/api-general'; import { BaseApiResponse } from '@/types/api/api-general';
import dummyData from './dashboard.default.json'; import dummyData from './dashboard.overview.dummy.json';
import dummyData2 from './dashboard.comparasion.location.dummy.json';
import dummyData3 from './dashboard.comparasion.flock.dummy.json';
import dummyData4 from './dashboard.comparasion.kandang.dummy.json';
import dummyData5 from './dashboard.default.json';
/** /**
* Get dummy DashboardProduction data * Get dummy DashboardProduction data
* @returns Promise with BaseApiResponse containing DashboardProduction * @returns Promise with BaseApiResponse containing DashboardProduction
@@ -20,6 +23,18 @@ export async function getDummySingle(): Promise<BaseApiResponse<Dashboard>> {
code: 200, code: 200,
status: 'success', status: 'success',
message: 'Data retrieved successfully', message: 'Data retrieved successfully',
meta: {
page: 1,
limit: 1,
total_pages: 1,
total_results: 1,
filters: {
analysis_mode: 'OVERVIEW',
location_ids: [1],
flock_ids: [1],
kandang_ids: [1],
},
} as DashboardMeta,
data: dummyData as unknown as Dashboard, data: dummyData as unknown as Dashboard,
}); });
}); });
+10
View File
@@ -1,3 +1,5 @@
import { SuccessApiResponse } from '@/types/api/api-general';
export interface Dashboard { export interface Dashboard {
statistics_data: DashboardStatisticsData[]; statistics_data: DashboardStatisticsData[];
charts: DashboardComparisonCharts | DashboardOverviewCharts; charts: DashboardComparisonCharts | DashboardOverviewCharts;
@@ -48,3 +50,11 @@ export interface DashboardFilter {
flock_ids: number[]; flock_ids: number[];
kandang_ids: number[]; kandang_ids: number[];
} }
export interface DashboardMeta {
page: number;
limit: number;
total_pages: number;
total_results: number;
filters: DashboardFilter;
}