feat(FE-314-315): API Integration project budgets and refactoring UI

This commit is contained in:
randy-ar
2025-12-03 21:09:12 +07:00
parent 31f758d680
commit f0ec758d7f
10 changed files with 544 additions and 572 deletions
+33 -20
View File
@@ -7,26 +7,39 @@
default: false;
prefersdark: false;
color-scheme: 'light';
--color-base-100: oklch(98% 0.001 106.423);
--color-base-200: oklch(97% 0.001 106.424);
--color-base-300: oklch(92% 0.003 48.717);
--color-base-content: oklch(22.389% 0.031 278.072);
--color-primary: oklch(60% 0.126 221.723);
--color-primary-content: oklch(100% 0 0);
--color-secondary: oklch(52% 0.105 223.128);
--color-secondary-content: oklch(100% 0 0);
--color-accent: oklch(45% 0.085 224.283);
--color-accent-content: oklch(100% 0 0);
--color-neutral: oklch(39% 0.07 227.392);
--color-neutral-content: oklch(100% 0 0);
--color-info: oklch(58% 0.158 241.966);
--color-info-content: oklch(100% 0 0);
--color-success: oklch(62% 0.194 149.214);
--color-success-content: oklch(100% 0 0);
--color-warning: oklch(85% 0.199 91.936);
--color-warning-content: oklch(0% 0 0);
--color-error: oklch(57% 0.245 27.325);
--color-error-content: oklch(100% 0 0);
/* Primary Colors */
--color-primary: oklch(39.4% 0.177 301.9);
--color-primary-content: oklch(87.5% 0.038 274.5);
/* Secondary Colors */
--color-secondary: oklch(60.1% 0.258 335.7);
--color-secondary-content: oklch(99.4% 0.007 337.8);
/* Accent Colors */
--color-accent: oklch(76.2% 0.155 170.8);
--color-accent-content: oklch(7.2% 0.007 167.6);
/* Neutral Colors */
--color-neutral: oklch(22.4% 0.032 258.8);
--color-neutral-content: oklch(87.7% 0.016 257.0);
/* Base Colors */
--color-base-100: oklch(100% 0 0); /* #ffffff */
--color-base-200: oklch(97.2% 0 0); /* #f2f2f2 */
--color-base-300: oklch(93.1% 0.002 249.7); /* #e5e6e6 */
--color-base-content: oklch(18.6% 0.024 257.7); /* #1f2937 */
/* Status/Utility Colors */
--color-info: oklch(67.4% 0.176 238.9);
--color-info-content: oklch(0% 0 0); /* #000000 */
--color-success: oklch(62.3% 0.147 149.0);
--color-success-content: oklch(100% 0 0); /* #ffffff */
--color-warning: oklch(82.2% 0.165 91.9);
--color-warning-content: oklch(0% 0 0); /* #000000 */
--color-error: oklch(61.8% 0.203 27.8);
--color-error-content: oklch(100% 0 0); /* #fffffff */
--radius-selector: 0rem;
--radius-field: 0.25rem;
--radius-box: 0.25rem;
@@ -12,7 +12,7 @@ const AddProjectFlock = () => {
// },
// }));
return (
<section className='w-full p-4 flex flex-row justify-center'>
<section className='w-full flex flex-row justify-center'>
<ProjectFlockForm formType='add' />
</section>
);
@@ -37,7 +37,7 @@ const ProjectFlockEdit = () => {
}
return (
<div className='w-full p-4 flex flex-col justify-center'>
<div className='w-full flex flex-col justify-center'>
{isLoadingProjectFlock && (
<span className='loading loading-spinner loading-xl' />
)}
+13 -9
View File
@@ -1,5 +1,6 @@
'use client';
import Tooltip from '@/components/Tooltip';
import { cn } from '@/lib/helper';
import { Icon } from '@iconify/react';
@@ -50,7 +51,7 @@ const FloatingActionsButton = ({
<div
className={cn(
`absolute ${positionStyles} inset-x-1/2 -translate-x-1/2 z-50`,
'mx-auto w-full max-w-xl sm:mx-0 bg-base-300 p-4 rounded-xl shadow-md transition-all duration-300 transform',
'mx-auto w-full max-w-lg sm:mx-0 bg-base-300 p-4 rounded-xl shadow-md transition-all duration-300 transform',
'bg-slate-950 backdrop-blur-md'
)}
>
@@ -72,14 +73,15 @@ const FloatingActionsButton = ({
key={index}
onClick={action.onClick}
className='text-white hover:text-gray-400 tooltip tooltip-bottom'
data-tip={action.label}
>
<Icon
icon={action.icon}
width={24}
height={24}
className={`text-${getActionColor(action.action)} font-thin`}
/>
<Tooltip content={action.label || action.action}>
<Icon
icon={action.icon}
width={20}
height={20}
className={`text-${getActionColor(action.action)} font-thin`}
/>
</Tooltip>
</button>
);
})}
@@ -91,7 +93,9 @@ const FloatingActionsButton = ({
onClick={onClose}
className='text-white hover:text-gray-400'
>
<Icon icon='mdi:close' width={24} height={24} />
<Tooltip content='Close'>
<Icon icon='mdi:close' width={20} height={20} />
</Tooltip>
</button>
</div>
</div>
+1 -1
View File
@@ -53,7 +53,7 @@ const CheckboxInput = ({
id={name}
name={name}
className={cn(
'checkbox cursor-pointer',
'checkbox rounded-md cursor-pointer',
{
'border-error': isError,
'border-success': isValid,
@@ -1,5 +1,6 @@
'use client';
import Badge from '@/components/Badge';
import Button from '@/components/Button';
import FloatingActionsButton from '@/components/FloatingActionsButton';
import CheckboxInput from '@/components/input/CheckboxInput';
@@ -13,7 +14,7 @@ import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import { ROWS_OPTIONS } from '@/config/constant';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { cn } from '@/lib/helper';
import { cn, formatDate } from '@/lib/helper';
import { AreaApi, KandangApi, LocationApi } from '@/services/api/master-data';
import { ProjectFlockApi } from '@/services/api/production/project-flock';
import { useTableFilter } from '@/services/hooks/useTableFilter';
@@ -270,7 +271,6 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
<div className='w-full flex flex-col justify-between items-end gap-2'>
<div className='flex flex-col sm:flex-row gap-3 w-full'>
<Button
variant='outline'
color='primary'
className='w-full sm:w-fit'
href='/production/project-flock/add'
@@ -278,7 +278,7 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
<Icon icon='ic:round-plus' width={24} height={24} />
Tambah
</Button>
<Button
{/* <Button
variant='outline'
color='success'
onClick={() => {
@@ -303,7 +303,7 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
>
<Icon icon='mdi:times' width={24} height={24} />
Reject
</Button>
</Button> */}
<div className='ms-auto w-full sm:w-auto'>
<DebouncedTextInput
name='search'
@@ -395,9 +395,7 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
id: 'select',
header: ({ table }) => {
const allRows = table.getRowModel().rows;
const selectableRows = allRows.filter(
(row) => row.original?.approval?.step_number == 1
);
const selectableRows = allRows;
const allSelected =
selectableRows.every((row) => row.getIsSelected()) &&
@@ -421,12 +419,6 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
checked={allSelected}
indeterminate={someSelected}
onChange={toggleSelectableRows}
disabled={
isResponseSuccess(projectFlocks) &&
projectFlocks?.data?.filter(
(flock) => flock.approval.step_number == 1
).length == 0
}
/>
</div>
);
@@ -435,14 +427,8 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
return (
<CheckboxInput
name='row'
checked={
row.getIsSelected() &&
row.original.approval.step_number == 1
}
disabled={
!row.getCanSelect() ||
row.original.approval.step_number != 1
}
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
indeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()}
/>
@@ -473,6 +459,40 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
{
accessorKey: 'approval.step_name',
header: 'Status',
cell: (props) => {
const approval = props.row.original.approval;
return (
<Badge
variant='soft'
className={{
badge:
'rounded-lg px-2 w-full flex flex-row justify-start',
}}
color={
approval.step_number == 1
? 'neutral'
: approval.step_number == 2
? 'success'
: 'error'
}
>
<Icon
icon='mdi:circle'
width={12}
height={12}
color={
approval.step_number == 1
? 'neutral'
: approval.step_number == 2
? 'success'
: 'error'
}
/>
{approval.step_name}
</Badge>
);
},
},
{
header: 'Kandang',
@@ -500,51 +520,51 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
accessorKey: 'created_at',
header: 'Dibuat pada',
cell: (props) =>
new Date(props.row.original.created_at).toLocaleDateString(),
formatDate(props.row.original.created_at, 'MMM DD, YYYY'),
},
{
header: 'Aksi',
cell: (props) => {
const currentPageSize =
props.table.getPaginationRowModel().rows.length;
const currentPageRows =
props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
// {
// header: 'Aksi',
// cell: (props) => {
// const currentPageSize =
// props.table.getPaginationRowModel().rows.length;
// const currentPageRows =
// props.table.getPaginationRowModel().flatRows;
// const currentRowRelativeIndex =
// currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows =
currentRowRelativeIndex > currentPageSize - 2;
// const isLast2Rows =
// currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedProjectFlock(props.row.original);
deleteModal.openModal();
};
// const deleteClickHandler = () => {
// setSelectedProjectFlock(props.row.original);
// deleteModal.openModal();
// };
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowDropdownOptions>
)}
// return (
// <>
// {currentPageSize > 2 && (
// <RowDropdownOptions isLast2Rows={isLast2Rows}>
// <RowOptionsMenu
// type='dropdown'
// props={props}
// deleteClickHandler={deleteClickHandler}
// />
// </RowDropdownOptions>
// )}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptionsMenu
type='collapse'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowCollapseOptions>
)}
</>
);
},
},
// {currentPageSize <= 2 && (
// <RowCollapseOptions>
// <RowOptionsMenu
// type='collapse'
// props={props}
// deleteClickHandler={deleteClickHandler}
// />
// </RowCollapseOptions>
// )}
// </>
// );
// },
// },
]}
pageSize={tableFilterState.pageSize}
page={
@@ -597,7 +617,7 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
{
action: 'DELETE',
icon: 'material-symbols:delete-outline-rounded',
label: 'Hapus Massal',
label: `Hapus ${selectedRowIds.length} data`,
onClick: () => {
toast.error(`Konfirmasi hapus ${selectedRowIds.length} data.`);
},
@@ -611,7 +631,6 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
onClick: () => {
setApprovalAction('APPROVED');
confirmModal.openModal();
setRowSelection({});
},
},
{
@@ -621,7 +640,6 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
onClick: () => {
setApprovalAction('REJECTED');
confirmModal.openModal();
setRowSelection({});
},
},
]}
@@ -1,6 +1,7 @@
import Badge from '@/components/Badge';
import Button from '@/components/Button';
import Card from '@/components/Card';
import Tooltip from '@/components/Tooltip';
import {
formatCurrency,
formatDate,
@@ -10,6 +11,7 @@ import {
import { ProjectFlock } from '@/types/api/production/project-flock';
import { Icon } from '@iconify/react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
const ProjectFlockDetail = ({
@@ -17,6 +19,7 @@ const ProjectFlockDetail = ({
}: {
projectFlock: ProjectFlock;
}) => {
const router = useRouter();
const [openBudgets, setOpenBudget] = useState(false);
return (
@@ -32,18 +35,37 @@ const ProjectFlockDetail = ({
<Icon icon='mdi:close' width={24} height={24} />
</Link>
<div className='divider divider-horizontal p-0 m-0'></div>
<div className='text-sm text-secondary'>
<div className='text-sm text-neutral'>
Created On {formatDate(projectFlock.created_at, 'MMM DD, YYYY')}
</div>
</div>
<div className='flex flex-row gap-3 justify-end'>
{projectFlock?.approval?.step_number == 2 && (
<Link
href={`/production/project-flock/chickin/add?projectFlockId=${projectFlock?.id}`}
className='text-success'
>
<Tooltip content='Chick In' position='bottom'>
<Icon
icon='mdi:checkbox-marked-outline'
width={20}
height={20}
data-tip={'Chick In'}
/>
</Tooltip>
</Link>
)}
<Link
href={`/production/project-flock/detail/edit?projectFlockId=${projectFlock.id}`}
>
<Icon icon='mdi:square-edit-outline' width={20} height={20} />
<Tooltip content='Edit' position='bottom'>
<Icon icon='mdi:square-edit-outline' width={20} height={20} />
</Tooltip>
</Link>
<Button variant='link' className='p-0 text-error'>
<Icon icon='mdi:trash-can-outline' width={20} height={20} />
<Tooltip content='Hapus' position='bottom'>
<Icon icon='mdi:trash-can-outline' width={20} height={20} />
</Tooltip>
</Button>
</div>
</div>
@@ -58,7 +80,7 @@ const ProjectFlockDetail = ({
variant='soft'
color={
projectFlock.approval.step_number == 1
? 'secondary'
? 'neutral'
: projectFlock.approval.step_number == 2
? 'success'
: projectFlock.approval.step_number >= 3
@@ -75,7 +97,7 @@ const ProjectFlockDetail = ({
height={12}
color={
projectFlock.approval.step_number == 1
? 'secondary'
? 'neutral'
: projectFlock.approval.step_number == 2
? 'success'
: projectFlock.approval.step_number >= 3
@@ -87,7 +109,7 @@ const ProjectFlockDetail = ({
</Badge>
<div className='divider divider-horizontal p-0 m-0'></div>
<Badge
color='secondary'
color='neutral'
variant='soft'
className={{ badge: 'rounded-lg px-2' }}
>
@@ -103,7 +125,7 @@ const ProjectFlockDetail = ({
<div className='col-span-2'>
<Badge
variant='soft'
color='secondary'
color='neutral'
className={{
badge: 'rounded-lg px-2',
}}
@@ -191,7 +213,7 @@ const ProjectFlockDetail = ({
</Badge>
<div className='divider divider-horizontal p-0 m-0'></div>
<Badge
color='secondary'
color='neutral'
variant='soft'
className={{ badge: 'rounded-lg px-2 cursor-pointer' }}
onClick={() => {
@@ -17,7 +17,7 @@ import {
import { Icon } from '@iconify/react';
import { FormikErrors, useFormik } from 'formik';
import { useRouter } from 'next/navigation';
import { use, useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import useSWR, { KeyedMutator } from 'swr';
import {
ProjectFlockBudgetsSchemaType,
@@ -47,6 +47,7 @@ import Card from '@/components/Card';
import ProjectFlockKandangTable from '@/components/pages/production/project-flock/form/ProjectFlockKandangTable';
import { Nonstock } from '@/types/api/master-data/nonstock';
import { useUiStore } from '@/stores/ui/ui.store';
import Link from 'next/link';
interface ProjectFlockFormProps {
formType?: 'add' | 'edit' | 'detail';
@@ -110,19 +111,6 @@ const ProjectFlockForm = ({
)
);
useEffect(() => {
if (initialValues?.approval?.step_name) {
const pengajuanRejected =
initialValues.approval.step_number == 1 &&
initialValues.approval.action == 'REJECTED';
const approvedDisabled =
initialValues.approval.step_number !== 1 || pengajuanRejected;
setIsApprovedDisabled(approvedDisabled);
setIsRejectedDisabled(!approvedDisabled || pengajuanRejected);
setApprovalAction(!approvedDisabled ? 'APPROVED' : 'REJECTED');
}
}, [initialValues]);
// Fetch Data
const { isLoadingOptions: isLoadingFlocks, options: optionsFlock } =
useSelect(FlockApi.basePath, 'id', 'name');
@@ -221,7 +209,12 @@ const ProjectFlockForm = ({
formik.setFieldValue('area_id', (val as OptionType)?.value);
formik.setFieldValue('area', val);
formik.setFieldTouched('area_id', true);
if (Boolean(val)) {
formik.setFieldTouched('area_id', false);
formik.setFieldError('area_id', '');
} else {
formik.setFieldTouched('area_id', true);
}
setSelectedArea((val as OptionType)?.value as string);
setSelectedLocation('');
@@ -254,7 +247,12 @@ const ProjectFlockForm = ({
val ? (val as OptionType)?.value : 0
);
formik.setFieldTouched(`${inputName}_id`, true);
if (Boolean(val)) {
formik.setFieldTouched(`${inputName}_id`, false);
formik.setFieldError(`${inputName}_id`, '');
} else {
formik.setFieldTouched(`${inputName}_id`, true);
}
};
const categoryChangeHandler = (val: OptionType | OptionType[] | null) => {
@@ -282,7 +280,7 @@ const ProjectFlockForm = ({
const updateProjectFlockHandler = async (
payload: CreateProjectFlockPayload
) => {
const updateProjectFlockRes = await ProjectFlockApi.update(
const updateProjectFlockRes = await ProjectFlockApi.resubmit(
initialValues?.id as number,
payload
);
@@ -314,21 +312,14 @@ const ProjectFlockForm = ({
0,
initialValues?.flock_name?.lastIndexOf(' ')
) ?? '';
const optionFind = optionsFlock.find((flock) => {
return flock.label == trimFlock;
}) as OptionType;
return {
flock: initialValues?.flock_name
? {
value:
optionsFlock.find((flock) => {
return flock.label == trimFlock;
})?.value ?? 0,
label:
formType != 'detail'
? (optionsFlock.find((flock) => {
return flock.label == trimFlock;
})?.label ?? '')
: initialValues?.flock_name,
}
: null,
flock:
optionsFlock.find((flock) => {
return flock.label == trimFlock;
}) ?? null,
area: initialValues?.area
? {
value: initialValues.area?.id,
@@ -355,14 +346,8 @@ const ProjectFlockForm = ({
: null,
flock_name:
optionsFlock.find((flock) => {
return (
flock.label ==
initialValues?.flock_name?.slice(
0,
initialValues?.flock_name?.lastIndexOf(' ')
)
);
})?.label ?? '',
return flock.label == trimFlock;
})?.label ?? trimFlock,
area_id: initialValues?.area?.id ?? 0,
category: initialValues?.category as NonNullable<
'GROWING' | 'LAYING' | undefined
@@ -372,7 +357,18 @@ const ProjectFlockForm = ({
kandang_ids: initialValues?.kandangs?.map(
(k: Kandang) => k.id
) as number[],
project_budgets: [
project_budgets: initialValues?.project_budgets?.map((budget) => {
return {
nonstock: {
value: budget.nonstock?.id ?? '',
label: budget.nonstock?.name ?? '',
},
nonstock_id: budget.nonstock?.id ?? '',
qty: budget.qty,
price: budget.price,
total_price: budget.qty * budget.price,
};
}) ?? [
{
nonstock: null,
nonstock_id: '',
@@ -387,94 +383,13 @@ const ProjectFlockForm = ({
// Formik
const formik = useFormik<ProjectFlockFormValues>({
initialValues: {
flock: initialValues?.flock_name
? {
value:
optionsFlock.find((flock) => {
return (
flock.label ==
initialValues?.flock_name?.slice(
0,
initialValues?.flock_name?.lastIndexOf(' ')
)
);
})?.value ?? 0,
label:
formType != 'detail'
? (optionsFlock.find((flock) => {
return (
flock.label ==
initialValues?.flock_name?.slice(
0,
initialValues?.flock_name?.lastIndexOf(' ')
)
);
})?.label ?? '')
: initialValues?.flock_name,
}
: null,
area: initialValues?.area
? {
value: initialValues.area?.id,
label: initialValues.area.name,
}
: null,
category_option: initialValues?.category
? {
value: initialValues.category,
label: initialValues.category,
}
: null,
fcr: initialValues?.fcr
? {
value: initialValues.fcr?.id,
label: initialValues.fcr.name,
}
: null,
location: initialValues?.location
? {
value: initialValues.location?.id,
label: initialValues.location.name,
}
: null,
flock_name:
formType != 'detail'
? optionsFlock.find((flock) => {
return (
flock.label ==
initialValues?.flock_name?.slice(
0,
initialValues?.flock_name?.lastIndexOf(' ')
)
);
})?.label
: (initialValues?.flock_name ?? ''),
area_id: initialValues?.area?.id ?? 0,
category: initialValues?.category as NonNullable<
'GROWING' | 'LAYING' | undefined
>,
fcr_id: initialValues?.fcr?.id ?? 0,
location_id: initialValues?.location?.id ?? 0,
kandang_ids: initialValues?.kandangs?.map((k: Kandang) => k.id) as (
| number
| undefined
)[],
project_budgets: [
{
nonstock: null,
nonstock_id: '',
qty: '',
price: '',
total_price: '',
},
],
...formikInitialValues,
} as ProjectFlockFormValues,
enableReinitialize: true,
validationSchema:
formType == 'add' ? ProjectFlockFormSchema : UpdateProjectFlockFormSchema,
validateOnBlur: true,
validateOnChange: true,
validateOnMount: true,
// validateOnChange: true,
// validateOnMount: true,
onSubmit: async (values) => {
setProjectFlockFormErrorMessage('');
const payload: CreateProjectFlockPayload = {
@@ -522,7 +437,18 @@ const ProjectFlockForm = ({
}, [initialValues, setSelectedArea, formType]);
useEffect(() => {
formikSetValues(formikInitialValues);
const trimFlock =
initialValues?.flock_name?.slice(
0,
initialValues?.flock_name?.lastIndexOf(' ')
) ?? '';
formikSetValues({
...formikInitialValues,
flock: optionsFlock.find((flock) => {
return flock.label == trimFlock;
}) as OptionType,
flock_name: trimFlock ?? '',
});
}, [formikSetValues]);
// Aktifkan lokasi jika formType = 'detail'
@@ -542,10 +468,6 @@ const ProjectFlockForm = ({
}
}, [formType, initialValues]);
useEffect(() => {
formik.validateForm();
}, [formik.values]);
useEffect(() => {
const selectedRowIds = Object.keys(rowSelection)
.filter((id) => rowSelection[id])
@@ -583,6 +505,19 @@ const ProjectFlockForm = ({
return unsub;
}, []);
useEffect(() => {
if (initialValues?.approval?.step_name) {
const pengajuanRejected =
initialValues.approval.step_number == 1 &&
initialValues.approval.action == 'REJECTED';
const approvedDisabled =
initialValues.approval.step_number !== 1 || pengajuanRejected;
setIsApprovedDisabled(approvedDisabled);
setIsRejectedDisabled(!approvedDisabled || pengajuanRejected);
setApprovalAction(!approvedDisabled ? 'APPROVED' : 'REJECTED');
}
}, [initialValues]);
// Actions handler
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
@@ -727,31 +662,59 @@ const ProjectFlockForm = ({
: undefined;
const inputPeriod =
(initialValues?.period ?? selectedPeriod == 0) ? 1 : selectedPeriod;
const filteredNonStockOptions = optionsNonstock.filter((nonstock) => {
return !(formik.values.project_budgets ?? []).some(
(budget) => budget.nonstock_id === nonstock.value
);
const isNonstockAlreadyInBudgets = (
formik.values.project_budgets ?? []
).some((budget) => budget.nonstock_id === nonstock.value);
return !isNonstockAlreadyInBudgets;
});
return (
<>
<section className='w-full'>
<header className='flex flex-col gap-4 mb-6'>
<Button
href='/production/project-flock'
variant='link'
className='w-fit p-0 text-primary'
>
<Icon icon='uil:arrow-left' width={24} height={24} />
Kembali
</Button>
<h1 className='text-2xl font-bold text-center'>
{formType === 'add' && 'Tambah Project Flock'}
{formType === 'edit' && 'Edit Project Flock'}
{formType === 'detail' && 'Detail Project Flock'}
</h1>
</header>
{/* Header */}
<div className='flex flex-row justify-between items-center px-4 pt-4'>
<div className='flex flex-row h-full gap-2'>
<Link
href={
formType == 'add'
? '/production/project-flock'
: `/production/project-flock/detail?projectFlockId=${initialValues?.id}`
}
className='hover:text-gray-400'
>
<Icon
icon={formType == 'add' ? 'mdi:close' : 'mdi:arrow-left'}
width={24}
height={24}
/>
</Link>
<div className='divider divider-horizontal p-0 m-0'></div>
<div className='text-sm text-neutral'>
{formType == 'add' ? 'Add Flock' : 'Update Flock'}
</div>
</div>
<div className='flex flex-row justify-end'>
<Button
onClick={() => {
if (initialValues?.id) {
deleteModal.openModal();
}
}}
variant='link'
className='p-0 text-error'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={20}
height={20}
className='justify-start text-sm'
/>
</Button>
</div>
</div>
{projectFlockFormErrorMessage && (
<div className='my-4'>
<div role='alert' className='alert alert-error'>
@@ -829,15 +792,11 @@ const ProjectFlockForm = ({
onSubmit={formik.handleSubmit}
onReset={formik.handleReset}
>
{/* Card Informasi Umum */}
<Card
title='Informasi Umum'
variant='bordered'
className={{
wrapper: 'w-full mb-4',
}}
>
<div className='grid sm:grid-cols-2 gap-4'>
{/* Form Informasi Umum */}
<div className='divider mt-3'></div>
<div className='flex flex-col gap-4 px-4'>
<h2 className='text-2xl font-semibold'>Informasi Umum</h2>
<div className='flex flex-col gap-4'>
<SelectInput
required
label='Area'
@@ -852,6 +811,25 @@ const ProjectFlockForm = ({
isClearable
isDisabled={formType === 'detail'}
/>
<SelectInput
required
label='Lokasi'
value={formik.values.location as OptionType}
onChange={locationChangeHandler}
options={
selectedArea != '' || initialValues?.area?.id
? optionsLocation
: []
}
isLoading={isLoadingLocations}
isError={
formik.touched.location_id &&
Boolean(formik.errors.location_id)
}
errorMessage={formik.errors.location_id as string}
isClearable
isDisabled={formType === 'detail' || disabledLocation}
/>
<SelectInput
required
label='Flock'
@@ -882,25 +860,6 @@ const ProjectFlockForm = ({
isClearable
isDisabled={formType === 'detail'}
/>
<SelectInput
required
label='Lokasi'
value={formik.values.location as OptionType}
onChange={locationChangeHandler}
options={
selectedArea != '' || initialValues?.area?.id
? optionsLocation
: []
}
isLoading={isLoadingLocations}
isError={
formik.touched.location_id &&
Boolean(formik.errors.location_id)
}
errorMessage={formik.errors.location_id as string}
isClearable
isDisabled={formType === 'detail' || disabledLocation}
/>
<SelectInput
required
label='FCR'
@@ -937,17 +896,12 @@ const ProjectFlockForm = ({
value={selectedLocation ? inputPeriod : ''}
/>
</div>
</Card>
</div>
{/* Card Pilih Kandang */}
<Card
collapsible
title='Pilih Kandang'
variant='bordered'
className={{
wrapper: 'w-full mb-4',
}}
>
{/* Form Pilih Kandang */}
<div className='divider'></div>
<div className='flex flex-col gap-4 px-4 pb-4'>
<h2 className='text-2xl font-semibold'>Pilih Kandang</h2>
<div className='overflow-x-auto duration-300 ease-in-out'>
{isLoadingKandang && (
<span className='loading loading-dots loading-xl'></span>
@@ -964,33 +918,43 @@ const ProjectFlockForm = ({
initialValues={initialValues}
/>
</div>
</Card>
</div>
{/* 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'>
<div className='divider'></div>
<div className='flex flex-col gap-4 px-4 pb-4'>
<h2 className='text-2xl font-semibold'>
Estimasi Aggaran Per Flock
</h2>
<div className='flex flex-col gap-4'>
{formik.values.project_budgets &&
formik.values.project_budgets.length > 0 ? (
formik.values.project_budgets.map((budget, index) => (
<Card
key={index}
variant='bordered'
className={{
wrapper: 'w-full',
body: 'p-3',
}}
>
<div className='flex flex-col gap-2'>
<div className='flex flex-row justify-between items-center mb-2'>
<div className='text-lg'>Anggaran ke-{index + 1}</div>
<Button
type='button'
color='error'
onClick={() =>
onDeleteBudgetRowHandler(
budget.nonstock_id as number,
index
)
}
>
<Icon icon='mdi:trash' width={16} height={16} />
</Button>
</div>
<div className='flex flex-row justify-between items-center'>
<SelectInput
isClearable
options={filteredNonStockOptions ?? []}
@@ -1034,8 +998,8 @@ const ProjectFlockForm = ({
)
}
/>
</td>
<td className='px-2 py-3'>
</div>
<div className='flex flex-row justify-between items-center'>
<NumberInput
name={`project_budgets[${index}].qty`}
placeholder='Masukkan jumlah'
@@ -1046,11 +1010,13 @@ const ProjectFlockForm = ({
onBlur={formik.handleBlur}
allowNegative={false}
endAdornment={
isResponseSuccess(nonstocks)
? (nonstocks.data.find(
(ns) => ns.id === budget.nonstock_id
)?.uom?.name ?? '')
: ''
<div className='text-gray-500'>
{isResponseSuccess(nonstocks)
? (nonstocks.data.find(
(ns) => ns.id === budget.nonstock_id
)?.uom?.name ?? '')
: ''}
</div>
}
errorMessage={
(
@@ -1070,8 +1036,8 @@ const ProjectFlockForm = ({
)
}
/>
</td>
<td className='px-2 py-3'>
</div>
<div className='flex flex-row justify-between items-center'>
<NumberInput
name={`project_budgets[${index}].price`}
value={formik.values.project_budgets[index].price}
@@ -1082,6 +1048,17 @@ const ProjectFlockForm = ({
placeholder='Masukkan harga satuan'
allowNegative={false}
startAdornment='Rp'
endAdornment={
<div className='text-gray-500'>
{`Per ${
isResponseSuccess(nonstocks)
? (nonstocks.data.find(
(ns) => ns.id === budget.nonstock_id
)?.uom?.name ?? 'Item')
: 'Item'
}`}
</div>
}
errorMessage={
(
formik.errors.project_budgets?.[
@@ -1100,8 +1077,8 @@ const ProjectFlockForm = ({
)
}
/>
</td>
<td className='px-2 py-3'>
</div>
<div className='flex flex-row justify-between items-center'>
<NumberInput
name={`project_budgets[${index}].total_price`}
value={
@@ -1115,9 +1092,12 @@ const ProjectFlockForm = ({
)
}
onBlur={formik.handleBlur}
placeholder='Masukkan harga satuan'
placeholder='Masukkan harga total'
allowNegative={false}
startAdornment='Rp'
endAdornment={
<div className='text-gray-500'>Total</div>
}
errorMessage={
(
formik.errors.project_budgets?.[
@@ -1137,108 +1117,50 @@ const ProjectFlockForm = ({
)
}
/>
</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>
</div>
</div>
</Card>
))
) : (
<div className='text-center py-4 text-gray-400'>
Tidak ada data estimasi anggaran.
</div>
)}
<Button
type='button'
onClick={onAddBudgetRowHandler}
disabled={filteredNonStockOptions.length == 0}
color='success'
className='w-fit self-center'
>
<Icon icon='mdi:plus' width={16} height={16} /> Add Budget
</Button>
</div>
</div>
<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'>
<Button
type='button'
color='warning'
className='px-4'
onClick={formik.handleReset}
>
Reset
</Button>
<Button
type='submit'
color='primary'
isLoading={formik.isSubmitting}
disabled={
!formik.isValid || formik.isSubmitting
// TODO: Add logic && ketika nilai kandang_ids sudah beda dari initial values
}
className='px-4'
>
Submit
</Button>
<div className='flex flex-row justify-center gap-2 flex-wrap my-6 px-4'>
{/* <div className='w-120'>
<div className='text-primary text-sm'>
{JSON.stringify(formik.values)}
</div>
<div className='text-error text-sm'>
{JSON.stringify(formik.errors)}
</div>
</div> */}
{formType !== 'detail' && (
<Button
type='submit'
color='primary'
isLoading={formik.isSubmitting}
disabled={!formik.isValid || formik.isSubmitting}
className='px-4 w-full'
>
<Icon icon='mdi:plus' width={24} height={24} />
{formType == 'add' ? 'Add Flock' : 'Update Flock'}
</Button>
)}
</div>
</form>
{formType != 'add' && (
<div className='flex flex-row gap-2 mb-6'>
{formType != 'edit' && (
<Button
onClick={() => {
router.push(
`/production/project-flock/detail/edit?projectFlockId=${initialValues?.id}`
);
}}
color='warning'
>
<Icon
icon='mdi:pencil-outline'
width={16}
height={16}
className='justify-start text-sm'
/>
Edit
</Button>
)}
<Button
onClick={() => {
if (initialValues?.id) {
deleteModal.openModal();
}
}}
color='error'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={16}
height={16}
className='justify-start text-sm'
/>
Delete
</Button>
</div>
)}
</section>
<ConfirmationModal
@@ -1,5 +1,7 @@
'use client';
import Badge from '@/components/Badge';
import Card from '@/components/Card';
import CheckboxInput from '@/components/input/CheckboxInput';
import PillBadge from '@/components/PillBadge';
import Table from '@/components/Table';
@@ -9,6 +11,7 @@ import {
ProjectFlock,
ProjectFlockPeriods,
} from '@/types/api/production/project-flock';
import { Icon } from '@iconify/react';
import { OnChangeFn, Row } from '@tanstack/react-table';
import { useMemo } from 'react';
@@ -29,161 +32,119 @@ const ProjectFlockKandangTable = ({
initialValues?: ProjectFlock;
formType: 'add' | 'edit' | 'detail';
}) => {
const initialKandangIdSet = useMemo(() => {
return initialValues?.kandangs.map((k) => k.id) ?? [];
}, [initialValues]);
const isRowEnabled = (row: Row<Kandang>) => {
const isDisabled =
!initialKandangIdSet.includes(row.original.id) &&
(row.original.status == 'ACTIVE' ||
row.original.status == 'PENGAJUAN' ||
formType == 'detail');
return !isDisabled;
// Fungsi untuk menangani perubahan checkbox
const handleCheckboxChange = (kandang: Kandang, isChecked: boolean) => {
// Hanya izinkan perubahan jika tidak dalam mode 'detail'
if (formType === 'detail') return;
// Pastikan kandang.id ada dan tidak null/undefined
if (kandang.id === undefined) return;
const kandangIdString = kandang.id.toString();
setRowSelection((prev) => {
const newSelection = { ...prev };
if (isChecked) {
newSelection[kandangIdString] = true;
} else {
delete newSelection[kandangIdString];
}
return newSelection;
});
};
return (
<>
<Table<Kandang>
data={listKandang}
columns={[
{
id: 'select',
header: ({ table }) => {
const allRows = table.getRowModel().rows;
// 1. Filter semua baris dengan logika yang sama persis seperti di cell
const selectableRows = allRows.filter(isRowEnabled);
{listKandang.length > 0 ? (
<>
{/* ... Bagian Badge Status ... */}
<div className='flex flex-row mb-4'>
<Badge
variant='soft'
color='primary'
className={{
badge: 'rounded-lg px-2',
}}
>
<Icon icon='mdi:circle' width={12} height={12} />
Tersedia (
{
listKandang.filter((kandang) => kandang.status == 'NON_ACTIVE')
.length
}
)
</Badge>
<div className='divider divider-horizontal mx-1'></div>
<Badge
variant='soft'
color='neutral'
className={{
badge: 'rounded-lg px-2',
}}
>
<Icon icon='mdi:circle' width={12} height={12} />
Tidak Tersedia (
{
listKandang.filter((kandang) => kandang.status != 'NON_ACTIVE')
.length
}
)
</Badge>
</div>
{/* --- */}
<Card
variant='bordered'
className={{
wrapper: 'w-full rounded-lg',
body: 'p-4',
}}
>
<div className='flex flex-col gap-4 w-full'>
{listKandang.map((kandang, index) => {
const kandangIdString =
kandang.id?.toString() ?? `temp-${index}`;
// 2. Cek apakah SEMUA baris yang BISA DIPILIH sudah terpilih
const allSelected =
selectableRows.length > 0 &&
selectableRows.every((row) => row.getIsSelected());
const isSelected =
!!rowSelection[kandangIdString] ||
(kandang.id !== undefined &&
selectedIds.includes(kandang.id));
// 3. Cek apakah BEBERAPA baris yang BISA DIPILIH sudah terpilih
const someSelected =
selectableRows.some((row) => row.getIsSelected()) &&
!allSelected;
const isDisabled =
formType == 'detail' || kandang.status != 'NON_ACTIVE';
// 4. Fungsi toggle HANYA akan mentoggle baris yang BISA DIPILIH
const toggleSelectableRows = () => {
const shouldSelect = !allSelected;
selectableRows.forEach((row) =>
row.toggleSelected(shouldSelect)
return (
<div key={index} className='flex flex-row justify-between'>
<CheckboxInput
name={`kandang-${kandang.id}`} // Nama unik untuk setiap checkbox
label={kandang.name}
checked={isSelected}
disabled={isDisabled}
onChange={(e) =>
handleCheckboxChange(kandang, e.currentTarget.checked)
}
/>
<Badge
variant='soft'
color={
kandang.status == 'NON_ACTIVE' ? 'primary' : 'neutral'
}
className={{
badge: 'rounded-lg px-2',
}}
>
<Icon icon='mdi:circle' width={12} height={12} />
{kandang.status != 'NON_ACTIVE' && 'Tidak'} Tersedia
</Badge>
</div>
);
};
return (
<div className='w-full flex flex-row justify-center'>
<CheckboxInput
name='allRow'
checked={allSelected}
indeterminate={someSelected}
onChange={toggleSelectableRows}
disabled={
selectableRows.length === 0 || formType == 'detail'
}
/>
</div>
);
},
cell: ({ row }) => {
return (
<CheckboxInput
name='row'
checked={
(row.getIsSelected() &&
(row.original.status == 'NON_ACTIVE' ||
row.original.status == 'PENGAJUAN')) ||
(selectedIds && selectedIds.includes(row.original.id))
}
disabled={
formType == 'detail' ||
(!initialKandangIdSet.includes(row.original.id) &&
(row.original.status == 'ACTIVE' ||
row.original.status == 'PENGAJUAN'))
}
indeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()}
/>
);
},
},
{
accessorFn: (row) => row.name,
header: 'Kandang',
},
{
accessorFn: (row) => row.status,
header: 'Status',
cell: (props) => {
return (
<PillBadge
color={(() => {
switch (props.row.original.status) {
case 'ACTIVE':
return 'red';
case 'PENGAJUAN':
return 'green';
case 'NON_ACTIVE':
return 'blue';
default:
return 'gray';
}
})()}
content={props.row.original.status
.toLowerCase()
.replace(/_/g, ' ')
.replace(/\b\w/g, (char) => char.toUpperCase())}
/>
);
},
},
{
accessorFn: (row) => row.capacity,
header: 'Kapasitas',
},
{
accessorFn: (row) => row.location?.name,
header: 'Periode',
cell: (props) => {
const period =
listPeriods.length > 0
? listPeriods.find((p) => p.id == props.row.original.id)
: undefined;
const calcPeriod = period?.period == 0 ? 1 : period?.period;
const selected = props.row.getIsSelected();
const initPeriod = initialValues?.period;
return formType == 'detail'
? selected
? initPeriod
: '-'
: formType == 'add'
? (calcPeriod ?? '-')
: selected
? (initPeriod ?? '-')
: (calcPeriod ?? '-');
},
},
{
accessorFn: (row) => row.pic?.name,
header: 'Penanggung Jawab',
},
]}
className={{
containerClassName: cn({
'mb-20': listKandang?.length === 0,
}),
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 last:flex last:flex-row last:justify-end',
paginationClassName: 'hidden',
}}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
/>
})}
</div>
</Card>
</>
) : (
<div className='text-center py-4 text-gray-400'>
Pilih lokasi terlebih dahulu
</div>
)}
</>
);
};
@@ -141,6 +141,38 @@ export class ProjectFlockService extends BaseApiService<
}
}
/**
* Resubmit Project Flock
*/
async resubmit(
id: number,
payload: UpdateProjectFlockPayload
): Promise<BaseApiResponse<ProjectFlock> | undefined> {
try {
const updatePath = `${this.basePath}/${id}/resubmit`;
const headers = {
'Content-Type': 'application/json',
...(this.header ?? {}),
};
const updateRes = await httpClient<BaseApiResponse<ProjectFlock>>(
updatePath,
{
method: 'PUT',
body: payload,
headers,
}
);
return updateRes;
} catch (error: unknown) {
if (axios.isAxiosError<BaseApiResponse<ProjectFlock>>(error)) {
return error.response?.data;
}
return undefined;
}
}
/**
* Approve single Project Flock
*/