feat(FE): US#278 slicing UI from and client side validation

This commit is contained in:
randy-ar
2025-12-02 04:11:01 +07:00
parent 48435a9cbb
commit c76f3a3715
8 changed files with 556 additions and 115 deletions
+8 -6
View File
@@ -4,7 +4,7 @@ import { usePathname, useRouter } from 'next/navigation';
import Drawer from '@/components/Drawer';
import React, { ReactNode } from 'react';
import ProjectFlockTable from '@/components/pages/production/project-flock/ProjectFlockTable';
import { useUiStore } from '@/stores/ui/ui.store';
import { useProjectFlockUiStore } from '@/stores/ui/slices/production/project-flock.slice';
export default function ProjectFlockLayout({
children,
@@ -13,7 +13,7 @@ export default function ProjectFlockLayout({
}) {
const pathname = usePathname();
const router = useRouter();
const toggleValidate = useUiStore((s) => s.toggleValidate);
const toggleValidate = useProjectFlockUiStore((s) => s.toggleValidate);
const isAdd = pathname.endsWith('/add');
const isEdit = pathname.includes('/detail/edit');
@@ -22,10 +22,10 @@ export default function ProjectFlockLayout({
const isOpen = isAdd || isEdit || isDetail || isChickin;
// const childRef = useRef<ProjectFlockFormRef>(null);
const handleBackdropClick = () => {
const unsub = useUiStore.getState().subscribeIsValid((isValid) => {
const unsub = useProjectFlockUiStore
.getState()
.subscribeIsValid((isValid) => {
if (isValid) {
unsub(); // berhenti listen
router.push('/production/project-flock');
@@ -39,7 +39,9 @@ export default function ProjectFlockLayout({
<>
{/* List page always rendered */}
<div>
<ProjectFlockTable />
<ProjectFlockTable
refresh={() => !isOpen && router.push('/production/project-flock')}
/>
</div>
{/* Render Drawer only on /add */}
@@ -20,7 +20,7 @@ import { Kandang } from '@/types/api/master-data/kandang';
import { ProjectFlock } from '@/types/api/production/project-flock';
import { Icon } from '@iconify/react';
import { CellContext, SortingState } from '@tanstack/react-table';
import { ChangeEventHandler, useRef, useState } from 'react';
import { ChangeEventHandler, useEffect, useState } from 'react';
import toast from 'react-hot-toast';
import useSWR from 'swr';
@@ -94,7 +94,7 @@ const RowOptionsMenu = ({
);
};
const ProjectFlockTable = () => {
const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
const {
state: tableFilterState,
updateFilter,
@@ -154,7 +154,8 @@ const ProjectFlockTable = () => {
mutate: refreshProjectFlocks,
} = useSWR(
`${ProjectFlockApi.basePath}${getTableFilterQueryString()}`,
ProjectFlockApi.getAllFetcher
ProjectFlockApi.getAllFetcher,
{ revalidateOnMount: true }
);
const areaUrl = `${AreaApi.basePath}?${new URLSearchParams({
@@ -255,6 +256,10 @@ const ProjectFlockTable = () => {
setIsApproveLoading(false);
};
useEffect(() => {
refreshProjectFlocks();
}, [refresh]);
return (
<>
<div className='w-full p-0 sm:p-4'>
@@ -1,6 +1,71 @@
import * as Yup from 'yup';
export const ProjectFlockFormSchema = Yup.object({
type ProjectFlockFormSchemaType = {
flock: {
value: number | string;
label: string;
} | null;
flock_name: string;
area: {
value: number | string;
label: string;
} | null;
area_id: number;
category_option: {
value: string;
label: string;
} | null;
category: string;
fcr: {
value: number | string;
label: string;
} | null;
fcr_id: number;
location: {
value: number | string;
label: string;
} | null;
location_id: number;
kandang_ids: number[];
project_budgets: ProjectFlockBudgetsSchemaType[];
};
export type ProjectFlockBudgetsSchemaType = {
nonstock: {
value: number | string;
label: string;
} | null;
nonstock_id: number | string;
qty: number | string;
price: number | string;
total_price: number | string;
};
export const ProjectFlockBudgetsSchema: Yup.ObjectSchema<ProjectFlockBudgetsSchemaType> =
Yup.object({
nonstock: Yup.object({
value: Yup.number().required('ID Nonstock wajib diisi!'),
label: Yup.string().required('Nama Nonstock wajib diisi!'),
}).required('Nonstock wajib diisi!'),
nonstock_id: Yup.number()
.min(1, 'Nonstock wajib diisi!')
.required('Nonstock wajib diisi!'),
qty: Yup.number()
.typeError('Jumlah harus berupa angka!')
.min(1, 'Jumlah minimal 1!')
.required('Jumlah wajib diisi!'),
price: Yup.number()
.typeError('Harga harus berupa angka!')
.min(1, 'Harga minimal 1!')
.required('Harga wajib diisi!'),
total_price: Yup.number()
.typeError('Harga harus berupa angka!')
.min(1, 'Harga minimal 1!')
.required('Harga wajib diisi!'),
});
export const ProjectFlockFormSchema: Yup.ObjectSchema<ProjectFlockFormSchemaType> =
Yup.object({
// Flock
flock: Yup.object({
value: Yup.number().required('ID Flock wajib diisi!'),
@@ -31,7 +96,9 @@ export const ProjectFlockFormSchema = Yup.object({
value: Yup.number().required('ID FCR wajib diisi!'),
label: Yup.string().required('Nama FCR wajib diisi!'),
}).nullable(),
fcr_id: Yup.number().min(1, 'FCR wajib diisi!').required('FCR wajib diisi!'),
fcr_id: Yup.number()
.min(1, 'FCR wajib diisi!')
.required('FCR wajib diisi!'),
// Location
location: Yup.object({
@@ -43,9 +110,14 @@ export const ProjectFlockFormSchema = Yup.object({
.required('Lokasi wajib diisi!'),
kandang_ids: Yup.array()
.of(Yup.number().typeError('Kandang tidak valid!'))
.of(Yup.number().required('Kandang tidak valid!'))
.min(1, 'Minimal harus ada 1 kandang!')
.required('Kandang wajib diisi!'),
project_budgets: Yup.array()
.of(ProjectFlockBudgetsSchema)
.min(1, 'Minimal harus ada 1 data budget!')
.required('Data budget wajib diisi!'),
});
export type ProjectFlockFormValues = Yup.InferType<
@@ -12,13 +12,15 @@ import {
FlockApi,
KandangApi,
LocationApi,
NonstockApi,
} from '@/services/api/master-data';
import { Icon } from '@iconify/react';
import { useFormik } from 'formik';
import { FormikErrors, useFormik } from 'formik';
import { useRouter } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
import useSWR, { KeyedMutator } from 'swr';
import {
ProjectFlockBudgetsSchemaType,
ProjectFlockFormSchema,
ProjectFlockFormValues,
UpdateProjectFlockFormSchema,
@@ -26,6 +28,7 @@ import {
import {
CreateProjectFlockPayload,
ProjectFlock,
ProjectFlockBudget,
} from '@/types/api/production/project-flock';
import toast from 'react-hot-toast';
import { Kandang } from '@/types/api/master-data/kandang';
@@ -41,8 +44,9 @@ import { PROJECT_FLOCK_APPROVAL_LINE } from '@/config/approval-line';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import NumberInput from '@/components/input/NumberInput';
import Card from '@/components/Card';
import { useUiStore } from '@/stores/ui/ui.store';
import ProjectFlockKandangTable from '@/components/pages/production/project-flock/form/ProjectFlockKandangTable';
import { useProjectFlockUiStore } from '@/stores/ui/slices/production/project-flock.slice';
import { Nonstock } from '@/types/api/master-data/nonstock';
interface ProjectFlockFormProps {
formType?: 'add' | 'edit' | 'detail';
@@ -79,8 +83,8 @@ const ProjectFlockForm = ({
initialValues?.flock_name?.lastIndexOf(' ')
) ?? ''
);
const subscribeValidate = useUiStore((s) => s.subscribeValidate);
const setIsValid = useUiStore((s) => s.setIsValid);
const subscribeValidate = useProjectFlockUiStore((s) => s.subscribeValidate);
const setIsValid = useProjectFlockUiStore((s) => s.setIsValid);
const deleteModal = useModal();
const confirmModal = useModal();
@@ -158,6 +162,12 @@ const ProjectFlockForm = ({
() => ProjectFlockApi.getNextPeriod(parseInt(selectedLocation as string))
);
const {
options: optionsNonstock,
rawData: nonstocks,
isLoadingOptions: isLoadingNonstocks,
} = useSelect<Nonstock>(NonstockApi.basePath, 'id', 'name');
const {
approvals,
isLoading: approvalsLoading,
@@ -359,10 +369,18 @@ const ProjectFlockForm = ({
>,
fcr_id: initialValues?.fcr?.id ?? 0,
location_id: initialValues?.location?.id ?? 0,
kandang_ids: initialValues?.kandangs?.map((k: Kandang) => k.id) as (
| number
| undefined
)[],
kandang_ids: initialValues?.kandangs?.map(
(k: Kandang) => k.id
) as number[],
project_budgets: [
{
nonstock: null,
nonstock_id: '',
qty: '',
price: '',
total_price: '',
},
],
};
}, [initialValues, optionsFlock]);
@@ -441,6 +459,15 @@ const ProjectFlockForm = ({
| number
| undefined
)[],
project_budgets: [
{
nonstock: null,
nonstock_id: '',
qty: '',
price: '',
total_price: '',
},
],
} as ProjectFlockFormValues,
enableReinitialize: true,
validationSchema:
@@ -457,6 +484,13 @@ const ProjectFlockForm = ({
fcr_id: values.fcr_id as number,
location_id: values.location_id as number,
kandang_ids: values.kandang_ids as number[],
project_budgets: values.project_budgets.flatMap((budget) => {
return {
nonstock_id: budget.nonstock_id,
qty: budget.qty,
price: budget.price,
} as ProjectFlockBudget;
}),
};
switch (formType) {
@@ -471,8 +505,8 @@ const ProjectFlockForm = ({
}
},
});
const { setValues: formikSetValues } = formik;
// Effect Initial
useEffect(() => {
if (formType == 'detail') {
@@ -522,6 +556,33 @@ const ProjectFlockForm = ({
});
}, [rowSelection, formikSetValues]);
useEffect(() => {
const unsub = subscribeValidate(() => {
formik.validateForm().then((errors) => {
if (Object.keys(errors).length > 0) {
// Membentuk touched object yang strongly-typed
const touched: Record<string, boolean | Record<string, boolean>[]> =
{};
Object.keys(formik.values).forEach((key) => {
if (
key === 'project_budgets' &&
Array.isArray(formik.values.project_budgets)
) {
touched[key] = formik.values.project_budgets.map(() => ({})); // Mark each item as touched if it's an array
} else {
touched[key] = true;
}
});
formik.setTouched(touched, true);
}
setIsValid(Object.keys(errors).length === 0);
});
});
return unsub;
}, []);
// Actions handler
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
@@ -539,6 +600,42 @@ const ProjectFlockForm = ({
setIsDeleteLoading(false);
};
const onAddBudgetRowHandler = () => {
const newProjectBudgets = [
...(formik.values.project_budgets ?? []),
{
nonstock: null,
nonstock_id: '',
qty: '',
price: '',
},
];
formik.setFieldValue('project_budgets', newProjectBudgets);
};
const onDeleteBudgetRowHandler = (nonstock_id: number, index?: number) => {
console.log(`nonstock_id: ${nonstock_id}, index: ${index}`);
if (!nonstock_id) {
const updatedBudgets = formik.values.project_budgets
.map((budget, i) => {
if (i == index) {
console.log(`buget: ${null}, index: ${index}, i: ${i}`);
return null;
} else {
console.log(`buget: ${budget}, index: ${index}, i: ${i}`);
return budget;
}
})
.filter((budget) => budget != null);
formik.setFieldValue('project_budgets', updatedBudgets);
} else {
const updatedBudgets = (formik.values.project_budgets ?? []).filter(
(budget) => budget.nonstock_id !== nonstock_id
);
formik.setFieldValue('project_budgets', updatedBudgets);
}
};
const confirmApprovalHandler = async (
notes: string,
approvalAction: 'REJECTED' | 'APPROVED'
@@ -562,6 +659,67 @@ const ProjectFlockForm = ({
setIsApproveLoading(false);
};
const handleBudgetChange = (
index: number,
fieldName: 'qty' | 'price' | 'total_price',
value: string
) => {
const updatedBudgets = [...formik.values.project_budgets];
const currentBudget = updatedBudgets[index];
const isNewValueEmpty = value === '';
let numericValue: number;
if (isNewValueEmpty) {
(currentBudget[fieldName] as string) = '';
numericValue = 0;
formik.setFieldValue('project_budgets', updatedBudgets);
return;
} else {
numericValue = Math.max(0, parseFloat(value) || 0);
(currentBudget[fieldName] as number) = numericValue;
}
const getSafeNumber = (val: string | number) =>
Math.max(0, parseFloat(String(val)) || 0);
const currentQty = getSafeNumber(currentBudget.qty);
const currentPrice = getSafeNumber(currentBudget.price);
const currentTotal = getSafeNumber(currentBudget.total_price);
let newQty = currentQty;
let newPrice = currentPrice;
let newTotal = currentTotal;
if (fieldName === 'price') {
// Jika Harga Satuan diubah, hitung Total Harga
newTotal = newQty * numericValue;
newPrice = numericValue;
} else if (fieldName === 'qty') {
// Jika Kuantitas diubah, hitung Total Harga
newTotal = numericValue * newPrice;
newQty = numericValue;
} else if (fieldName === 'total_price') {
// Jika Total Harga diubah, hitung Harga Satuan
newTotal = numericValue;
if (newQty > 0) {
newPrice = newTotal / newQty;
} else {
// Jika Qty 0, Harga Satuan tetap 0
newPrice = 0;
}
}
currentBudget.qty = newQty;
currentBudget.price = newPrice;
currentBudget.total_price = newTotal;
formik.setFieldValue('project_budgets', updatedBudgets);
};
const selectedPeriod = isResponseSuccess(periodFlocks)
? periodFlocks.data.find((kandang) =>
formik.values.kandang_ids?.includes(kandang.id)
@@ -569,39 +727,11 @@ const ProjectFlockForm = ({
: undefined;
const inputPeriod =
(initialValues?.period ?? selectedPeriod == 0) ? 1 : selectedPeriod;
// expose method validate() ke parent
// TODO: Buat Store untuk kirim props formik.isValid ke parent (layout.tsx)
// useImperativeHandle(ref, () => ({
// validate() {
// formik.validateForm();
// const isValid = formik.isValid;
// return isValid;
// },
// }));
useEffect(() => {
const unsub = subscribeValidate(() => {
formik.validateForm().then((errors) => {
if (Object.keys(errors).length > 0) {
// Membentuk touched object yang strongly-typed
const touched = Object.keys(formik.values).reduce<
Record<keyof typeof formik.values, boolean>
>(
(acc, key) => {
acc[key as keyof typeof formik.values] = true;
return acc;
},
{} as Record<keyof typeof formik.values, boolean>
const filteredNonStockOptions = optionsNonstock.filter((nonstock) => {
return !(formik.values.project_budgets ?? []).some(
(budget) => budget.nonstock_id === nonstock.value
);
formik.setTouched(touched, true);
}
setIsValid(Object.keys(errors).length === 0);
});
});
return unsub;
}, []);
return (
<>
@@ -699,6 +829,7 @@ const ProjectFlockForm = ({
onSubmit={formik.handleSubmit}
onReset={formik.handleReset}
>
{/* Card Informasi Umum */}
<Card
title='Informasi Umum'
variant='bordered'
@@ -807,12 +938,14 @@ const ProjectFlockForm = ({
/>
</div>
</Card>
{/* Card Pilih Kandang */}
<Card
collapsible
title='Pilih Kandang'
variant='bordered'
className={{
wrapper: 'w-full',
wrapper: 'w-full mb-4',
}}
>
<div className='overflow-x-auto duration-300 ease-in-out'>
@@ -833,6 +966,214 @@ const ProjectFlockForm = ({
</div>
</Card>
{/* Card Estimasi Budget */}
<Card
collapsible
title='Estimasi Aggaran per Kandang'
variant='bordered'
className={{
wrapper: 'w-full',
}}
>
<table className='w-full mt-4'>
<thead>
<tr className='border-b border-gray-300'>
<th className='text-start px-2 py-3'>Produk</th>
<th className='text-start px-2 py-3'>Kuantitas</th>
<th className='text-start px-2 py-3'>Harga Satuan</th>
<th className='text-start px-2 py-3'>Harga Total</th>
<th className='text-start px-2 py-3'>Aksi</th>
</tr>
</thead>
<tbody>
{formik.values.project_budgets &&
formik.values.project_budgets.length > 0 ? (
formik.values.project_budgets.map((budget, index) => (
<tr key={index} className='align-top'>
<td className='px-2 py-3'>
<SelectInput
isClearable
options={filteredNonStockOptions ?? []}
isLoading={isLoadingNonstocks}
placeholder='Pilih barang non stock'
value={formik.values.project_budgets[index].nonstock}
onChange={(val) => {
const updatedBudgets = [
...formik.values.project_budgets,
];
updatedBudgets[index].nonstock = val as OptionType;
updatedBudgets[index].nonstock_id =
(val as OptionType)
? (val as OptionType).value
: 0;
formik.setFieldValue(
'project_budgets',
updatedBudgets
);
formik.setFieldTouched(
`project_budgets[${index}].nonstock_id`,
true
);
}}
errorMessage={
(
formik.errors.project_budgets?.[
index
] as FormikErrors<ProjectFlockBudgetsSchemaType>
)?.nonstock_id as string
}
isError={
formik.touched.project_budgets?.[index]
?.nonstock_id &&
Boolean(
(
formik.errors.project_budgets?.[
index
] as FormikErrors<ProjectFlockBudgetsSchemaType>
)?.nonstock_id as string
)
}
/>
</td>
<td className='px-2 py-3'>
<NumberInput
name={`project_budgets[${index}].qty`}
placeholder='Masukkan jumlah'
value={formik.values.project_budgets[index].qty}
onChange={(e) =>
handleBudgetChange(index, 'qty', e.target.value)
}
onBlur={formik.handleBlur}
allowNegative={false}
endAdornment={
isResponseSuccess(nonstocks)
? (nonstocks.data.find(
(ns) => ns.id === budget.nonstock_id
)?.uom?.name ?? '')
: ''
}
errorMessage={
(
formik.errors.project_budgets?.[
index
] as FormikErrors<ProjectFlockBudgetsSchemaType>
)?.qty as string
}
isError={
formik.touched.project_budgets?.[index]?.qty &&
Boolean(
(
formik.errors.project_budgets?.[
index
] as FormikErrors<ProjectFlockBudgetsSchemaType>
)?.qty as string
)
}
/>
</td>
<td className='px-2 py-3'>
<NumberInput
name={`project_budgets[${index}].price`}
value={formik.values.project_budgets[index].price}
onChange={(e) =>
handleBudgetChange(index, 'price', e.target.value)
}
onBlur={formik.handleBlur}
placeholder='Masukkan harga satuan'
allowNegative={false}
startAdornment='Rp'
errorMessage={
(
formik.errors.project_budgets?.[
index
] as FormikErrors<ProjectFlockBudgetsSchemaType>
)?.price as string
}
isError={
formik.touched.project_budgets?.[index]?.price &&
Boolean(
(
formik.errors.project_budgets?.[
index
] as FormikErrors<ProjectFlockBudgetsSchemaType>
)?.price as string
)
}
/>
</td>
<td className='px-2 py-3'>
<NumberInput
name={`project_budgets[${index}].total_price`}
value={
formik.values.project_budgets[index].total_price
}
onChange={(e) =>
handleBudgetChange(
index,
'total_price',
e.target.value
)
}
onBlur={formik.handleBlur}
placeholder='Masukkan harga satuan'
allowNegative={false}
startAdornment='Rp'
errorMessage={
(
formik.errors.project_budgets?.[
index
] as FormikErrors<ProjectFlockBudgetsSchemaType>
)?.total_price as string
}
isError={
formik.touched.project_budgets?.[index]
?.total_price &&
Boolean(
(
formik.errors.project_budgets?.[
index
] as FormikErrors<ProjectFlockBudgetsSchemaType>
)?.total_price as string
)
}
/>
</td>
<td className='px-2 py-4'>
<Button
type='button'
color='error'
onClick={() =>
onDeleteBudgetRowHandler(
budget.nonstock_id as number,
index
)
}
>
<Icon icon='mdi:trash' width={16} height={16} />
</Button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={4} className='text-center py-4 text-gray-500'>
Tidak ada data estimasi anggaran.
</td>
</tr>
)}
</tbody>
</table>
<Button
type='button'
onClick={onAddBudgetRowHandler}
disabled={filteredNonStockOptions.length == 0}
>
<Icon icon='mdi:plus' width={16} height={16} />
Tambah Item
</Button>
</Card>
<div className='flex flex-row justify-center gap-2 flex-wrap my-6'>
{formType !== 'detail' && (
<div className='flex flex-row justify-end gap-2'>
@@ -1,11 +1,21 @@
import { StateCreator } from 'zustand';
import { DrawerUiSlice } from '@/types/stores';
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
export const createFormDrawerUiSlice: StateCreator<
DrawerUiSlice,
export type ProjectFloockUISlice = {
triggerValidate: boolean;
toggleValidate: () => void;
subscribeValidate: (callback: () => void) => void;
isValid: boolean;
setIsValid: (v: boolean) => void;
subscribeIsValid: (callback: (isValid: boolean) => void) => () => void;
};
export const createProjectFlockUiSlice: StateCreator<
ProjectFloockUISlice,
[],
[],
DrawerUiSlice
ProjectFloockUISlice
> = (set, get, api) => ({
// event flag untuk memicu formik validate
triggerValidate: false,
@@ -38,3 +48,14 @@ export const createFormDrawerUiSlice: StateCreator<
});
},
});
export const useProjectFlockUiStore = create<ProjectFloockUISlice>()(
devtools(
(...args) => ({
...createProjectFlockUiSlice(...args),
}),
{
name: 'ProjectFlockUiStore',
}
)
);
-2
View File
@@ -5,13 +5,11 @@ import { devtools } from 'zustand/middleware';
import { UIStore } from '@/types/stores';
import { createMainUiSlice } from '@/stores/ui/slices/main.slice';
import { createFormDrawerUiSlice } from '@/stores/ui/slices/drawer.slice';
export const useUiStore = create<UIStore>()(
devtools(
(...args) => ({
...createMainUiSlice(...args),
...createFormDrawerUiSlice(...args),
}),
{
name: 'UIStore',
+11
View File
@@ -4,6 +4,7 @@ import { Flock } from '@/types/api/master-data/flock';
import { Kandang } from '@/types/api/master-data/kandang';
import { Location } from '@/types/api/master-data/location';
import { BaseApproval, BaseMetadata } from '@/types/api/api-general';
import { Nonstock } from '@/types/api/master-data/nonstock';
export type BaseProjectFlock = {
id: number;
@@ -30,6 +31,15 @@ export type PeriodFlock = {
next_period: number;
};
export type ProjectFlockBudget = {
id?: number;
project_flock_id?: number;
nonstock_id: number;
nonstock?: Nonstock;
qty: number;
price: number;
};
export type ProjectFlock = BaseMetadata & BaseProjectFlock;
export type CreateProjectFlockPayload = {
@@ -39,6 +49,7 @@ export type CreateProjectFlockPayload = {
fcr_id: number;
location_id: number;
kandang_ids: number[];
project_budgets?: ProjectFlockBudget[];
};
export type UpdateProjectFlockPayload = CreateProjectFlockPayload;
+1 -10
View File
@@ -3,13 +3,4 @@ type MainUiSlice = {
setMainDrawerOpen: (open: boolean) => void;
};
type DrawerUiSlice = {
triggerValidate: boolean;
toggleValidate: () => void;
subscribeValidate: (callback: () => void) => void;
isValid: boolean;
setIsValid: (v: boolean) => void;
subscribeIsValid: (callback: (isValid: boolean) => void) => () => void;
};
export type UIStore = MainUiSlice & DrawerUiSlice;
export type UIStore = MainUiSlice;