Merge branch 'development' into feat/FE/US-81/production-result-report

This commit is contained in:
ValdiANS
2025-12-30 22:30:56 +07:00
44 changed files with 4799 additions and 449 deletions
+3 -5
View File
@@ -1,9 +1,7 @@
import DashboardProduction from '@/components/pages/dashboard/DashboardProduction';
const Dashboard = () => {
return (
<section className='w-full p-4'>
<h1 className='text-3xl font-bold text-primary'>Dashboard</h1>
</section>
);
return <DashboardProduction />;
};
export default Dashboard;
@@ -3,7 +3,7 @@
import Card from '@/components/Card';
import Table from '@/components/Table';
import { cn, formatCurrency, formatNumber } from '@/lib/helper';
import { formatCurrency, formatNumber } from '@/lib/helper';
import {
RowSapronakCalculation,
TotalSapronakCalculation,
@@ -54,7 +54,7 @@ const ClosingSapronakCalculationTable = ({
footer: total
? () => (
<div className='font-semibold text-gray-900'>
{formatNumber(total.qty_masuk)}
{formatNumber(total?.qty_masuk)}
</div>
)
: '',
@@ -66,7 +66,7 @@ const ClosingSapronakCalculationTable = ({
footer: total
? () => (
<div className='font-semibold text-gray-900'>
{formatNumber(total.qty_keluar)}
{formatNumber(total?.qty_keluar)}
</div>
)
: '',
@@ -78,7 +78,7 @@ const ClosingSapronakCalculationTable = ({
footer: total
? () => (
<div className='font-semibold text-gray-900'>
{formatNumber(total.qty_pakai)}
{formatNumber(total?.qty_pakai)}
</div>
)
: '',
@@ -102,7 +102,7 @@ const ClosingSapronakCalculationTable = ({
footer: total
? () => (
<div className='font-semibold text-gray-900'>
{formatCurrency(total.harga_beli_per_qty)}
{formatCurrency(total?.harga_beli_per_qty)}
</div>
)
: '',
@@ -114,7 +114,7 @@ const ClosingSapronakCalculationTable = ({
footer: total
? () => (
<div className='font-semibold text-gray-900'>
{formatCurrency(total.total_harga)}
{formatCurrency(total?.total_harga)}
</div>
)
: '',
@@ -131,7 +131,7 @@ const ClosingSapronakCalculationTable = ({
const docBroilerColumns = useMemo(
() =>
isResponseSuccess(sapronakCalculation)
? createColumns(sapronakCalculation.data?.doc_broiler.total)
? createColumns(sapronakCalculation.data?.doc_broiler?.total)
: createColumns(),
[sapronakCalculation]
);
@@ -139,7 +139,7 @@ const ClosingSapronakCalculationTable = ({
const ovkColumns = useMemo(
() =>
isResponseSuccess(sapronakCalculation)
? createColumns(sapronakCalculation.data?.ovk.total)
? createColumns(sapronakCalculation.data?.ovk?.total)
: createColumns(),
[sapronakCalculation]
);
@@ -147,7 +147,7 @@ const ClosingSapronakCalculationTable = ({
const pakanColumns = useMemo(
() =>
isResponseSuccess(sapronakCalculation)
? createColumns(sapronakCalculation.data?.pakan.total)
? createColumns(sapronakCalculation.data?.pakan?.total)
: createColumns(),
[sapronakCalculation]
);
@@ -166,7 +166,7 @@ const ClosingSapronakCalculationTable = ({
<Table<RowSapronakCalculation>
data={
isResponseSuccess(sapronakCalculation)
? (sapronakCalculation.data?.doc_broiler.rows ?? [])
? (sapronakCalculation.data?.doc_broiler?.rows ?? [])
: []
}
columns={docBroilerColumns}
@@ -189,7 +189,7 @@ const ClosingSapronakCalculationTable = ({
<Table<RowSapronakCalculation>
data={
isResponseSuccess(sapronakCalculation)
? (sapronakCalculation.data?.ovk.rows ?? [])
? (sapronakCalculation.data?.ovk?.rows ?? [])
: []
}
columns={ovkColumns}
@@ -212,7 +212,7 @@ const ClosingSapronakCalculationTable = ({
<Table<RowSapronakCalculation>
data={
isResponseSuccess(sapronakCalculation)
? (sapronakCalculation.data?.pakan.rows ?? [])
? (sapronakCalculation.data?.pakan?.rows ?? [])
: []
}
columns={pakanColumns}
@@ -0,0 +1,399 @@
'use client';
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';
const DashboardProduction = () => {
const filterModal = useModal();
const [selectedPeriod, setSelectedPeriod] = useState('daily');
const [selectedStandards, setSelectedStandards] = useState<string[]>([
'hen_day',
'hen_house',
]);
const [endpointUrl, setEndpointUrl] = useState('/dashboard');
// ===== FETCH DATA =====
const {
data: dashboardProductionResponse,
isLoading: isLoadingDashboardProductionData,
error: dashboardProductionError,
} = useSWR(endpointUrl, () =>
DashboardApi.getDashboardProductionFetcher(endpointUrl)
);
const dashboardProductionData =
dashboardProductionResponse?.status === 'success'
? dashboardProductionResponse.data
: undefined;
// ===== SELECT =====
const { options: flockOptions, isLoadingOptions: isLoadingFlockOptions } =
useSelect(ProjectFlockApi.basePath, 'id', 'flock_name', '', {
limit: 'limit',
category: 'LAYING',
});
const {
options: standardProductionOptions,
isLoadingOptions: isLoadingStandardProductionOptions,
} = useSelect(ProductionStandardApi.basePath, 'id', 'name', '', {
limit: 'limit',
});
// ===== FORMIK =====
const formik = useFormik({
initialValues: {
startDate: '',
endDate: '',
flock: [] as OptionType[],
standard_production_id: [] as OptionType[],
standard_productions: [] as OptionType[],
period: selectedPeriod,
},
validationSchema: dashboardProductionFilterSchema,
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();
},
});
const handleResetFilter = () => {
formik.resetForm();
setSelectedPeriod('daily');
setSelectedStandards(['hen_day', 'hen_house']);
setEndpointUrl('/dashboard');
};
if (isLoadingDashboardProductionData) {
return (
<div className='w-full min-h-screen flex items-center justify-center'>
<span className='loading loading-spinner loading-xl'></span>
</div>
);
}
return (
<>
<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 className='flex flex-row justify-end gap-2'>
<Button
variant='outline'
className='min-w-28 rounded-lg'
onClick={() => filterModal.openModal()}
>
<Icon icon='heroicons:funnel' width={20} height={20} />
Filter
</Button>
<Button
variant='outline'
color='neutral'
className='min-w-28 rounded-lg'
>
<Icon icon='heroicons:arrow-down-tray' width={20} height={20} />
Export
<Icon icon='heroicons:chevron-down' width={20} height={20} />
</Button>
</div>
</div>
{/* Dashboard Statistics */}
<ProductionStat 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>
</section>
<Modal
ref={filterModal.ref}
className={{
modal: 'p-0',
modalBox: 'p-0 rounded-xl',
}}
>
<div className='space-y-6'>
{/* Modal Header */}
<div className='flex items-center justify-between gap-2 py-3 border-b border-gray-300'>
<div className='flex items-center gap-2 ms-4'>
<Icon icon='heroicons:funnel' width={20} height={20} />
<h3 className='font-semibold'>Filter Data</h3>
</div>
<Button
variant='link'
onClick={() => filterModal.closeModal()}
className='text-gray-500 hover:text-gray-700 me-4 '
>
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<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>
<div className='flex items-center gap-2'>
<DateInput
name='startDate'
placeholder='Tanggal Mulai'
value={formik.values.startDate}
errorMessage={formik.errors.startDate}
onChange={formik.handleChange}
className={{
inputWrapper: 'rounded-lg',
}}
isError={
Boolean(formik.errors.startDate) &&
Boolean(formik.touched.startDate)
}
/>
<span className='hidden md:block text-center'></span>
<DateInput
name='endDate'
placeholder='Tanggal Akhir'
value={formik.values.endDate}
errorMessage={formik.errors.endDate}
onChange={formik.handleChange}
className={{
inputWrapper: 'rounded-lg',
}}
isError={
Boolean(formik.errors.endDate) &&
Boolean(formik.touched.endDate)
}
/>
</div>
</div>
{/* Flock */}
<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']
);
}}
isError={
Boolean(formik.errors.standard_productions) &&
Boolean(formik.touched.standard_productions)
}
/>
</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>
</div>
</div>
{/* Action Buttons */}
<div className='flex justify-between gap-4 py-4 mt-8 border-t border-gray-300 bg-gray-100'>
<Button
type='reset'
variant='soft'
className='ms-4 min-w-36 rounded-lg'
onClick={handleResetFilter}
>
Reset Filter
</Button>
<Button type='submit' className='me-4 min-w-36 rounded-lg'>
Terapkan Filter
</Button>
</div>
</form>
</div>
</Modal>
</>
);
};
export default DashboardProduction;
@@ -0,0 +1,89 @@
'use client';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Cell,
} from 'recharts';
import { DashboardProductionEggWeights } from '@/types/api/dashboard/dashboard-production';
interface EggWeightBarChartProps {
data?: DashboardProductionEggWeights[];
}
const EggWeightBarChart = ({ data }: EggWeightBarChartProps) => {
// Show loading state if no data
if (!data || data.length === 0) {
return (
<div className='w-full h-full'>
<h3 className='text-lg font-semibold mb-4'>
Rata-rata Berat Telur (EW)
</h3>
<div className='flex items-center justify-center h-[350px]'>
<p className='text-gray-500'>Memuat data...</p>
</div>
</div>
);
}
return (
<div className='w-full h-full'>
<h3 className='text-lg font-semibold mb-4'>Rata-rata Berat Telur (EW)</h3>
<ResponsiveContainer width='100%' height={350}>
<BarChart
data={data}
margin={{
top: 5,
right: 30,
left: 0,
bottom: 5,
}}
>
<CartesianGrid strokeDasharray='3 3' stroke='#e5e7eb' />
<XAxis
dataKey='flock.name'
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
/>
<YAxis
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
domain={[0, 'auto']}
label={{
value: 'Berat (gram)',
angle: -90,
position: 'insideLeft',
style: { fontSize: 12 },
}}
/>
<Tooltip
contentStyle={{
backgroundColor: 'white',
border: '1px solid #e5e7eb',
borderRadius: '8px',
padding: '8px 12px',
}}
formatter={(value: number | undefined) =>
value !== undefined ? [`${value} gram`, ''] : ['', '']
}
cursor={{ fill: 'rgba(59, 130, 246, 0.1)' }}
/>
<Bar dataKey='weight' radius={[8, 8, 0, 0]}>
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill='#3b82f6' />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
);
};
export default EggWeightBarChart;
@@ -0,0 +1,97 @@
'use client';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Cell,
} from 'recharts';
import { DashboardProductionFcrData } from '@/types/api/dashboard/dashboard-production';
interface FCRBarChartProps {
data?: DashboardProductionFcrData[];
}
// Alternating colors: green and red
const colors = ['#10b981', '#ef4444'];
const FCRBarChart = ({ data }: FCRBarChartProps) => {
// Show loading state if no data
if (!data || data.length === 0) {
return (
<div className='w-full h-full'>
<h3 className='text-lg font-semibold mb-4'>
Feed Conversion Ratio (FCR)
</h3>
<div className='flex items-center justify-center h-[350px]'>
<p className='text-gray-500'>Memuat data...</p>
</div>
</div>
);
}
return (
<div className='w-full h-full'>
<h3 className='text-lg font-semibold mb-4'>
Feed Conversion Ratio (FCR)
</h3>
<ResponsiveContainer width='100%' height={350}>
<BarChart
data={data}
margin={{
top: 5,
right: 30,
left: 0,
bottom: 5,
}}
>
<CartesianGrid strokeDasharray='3 3' stroke='#e5e7eb' />
<XAxis
dataKey='flock.name'
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
/>
<YAxis
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
domain={[0, 'auto']}
label={{
value: 'FCR',
angle: -90,
position: 'insideLeft',
style: { fontSize: 12 },
}}
/>
<Tooltip
contentStyle={{
backgroundColor: 'white',
border: '1px solid #e5e7eb',
borderRadius: '8px',
padding: '8px 12px',
}}
formatter={(value: number | undefined) =>
value !== undefined ? [value.toFixed(2), 'FCR'] : ['', '']
}
cursor={{ fill: 'rgba(16, 185, 129, 0.1)' }}
/>
<Bar dataKey='fcr' radius={[8, 8, 0, 0]}>
{data.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={colors[index % colors.length]}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
);
};
export default FCRBarChart;
@@ -0,0 +1,357 @@
'use client';
import { useState } from 'react';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
// Sample data in API format
const sampleApiData: ProductionChartItem[] = [
{
date: '2025-12-01T00:00:00Z',
flocks: [
{ id: 1, name: 'Flock A-002', data: 88 },
{ id: 2, name: 'Flock A-001', data: 92 },
{ id: 3, name: 'Flock B-001', data: 90 },
{ id: 4, name: 'Flock B-002', data: 85 },
],
},
{
date: '2025-12-03T00:00:00Z',
flocks: [
{ id: 1, name: 'Flock A-002', data: 85 },
{ id: 2, name: 'Flock A-001', data: 95 },
{ id: 3, name: 'Flock B-001', data: 93 },
{ id: 4, name: 'Flock B-002', data: 87 },
],
},
{
date: '2025-12-05T00:00:00Z',
flocks: [
{ id: 1, name: 'Flock A-002', data: 82 },
{ id: 2, name: 'Flock A-001', data: 98 },
{ id: 3, name: 'Flock B-001', data: 91 },
{ id: 4, name: 'Flock B-002', data: 84 },
],
},
{
date: '2025-12-07T00:00:00Z',
flocks: [
{ id: 1, name: 'Flock A-002', data: 80 },
{ id: 2, name: 'Flock A-001', data: 89 },
{ id: 3, name: 'Flock B-001', data: 88 },
{ id: 4, name: 'Flock B-002', data: 82 },
],
},
{
date: '2025-12-08T00:00:00Z',
flocks: [
{ id: 1, name: 'Flock A-002', data: 83 },
{ id: 2, name: 'Flock A-001', data: 92 },
{ id: 3, name: 'Flock B-001', data: 95 },
{ id: 4, name: 'Flock B-002', data: 85 },
],
},
{
date: '2025-12-11T00:00:00Z',
flocks: [
{ id: 1, name: 'Flock A-002', data: 81 },
{ id: 2, name: 'Flock A-001', data: 88 },
{ id: 3, name: 'Flock B-001', data: 92 },
{ id: 4, name: 'Flock B-002', data: 83 },
],
},
{
date: '2025-12-13T00:00:00Z',
flocks: [
{ id: 1, name: 'Flock A-002', data: 84 },
{ id: 2, name: 'Flock A-001', data: 90 },
{ id: 3, name: 'Flock B-001', data: 89 },
{ id: 4, name: 'Flock B-002', data: 86 },
],
},
{
date: '2025-12-15T00:00:00Z',
flocks: [
{ id: 1, name: 'Flock A-002', data: 82 },
{ id: 2, name: 'Flock A-001', data: 94 },
{ id: 3, name: 'Flock B-001', data: 96 },
{ id: 4, name: 'Flock B-002', data: 84 },
],
},
{
date: '2025-12-17T00:00:00Z',
flocks: [
{ id: 1, name: 'Flock A-002', data: 80 },
{ id: 2, name: 'Flock A-001', data: 91 },
{ id: 3, name: 'Flock B-001', data: 93 },
{ id: 4, name: 'Flock B-002', data: 82 },
],
},
{
date: '2025-12-19T00:00:00Z',
flocks: [
{ id: 1, name: 'Flock A-002', data: 79 },
{ id: 2, name: 'Flock A-001', data: 88 },
{ id: 3, name: 'Flock B-001', data: 90 },
{ id: 4, name: 'Flock B-002', data: 81 },
],
},
{
date: '2025-12-21T00:00:00Z',
flocks: [
{ id: 1, name: 'Flock A-002', data: 81 },
{ id: 2, name: 'Flock A-001', data: 97 },
{ id: 3, name: 'Flock B-001', data: 92 },
{ id: 4, name: 'Flock B-002', data: 83 },
],
},
{
date: '2025-12-23T00:00:00Z',
flocks: [
{ id: 1, name: 'Flock A-002', data: 83 },
{ id: 2, name: 'Flock A-001', data: 95 },
{ id: 3, name: 'Flock B-001', data: 98 },
{ id: 4, name: 'Flock B-002', data: 85 },
],
},
{
date: '2025-12-25T00:00:00Z',
flocks: [
{ id: 1, name: 'Flock A-002', data: 80 },
{ id: 2, name: 'Flock A-001', data: 89 },
{ id: 3, name: 'Flock B-001', data: 94 },
{ id: 4, name: 'Flock B-002', data: 82 },
],
},
{
date: '2025-12-27T00:00:00Z',
flocks: [
{ id: 1, name: 'Flock A-002', data: 82 },
{ id: 2, name: 'Flock A-001', data: 93 },
{ id: 3, name: 'Flock B-001', data: 96 },
{ id: 4, name: 'Flock B-002', data: 84 },
],
},
{
date: '2025-12-28T00:00:00Z',
flocks: [
{ id: 1, name: 'Flock A-002', data: 85 },
{ id: 2, name: 'Flock A-001', data: 96 },
{ id: 3, name: 'Flock B-001', data: 95 },
{ id: 4, name: 'Flock B-002', data: 87 },
],
},
];
// Helper function to format date based on period
const formatDateByPeriod = (
dateString: string,
period: 'daily' | 'weekly' | 'monthly' | 'yearly'
): string => {
const date = new Date(dateString);
const monthNames = [
'Jan',
'Feb',
'Mar',
'Apr',
'Mei',
'Jun',
'Jul',
'Agu',
'Sep',
'Okt',
'Nov',
'Des',
];
switch (period) {
case 'daily':
// Format: "1 Des"
return `${date.getDate()} ${monthNames[date.getMonth()]}`;
case 'weekly':
// Format: "Week 1 Des"
const weekNumber = Math.ceil(date.getDate() / 7);
return `Week ${weekNumber} ${monthNames[date.getMonth()]}`;
case 'monthly':
// Format: "Des"
return monthNames[date.getMonth()];
case 'yearly':
// Format: "2025"
return date.getFullYear().toString();
default:
return dateString;
}
};
// Type definitions for API data
interface FlockData {
id: number;
name: string;
data: number;
}
interface ProductionChartItem {
date: string;
flocks: FlockData[];
}
interface ProductionChartsData {
production_charts: ProductionChartItem[];
}
// Transform API data to Recharts format
const transformProductionData = (apiData: ProductionChartItem[]) => {
return apiData.map((item) => {
const transformed: Record<string, string | number> = {
date: item.date.split('T')[0], // Extract YYYY-MM-DD from ISO string
};
// Add each flock's data as a property
item.flocks.forEach((flock) => {
transformed[flock.name] = flock.data;
});
return transformed;
});
};
interface ProductionLineChartProps {
period?: 'daily' | 'weekly' | 'monthly' | 'yearly';
data?: ProductionChartItem[]; // Optional API data
}
const ProductionLineChart = ({
period = 'daily',
data: apiData,
}: ProductionLineChartProps) => {
// State to track which lines are hidden
const [hiddenLines, setHiddenLines] = useState<string[]>([]);
// Use API data if provided, otherwise use sample data
const chartData = apiData
? transformProductionData(apiData)
: transformProductionData(sampleApiData);
// Handle legend click to show/hide lines
const handleLegendClick = (dataKey: string) => {
setHiddenLines((prev) =>
prev.includes(dataKey)
? prev.filter((key) => key !== dataKey)
: [...prev, dataKey]
);
};
return (
<div className='w-full h-full'>
<h3 className='text-lg font-semibold mb-4'>
Performa Produksi per Flock
</h3>
<ResponsiveContainer width='100%' height={400}>
<LineChart
data={chartData}
margin={{
top: 5,
right: 30,
left: 0,
bottom: 5,
}}
>
<CartesianGrid strokeDasharray='3 3' stroke='#e5e7eb' />
<XAxis
dataKey='date'
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
tickFormatter={(value) => formatDateByPeriod(value, period)}
/>
<YAxis
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
domain={[0, 100]}
label={{
value: 'Persentase (%)',
angle: -90,
position: 'insideLeft',
style: { fontSize: 12 },
}}
/>
<Tooltip
contentStyle={{
backgroundColor: 'white',
border: '1px solid #e5e7eb',
borderRadius: '8px',
padding: '8px 12px',
}}
labelFormatter={(value) =>
formatDateByPeriod(value as string, period)
}
/>
<Legend
wrapperStyle={{
paddingTop: '20px',
}}
iconType='circle'
onClick={(e) => {
if (e.dataKey) handleLegendClick(e.dataKey as string);
}}
style={{ cursor: 'pointer' }}
/>
<Line
type='monotone'
dataKey='Flock A-002'
stroke='#3b82f6'
strokeWidth={2}
dot={{ r: 4, fill: '#3b82f6' }}
activeDot={{ r: 6 }}
hide={hiddenLines.includes('Flock A-002')}
/>
<Line
type='monotone'
dataKey='Flock A-001'
stroke='#10b981'
strokeWidth={2}
dot={{ r: 4, fill: '#10b981' }}
activeDot={{ r: 6 }}
hide={hiddenLines.includes('Flock A-001')}
/>
<Line
type='monotone'
dataKey='Flock B-001'
stroke='#f59e0b'
strokeWidth={2}
dot={{ r: 4, fill: '#f59e0b' }}
activeDot={{ r: 6 }}
hide={hiddenLines.includes('Flock B-001')}
/>
<Line
type='monotone'
dataKey='Flock B-002'
stroke='#ef4444'
strokeWidth={2}
dot={{ r: 4, fill: '#ef4444' }}
activeDot={{ r: 6 }}
hide={hiddenLines.includes('Flock B-002')}
/>
</LineChart>
</ResponsiveContainer>
</div>
);
};
export default ProductionLineChart;
// Export types for external use
export type { FlockData, ProductionChartItem, ProductionChartsData };
@@ -0,0 +1,107 @@
import Card from '@/components/Card';
import { Icon } from '@iconify/react';
import { DashboardProductionStatisticsData } from '@/types/api/dashboard/dashboard-production';
import { formatCurrency } from '@/lib/helper';
interface ProductionStatProps {
data?: DashboardProductionStatisticsData[];
}
const ProductionStat = ({ data }: ProductionStatProps) => {
// Helper function to get icon based on title
const getIcon = (title: string) => {
if (title.toLowerCase().includes('keuangan'))
return 'heroicons:currency-dollar';
if (title.toLowerCase().includes('penjualan'))
return 'heroicons:arrow-trending-up';
if (title.toLowerCase().includes('pembelian'))
return 'heroicons:shopping-cart';
if (title.toLowerCase().includes('overhead')) return 'heroicons:calculator';
return 'heroicons:chart-bar';
};
// Helper function to get icon background color
const getIconBgColor = (title: string) => {
if (title.toLowerCase().includes('keuangan')) return 'bg-blue-500';
if (title.toLowerCase().includes('penjualan')) return 'bg-green-500';
if (title.toLowerCase().includes('pembelian')) return 'bg-orange-500';
if (title.toLowerCase().includes('overhead')) return 'bg-purple-500';
return 'bg-gray-500';
};
// Show loading state if no data
if (!data || data.length === 0) {
return (
<section className='grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4'>
{[1, 2, 3, 4].map((i) => (
<Card
key={i}
variant='bordered'
className={{ wrapper: 'w-full', body: 'p-4' }}
>
<div className='animate-pulse'>
<div className='h-4 bg-gray-200 rounded w-1/2 mb-2'></div>
<div className='h-6 bg-gray-200 rounded w-3/4 mb-1'></div>
<div className='h-4 bg-gray-200 rounded w-1/3'></div>
</div>
</Card>
))}
</section>
);
}
return (
<section className='grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4'>
{data.map((stat, index) => (
<Card
key={index}
variant='bordered'
className={{ wrapper: 'w-full', body: 'p-4' }}
>
<div className='flex items-start justify-between'>
<div className='flex-1'>
<p className='text-sm text-gray-600 mb-2'>{stat.title}</p>
<p className='text-xl font-bold text-gray-900 mb-1'>
{formatCurrency(stat.value)}
</p>
<p
className={`text-sm flex items-center gap-1 ${
stat.changeType === 'increase'
? 'text-green-600'
: 'text-red-600'
}`}
>
<Icon
icon={
stat.changeType === 'increase'
? 'heroicons:arrow-trending-up'
: 'heroicons:arrow-trending-down'
}
width={16}
height={16}
/>
{stat.change > 0 ? '+' : ''}
{stat.change}% vs{' '}
{stat.period === 'monthly' ? 'bulan lalu' : 'periode lalu'}
</p>
</div>
<div className='flex-shrink-0'>
<div
className={`w-12 h-12 rounded-lg ${getIconBgColor(stat.title)} flex items-center justify-center`}
>
<Icon
icon={getIcon(stat.title)}
width={24}
height={24}
className='text-white'
/>
</div>
</div>
</div>
</Card>
))}
</section>
);
};
export default ProductionStat;
@@ -0,0 +1,691 @@
'use client';
import { useState } from 'react';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
// Type definitions for API data
interface FlockData {
id: number;
name: string;
data: number;
}
interface StandardData {
name: string;
value: number;
}
interface StandardChartItem {
week: number;
standards: StandardData[];
flocks: FlockData[];
}
// Sample data in API format
const sampleApiData: StandardChartItem[] = [
{
week: 18,
standards: [
{ name: 'hen_day', value: 40 },
{ name: 'hen_house', value: 38 },
{ name: 'uniformity', value: 85 },
{ name: 'egg_weight', value: 52 },
{ name: 'egg_mass', value: 20 },
],
flocks: [
{ id: 1, name: 'Flock A-001', data: 38 },
{ id: 2, name: 'Flock A-002', data: 37 },
{ id: 3, name: 'Flock B-001', data: 39 },
{ id: 4, name: 'Flock B-002', data: 36 },
],
},
{
week: 20,
standards: [
{ name: 'hen_day', value: 45 },
{ name: 'hen_house', value: 43 },
{ name: 'uniformity', value: 86 },
{ name: 'egg_weight', value: 54 },
{ name: 'egg_mass', value: 24 },
],
flocks: [
{ id: 1, name: 'Flock A-001', data: 43 },
{ id: 2, name: 'Flock A-002', data: 42 },
{ id: 3, name: 'Flock B-001', data: 44 },
{ id: 4, name: 'Flock B-002', data: 41 },
],
},
{
week: 22,
standards: [
{ name: 'hen_day', value: 48 },
{ name: 'hen_house', value: 46 },
{ name: 'uniformity', value: 87 },
{ name: 'egg_weight', value: 55 },
{ name: 'egg_mass', value: 26 },
],
flocks: [
{ id: 1, name: 'Flock A-001', data: 47 },
{ id: 2, name: 'Flock A-002', data: 46 },
{ id: 3, name: 'Flock B-001', data: 48 },
{ id: 4, name: 'Flock B-002', data: 45 },
],
},
{
week: 24,
standards: [
{ name: 'hen_day', value: 50 },
{ name: 'hen_house', value: 48 },
{ name: 'uniformity', value: 88 },
{ name: 'egg_weight', value: 56 },
{ name: 'egg_mass', value: 28 },
],
flocks: [
{ id: 1, name: 'Flock A-001', data: 49 },
{ id: 2, name: 'Flock A-002', data: 48 },
{ id: 3, name: 'Flock B-001', data: 50 },
{ id: 4, name: 'Flock B-002', data: 47 },
],
},
{
week: 26,
standards: [
{ name: 'hen_day', value: 52 },
{ name: 'hen_house', value: 50 },
{ name: 'uniformity', value: 89 },
{ name: 'egg_weight', value: 57 },
{ name: 'egg_mass', value: 30 },
],
flocks: [
{ id: 1, name: 'Flock A-001', data: 50 },
{ id: 2, name: 'Flock A-002', data: 49 },
{ id: 3, name: 'Flock B-001', data: 51 },
{ id: 4, name: 'Flock B-002', data: 48 },
],
},
{
week: 28,
standards: [
{ name: 'hen_day', value: 55 },
{ name: 'hen_house', value: 53 },
{ name: 'uniformity', value: 90 },
{ name: 'egg_weight', value: 58 },
{ name: 'egg_mass', value: 32 },
],
flocks: [
{ id: 1, name: 'Flock A-001', data: 53 },
{ id: 2, name: 'Flock A-002', data: 52 },
{ id: 3, name: 'Flock B-001', data: 54 },
{ id: 4, name: 'Flock B-002', data: 51 },
],
},
{
week: 30,
standards: [
{ name: 'hen_day', value: 58 },
{ name: 'hen_house', value: 56 },
{ name: 'uniformity', value: 91 },
{ name: 'egg_weight', value: 59 },
{ name: 'egg_mass', value: 34 },
],
flocks: [
{ id: 1, name: 'Flock A-001', data: 55 },
{ id: 2, name: 'Flock A-002', data: 54 },
{ id: 3, name: 'Flock B-001', data: 56 },
{ id: 4, name: 'Flock B-002', data: 53 },
],
},
{
week: 32,
standards: [
{ name: 'hen_day', value: 60 },
{ name: 'hen_house', value: 58 },
{ name: 'uniformity', value: 92 },
{ name: 'egg_weight', value: 60 },
{ name: 'egg_mass', value: 36 },
],
flocks: [
{ id: 1, name: 'Flock A-001', data: 58 },
{ id: 2, name: 'Flock A-002', data: 57 },
{ id: 3, name: 'Flock B-001', data: 59 },
{ id: 4, name: 'Flock B-002', data: 56 },
],
},
{
week: 34,
standards: [
{ name: 'hen_day', value: 62 },
{ name: 'hen_house', value: 60 },
{ name: 'uniformity', value: 92 },
{ name: 'egg_weight', value: 61 },
{ name: 'egg_mass', value: 38 },
],
flocks: [
{ id: 1, name: 'Flock A-001', data: 60 },
{ id: 2, name: 'Flock A-002', data: 59 },
{ id: 3, name: 'Flock B-001', data: 61 },
{ id: 4, name: 'Flock B-002', data: 58 },
],
},
{
week: 36,
standards: [
{ name: 'hen_day', value: 64 },
{ name: 'hen_house', value: 62 },
{ name: 'uniformity', value: 93 },
{ name: 'egg_weight', value: 62 },
{ name: 'egg_mass', value: 40 },
],
flocks: [
{ id: 1, name: 'Flock A-001', data: 62 },
{ id: 2, name: 'Flock A-002', data: 61 },
{ id: 3, name: 'Flock B-001', data: 63 },
{ id: 4, name: 'Flock B-002', data: 60 },
],
},
{
week: 38,
standards: [
{ name: 'hen_day', value: 66 },
{ name: 'hen_house', value: 64 },
{ name: 'uniformity', value: 93 },
{ name: 'egg_weight', value: 63 },
{ name: 'egg_mass', value: 42 },
],
flocks: [
{ id: 1, name: 'Flock A-001', data: 64 },
{ id: 2, name: 'Flock A-002', data: 63 },
{ id: 3, name: 'Flock B-001', data: 65 },
{ id: 4, name: 'Flock B-002', data: 62 },
],
},
{
week: 40,
standards: [
{ name: 'hen_day', value: 68 },
{ name: 'hen_house', value: 66 },
{ name: 'uniformity', value: 94 },
{ name: 'egg_weight', value: 64 },
{ name: 'egg_mass', value: 44 },
],
flocks: [
{ id: 1, name: 'Flock A-001', data: 66 },
{ id: 2, name: 'Flock A-002', data: 65 },
{ id: 3, name: 'Flock B-001', data: 67 },
{ id: 4, name: 'Flock B-002', data: 64 },
],
},
{
week: 42,
standards: [
{ name: 'hen_day', value: 70 },
{ name: 'hen_house', value: 68 },
{ name: 'uniformity', value: 94 },
{ name: 'egg_weight', value: 65 },
{ name: 'egg_mass', value: 46 },
],
flocks: [
{ id: 1, name: 'Flock A-001', data: 68 },
{ id: 2, name: 'Flock A-002', data: 67 },
{ id: 3, name: 'Flock B-001', data: 69 },
{ id: 4, name: 'Flock B-002', data: 66 },
],
},
{
week: 44,
standards: [
{ name: 'hen_day', value: 72 },
{ name: 'hen_house', value: 70 },
{ name: 'uniformity', value: 95 },
{ name: 'egg_weight', value: 66 },
{ name: 'egg_mass', value: 48 },
],
flocks: [
{ id: 1, name: 'Flock A-001', data: 70 },
{ id: 2, name: 'Flock A-002', data: 69 },
{ id: 3, name: 'Flock B-001', data: 71 },
{ id: 4, name: 'Flock B-002', data: 68 },
],
},
{
week: 46,
standards: [
{ name: 'hen_day', value: 74 },
{ name: 'hen_house', value: 72 },
{ name: 'uniformity', value: 95 },
{ name: 'egg_weight', value: 67 },
{ name: 'egg_mass', value: 50 },
],
flocks: [
{ id: 1, name: 'Flock A-001', data: 72 },
{ id: 2, name: 'Flock A-002', data: 71 },
{ id: 3, name: 'Flock B-001', data: 73 },
{ id: 4, name: 'Flock B-002', data: 70 },
],
},
{
week: 48,
standards: [
{ name: 'hen_day', value: 76 },
{ name: 'hen_house', value: 74 },
{ name: 'uniformity', value: 95 },
{ name: 'egg_weight', value: 68 },
{ name: 'egg_mass', value: 52 },
],
flocks: [
{ id: 1, name: 'Flock A-001', data: 74 },
{ id: 2, name: 'Flock A-002', data: 73 },
{ id: 3, name: 'Flock B-001', data: 75 },
{ id: 4, name: 'Flock B-002', data: 72 },
],
},
{
week: 50,
standards: [
{ name: 'hen_day', value: 78 },
{ name: 'hen_house', value: 76 },
{ name: 'uniformity', value: 96 },
{ name: 'egg_weight', value: 69 },
{ name: 'egg_mass', value: 54 },
],
flocks: [
{ id: 1, name: 'Flock A-001', data: 76 },
{ id: 2, name: 'Flock A-002', data: 75 },
{ id: 3, name: 'Flock B-001', data: 77 },
{ id: 4, name: 'Flock B-002', data: 74 },
],
},
{
week: 52,
standards: [
{ name: 'hen_day', value: 80 },
{ name: 'hen_house', value: 78 },
{ name: 'uniformity', value: 96 },
{ name: 'egg_weight', value: 70 },
{ name: 'egg_mass', value: 56 },
],
flocks: [
{ id: 1, name: 'Flock A-001', data: 78 },
{ id: 2, name: 'Flock A-002', data: 77 },
{ id: 3, name: 'Flock B-001', data: 79 },
{ id: 4, name: 'Flock B-002', data: 76 },
],
},
{
week: 54,
standards: [
{ name: 'hen_day', value: 82 },
{ name: 'hen_house', value: 80 },
{ name: 'uniformity', value: 96 },
{ name: 'egg_weight', value: 71 },
{ name: 'egg_mass', value: 58 },
],
flocks: [
{ id: 1, name: 'Flock A-001', data: 80 },
{ id: 2, name: 'Flock A-002', data: 79 },
{ id: 3, name: 'Flock B-001', data: 81 },
{ id: 4, name: 'Flock B-002', data: 78 },
],
},
{
week: 56,
standards: [
{ name: 'hen_day', value: 84 },
{ name: 'hen_house', value: 82 },
{ name: 'uniformity', value: 97 },
{ name: 'egg_weight', value: 72 },
{ name: 'egg_mass', value: 60 },
],
flocks: [
{ id: 1, name: 'Flock A-001', data: 82 },
{ id: 2, name: 'Flock A-002', data: 81 },
{ id: 3, name: 'Flock B-001', data: 83 },
{ id: 4, name: 'Flock B-002', data: 80 },
],
},
{
week: 58,
standards: [
{ name: 'hen_day', value: 86 },
{ name: 'hen_house', value: 84 },
{ name: 'uniformity', value: 97 },
{ name: 'egg_weight', value: 73 },
{ name: 'egg_mass', value: 62 },
],
flocks: [
{ id: 1, name: 'Flock A-001', data: 84 },
{ id: 2, name: 'Flock A-002', data: 83 },
{ id: 3, name: 'Flock B-001', data: 85 },
{ id: 4, name: 'Flock B-002', data: 82 },
],
},
{
week: 60,
standards: [
{ name: 'hen_day', value: 88 },
{ name: 'hen_house', value: 86 },
{ name: 'uniformity', value: 97 },
{ name: 'egg_weight', value: 74 },
{ name: 'egg_mass', value: 64 },
],
flocks: [
{ id: 1, name: 'Flock A-001', data: 86 },
{ id: 2, name: 'Flock A-002', data: 85 },
{ id: 3, name: 'Flock B-001', data: 87 },
{ id: 4, name: 'Flock B-002', data: 84 },
],
},
{
week: 62,
standards: [
{ name: 'hen_day', value: 90 },
{ name: 'hen_house', value: 88 },
{ name: 'uniformity', value: 98 },
{ name: 'egg_weight', value: 75 },
{ name: 'egg_mass', value: 66 },
],
flocks: [
{ id: 1, name: 'Flock A-001', data: 88 },
{ id: 2, name: 'Flock A-002', data: 87 },
{ id: 3, name: 'Flock B-001', data: 89 },
{ id: 4, name: 'Flock B-002', data: 86 },
],
},
{
week: 64,
standards: [
{ name: 'hen_day', value: 92 },
{ name: 'hen_house', value: 90 },
{ name: 'uniformity', value: 98 },
{ name: 'egg_weight', value: 76 },
{ name: 'egg_mass', value: 68 },
],
flocks: [
{ id: 1, name: 'Flock A-001', data: 90 },
{ id: 2, name: 'Flock A-002', data: 89 },
{ id: 3, name: 'Flock B-001', data: 91 },
{ id: 4, name: 'Flock B-002', data: 88 },
],
},
{
week: 66,
standards: [
{ name: 'hen_day', value: 94 },
{ name: 'hen_house', value: 92 },
{ name: 'uniformity', value: 98 },
{ name: 'egg_weight', value: 77 },
{ name: 'egg_mass', value: 70 },
],
flocks: [
{ id: 1, name: 'Flock A-001', data: 92 },
{ id: 2, name: 'Flock A-002', data: 91 },
{ id: 3, name: 'Flock B-001', data: 93 },
{ id: 4, name: 'Flock B-002', data: 90 },
],
},
{
week: 68,
standards: [
{ name: 'hen_day', value: 95 },
{ name: 'hen_house', value: 93 },
{ name: 'uniformity', value: 98 },
{ name: 'egg_weight', value: 78 },
{ name: 'egg_mass', value: 72 },
],
flocks: [
{ id: 1, name: 'Flock A-001', data: 93 },
{ id: 2, name: 'Flock A-002', data: 92 },
{ id: 3, name: 'Flock B-001', data: 94 },
{ id: 4, name: 'Flock B-002', data: 91 },
],
},
{
week: 70,
standards: [
{ name: 'hen_day', value: 96 },
{ name: 'hen_house', value: 94 },
{ name: 'uniformity', value: 99 },
{ name: 'egg_weight', value: 79 },
{ name: 'egg_mass', value: 74 },
],
flocks: [
{ id: 1, name: 'Flock A-001', data: 94 },
{ id: 2, name: 'Flock A-002', data: 93 },
{ id: 3, name: 'Flock B-001', data: 95 },
{ id: 4, name: 'Flock B-002', data: 92 },
],
},
{
week: 72,
standards: [
{ name: 'hen_day', value: 97 },
{ name: 'hen_house', value: 95 },
{ name: 'uniformity', value: 99 },
{ name: 'egg_weight', value: 80 },
{ name: 'egg_mass', value: 76 },
],
flocks: [
{ id: 1, name: 'Flock A-001', data: 95 },
{ id: 2, name: 'Flock A-002', data: 94 },
{ id: 3, name: 'Flock B-001', data: 96 },
{ id: 4, name: 'Flock B-002', data: 93 },
],
},
];
// Transform API data to Recharts format
const transformStandardData = (
apiData: StandardChartItem[],
selectedStandards: string[] = [
'hen_day',
'hen_house',
'uniformity',
'egg_weight',
'egg_mass',
]
) => {
return apiData.map((item) => {
const transformed: Record<string, number> = {
week: item.week,
};
// Add selected standards as properties
selectedStandards.forEach((standardName) => {
const standardData = item.standards.find((s) => s.name === standardName);
if (standardData) {
transformed[standardName] = standardData.value;
}
});
// Add each flock's data as a property
item.flocks.forEach((flock) => {
transformed[flock.name] = flock.data;
});
return transformed;
});
};
interface StandardLineChartProps {
data?: StandardChartItem[];
selectedStandards?: string[];
}
const StandardLineChart = ({
data: apiData,
selectedStandards = [
'hen_day',
'hen_house',
'uniformity',
'egg_weight',
'egg_mass',
],
}: StandardLineChartProps) => {
// State to track which lines are hidden
const [hiddenLines, setHiddenLines] = useState<string[]>([]);
// Use API data if provided, otherwise use sample data
const chartData = apiData
? transformStandardData(apiData, selectedStandards)
: transformStandardData(sampleApiData, selectedStandards);
// Handle legend click to show/hide lines
const handleLegendClick = (dataKey: string) => {
setHiddenLines((prev) =>
prev.includes(dataKey)
? prev.filter((key) => key !== dataKey)
: [...prev, dataKey]
);
};
// Standard line colors mapping
const standardColors: Record<string, string> = {
hen_day: '#94a3b8',
hen_house: '#64748b',
uniformity: '#475569',
egg_weight: '#334155',
egg_mass: '#1e293b',
};
// Standard names mapping for display
const standardLabels: Record<string, string> = {
hen_day: 'Hen Day',
hen_house: 'Hen House',
uniformity: 'Uniformity',
egg_weight: 'Egg Weight',
egg_mass: 'Egg Mass',
};
return (
<div className='w-full h-full'>
<h3 className='text-lg font-semibold mb-4'>
Perbandingan Henday per Umur
</h3>
<ResponsiveContainer width='100%' height={400}>
<LineChart
data={chartData}
margin={{
top: 5,
right: 30,
left: 0,
bottom: 5,
}}
>
<CartesianGrid strokeDasharray='3 3' stroke='#e5e7eb' />
<XAxis
dataKey='week'
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
label={{
value: 'Umur (minggu)',
position: 'insideBottom',
offset: -5,
style: { fontSize: 12 },
}}
/>
<YAxis
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
domain={[0, 100]}
label={{
value: 'Henday (%)',
angle: -90,
position: 'insideLeft',
style: { fontSize: 12 },
}}
/>
<Tooltip
contentStyle={{
backgroundColor: 'white',
border: '1px solid #e5e7eb',
borderRadius: '8px',
padding: '8px 12px',
}}
formatter={(value: number | undefined) =>
value !== undefined ? [`${value}%`, ''] : ['', '']
}
labelFormatter={(label) => `Minggu ${label}`}
/>
<Legend
wrapperStyle={{
paddingTop: '20px',
}}
iconType='circle'
onClick={(e) => {
if (e.dataKey) handleLegendClick(e.dataKey as string);
}}
style={{ cursor: 'pointer' }}
/>
{/* Dynamic Standard Lines */}
{selectedStandards.map((standardName) => (
<Line
key={standardName}
type='monotone'
dataKey={standardName}
name={standardLabels[standardName] || standardName}
stroke={standardColors[standardName] || '#94a3b8'}
strokeWidth={2}
strokeDasharray='5 5'
dot={{ r: 3, fill: standardColors[standardName] || '#94a3b8' }}
activeDot={{ r: 5 }}
hide={hiddenLines.includes(standardName)}
/>
))}
{/* Flock Lines */}
<Line
type='monotone'
dataKey='Flock A-002'
stroke='#3b82f6'
strokeWidth={2}
dot={{ r: 4, fill: '#3b82f6' }}
activeDot={{ r: 6 }}
hide={hiddenLines.includes('Flock A-002')}
/>
<Line
type='monotone'
dataKey='Flock A-001'
stroke='#10b981'
strokeWidth={2}
dot={{ r: 4, fill: '#10b981' }}
activeDot={{ r: 6 }}
hide={hiddenLines.includes('Flock A-001')}
/>
<Line
type='monotone'
dataKey='Flock B-001'
stroke='#f59e0b'
strokeWidth={2}
dot={{ r: 4, fill: '#f59e0b' }}
activeDot={{ r: 6 }}
hide={hiddenLines.includes('Flock B-001')}
/>
<Line
type='monotone'
dataKey='Flock B-002'
stroke='#ef4444'
strokeWidth={2}
dot={{ r: 4, fill: '#ef4444' }}
activeDot={{ r: 6 }}
hide={hiddenLines.includes('Flock B-002')}
/>
</LineChart>
</ResponsiveContainer>
</div>
);
};
export default StandardLineChart;
// Export types for external use
export type { FlockData, StandardData, StandardChartItem };
@@ -0,0 +1,16 @@
import * as yup from 'yup';
const dashboardProductionFilterSchema = yup.object({
startDate: yup.string().optional(),
endDate: yup.string().optional(),
flock: yup.array().optional(),
standard_production_id: yup.array().optional(),
standard_productions: yup.array().optional(),
period: yup.string().optional(),
});
export type DashboardProductionFilterValues = yup.InferType<
typeof dashboardProductionFilterSchema
>;
export default dashboardProductionFilterSchema;
@@ -16,7 +16,7 @@ import {
} from '@/components/pages/expense/form/ExpenseRequestForm.schema';
import { ExpenseApi } from '@/services/api/expense';
import { isResponseSuccess } from '@/lib/api-helper';
import { ACCEPTED_FILE_TYPE } from '@/config/constant';
import { ACCEPTED_FILE_TYPE, S3_PUBLIC_BASE_URL } from '@/config/constant';
interface ExpenseRealizationContentProps {
initialValues?: Expense;
@@ -103,24 +103,32 @@ const ExpenseRealizationContent = ({
initialValues?.realization_docs.length > 0 && (
<ul className='list-disc'>
{initialValues?.realization_docs.map(
(realizationDocument, realizationDocumentIdx) => (
<li key={realizationDocumentIdx}>
<Link
href={realizationDocument.path}
target='_blank'
rel='noopener noreferrer'
className='text-blue-500 underline'
>
{realizationDocument.path}{' '}
<Icon
icon='cuida:open-in-new-tab-outline'
width={12}
height={12}
className='inline'
/>
</Link>
</li>
)
(realizationDocument, realizationDocumentIdx) => {
const path = realizationDocument.path.startsWith(
'/'
)
? realizationDocument.path.slice(1)
: realizationDocument.path;
const documentUrl = `${S3_PUBLIC_BASE_URL}/${path}`;
return (
<li key={realizationDocumentIdx}>
<Link
href={documentUrl}
target='_blank'
rel='noopener noreferrer'
className='text-blue-500 underline'
>
{realizationDocument.path}{' '}
<Icon
icon='cuida:open-in-new-tab-outline'
width={12}
height={12}
className='inline'
/>
</Link>
</li>
);
}
)}
</ul>
)}
@@ -211,7 +219,7 @@ const ExpenseRealizationContent = ({
let expenseGrandTotal = 0;
kandangExpense.pengajuans?.forEach(
(item) => (expenseGrandTotal += item.price)
(item) => (expenseGrandTotal += item.qty * item.price)
);
return (
@@ -273,7 +281,7 @@ const ExpenseRealizationContent = ({
let expenseGrandTotal = 0;
kandangExpense.realisasi?.forEach(
(item) => (expenseGrandTotal += item.price)
(item) => (expenseGrandTotal += item.qty * item.price)
);
return (
@@ -27,7 +27,7 @@ import {
UploadRequestDocumentsFormSchema,
UploadRequestDocumentsFormValues,
} from '@/components/pages/expense/form/ExpenseRequestForm.schema';
import { ACCEPTED_FILE_TYPE } from '@/config/constant';
import { ACCEPTED_FILE_TYPE, S3_PUBLIC_BASE_URL } from '@/config/constant';
import { ExpenseApi } from '@/services/api/expense';
import { isResponseSuccess } from '@/lib/api-helper';
import { EXPENSE_REQUEST_APPROVAL_LINE } from '@/config/approval-line';
@@ -408,9 +408,13 @@ const ExpenseRequestContent = ({
<th>Kandang</th>
<th>:</th>
<td>
{initialValues?.kandangs
.map((item) => item.name)
.join(', ')}
{initialValues?.kandangs &&
initialValues?.kandangs.some((k) => k.name)
? initialValues?.kandangs
.filter((item) => item.name)
.map((item) => item.name)
.join(', ')
: '-'}
</td>
</tr>
<tr>
@@ -448,7 +452,14 @@ const ExpenseRequestContent = ({
<tr>
<th>Nominal Biaya</th>
<th>:</th>
<td>{formatCurrency(initialValues?.grand_total ?? 0)}</td>
<td>
{formatCurrency(
initialValues?.latest_approval.step_number === 4 ||
initialValues?.latest_approval.step_number === 5
? (initialValues?.total_realisasi ?? 0)
: (initialValues?.total_pengajuan ?? 0)
)}
</td>
</tr>
<tr>
<th>Status Pencairan</th>
@@ -482,24 +493,32 @@ const ExpenseRequestContent = ({
initialValues?.documents.length > 0 && (
<ul className='list-disc'>
{initialValues?.documents.map(
(requestDocument, requestDocumentIdx) => (
<li key={requestDocumentIdx}>
<Link
href={requestDocument.path}
target='_blank'
rel='noopener noreferrer'
className='text-blue-500 underline'
>
{requestDocument.path}{' '}
<Icon
icon='cuida:open-in-new-tab-outline'
width={12}
height={12}
className='inline'
/>
</Link>
</li>
)
(requestDocument, requestDocumentIdx) => {
const path = requestDocument.path.startsWith(
'/'
)
? requestDocument.path.slice(1)
: requestDocument.path;
const documentUrl = `${S3_PUBLIC_BASE_URL}/${path}`;
return (
<li key={requestDocumentIdx}>
<Link
href={documentUrl}
target='_blank'
rel='noopener noreferrer'
className='text-blue-500 underline'
>
{requestDocument.path}{' '}
<Icon
icon='cuida:open-in-new-tab-outline'
width={12}
height={12}
className='inline'
/>
</Link>
</li>
);
}
)}
</ul>
)}
@@ -558,7 +577,7 @@ const ExpenseRequestContent = ({
let expenseGrandTotal = 0;
kandangExpense.pengajuans?.forEach(
(item) => (expenseGrandTotal += item.price)
(item) => (expenseGrandTotal += item.qty * item.price)
);
return (
@@ -573,7 +592,9 @@ const ExpenseRequestContent = ({
colSpan={5}
className='font-bold text-center text-base-content text-lg'
>
Biaya {kandangExpense.name}
{kandangExpense.kandang_id && kandangExpense.name
? `Biaya ${kandangExpense.name}`
: `Biaya ${initialValues?.location.name || 'Umum'}`}
</th>
</tr>
<tr>
@@ -20,10 +20,10 @@ interface ExpenseKandangsTableProps {
locationId?: number;
type: 'add' | 'edit' | 'detail';
selectedKandangs: {
id: number;
name: string;
id?: number;
name?: string;
}[];
onChange: (kandangs: { id: number; name: string }[]) => void;
onChange: (kandangs: { id?: number; name?: string }[]) => void;
className?: {
wrapper?: string;
};
@@ -67,7 +67,11 @@ const ExpenseKandangsTable = ({
);
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>(
convertRowSelectionArrToObj(selectedKandangs.map((item) => item.id))
convertRowSelectionArrToObj(
selectedKandangs
.map((item) => item.id)
.filter((id): id is number => id !== undefined)
)
);
const kandangsColumns: ColumnDef<Kandang>[] = [
@@ -1,6 +1,7 @@
import * as Yup from 'yup';
import { Expense } from '@/types/api/expense';
import { formatDate } from '@/lib/helper';
import { S3_PUBLIC_BASE_URL } from '@/config/constant';
type ExpenseRealizationFormSchemaType = {
category?: {
@@ -12,7 +13,7 @@ type ExpenseRealizationFormSchemaType = {
label: string;
};
realization_date?: string;
kandangs?: { id: number; name: string }[];
kandangs?: { id?: number; name?: string }[];
supplier?: {
value: number;
label: string;
@@ -20,7 +21,7 @@ type ExpenseRealizationFormSchemaType = {
existing_documents?: { name: string; url: string }[];
documents?: File[];
realizations: {
kandang_id: number;
kandang_id?: number;
cost_items: {
nonstock?: {
value: number;
@@ -49,12 +50,11 @@ export const ExpenseRealizationFormSchema: Yup.ObjectSchema<ExpenseRealizationFo
kandangs: Yup.array()
.of(
Yup.object({
id: Yup.number().required('Kandang wajib dipilih!'),
name: Yup.string().required('Kandang wajib dipilih!'),
id: Yup.number().optional(),
name: Yup.string().optional(),
})
)
.min(1, 'Kandang wajib dipilih!')
.required('Kandang wajib dipilih!'),
.optional(),
supplier: Yup.object({
value: Yup.number().min(1).required(),
@@ -73,7 +73,7 @@ export const ExpenseRealizationFormSchema: Yup.ObjectSchema<ExpenseRealizationFo
realizations: Yup.array()
.of(
Yup.object({
kandang_id: Yup.number().min(1, 'Wajib memilih kandang!').required(),
kandang_id: Yup.number().min(1, 'Wajib memilih kandang!').optional(),
cost_items: Yup.array()
.of(
Yup.object({
@@ -86,12 +86,12 @@ export const ExpenseRealizationFormSchema: Yup.ObjectSchema<ExpenseRealizationFo
notes: Yup.string(),
})
)
.min(1, 'Kandang harus memiliki setidaknya 1 biaya!')
.required('Biaya kandang wajib diisi!'),
.min(1, 'Harus memiliki setidaknya 1 biaya!')
.required('Biaya wajib diisi!'),
})
)
.min(1, 'Biaya kandang wajib diisi!')
.required('Biaya kandang wajib diisi!'),
.min(1, 'Biaya wajib diisi!')
.required('Biaya wajib diisi!'),
});
export const UpdateExpenseRealizationFormSchema = ExpenseRealizationFormSchema;
@@ -139,10 +139,13 @@ export const getExpenseRealizationFormInitialValues = (
label: initialValues.supplier.name,
}
: undefined,
existing_documents: initialValues?.realization_docs?.map((doc) => ({
name: doc.path,
url: doc.path,
})),
existing_documents: initialValues?.realization_docs?.map((doc) => {
const path = doc.path.startsWith('/') ? doc.path.slice(1) : doc.path;
return {
name: doc.path,
url: `${S3_PUBLIC_BASE_URL}/${path}`,
};
}),
documents: [],
realizations: initialValues?.kandangs
? initialValues.kandangs.map((kandangExpense) => {
@@ -150,25 +150,10 @@ const ExpenseRealizationForm = ({
formik.setFieldValue('location', val);
formik.setFieldValue('kandangs', []);
formik.setFieldValue('realizations', []);
};
const kandangsChangeHandler = (kandangs: { id: number; name: string }[]) => {
formik.setFieldTouched('kandangs', true);
formik.setFieldValue('kandangs', kandangs);
const newRealizations = [...(formik.values.realizations ?? [])];
// add new realizations
kandangs.forEach((kandangItem) => {
const isKandangExistInRealization = newRealizations.find(
(realizationItem) => realizationItem.kandang_id === kandangItem.id
);
if (isKandangExistInRealization) return;
newRealizations.push({
kandang_id: kandangItem.id,
// Auto-create realization item for location (without kandang)
formik.setFieldValue('realizations', [
{
cost_items: [
{
nonstock: undefined,
@@ -177,25 +162,57 @@ const ExpenseRealizationForm = ({
notes: '',
},
],
},
]);
};
const kandangsChangeHandler = (
kandangs: { id?: number; name?: string }[]
) => {
formik.setFieldTouched('kandangs', true);
formik.setFieldValue('kandangs', kandangs);
// If no kandangs selected, create realization item for location
if (kandangs.length === 0) {
formik.setFieldValue('realizations', [
{
cost_items: [
{
nonstock: undefined,
quantity: undefined,
price: undefined,
notes: '',
},
],
},
]);
return;
}
// Start with empty array when kandangs are selected
const newRealizations: typeof formik.values.realizations = [];
// add new realizations for each kandang
kandangs.forEach((kandangItem) => {
if (!kandangItem.id) return;
const existingRealization = formik.values.realizations?.find(
(realizationItem) => realizationItem.kandang_id === kandangItem.id
);
newRealizations.push({
kandang_id: kandangItem.id,
cost_items: existingRealization?.cost_items || [
{
nonstock: undefined,
quantity: undefined,
price: undefined,
notes: '',
},
],
});
});
// prune realizations
const kandangIds = new Set(kandangs.map((kandang) => kandang.id));
const deletedRealizationsIdx: number[] = [];
newRealizations.forEach((realization, idx) => {
const isRealizationValid = kandangIds.has(realization.kandang_id);
if (!isRealizationValid) {
deletedRealizationsIdx.push(idx);
}
});
deletedRealizationsIdx.forEach((deletedRealizationIdx) => {
newRealizations.splice(deletedRealizationIdx, 1);
});
formik.setFieldValue('realizations', newRealizations);
};
@@ -338,7 +355,10 @@ const ExpenseRealizationForm = ({
)}
<ExpenseRealizationKandangDetailExpense
type={type}
formik={formik}
supplierId={formik.values.supplier?.value as number}
location={formik.values.location}
className={{
wrapper: 'col-span-12',
}}
@@ -18,6 +18,11 @@ import { Nonstock } from '@/types/api/master-data/nonstock';
interface ExpenseRealizationKandangDetailExpenseProps {
type?: 'add' | 'edit' | 'detail';
formik: FormikContextType<ExpenseRealizationFormValues>;
supplierId?: number;
location?: {
value: number;
label: string;
};
className?: {
wrapper?: string;
};
@@ -25,12 +30,18 @@ interface ExpenseRealizationKandangDetailExpenseProps {
const ExpenseRealizationKandangDetailExpense: React.FC<
ExpenseRealizationKandangDetailExpenseProps
> = ({ type, formik, className }) => {
> = ({ type, formik, supplierId, location, className }) => {
const {
setInputValue: setNonstockInputValue,
options: nonstockOptions,
isLoadingOptions: isLoadingNonstockOptions,
} = useSelect<Nonstock>(NonstockApi.basePath, 'id', 'name');
} = useSelect<Nonstock>(
NonstockApi.basePath,
'id',
'name',
'search',
supplierId ? { supplier_id: String(supplierId) } : undefined
);
const nonstockChangeHandler = (
kandangExpenseIdx: number,
@@ -82,140 +93,159 @@ const ExpenseRealizationKandangDetailExpense: React.FC<
</div>
<div className='w-full flex flex-col gap-6'>
{formik.values.realizations.length === 0 && (
{!formik.values.supplier?.value && (
<div>
<p className='text-sm text-gray-400 text-center'>
Pilih kandang terlebih dahulu!
Pilih supplier terlebih dahulu!
</p>
</div>
)}
{formik.values.realizations.map((kandangExpense, kandangExpenseIdx) => {
const kandangName = formik.values.kandangs?.find(
(kandang) => kandang.id === kandangExpense.kandang_id
);
{formik.values.realizations.length === 0 &&
formik.values.supplier?.value && (
<div>
<p className='text-sm text-gray-400 text-center'>
Belum ada item biaya. Silakan pilih lokasi terlebih dahulu.
</p>
</div>
)}
return (
kandangName?.name && (
<div
key={`kandangExpense-${kandangExpenseIdx}`}
className='w-full flex flex-col gap-4'
>
<div>
<h5 className='mb-2 text-lg font-bold text-center'>
Biaya {kandangName?.name}
</h5>
{formik.values.realizations.length > 0 &&
formik.values.supplier?.value &&
formik.values.realizations.map(
(kandangExpense, kandangExpenseIdx) => {
const kandangName = kandangExpense.kandang_id
? formik.values.kandangs?.find(
(kandang) => kandang.id === kandangExpense.kandang_id
)
: null;
<div className='overflow-x-auto'>
<table className='table'>
<thead>
<tr>
<th>Nonstock</th>
<th>Total Kuantitas</th>
<th>Harga Satuan</th>
<th>Catatan</th>
</tr>
</thead>
return (
(kandangName?.name || !kandangExpense.kandang_id) && (
<div
key={`kandangExpense-${kandangExpenseIdx}`}
className='w-full flex flex-col gap-4'
>
<div>
<h5 className='mb-2 text-lg font-bold text-center'>
{kandangName?.name
? `Biaya ${kandangName.name}`
: location?.label
? `Biaya ${location.label}`
: 'Biaya Umum'}
</h5>
<tbody>
{kandangExpense.cost_items.map(
(expenseItem, expenseIdx) => (
<tr key={`expense-${expenseIdx}`}>
<td className='p-2'>
<SelectInput
placeholder='Pilih Nonstock'
value={expenseItem.nonstock}
onChange={(val) => {
nonstockChangeHandler(
kandangExpenseIdx,
expenseIdx,
val
);
}}
options={nonstockOptions}
isLoading={isLoadingNonstockOptions}
onInputChange={setNonstockInputValue}
className={{ wrapper: 'min-w-48' }}
isDisabled
/>
</td>
<td className='p-2'>
<NumberInput
required
name={`realizations[${kandangExpenseIdx}].cost_items[${expenseIdx}].quantity`}
placeholder='Masukkan Total Kuantitas'
value={
formik.values.realizations[
kandangExpenseIdx
].cost_items[expenseIdx].quantity ?? ''
}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={isExpenseRepeaterInputError(
'quantity',
kandangExpenseIdx,
expenseIdx
)}
className={{ wrapper: 'min-w-24' }}
/>
</td>
<td className='p-2'>
<NumberInput
name={`realizations[${kandangExpenseIdx}].cost_items[${expenseIdx}].price`}
placeholder='Masukkan Harga Satuan'
value={
formik.values.realizations[
kandangExpenseIdx
].cost_items[expenseIdx].price ?? ''
}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={isExpenseRepeaterInputError(
'price',
kandangExpenseIdx,
expenseIdx
)}
inputPrefix={
<span className='text-gray-600 font-medium'>
Rp
</span>
}
className={{ wrapper: 'min-w-24' }}
/>
</td>
<td className='p-2'>
<TextInput
name={`realizations[${kandangExpenseIdx}].cost_items[${expenseIdx}].notes`}
placeholder='Tuliskan catatan'
value={
formik.values.realizations[
kandangExpenseIdx
].cost_items[expenseIdx].notes ?? ''
}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={isExpenseRepeaterInputError(
'notes',
kandangExpenseIdx,
expenseIdx
)}
className={{ wrapper: 'min-w-24' }}
/>
</td>
<div className='overflow-x-auto'>
<table className='table'>
<thead>
<tr>
<th>Nonstock</th>
<th>Total Kuantitas</th>
<th>Harga Satuan</th>
<th>Catatan</th>
</tr>
)
)}
</tbody>
</table>
</thead>
<tbody>
{kandangExpense.cost_items.map(
(expenseItem, expenseIdx) => (
<tr key={`expense-${expenseIdx}`}>
<td className='p-2'>
<SelectInput
placeholder='Pilih Nonstock'
value={expenseItem.nonstock}
onChange={(val) => {
nonstockChangeHandler(
kandangExpenseIdx,
expenseIdx,
val
);
}}
options={nonstockOptions}
isLoading={isLoadingNonstockOptions}
onInputChange={setNonstockInputValue}
className={{ wrapper: 'min-w-48' }}
isDisabled
/>
</td>
<td className='p-2'>
<NumberInput
required
name={`realizations[${kandangExpenseIdx}].cost_items[${expenseIdx}].quantity`}
placeholder='Masukkan Total Kuantitas'
value={
formik.values.realizations[
kandangExpenseIdx
].cost_items[expenseIdx].quantity ?? ''
}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={isExpenseRepeaterInputError(
'quantity',
kandangExpenseIdx,
expenseIdx
)}
className={{ wrapper: 'min-w-24' }}
/>
</td>
<td className='p-2'>
<NumberInput
name={`realizations[${kandangExpenseIdx}].cost_items[${expenseIdx}].price`}
placeholder='Masukkan Harga Satuan'
value={
formik.values.realizations[
kandangExpenseIdx
].cost_items[expenseIdx].price ?? ''
}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={isExpenseRepeaterInputError(
'price',
kandangExpenseIdx,
expenseIdx
)}
inputPrefix={
<span className='text-gray-600 font-medium'>
Rp
</span>
}
className={{ wrapper: 'min-w-24' }}
/>
</td>
<td className='p-2'>
<TextInput
name={`realizations[${kandangExpenseIdx}].cost_items[${expenseIdx}].notes`}
placeholder='Tuliskan catatan'
value={
formik.values.realizations[
kandangExpenseIdx
].cost_items[expenseIdx].notes ?? ''
}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={isExpenseRepeaterInputError(
'notes',
kandangExpenseIdx,
expenseIdx
)}
className={{ wrapper: 'min-w-24' }}
/>
</td>
</tr>
)
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
)
);
})}
)
);
}
)}
</div>
</Card>
);
@@ -1,6 +1,7 @@
import * as Yup from 'yup';
import { Expense } from '@/types/api/expense';
import { formatDate } from '@/lib/helper';
import { S3_PUBLIC_BASE_URL } from '@/config/constant';
type ExpenseFormSchemaType = {
category?: {
@@ -11,8 +12,9 @@ type ExpenseFormSchemaType = {
value: number;
label: string;
};
location_id: number;
transaction_date?: string;
kandangs?: { id: number; name: string }[];
kandangs?: { id?: number; name?: string }[];
supplier?: {
value: number;
label: string;
@@ -21,7 +23,7 @@ type ExpenseFormSchemaType = {
deleted_documents?: number[];
documents?: File[];
expense_nonstocks: {
kandang_id: number;
kandang_id?: number;
cost_items: {
nonstock?: {
value: number;
@@ -46,16 +48,17 @@ export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> =
label: Yup.string().required(),
}).required('Lokasi wajib diisi!'),
location_id: Yup.number().min(1).required('Lokasi wajib diisi!'),
transaction_date: Yup.string().required('Tanggal transaksi wajib diisi!'),
kandangs: Yup.array()
.of(
Yup.object({
id: Yup.number().required('Kandang wajib dipilih!'),
name: Yup.string().required('Kandang wajib dipilih!'),
id: Yup.number().optional(),
name: Yup.string().optional(),
})
)
.min(1, 'Kandang wajib dipilih!')
.required('Kandang wajib dipilih!'),
.optional(),
supplier: Yup.object({
value: Yup.number().min(1).required(),
@@ -77,7 +80,7 @@ export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> =
expense_nonstocks: Yup.array()
.of(
Yup.object({
kandang_id: Yup.number().min(1, 'Wajib memilih kandang!').required(),
kandang_id: Yup.number().min(1, 'Wajib memilih kandang!').optional(),
cost_items: Yup.array()
.of(
Yup.object({
@@ -128,6 +131,7 @@ export const getExpenseFormInitialValues = (
label: initialValues.location.name,
}
: undefined,
location_id: Number(initialValues?.location.id || 0),
transaction_date: initialValues?.transaction_date
? formatDate(initialValues.transaction_date, 'YYYY-MM-DD')
: undefined,
@@ -141,11 +145,14 @@ export const getExpenseFormInitialValues = (
label: initialValues.supplier.name,
}
: undefined,
existing_documents: initialValues?.documents?.map((doc) => ({
id: doc.id,
name: doc.path,
url: doc.path,
})),
existing_documents: initialValues?.documents?.map((doc) => {
const path = doc.path.startsWith('/') ? doc.path.slice(1) : doc.path;
return {
id: doc.id,
name: doc.path,
url: `${S3_PUBLIC_BASE_URL}/${path}`,
};
}),
deleted_documents: [],
documents: [],
expense_nonstocks: initialValues?.kandangs
@@ -108,18 +108,24 @@ const ExpenseRequestForm = ({
const expensePayload: CreateExpensePayload = {
category: formik.values.category?.value as 'BOP' | 'NON-BOP',
location_id: values.location_id as number,
transaction_date: values?.transaction_date as string,
supplier_id: values.supplier?.value as number,
documents: values.documents as File[],
expense_nonstocks: values.expense_nonstocks.map((expenseNonstock) => ({
kandang_id: expenseNonstock.kandang_id,
cost_items: expenseNonstock.cost_items.map((costItem) => ({
nonstock_id: costItem.nonstock?.value as number,
quantity: parseFloat(String(costItem.quantity)) as number,
price: parseFloat(String(costItem.price)) as number,
notes: costItem.notes ?? '',
})),
})),
expense_nonstocks: values.expense_nonstocks.map((expenseNonstock) => {
const basePayload = {
cost_items: expenseNonstock.cost_items.map((costItem) => ({
nonstock_id: costItem.nonstock?.value as number,
quantity: parseFloat(String(costItem.quantity)) as number,
price: parseFloat(String(costItem.price)) as number,
notes: costItem.notes ?? '',
})),
};
return expenseNonstock.kandang_id
? { ...basePayload, kandang_id: expenseNonstock.kandang_id }
: basePayload;
}),
};
switch (type) {
@@ -130,19 +136,25 @@ const ExpenseRequestForm = ({
case 'edit':
const expenseUpdatePayload: UpdateExpensePayload = {
category: formik.values.category?.value as 'BOP' | 'NON-BOP',
location_id: values.location_id as number,
transaction_date: values?.transaction_date as string,
supplier_id: values.supplier?.value as number,
documents: values.documents as File[],
expense_nonstocks: values.expense_nonstocks.map(
(expenseNonstock) => ({
kandang_id: expenseNonstock.kandang_id,
cost_items: expenseNonstock.cost_items.map((costItem) => ({
nonstock_id: costItem.nonstock?.value as number,
quantity: parseFloat(String(costItem.quantity)) as number,
price: parseFloat(String(costItem.price)) as number,
notes: costItem.notes ?? '',
})),
})
(expenseNonstock) => {
const basePayload = {
cost_items: expenseNonstock.cost_items.map((costItem) => ({
nonstock_id: costItem.nonstock?.value as number,
quantity: parseFloat(String(costItem.quantity)) as number,
price: parseFloat(String(costItem.price)) as number,
notes: costItem.notes ?? '',
})),
};
return expenseNonstock.kandang_id
? { ...basePayload, kandang_id: expenseNonstock.kandang_id }
: basePayload;
}
),
};
@@ -179,27 +191,14 @@ const ExpenseRequestForm = ({
formik.setFieldTouched('location', true);
formik.setFieldValue('location', val);
const locationId = Array.isArray(val) ? val[0]?.value : val?.value;
formik.setFieldValue('location_id', locationId);
formik.setFieldValue('kandangs', []);
formik.setFieldValue('expense_nonstocks', []);
};
const kandangsChangeHandler = (kandangs: { id: number; name: string }[]) => {
formik.setFieldTouched('kandangs', true);
formik.setFieldValue('kandangs', kandangs);
const newExpenseNonstocks = [...(formik.values.expense_nonstocks ?? [])];
// add new expense_nonstocks
kandangs.forEach((kandangItem) => {
const isKandangExistInExpenseNonstocks = newExpenseNonstocks.find(
(expenseNonstockItem) =>
expenseNonstockItem.kandang_id === kandangItem.id
);
if (isKandangExistInExpenseNonstocks) return;
newExpenseNonstocks.push({
kandang_id: kandangItem.id,
// Auto-create expense item for location (without kandang)
formik.setFieldValue('expense_nonstocks', [
{
cost_items: [
{
nonstock: undefined,
@@ -208,25 +207,56 @@ const ExpenseRequestForm = ({
notes: '',
},
],
},
]);
};
const kandangsChangeHandler = (
kandangs: { id?: number; name?: string }[]
) => {
formik.setFieldTouched('kandangs', true);
formik.setFieldValue('kandangs', kandangs);
// If no kandangs selected, create expense item for location
if (kandangs.length === 0) {
formik.setFieldValue('expense_nonstocks', [
{
cost_items: [
{
nonstock: undefined,
quantity: undefined,
price: undefined,
notes: '',
},
],
},
]);
return;
}
const newExpenseNonstocks: typeof formik.values.expense_nonstocks = [];
kandangs.forEach((kandangItem) => {
if (!kandangItem.id) return;
const existingExpenseNonstock = formik.values.expense_nonstocks?.find(
(expenseNonstockItem) =>
expenseNonstockItem.kandang_id === kandangItem.id
);
newExpenseNonstocks.push({
kandang_id: kandangItem.id,
cost_items: existingExpenseNonstock?.cost_items || [
{
nonstock: undefined,
quantity: undefined,
price: undefined,
notes: '',
},
],
});
});
// prune expense_nonstocks
const kandangIds = new Set(kandangs.map((kandang) => kandang.id));
const deletedExpenseNonstocksIdx: number[] = [];
newExpenseNonstocks.forEach((expenseNonstock, idx) => {
const isExpenseNonstockValid = kandangIds.has(expenseNonstock.kandang_id);
if (!isExpenseNonstockValid) {
deletedExpenseNonstocksIdx.push(idx);
}
});
deletedExpenseNonstocksIdx.forEach((deletedExpenseNonstockIdx) => {
newExpenseNonstocks.splice(deletedExpenseNonstockIdx, 1);
});
formik.setFieldValue('expense_nonstocks', newExpenseNonstocks);
};
@@ -454,7 +484,10 @@ const ExpenseRequestForm = ({
)}
<ExpenseRequestKandangDetailExpense
type={type}
formik={formik}
supplierId={formik.values.supplier?.value as number}
location={formik.values.location}
className={{
wrapper: 'col-span-12',
}}
@@ -21,6 +21,11 @@ import { removeArrayItemAndSync } from '@/lib/utils/formik';
interface ExpenseRequestKandangDetailExpenseProps {
type?: 'add' | 'edit' | 'detail';
formik: FormikContextType<ExpenseRequestFormValues>;
supplierId?: number;
location?: {
value: number;
label: string;
};
className?: {
wrapper?: string;
};
@@ -28,12 +33,18 @@ interface ExpenseRequestKandangDetailExpenseProps {
const ExpenseRequestKandangDetailExpense: React.FC<
ExpenseRequestKandangDetailExpenseProps
> = ({ type, formik, className }) => {
> = ({ type, formik, supplierId, location, className }) => {
const {
setInputValue: setNonstockInputValue,
options: nonstockOptions,
isLoadingOptions: isLoadingNonstockOptions,
} = useSelect<Nonstock>(NonstockApi.basePath, 'id', 'name');
} = useSelect<Nonstock>(
NonstockApi.basePath,
'id',
'name',
'search',
supplierId ? { supplier_id: String(supplierId) } : undefined
);
const nonstockChangeHandler = (
kandangExpenseIdx: number,
@@ -113,41 +124,57 @@ const ExpenseRequestKandangDetailExpense: React.FC<
</div>
<div className='w-full flex flex-col gap-6'>
{(formik.values.expense_nonstocks.length === 0 ||
!formik.values.supplier?.value) && (
{!formik.values.supplier?.value && (
<div>
<p className='text-sm text-gray-400 text-center'>
Pilih kandang terlebih dahulu!
Pilih supplier terlebih dahulu!
</p>
</div>
)}
{formik.values.expense_nonstocks.length === 0 &&
formik.values.supplier?.value && (
<div>
<p className='text-sm text-gray-400 text-center'>
Belum ada item biaya. Silakan pilih lokasi terlebih dahulu.
</p>
</div>
)}
{formik.values.expense_nonstocks.length > 0 &&
formik.values.supplier?.value &&
formik.values.expense_nonstocks.map(
(kandangExpense, kandangExpenseIdx) => {
const kandangName = formik.values.kandangs?.find(
(kandang) => kandang.id === kandangExpense.kandang_id
);
const kandangName = kandangExpense.kandang_id
? formik.values.kandangs?.find(
(kandang) => kandang.id === kandangExpense.kandang_id
)
: null;
return (
kandangName?.name && (
(kandangName?.name || !kandangExpense.kandang_id) && (
<div
key={`kandangExpense-${kandangExpenseIdx}`}
className='w-full flex flex-col gap-4'
>
<div>
<h5 className='mb-2 text-lg font-bold text-center'>
Biaya {kandangName?.name}
Biaya {kandangName?.name || location?.label || 'Umum'}
</h5>
<div className='overflow-x-auto'>
<table className='table'>
<thead>
<tr>
<th>Nonstock</th>
<th>Total Kuantitas</th>
<th>Harga Satuan</th>
<th className='after:content-["*"] after:text-red-500 after:ml-0.5'>
Nonstock
</th>
<th className='after:content-["*"] after:text-red-500 after:ml-0.5'>
Total Kuantitas
</th>
<th className='after:content-["*"] after:text-red-500 after:ml-0.5'>
Harga Satuan
</th>
<th>Catatan</th>
{type !== 'detail' && <th>Aksi</th>}
</tr>
@@ -219,7 +219,13 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
{ label: 'Lokasi', value: expense?.location.name },
{
label: 'Kandang',
value: expense?.kandangs.map((item) => item.name).join(', '),
value:
expense?.kandangs && expense?.kandangs.some((k) => k.name)
? expense?.kandangs
.filter((item) => item.name)
.map((item) => item.name)
.join(', ')
: '-',
},
{ label: 'Vendor', value: expense?.supplier.name },
{
@@ -235,7 +241,12 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
{ label: 'Nama Pengaju', value: expense?.created_user.name },
{
label: 'Nominal Biaya',
value: formatCurrency(expense?.grand_total ?? 0),
value: formatCurrency(
expense?.latest_approval.step_number === 4 ||
expense?.latest_approval.step_number === 5
? (expense?.total_realisasi ?? 0)
: (expense?.total_pengajuan ?? 0)
),
},
{
label: 'Nominal Pengajuan',
@@ -326,7 +337,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
let expenseRequestTotal = 0;
kandangExpense.pengajuans?.forEach(
(item) => (expenseRequestTotal += item.price)
(item) => (expenseRequestTotal += item.qty * item.price)
);
return (
@@ -335,7 +346,9 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
style={ExpensePDFStyle.kandangExpenseContainer}
>
<Text style={ExpensePDFStyle.kandangExpenseTitle}>
{kandangExpense.name}
{kandangExpense.kandang_id && kandangExpense.name
? `Biaya ${kandangExpense.name}`
: `Biaya ${expense?.location.name || 'Umum'}`}
</Text>
<View style={ExpensePDFStyle.kandangExpenseTable}>
@@ -484,7 +497,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
let expenseRealizationTotal = 0;
kandangExpense.realisasi?.forEach(
(item) => (expenseRealizationTotal += item.price)
(item) => (expenseRealizationTotal += item.qty * item.price)
);
return (
@@ -493,7 +506,9 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
style={ExpensePDFStyle.kandangExpenseContainer}
>
<Text style={ExpensePDFStyle.kandangExpenseTitle}>
{kandangExpense.name}
{kandangExpense.kandang_id && kandangExpense.name
? `Biaya ${kandangExpense.name}`
: `Biaya ${expense?.location.name || 'Umum'}`}
</Text>
<View style={ExpensePDFStyle.kandangExpenseTable}>
@@ -39,6 +39,12 @@ interface FormFinanceAddProps {
initialValues?: Finance;
}
interface PartyCommonProps {
id: number;
name: string;
account_number: string;
}
const FormFinanceAdd = ({
type = 'add',
initialValues,
@@ -52,10 +58,12 @@ const FormFinanceAdd = ({
FINANCE_PARTY_TYPE_OPTIONS.find(
(option) => option.value === initialValues?.party.type
) || null,
party_id_option: {
label: initialValues?.party.name || '',
value: initialValues?.party.id || 0,
},
party_id_option: initialValues?.party
? {
label: initialValues?.party.name || '',
value: initialValues?.party.id || 0,
}
: null,
payment_date: initialValues?.payment_date || '',
payment_method_option:
FINANCE_PAYMENT_METHOD_OPTIONS.find(
@@ -97,16 +105,19 @@ const FormFinanceAdd = ({
});
// ===== Options =====
const { options: partyOptions, isLoadingOptions: isLoadingPartyOptions } =
useSelect(
formik.values.party_type_option?.value === 'CUSTOMER'
? CustomerApi.basePath
: SupplierApi.basePath,
'id',
'name',
'',
{ limit: 'limit' }
);
const {
options: partyOptions,
isLoadingOptions: isLoadingPartyOptions,
rawData: partyRawData,
} = useSelect<PartyCommonProps>(
formik.values.party_type_option?.value === 'CUSTOMER'
? CustomerApi.basePath
: SupplierApi.basePath,
'id',
'name',
'',
{ limit: 'limit' }
);
const {
options: bankOptions,
rawData: bankRawData,
@@ -204,6 +215,14 @@ const FormFinanceAdd = ({
value={formik.values.party_id_option}
onChange={(value) => {
formik.setFieldValue('party_id_option', value);
if (isResponseSuccess(partyRawData) && value) {
formik.setFieldValue(
'party_account_number',
partyRawData.data?.find(
(item) => item.id === (value as OptionType)?.value
)?.account_number || ''
);
}
}}
isLoading={isLoadingPartyOptions}
isError={Boolean(
@@ -312,6 +331,7 @@ const FormFinanceAdd = ({
: ''
}
required
readOnly
/>
<TextInput
label='Nomor Referensi'
@@ -1,19 +1,6 @@
import * as Yup from 'yup';
import { OptionType } from '@/components/input/SelectInput';
/**
* API Payload format for Initial Balance:
* {
"party_type": "CUSTOMER",
"party_id": 1,
"bank_id": 1,
"reference_number": "IB.MBU.001",
"initial_balance_type": "DEBIT",
"nominal": 5000000,
"note": "Saldo awal piutang customer"
}
*/
// Type for form values (includes option objects for SelectInput)
export type InitialBalanceFormValues = {
party_type_option: OptionType | null;
@@ -431,7 +431,7 @@ const MarketingDetail = ({
<Button
color='warning'
type='button'
href={`/marketing/detail/${initialValues?.latest_approval.step_number == 3 ? 'delivery-orders' : 'sales-orders'}/edit?marketingId=${initialValues?.id}`}
href={`/marketing/detail/${initialValues?.latest_approval?.step_number == 3 ? 'delivery-orders' : 'sales-orders'}/edit?marketingId=${initialValues?.id}`}
>
<Icon icon='mdi:pencil' width={24} height={24} />
Edit
@@ -174,19 +174,6 @@ const DeliveryOrderProductForm = ({
}}
onReset={handleResetForm}
>
{/* <small className='block text-blue-500'>
{JSON.stringify(exisitingValues)}
</small>
<small className='block text-emerald-500'>
{JSON.stringify(formik.values)}
</small> */}
{/* <small className='block text-red-500'>
{JSON.stringify(formik.errors)}
</small>
<div className='hidden'>
{JSON.stringify(formik.values.marketing_product)}
</div> */}
{formikErrorMessage && (
<div onClick={() => setFormErrorMessage('')} className='my-3 w-full'>
<Alert color='error'>{formikErrorMessage}</Alert>
@@ -11,7 +11,7 @@ import SelectInput, {
useSelect,
} from '@/components/input/SelectInput';
import { Kandang } from '@/types/api/master-data/kandang';
import { KandangApi } from '@/services/api/master-data';
import { KandangApi, WarehouseApi } from '@/services/api/master-data';
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
import { ProductWarehouseApi } from '@/services/api/inventory';
import NumberInput from '@/components/input/NumberInput';
@@ -61,7 +61,7 @@ const SalesOrderProductForm = ({
const {
options: kandangSourceOptions,
isLoadingOptions: isLoadingKandangSourceOptions,
} = useSelect<Kandang>(KandangApi.basePath, 'id', 'name');
} = useSelect<Kandang>(WarehouseApi.basePath, 'id', 'name');
const {
options: warehouseSourceOptions,
@@ -79,14 +79,14 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
uomId: initialValues?.uom_id ?? 0,
uom: initialValues?.uom
? {
value: initialValues?.uom.id,
label: initialValues?.uom.name,
value: initialValues?.uom?.id,
label: initialValues?.uom?.name,
}
: null,
supplierIds:
initialValues?.suppliers.map((supplier) => supplier.id) ?? [],
initialValues?.suppliers?.map((supplier) => supplier.id) ?? [],
suppliers:
initialValues?.suppliers.map((supplier) => ({
initialValues?.suppliers?.map((supplier) => ({
value: supplier.id,
label: supplier.name,
})) ?? [],
@@ -18,6 +18,7 @@ const LayingRepeaterFormSchema = Yup.object({
),
target_egg_weight: Yup.number().required('Berat telur wajib diisi!'),
target_egg_mass: Yup.number().required('Massa telur wajib diisi!'),
standard_fcr: Yup.number().required('FCR wajib diisi!'),
}).required(),
});
@@ -35,6 +36,7 @@ const GrowingRepeaterFormSchema = Yup.object({
target_hen_house_production: Yup.number().optional(),
target_egg_weight: Yup.number().optional(),
target_egg_mass: Yup.number().optional(),
standard_fcr: Yup.number().optional(),
}).optional(),
});
@@ -68,7 +70,9 @@ export const createProductionStandardRepeaterFormSchema = (
export const createProductionStandardFormSchema = (category: string) => {
return Yup.object({
name: Yup.string().required('Nama wajib diisi!'),
project_category: Yup.string().required('Kategori proyek wajib diisi!'),
project_category: Yup.string()
.min(1, 'Kategori proyek wajib diisi!')
.required('Kategori proyek wajib diisi!'),
details: Yup.array().of(
createProductionStandardRepeaterFormSchema(category)
),
@@ -29,6 +29,8 @@ import toast from 'react-hot-toast';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import { useModal } from '@/components/Modal';
import RequirePermission from '@/components/helper/RequirePermission';
import Tooltip from '@/components/Tooltip';
import Alert from '@/components/Alert';
type TableRowsType = {
customRow: boolean;
@@ -41,6 +43,7 @@ type ProductionDetailsErrors = {
target_hen_house_production?: string;
target_egg_weight?: string;
target_egg_mass?: string;
standard_fcr?: string;
};
type ProductionDetailsTouched = {
@@ -48,6 +51,7 @@ type ProductionDetailsTouched = {
target_hen_house_production?: boolean;
target_egg_weight?: boolean;
target_egg_mass?: boolean;
standard_fcr?: boolean;
};
const getProductionDetailsError = (
@@ -91,6 +95,9 @@ const convertPayloadToNumberTypes = (payload: ProductionStandardFormValues) => {
target_egg_mass: Number(
detail.production_standard_details.target_egg_mass
),
standard_fcr: Number(
detail.production_standard_details.standard_fcr
),
}
: undefined,
production_standard_uniformity_details: {
@@ -131,6 +138,9 @@ const convertStandardValueToFormValues = (
target_egg_mass: Number(
detail.egg_production_standard_detail.target_egg_mass
),
standard_fcr: Number(
detail.egg_production_standard_detail.standard_fcr
),
}
: undefined,
production_standard_uniformity_details: {
@@ -175,13 +185,15 @@ const ProductionStandardForm = ({
} = useFormStore();
// ===== Formik =====
// Initial values - only recalculate when initialValue changes (for edit/detail mode)
// For add mode, we load from cache via useEffect instead to avoid race conditions
const formikInitialValues = useMemo(() => {
// For add mode, merge cached data with initial values
if (formType === 'add' && formData) {
if (formType === 'add') {
// Don't use formData here - will be loaded via useEffect
return {
name: formData.name || '',
project_category: formData.project_category || '',
details: formData.details || [],
name: '',
project_category: '',
details: [],
} as ProductionStandardFormValues;
}
@@ -190,10 +202,11 @@ const ProductionStandardForm = ({
project_category: initialValue?.project_category || '',
details: convertStandardValueToFormValues(initialValue?.details || []),
} as ProductionStandardFormValues;
}, [initialValue, formData, formType]);
}, [initialValue, formType]);
const formik = useFormik<ProductionStandardFormValues>({
initialValues: formikInitialValues as ProductionStandardFormValues,
enableReinitialize: true,
// Only enable reinitialize for edit/detail mode, not add mode
enableReinitialize: formType !== 'add',
onSubmit: (values) => {
switch (formType) {
case 'add':
@@ -222,6 +235,7 @@ const ProductionStandardForm = ({
target_hen_house_production: '' as unknown as number,
target_egg_weight: '' as unknown as number,
target_egg_mass: '' as unknown as number,
standard_fcr: '' as unknown as number,
},
production_standard_uniformity_details: {
target_mean_bw: '' as unknown as number,
@@ -255,36 +269,38 @@ const ProductionStandardForm = ({
const { setValues: repeaterFormikSetValues } = repeaterFormik;
// ===== Effect =====
// Load initial values only when component mounts or when initialValue changes (for edit mode)
// This allows:
// 1. Add mode: Load cached data from formData store
// 2. Edit mode: Load existing data from initialValue
// We use initialValue?.id as dependency to avoid infinite loops
// Load cached data only once on mount for add mode
const [isInitialized, setIsInitialized] = useState(false);
useEffect(() => {
if (formType === 'add' && formData) {
// For add mode, load from cache
if (formType === 'add' && formData && !isInitialized) {
// For add mode, load from cache only on initial mount
formikSetValues({
name: formData.name || '',
project_category: formData.project_category || '',
details: formData.details || [],
} as ProductionStandardFormValues);
} else if (formType === 'detail' && initialValue) {
// For detail mode, load from initialValue and convert the details
setIsInitialized(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Only run once on mount
// For edit/detail mode, update when initialValue changes
useEffect(() => {
if (formType === 'detail' && initialValue) {
formikSetValues({
name: initialValue.name || '',
project_category: initialValue.project_category || '',
details: convertStandardValueToFormValues(initialValue.details || []),
} as ProductionStandardFormValues);
} else if (formType === 'edit' && initialValue) {
// For edit mode, load from initialValue and convert the details
formikSetValues({
name: initialValue.name || '',
project_category: initialValue.project_category || '',
details: convertStandardValueToFormValues(initialValue.details || []),
} as ProductionStandardFormValues);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [formData, initialValue?.id]); // Trigger when formData or initialValue.id changes
}, [initialValue?.id, formType]);
// ===== Data Table =====
const tableRows = useMemo(() => {
@@ -323,11 +339,6 @@ const ProductionStandardForm = ({
}, [formik.values.details]);
const columns = useMemo<ColumnDef<TableRowsType>[]>(() => {
const baseColumns: ColumnDef<TableRowsType>[] = [
{
header: 'No',
accessorFn: (row, index) => index + 1,
enableSorting: false,
},
{
header: 'Minggu',
accessorKey: 'week',
@@ -363,6 +374,12 @@ const ProductionStandardForm = ({
row.production_standard_details?.target_egg_mass,
enableSorting: false,
},
{
header: 'FCR',
accessorFn: (row) =>
row.production_standard_details?.standard_fcr,
enableSorting: false,
},
]
: [];
@@ -407,6 +424,7 @@ const ProductionStandardForm = ({
variant='outline'
color='warning'
className='p-2'
type='button'
onClick={() => handleEditClick(row.row.original.week)}
>
<Icon icon='mdi:pencil' />
@@ -415,6 +433,7 @@ const ProductionStandardForm = ({
variant='outline'
color='error'
className='p-2'
type='button'
onClick={() => handleRemoveRow(row.row.original.week)}
>
<Icon icon='mdi:delete' />
@@ -430,7 +449,7 @@ const ProductionStandardForm = ({
...uniformityColumns,
...(formType !== 'detail' ? [actionColumn] : []),
];
}, [formik.values.project_category, formType]);
}, [formik.values, formType]);
// ===== Handler =====
const handleAddRow = async (
@@ -488,9 +507,11 @@ const ProductionStandardForm = ({
setIsAddingRow(false);
};
const handleRemoveRow = (week: number) => {
const newValues = (formik.values.details || []).filter(
(detail) => detail.week !== week
const handleRemoveRow = async (week: number) => {
// Access formik.values directly to get the latest values
const currentDetails = formik.values.details || [];
const newValues = currentDetails.filter(
(detail) => Number(detail.week) !== Number(week)
);
const updatedFormValues = {
@@ -671,6 +692,7 @@ const ProductionStandardForm = ({
target_hen_house_production: 0,
target_egg_weight: 0,
target_egg_mass: 0,
standard_fcr: 0,
},
}));
}
@@ -745,6 +767,7 @@ const ProductionStandardForm = ({
}
required
isDisabled={formType === 'detail'}
isClearable
/>
</div>
<Table<TableRowsType>
@@ -803,7 +826,7 @@ const ProductionStandardForm = ({
className={cn(
'grid gap-4 items-start',
formik.values.project_category === 'LAYING'
? 'grid-cols-9'
? 'grid-cols-10'
: 'grid-cols-5'
)}
>
@@ -962,6 +985,41 @@ const ProductionStandardForm = ({
)
}
/>
<NumberInput
name='production_standard_details.standard_fcr'
label='FCR'
placeholder='1'
value={
repeaterFormik.values
.production_standard_details?.standard_fcr
}
onChange={repeaterFormik.handleChange}
onBlur={repeaterFormik.handleBlur}
endAdornment={
<div className='w-full h-full flex items-center justify-center'>
gr
</div>
}
errorMessage={getProductionDetailsError(
repeaterFormik.errors
.production_standard_details,
'standard_fcr'
)}
isError={
Boolean(
getProductionDetailsError(
repeaterFormik.errors
.production_standard_details,
'standard_fcr'
)
) &&
getProductionDetailsTouched(
repeaterFormik.touched
.production_standard_details,
'standard_fcr'
)
}
/>
</>
)}
<NumberInput
@@ -1105,16 +1163,27 @@ const ProductionStandardForm = ({
<Icon icon='mdi:close' /> Batal
</Button>
)}
<Button
type='submit'
color={editMode ? 'warning' : 'success'}
className='min-w-24'
disabled={isAddingRow}
isLoading={isAddingRow}
<Tooltip
content={
formik.values.project_category === ''
? 'Isi kategori proyek terlebih dahulu'
: ''
}
>
<Icon icon={editMode ? 'mdi:pencil' : 'mdi:plus'} />{' '}
{editMode ? 'Edit Data' : 'Tambah Data'}
</Button>
<Button
type='submit'
color={editMode ? 'warning' : 'success'}
className='min-w-24'
disabled={
isAddingRow ||
formik.values.project_category === ''
}
isLoading={isAddingRow}
>
<Icon icon={editMode ? 'mdi:pencil' : 'mdi:plus'} />{' '}
{editMode ? 'Edit Data' : 'Tambah Data'}
</Button>
</Tooltip>
{/* Should not be absolute */}
<Button
type='button'
@@ -1224,6 +1293,19 @@ const ProductionStandardForm = ({
</div>
)}
</form>
{productionStandardFormErrorMessage && (
<Alert color='error' className='w-full'>
<div className='flex items-center gap-2 stretch'>
<Icon icon='mdi:alert' />
<span>{productionStandardFormErrorMessage}</span>
</div>
<Icon
icon='mdi:close'
onClick={() => setProductionStandardFormErrorMessage('')}
className='ms-auto'
/>
</Alert>
)}
</div>
<ConfirmationModal
@@ -18,7 +18,7 @@ export const SupplierFormSchema = Yup.object({
value: Yup.string().required(),
label: Yup.string().required(),
}).required('Tipe wajib diisi!'),
hatchery: Yup.string().required('Hatchery wajib diisi!'),
hatchery: Yup.string().optional(),
phone: Yup.string()
.matches(/^[0-9]+$/, 'Nomor telepon hanya boleh berisi angka!')
.min(10, 'Nomor telepon minimal 10 digit!')
@@ -142,7 +142,7 @@ const SupplierForm = ({
pic: values.pic,
type: values.type.value,
category: values.category.value,
hatchery: values.hatchery,
hatchery: values.hatchery ?? '',
phone: values.phone,
email: values.email,
address: values.address,
@@ -171,12 +171,12 @@ const SupplierForm = ({
useEffect(() => {
formikSetValues(formikInitialValues);
if (formType != 'add') {
const hatcheryArrays = formikInitialValues.hatchery.split(',');
const hatcheryCreatedOptions = hatcheryArrays.map((item) => ({
const hatcheryArrays = formikInitialValues.hatchery?.split(',');
const hatcheryCreatedOptions = hatcheryArrays?.map((item) => ({
value: item,
label: item,
}));
setHatcheryOptionValues(hatcheryCreatedOptions);
setHatcheryOptionValues(hatcheryCreatedOptions ?? []);
}
}, [formikSetValues, formikInitialValues, setHatcheryOptionValues]);
useEffect(() => {
@@ -302,7 +302,6 @@ const SupplierForm = ({
<SelectInput
isMulti
createables
required
placeholder='Pilih Hatchery'
label='Hatchery'
value={hatcheryOptionsValues}
@@ -618,7 +618,7 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Project Flock ini (${selectedProjectFlock?.flock_name})?`}
text={`Apakah anda yakin ingin menghapus data Project Flock ini (${selectedRowIds?.length} data)?`}
secondaryButton={{
text: 'Tidak',
}}
@@ -633,7 +633,7 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
<ConfirmationModalWithNotes
ref={confirmModal.ref}
type={approvalAction == 'APPROVED' ? 'success' : 'error'}
text={`Apakah anda yakin ingin ${approvalAction == 'APPROVED' ? 'approve' : 'reject'} data Project Flock ini (${selectedRowIds.length} data)?`}
text={`Apakah anda yakin ingin ${approvalAction == 'APPROVED' ? 'approve' : 'reject'} data Project Flock ini (${selectedRowIds?.length} data)?`}
secondaryButton={{
text: 'Tidak',
}}
@@ -21,6 +21,11 @@ type ProjectFlockFormSchemaType = {
label: string;
} | null;
fcr_id: number;
production_standard: {
value: number | string;
label: string;
} | null;
production_standard_id: number;
location: {
value: number | string;
label: string;
@@ -100,6 +105,15 @@ export const ProjectFlockFormSchema: Yup.ObjectSchema<ProjectFlockFormSchemaType
.min(1, 'FCR wajib diisi!')
.required('FCR wajib diisi!'),
// Production Standard
production_standard: Yup.object({
value: Yup.number().required('ID Standar Produksi wajib diisi!'),
label: Yup.string().required('Nama Standar Produksi wajib diisi!'),
}).nullable(),
production_standard_id: Yup.number()
.min(1, 'Standar Produksi wajib diisi!')
.required('Standar Produksi wajib diisi!'),
// Location
location: Yup.object({
value: Yup.number().required('ID Lokasi wajib diisi!'),
@@ -13,6 +13,7 @@ import {
KandangApi,
LocationApi,
NonstockApi,
ProductionStandardApi,
} from '@/services/api/master-data';
import { Icon } from '@iconify/react';
import { FormikErrors, useFormik } from 'formik';
@@ -136,6 +137,11 @@ const ProjectFlockForm = ({
'name'
);
const {
options: optionsProductionStandards,
isLoadingOptions: isLoadingProductionStandards,
} = useSelect(ProductionStandardApi.basePath, 'id', 'name');
const kandangUrl = `${KandangApi.basePath}?${new URLSearchParams({
search: '',
location_id: selectedLocation == '' ? '0' : selectedLocation,
@@ -341,6 +347,12 @@ const ProjectFlockForm = ({
label: initialValues.fcr.name,
}
: null,
production_standard: initialValues?.production_standard
? {
value: initialValues.production_standard?.id,
label: initialValues.production_standard.name,
}
: null,
location: initialValues?.location
? {
value: initialValues.location?.id,
@@ -356,6 +368,7 @@ const ProjectFlockForm = ({
'GROWING' | 'LAYING' | undefined
>,
fcr_id: initialValues?.fcr?.id ?? 0,
production_standard_id: initialValues?.production_standard?.id ?? 0,
location_id: initialValues?.location?.id ?? 0,
kandang_ids: initialValues?.kandangs?.map(
(k: Kandang) => k.id
@@ -400,6 +413,7 @@ const ProjectFlockForm = ({
area_id: values.area_id as number,
category: values.category as string,
fcr_id: values.fcr_id as number,
production_standard_id: values.production_standard_id as number,
location_id: values.location_id as number,
kandang_ids: values.kandang_ids as number[],
project_budgets: values.project_budgets.flatMap((budget) => {
@@ -858,6 +872,23 @@ const ProjectFlockForm = ({
isClearable
isDisabled={formType != 'add'}
/>
<SelectInput
required
label='Standar Produksi'
value={formik.values.production_standard as OptionType}
onChange={(val) => {
optionChangeHandler(val, 'production_standard');
}}
options={optionsProductionStandards}
isLoading={isLoadingProductionStandards}
isError={
formik.touched.production_standard &&
Boolean(formik.errors.production_standard)
}
errorMessage={formik.errors.production_standard as string}
isClearable
isDisabled={formType != 'add'}
/>
<SelectInput
required
label='Kategori'
+12 -6
View File
@@ -268,17 +268,20 @@ export const FLOCK_CATEGORY_OPTIONS = [
value: 'LAYING',
},
];
export const PRODUCT_FLAG_OPTIONS = [
{ label: 'DOC', value: 'DOC' },
{ label: 'EKSPEDISI', value: 'EKSPEDISI' },
{ label: 'FINISHER', value: 'FINISHER' },
{ label: 'ACTIVE', value: 'IS_ACTIVE' },
{ label: 'KIMIA', value: 'KIMIA' },
{ label: 'LAYER', value: 'LAYER' },
{ label: 'OBAT', value: 'OBAT' },
{ label: 'OVK', value: 'OVK' },
{ label: 'PAKAN', value: 'PAKAN' },
{ label: 'PRE-STARTER', value: 'PRE-STARTER' },
{ label: 'PULLET', value: 'PULLET' },
{ label: 'STARTER', value: 'STARTER' },
{ label: 'FINISHER', value: 'FINISHER' },
{ label: 'OVK', value: 'OVK' },
{ label: 'OBAT', value: 'OBAT' },
{ label: 'VITAMIN', value: 'VITAMIN' },
{ label: 'KIMIA', value: 'KIMIA' },
];
export const SUPPLIER_FLAG_OPTIONS = [
@@ -309,7 +312,7 @@ export const FINANCE_INITIAL_BALANCE_TYPE_OPTIONS = [
{ label: 'Saldo Awal Negatif', value: 'NEGATIVE' },
];
export const FINANCE_TRANSACTION_STATUS = ['PENJUALAN', 'BIAYA'];
export const FINANCE_TRANSACTION_STATUS = ['PENJUALAN', 'PEMBELIAN', 'BIAYA'];
export const FINANCE_INITIAL_BALANCE_STATUS = ['SALDO_AWAL'];
@@ -353,6 +356,9 @@ export const ACCEPTED_FILE_TYPE = {
},
};
export const S3_PUBLIC_BASE_URL = process.env
.NEXT_PUBLIC_S3_PUBLIC_BASE_URL as string;
export const FILTER_TYPE_OPTIONS = [
{
label: 'Tanggal Realisasi',
-10
View File
@@ -69,16 +69,6 @@ export const ROUTE_PERMISSIONS: Record<string, string[]> = {
'/expense/realization/edit/': ['lti.expense.update.realization'],
// Finance
// // ===== FINANCE =====
// "lti.finance.transaction.list",
// "lti.finance.transaction.detail",
// "lti.finance.transaction.delete",
// "lti.finance.payments.create",
// "lti.finance.payments.update",
// "lti.finance.initial_balances.create",
// "lti.finance.initial_balances.update",
// "lti.finance.injections.create",
// "lti.finance.injections.update",
'/finance/': ['lti.finance.transaction.list'],
'/finance/detail/': ['lti.finance.transaction.detail'],
'/finance/add/': ['lti.finance.payments.create'],
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,39 @@
/**
* Dummy data for DashboardProduction
* Generated from: dashboard.production.dummy.json
*
* This file is auto-generated. Do not edit manually.
*/
import {
DashboardProductionStatisticsData,
DashboardProductionProductionChartsFlocks,
DashboardProductionProductionCharts,
DashboardProductionStandardProductionsStandards,
DashboardProductionStandardProductions,
DashboardProductionFcrDataFlock,
DashboardProductionEggWeights,
DashboardProductionFcrData,
DashboardProduction,
} from '../../types/api/dashboard/dashboard-production';
import { BaseApiResponse } from '@/types/api/api-general';
import dummyData from './dashboard.production.dummy.json';
/**
* Get dummy DashboardProduction data
* @returns Promise with BaseApiResponse containing DashboardProduction
*/
export async function getDummySingle(): Promise<
BaseApiResponse<DashboardProduction>
> {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
code: 200,
status: 'success',
message: 'Data retrieved successfully',
data: dummyData as unknown as DashboardProduction,
});
}, 500);
});
}
+34
View File
@@ -0,0 +1,34 @@
import { BaseApiService } from '@/services/api/base';
import { BaseApiResponse } from '@/types/api/api-general';
import { DashboardProduction } from '@/types/api/dashboard/dashboard-production';
import { getDummySingle } from '@/dummy/dashboard/dashboard.production.dummy';
class DashboardService extends BaseApiService<
DashboardProduction,
unknown,
unknown
> {
constructor(basePath: string) {
super(basePath);
}
/**
* Fetch dashboard production data
* @param endpoint - The endpoint URL with query parameters
* @returns Promise with BaseApiResponse containing DashboardProduction
*
* Note: Currently using dummy data. When real API is ready,
* uncomment the line below and remove getDummySingle() call:
* return await this.customRequest<BaseApiResponse<DashboardProduction>>(endpoint);
*/
async getDashboardProductionFetcher(
endpoint: string
): Promise<BaseApiResponse<DashboardProduction>> {
// For now, we're using dummy data regardless of the endpoint
// The endpoint parameter is kept for future API integration
console.log('Fetching dashboard data with endpoint:', endpoint);
return await getDummySingle();
}
}
export const DashboardApi = new DashboardService('/dashboard');
+2
View File
@@ -483,6 +483,7 @@ export class ExpenseApiService extends BaseApiService<
const formData = new FormData();
formData.append('category', payload.category);
formData.append('location_id', String(payload.location_id));
formData.append('transaction_date', payload.transaction_date);
formData.append('supplier_id', String(payload.supplier_id));
@@ -505,6 +506,7 @@ export class ExpenseApiService extends BaseApiService<
const formData = new FormData();
formData.append('category', payload.category);
formData.append('location_id', String(payload.location_id));
formData.append('transaction_date', payload.transaction_date);
formData.append('supplier_id', String(payload.supplier_id));
+52
View File
@@ -0,0 +1,52 @@
export interface DashboardProduction {
statistics_data: DashboardProductionStatisticsData[];
production_charts: DashboardProductionProductionCharts[];
standard_productions: DashboardProductionStandardProductions[];
egg_weights: DashboardProductionEggWeights[];
fcr_data: DashboardProductionFcrData[];
}
export interface DashboardProductionFcrData {
flock: DashboardProductionFcrDataFlock;
fcr: number;
}
export interface DashboardProductionEggWeights {
flock: DashboardProductionFcrDataFlock;
weight: number;
}
export interface DashboardProductionStandardProductions {
week: number;
standards: DashboardProductionStandardProductionsStandards[];
flocks: DashboardProductionProductionChartsFlocks[];
}
export interface DashboardProductionProductionCharts {
date: string;
flocks: DashboardProductionProductionChartsFlocks[];
}
export interface DashboardProductionStatisticsData {
title: string;
value: number;
change: number;
period: string;
changeType: string;
}
export interface DashboardProductionFcrDataFlock {
id: number;
name: string;
}
export interface DashboardProductionStandardProductionsStandards {
name: string;
value: number;
}
export interface DashboardProductionProductionChartsFlocks {
id: number;
name: string;
data: number;
}
+4 -2
View File
@@ -57,11 +57,12 @@ export type Expense = BaseMetadata & BaseExpense;
export type CreateExpensePayload = {
category: 'BOP' | 'NON-BOP';
location_id: number;
transaction_date: string;
supplier_id: number;
documents: File[];
expense_nonstocks: {
kandang_id: number;
kandang_id?: number;
cost_items: {
nonstock_id: number;
quantity: number;
@@ -72,12 +73,13 @@ export type CreateExpensePayload = {
};
export type UpdateExpensePayload = {
location_id: number;
category: 'BOP' | 'NON-BOP';
transaction_date: string;
supplier_id: number;
documents: File[];
expense_nonstocks: {
kandang_id: number;
kandang_id?: number;
cost_items: {
nonstock_id: number;
quantity: number;
+3
View File
@@ -21,6 +21,7 @@ export interface ProductionStandardDetails {
target_hen_house_production: number;
target_egg_weight: number;
target_egg_mass: number;
standard_fcr: number;
}
export interface StandardGrowthDetails {
@@ -46,6 +47,7 @@ export interface CreateProductionStandardPayload {
target_hen_house_production: number;
target_egg_weight: number;
target_egg_mass: number;
standard_fcr: number;
};
}[];
}
@@ -66,6 +68,7 @@ export interface UpdateProductionStandardPayload {
target_hen_house_production: number;
target_egg_weight: number;
target_egg_mass: number;
standard_fcr: number;
};
}[];
}
+3
View File
@@ -16,6 +16,8 @@ export type BaseProjectFlock = {
category: string;
fcr: Fcr;
fcr_id: number;
production_standard: ProductionStandard;
production_standard_id: number;
location: Location;
location_id: number;
period: number;
@@ -48,6 +50,7 @@ export type CreateProjectFlockPayload = {
area_id: number;
category: string;
fcr_id: number;
production_standard_id: number;
location_id: number;
kandang_ids: number[];
project_budgets?: ProjectFlockBudget[];