mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 13:32:00 +00:00
Compare commits
91 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1621f2ab7d | |||
| 5540787154 | |||
| 1b499bc967 | |||
| 44a5c51023 | |||
| aa13e989c1 | |||
| ebe7c367e7 | |||
| 2f085c287f | |||
| 058f9f403d | |||
| 8b8b7be4b7 | |||
| efcecf4f66 | |||
| a6c63a7dcb | |||
| 0263db9fae | |||
| cc08e3af15 | |||
| 0929461ec5 | |||
| ace6633f79 | |||
| f1a952ca6b | |||
| ed34a99117 | |||
| 4beaba1f15 | |||
| 8ea029efdd | |||
| 02e4dba288 | |||
| c42fdbf33d | |||
| 2cfa8c046b | |||
| 30d5516161 | |||
| f83abc91da | |||
| 918c51e83b | |||
| f1a4d9b648 | |||
| 29e33560f8 | |||
| fb9e863862 | |||
| 1b3e5f94f1 | |||
| e1856926ea | |||
| 2b096099d3 | |||
| ea25417e8d | |||
| deabb1c3ee | |||
| 121c44070c | |||
| 0dbad23cd5 | |||
| b9a17f472b | |||
| c07b245eeb | |||
| d7e32f8f5b | |||
| 698fe2e851 | |||
| cdf0442a2b | |||
| 422c7c9fb0 | |||
| 53e018aece | |||
| ca58e19a48 | |||
| 0971e6ddeb | |||
| 25fbf95062 | |||
| b2f6c6c485 | |||
| cc86151631 | |||
| 755f3fa0bb | |||
| ce1114d724 | |||
| 128b765045 | |||
| 92c07e7841 | |||
| 1aba297920 | |||
| 2aef6522bb | |||
| 3bab96c325 | |||
| 847772616e | |||
| 344140e973 | |||
| 3ce1299091 | |||
| aea35d4b9f | |||
| 5b134148a5 | |||
| 32f4cf411f | |||
| 04d01970aa | |||
| 84cbbaf238 | |||
| 9176373072 | |||
| 5c50e4a0c1 | |||
| 7e64ec0f79 | |||
| e2be39af18 | |||
| 9322d6298c | |||
| e9cd84e89e | |||
| 89cfd31155 | |||
| ec5962bccc | |||
| 0eb4fa99a7 | |||
| 2ef8b2dc9f | |||
| aed1a1ed01 | |||
| 2c9c2660c0 | |||
| b840f42ae0 | |||
| 6bc86af32f | |||
| 1603ae62e0 | |||
| 8fd442621a | |||
| 35471fc597 | |||
| bd4242c4fd | |||
| 56bde974ad | |||
| 38258e4311 | |||
| 149e525ff4 | |||
| 8fb761f02c | |||
| 3bc5a5b75e | |||
| 79112e0da8 | |||
| bf9eb91ea2 | |||
| e8c8ffadfe | |||
| 2ae1c5b382 | |||
| 961f81411b | |||
| de439275e0 |
+30
-2
@@ -15,7 +15,7 @@ default:
|
||||
# ==========================================================
|
||||
.build_template: &build_template
|
||||
stage: build
|
||||
image: node:20-alpine
|
||||
image: public.ecr.aws/docker/library/node:20-alpine
|
||||
cache:
|
||||
key: npm-cache
|
||||
paths:
|
||||
@@ -56,7 +56,7 @@ default:
|
||||
.deploy_template: &deploy_template
|
||||
stage: deploy
|
||||
image:
|
||||
name: amazon/aws-cli:latest
|
||||
name: public.ecr.aws/aws-cli/aws-cli:latest
|
||||
entrypoint: ['/bin/sh', '-c']
|
||||
script:
|
||||
- set -e
|
||||
@@ -183,3 +183,31 @@ deploy:staging:
|
||||
environment:
|
||||
name: staging
|
||||
url: https://stg-lti-erp.mbugroup.id
|
||||
|
||||
# ==========================================================
|
||||
# ====== STAGING (Branch production) ======
|
||||
# ==========================================================
|
||||
build:production:
|
||||
<<: *build_template
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == "production"'
|
||||
environment:
|
||||
name: staging
|
||||
variables:
|
||||
NEXT_PUBLIC_LTI_URL: 'https://lti-erp.mbugroup.id'
|
||||
NEXT_PUBLIC_SSO_LOGIN_URL: 'https://auth-erp.mbugroup.id'
|
||||
NEXT_PUBLIC_API_BASE_URL: 'https://api-lti.mbugroup.id/api'
|
||||
NEXT_PUBLIC_CLIENT_ID: 'Lumbung-Telur-Indonesia'
|
||||
|
||||
deploy:production:
|
||||
<<: *deploy_template
|
||||
needs: ['build:production']
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == "production"'
|
||||
variables:
|
||||
S3_BUCKET: 'production-lti-erp.mbugroup.id'
|
||||
AWS_REGION: 'ap-southeast-3'
|
||||
CLOUDFRONT_DISTRIBUTION_ID: 'E1SSLXKYYITASJ'
|
||||
environment:
|
||||
name: staging
|
||||
url: https://lti-erp.mbugroup.id
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine
|
||||
FROM public.ecr.aws/docker/library/node:20-alpine
|
||||
|
||||
RUN apk add --no-cache git bash build-base curl
|
||||
|
||||
@@ -22,4 +22,4 @@ RUN mkdir -p .next/server/app/_next && \
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npx", "serve", ".next/server/app", "-l", "3000"]
|
||||
CMD ["npx", "serve", ".next/server/app", "-l", "3000"]
|
||||
|
||||
+2
-1
@@ -8,7 +8,8 @@
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"prepare": "husky",
|
||||
"format": "prettier --write ."
|
||||
"format": "prettier --write .",
|
||||
"pre-commit": "npm run format && npm run lint && npx tsc --noEmit && npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-pdf/renderer": "^4.3.1",
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { MasterKandangContent } from '@/figma-make/components/pages/master-data/kandang/MasterKandangContent';
|
||||
|
||||
const MasterKandangPage = () => {
|
||||
return (
|
||||
<section className='w-full'>
|
||||
<MasterKandangContent />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default MasterKandangPage;
|
||||
@@ -314,6 +314,10 @@ const KandangsTable = () => {
|
||||
accessorFn: (row) => row.pic?.name ?? '-',
|
||||
header: 'PIC',
|
||||
},
|
||||
{
|
||||
accessorFn: (row) => row.kandang_group?.name ?? '-',
|
||||
header: 'Kandang Group',
|
||||
},
|
||||
{
|
||||
header: 'Aksi',
|
||||
cell: (props: CellContext<Kandang, unknown>) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { OptionType } from '@/components/input/SelectInput';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
type KandangFormSchemaType = {
|
||||
@@ -19,6 +20,7 @@ type KandangFormSchemaType = {
|
||||
}
|
||||
| undefined
|
||||
| null;
|
||||
group?: OptionType;
|
||||
};
|
||||
|
||||
export const KandangFormSchema: Yup.ObjectSchema<KandangFormSchemaType> =
|
||||
@@ -42,6 +44,11 @@ export const KandangFormSchema: Yup.ObjectSchema<KandangFormSchemaType> =
|
||||
value: Yup.number().min(1).required(),
|
||||
label: Yup.string().required(),
|
||||
}).nullable(),
|
||||
|
||||
group: Yup.object({
|
||||
value: Yup.number().min(1).required('Kandang Grup wajib diisi!'),
|
||||
label: Yup.string().required('Kandang Grup wajib diisi!'),
|
||||
}).required('Kandang Grup wajib diisi!'),
|
||||
});
|
||||
|
||||
export const UpdateKandangFormSchema = KandangFormSchema;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useFormik } from 'formik';
|
||||
import { getIn, useFormik } from 'formik';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
import { Icon } from '@iconify/react';
|
||||
@@ -34,6 +34,8 @@ import NumberInput from '@/components/input/NumberInput';
|
||||
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
|
||||
import AlertErrorList from '@/components/helper/form/FormErrors';
|
||||
import { User } from '@/types/api/api-general';
|
||||
import { DailyChecklistKandang } from '@/types/api/daily-checklist/kandang';
|
||||
import { DailyChecklistKandangApi } from '@/services/api/daily-checklist/kandang';
|
||||
|
||||
interface KandangFormProps {
|
||||
type?: 'add' | 'edit' | 'detail';
|
||||
@@ -96,6 +98,12 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
|
||||
label: initialValues.pic.name,
|
||||
}
|
||||
: null,
|
||||
group: initialValues?.kandang_group
|
||||
? {
|
||||
value: initialValues.kandang_group.id,
|
||||
label: initialValues.kandang_group.name,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}, [initialValues]);
|
||||
|
||||
@@ -111,6 +119,7 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
|
||||
location_id: values.locationId!,
|
||||
capacity: values.capacity ? parseInt(values.capacity.toString()) : 0,
|
||||
pic_id: values.picId!,
|
||||
group_id: values.group?.value as number,
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
@@ -162,6 +171,23 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
|
||||
formik.setFieldValue('picId', (val as OptionType)?.value);
|
||||
};
|
||||
|
||||
// Kandang Group
|
||||
const {
|
||||
setInputValue: setKandangGroupSelectInputValue,
|
||||
options: kandangGroupOptions,
|
||||
isLoadingOptions: isLoadingKandangGroupOptions,
|
||||
loadMore: loadMoreKandangGroups,
|
||||
} = useSelect<DailyChecklistKandang>(
|
||||
DailyChecklistKandangApi.basePath,
|
||||
'id',
|
||||
'name'
|
||||
);
|
||||
|
||||
const kandangGroupChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
formik.setFieldTouched('group', true);
|
||||
formik.setFieldValue('group', val);
|
||||
};
|
||||
|
||||
const deleteKandangClickHandler = () => {
|
||||
deleteModal.openModal();
|
||||
};
|
||||
@@ -269,6 +295,24 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
|
||||
isDisabled={type === 'detail'}
|
||||
isClearable
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
required
|
||||
label='Kandang Group'
|
||||
value={formik.values.group ?? undefined}
|
||||
onChange={kandangGroupChangeHandler}
|
||||
options={kandangGroupOptions}
|
||||
onInputChange={setKandangGroupSelectInputValue}
|
||||
onMenuScrollToBottom={loadMoreKandangGroups}
|
||||
isLoading={isLoadingKandangGroupOptions}
|
||||
isError={formik.touched.group && Boolean(formik.errors.group)}
|
||||
errorMessage={
|
||||
getIn(formik.errors.group, 'value') ??
|
||||
(formik.errors.group as string)
|
||||
}
|
||||
isDisabled={type === 'detail'}
|
||||
isClearable
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-row justify-between gap-2 flex-wrap'>
|
||||
|
||||
@@ -21,6 +21,7 @@ import SelectInput, { useSelect } from '@/components/input/SelectInput';
|
||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||
import PopoverButton from '@/components/popover/PopoverButton';
|
||||
import PopoverContent from '@/components/popover/PopoverContent';
|
||||
import Tooltip from '@/components/Tooltip';
|
||||
import { useFormik } from 'formik';
|
||||
import { AreaApi } from '@/services/api/master-data';
|
||||
import { LocationApi } from '@/services/api/master-data';
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
import RecordingTableSkeleton from '@/components/pages/production/recording/skeleton/RecordingTableSkeleton';
|
||||
import Table from '@/components/Table';
|
||||
import { type Recording } from '@/types/api/production/recording';
|
||||
import { getRecordingRestriction } from './recording-utils';
|
||||
import { RecordingApi } from '@/services/api/production';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
@@ -105,30 +107,75 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const isRecordingEditable = (recording: Recording) => {
|
||||
if (
|
||||
recording.executed_at &&
|
||||
recording.project_flock?.project_flock_category === 'GROWING'
|
||||
) {
|
||||
const isGrowingCategory =
|
||||
recording.project_flock?.project_flock_category === 'GROWING';
|
||||
const isGrowingLockedByLaying = isGrowingCategory && recording.is_laying;
|
||||
if (isGrowingLockedByLaying) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentIsLaying =
|
||||
recording.project_flock?.project_flock_category === 'LAYING';
|
||||
|
||||
const restriction = getRecordingRestriction(
|
||||
recording.is_laying,
|
||||
recording.is_transition,
|
||||
currentIsLaying
|
||||
);
|
||||
|
||||
if (restriction.isLocked) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const getRecordingRestrictionInfo = (recording: Recording) => {
|
||||
const isGrowingCategory =
|
||||
recording.project_flock?.project_flock_category === 'GROWING';
|
||||
const isGrowingLockedByLaying = isGrowingCategory && recording.is_laying;
|
||||
if (isGrowingLockedByLaying) {
|
||||
return {
|
||||
canEditStock: false,
|
||||
canEditDepletion: false,
|
||||
canEditEgg: false,
|
||||
isLocked: true,
|
||||
lockReason:
|
||||
'Recording Growing tidak dapat diubah karena sudah masuk fase laying dan dipakai pada recording laying',
|
||||
};
|
||||
}
|
||||
|
||||
const currentIsLaying =
|
||||
recording.project_flock?.project_flock_category === 'LAYING';
|
||||
|
||||
return getRecordingRestriction(
|
||||
recording.is_laying,
|
||||
recording.is_transition,
|
||||
currentIsLaying
|
||||
);
|
||||
};
|
||||
|
||||
const isApproved = isRecordingApproved(props.row.original);
|
||||
const isRejected = isRecordingRejected(props.row.original);
|
||||
const isEditable = isRecordingEditable(props.row.original);
|
||||
const restrictionInfo = getRecordingRestrictionInfo(props.row.original);
|
||||
|
||||
return (
|
||||
<div className='relative'>
|
||||
<PopoverButton
|
||||
tabIndex={0}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
popoverTarget={popoverId}
|
||||
anchorName={popoverAnchorName}
|
||||
<Tooltip
|
||||
content={restrictionInfo.isLocked ? restrictionInfo.lockReason : ''}
|
||||
position='top'
|
||||
>
|
||||
<Icon icon='material-symbols:more-vert' width={16} height={16} />
|
||||
</PopoverButton>
|
||||
<PopoverButton
|
||||
tabIndex={0}
|
||||
variant='ghost'
|
||||
color='none'
|
||||
popoverTarget={popoverId}
|
||||
anchorName={popoverAnchorName}
|
||||
className={restrictionInfo.isLocked ? 'text-error' : ''}
|
||||
>
|
||||
<Icon icon='material-symbols:more-vert' width={16} height={16} />
|
||||
</PopoverButton>
|
||||
</Tooltip>
|
||||
|
||||
<PopoverContent
|
||||
id={popoverId}
|
||||
@@ -163,7 +210,7 @@ const RowOptionsMenu = ({
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
{!isApproved && !isRejected && (
|
||||
{!restrictionInfo.isLocked && !isApproved && !isRejected && (
|
||||
<RequirePermission permissions='lti.production.recording.approve'>
|
||||
<Button
|
||||
onClick={() => {
|
||||
@@ -179,7 +226,7 @@ const RowOptionsMenu = ({
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
{!isApproved && !isRejected && (
|
||||
{!restrictionInfo.isLocked && !isApproved && !isRejected && (
|
||||
<RequirePermission permissions='lti.production.recording.approve'>
|
||||
<Button
|
||||
onClick={() => {
|
||||
@@ -560,12 +607,17 @@ const RecordingTable = () => {
|
||||
const singleDeleteHandler = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
|
||||
await RecordingApi.delete(selectedRecording?.id as number);
|
||||
refreshRecordings();
|
||||
const response = await RecordingApi.delete(selectedRecording?.id as number);
|
||||
|
||||
singleDeleteModal.closeModal();
|
||||
toast.success('Successfully delete Recording!');
|
||||
setIsDeleteLoading(false);
|
||||
|
||||
if (isResponseSuccess(response)) {
|
||||
toast.success(response?.message || 'Successfully delete Recording!');
|
||||
refreshRecordings();
|
||||
} else {
|
||||
toast.error(response?.message || 'Failed to delete Recording');
|
||||
}
|
||||
};
|
||||
|
||||
const approveHandler = async (notes: string) => {
|
||||
@@ -761,11 +813,21 @@ const RecordingTable = () => {
|
||||
{
|
||||
header: 'Kategori',
|
||||
cell: (props) => {
|
||||
const isTransition = props.row.original.is_transition;
|
||||
const category =
|
||||
props.row.original.project_flock?.project_flock_category;
|
||||
if (!category) return '-';
|
||||
props.row.original.project_flock?.project_flock_category ||
|
||||
'GROWING';
|
||||
const color = category === 'LAYING' ? 'info' : 'warning';
|
||||
return <StatusBadge color={color} text={formatTitleCase(category)} />;
|
||||
return (
|
||||
<div className='flex flex-col gap-1'>
|
||||
<StatusBadge color={color} text={formatTitleCase(category)} />
|
||||
{isTransition && (
|
||||
<span className='text-xs text-warning font-medium'>
|
||||
(Transisi)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -70,7 +70,7 @@ import {
|
||||
} from '@/components/pages/production/recording/form/RecordingForm.schema';
|
||||
|
||||
import { isResponseSuccess, isResponseError } from '@/lib/api-helper';
|
||||
import { formatDate, formatNumber } from '@/lib/helper';
|
||||
import { formatDate, formatNumber, cn } from '@/lib/helper';
|
||||
import toast from 'react-hot-toast';
|
||||
import ApprovalSteps, {
|
||||
useApprovalSteps,
|
||||
@@ -80,6 +80,7 @@ import {
|
||||
LAYING_RECORDING_APPROVAL_LINE,
|
||||
} from '@/config/approval-line';
|
||||
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
|
||||
import { getRecordingRestriction } from '../recording-utils';
|
||||
|
||||
interface RecordingFormProps {
|
||||
type?: 'add' | 'edit' | 'detail';
|
||||
@@ -242,6 +243,23 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
const [isProductionStandardModalOpen, setIsProductionStandardModalOpen] =
|
||||
useState(false);
|
||||
|
||||
const calculateWeek = useCallback(
|
||||
(day: number): number => {
|
||||
if (
|
||||
productionStandards?.details &&
|
||||
productionStandards.details.length > 0
|
||||
) {
|
||||
const firstWeek = productionStandards.details[0].week;
|
||||
|
||||
const weekOffset = Math.ceil(day / 7) - 1;
|
||||
return firstWeek + weekOffset;
|
||||
}
|
||||
|
||||
return Math.ceil(day / 7);
|
||||
},
|
||||
[productionStandards]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const checkProductionStandardModalOpen = () => {
|
||||
const isOpen = productionStandardModal.ref.current?.open || false;
|
||||
@@ -272,16 +290,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
return recording?.approval?.action === 'REJECTED';
|
||||
}, []);
|
||||
|
||||
const isRecordingEditable = useCallback((recording?: Recording) => {
|
||||
if (
|
||||
recording?.executed_at &&
|
||||
recording?.project_flock?.project_flock_category === 'GROWING'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
// ===== PAYLOAD CREATION HELPERS =====
|
||||
const createGrowingPayload = useCallback(
|
||||
(values: RecordingGrowingFormValues) => {
|
||||
@@ -380,11 +388,17 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
if (!initialValues?.id) return;
|
||||
|
||||
setIsDeleteLoading(true);
|
||||
await RecordingApi.delete(initialValues.id);
|
||||
const response = await RecordingApi.delete(initialValues.id);
|
||||
|
||||
deleteModal.closeModal();
|
||||
toast.success('Successfully delete Recording!');
|
||||
setIsDeleteLoading(false);
|
||||
router.push('/production/recording');
|
||||
|
||||
if (isResponseSuccess(response)) {
|
||||
toast.success(response?.message || 'Successfully delete Recording!');
|
||||
router.push('/production/recording');
|
||||
} else {
|
||||
toast.error(response?.message || 'Failed to delete Recording');
|
||||
}
|
||||
}, [deleteModal, initialValues?.id, router]);
|
||||
|
||||
// ===== API DATA FETCHING =====
|
||||
@@ -406,13 +420,15 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
});
|
||||
|
||||
const projectFlockKandangLookupUrl = useMemo(() => {
|
||||
if (!selectedProjectFlock || !selectedKandang) return null;
|
||||
if (!selectedProjectFlock || !selectedKandang || !selectedRecordDate)
|
||||
return null;
|
||||
const params = new URLSearchParams({
|
||||
project_flock_id: selectedProjectFlock.value.toString(),
|
||||
kandang_id: selectedKandang.value.toString(),
|
||||
record_date: selectedRecordDate,
|
||||
});
|
||||
return `${ProjectFlockApi.basePath}/kandangs/lookup?${params.toString()}`;
|
||||
}, [selectedProjectFlock, selectedKandang]);
|
||||
}, [selectedProjectFlock, selectedKandang, selectedRecordDate]);
|
||||
|
||||
const { data: projectFlockKandangLookupData } = useSWR(
|
||||
projectFlockKandangLookupUrl,
|
||||
@@ -444,13 +460,24 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
() => ProductionStandardApi.getSingle(productionStandardId!)
|
||||
);
|
||||
|
||||
const { data: productionStandardForAdd } = useSWR(
|
||||
type === 'add' && productionStandardId
|
||||
? `production-standard-add-${productionStandardId}`
|
||||
: null,
|
||||
() => ProductionStandardApi.getSingle(productionStandardId!)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (productionStandard?.status === 'success') {
|
||||
setProductionStandards(
|
||||
productionStandard.data as ProductionStandard | null
|
||||
);
|
||||
} else if (productionStandardForAdd?.status === 'success') {
|
||||
setProductionStandards(
|
||||
productionStandardForAdd.data as ProductionStandard | null
|
||||
);
|
||||
}
|
||||
}, [productionStandard]);
|
||||
}, [productionStandard, productionStandardForAdd]);
|
||||
|
||||
const projectFlockKandangDetailUrl = useMemo(() => {
|
||||
if (
|
||||
@@ -476,6 +503,74 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
? projectFlockKandangDetailData.data
|
||||
: undefined;
|
||||
|
||||
// ===== TRANSITION RESTRICTION LOGIC =====
|
||||
const isTransitionPeriod = useMemo(() => {
|
||||
return (
|
||||
initialValues?.is_transition ??
|
||||
projectFlockKandangLookup?.is_transition ??
|
||||
false
|
||||
);
|
||||
}, [initialValues, projectFlockKandangLookup]);
|
||||
|
||||
const recordingRestriction = useMemo(() => {
|
||||
// Determine isLaying primarily from transition flags.
|
||||
let isLaying: boolean;
|
||||
if (initialValues?.is_laying !== undefined) {
|
||||
isLaying = initialValues.is_laying;
|
||||
} else if (projectFlockKandangLookup?.is_laying !== undefined) {
|
||||
isLaying = projectFlockKandangLookup.is_laying;
|
||||
} else {
|
||||
isLaying =
|
||||
projectFlockKandangDetail?.project_flock?.category === 'LAYING' ||
|
||||
false;
|
||||
}
|
||||
|
||||
const isTransition =
|
||||
initialValues?.is_transition ??
|
||||
projectFlockKandangLookup?.is_transition ??
|
||||
false;
|
||||
|
||||
const currentIsLaying =
|
||||
projectFlockKandangDetail?.project_flock?.category === 'LAYING';
|
||||
|
||||
return getRecordingRestriction(
|
||||
isLaying,
|
||||
isTransition,
|
||||
type === 'edit' ? currentIsLaying : undefined
|
||||
);
|
||||
}, [
|
||||
initialValues,
|
||||
projectFlockKandangLookup,
|
||||
projectFlockKandangDetail,
|
||||
type,
|
||||
]);
|
||||
|
||||
const isRecordingEditable = useCallback((recording?: Recording) => {
|
||||
if (!recording) return true;
|
||||
|
||||
const isGrowingCategory =
|
||||
recording.project_flock?.project_flock_category === 'GROWING';
|
||||
const isGrowingLockedByLaying = isGrowingCategory && recording.is_laying;
|
||||
if (isGrowingLockedByLaying) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentIsLaying =
|
||||
recording.project_flock?.project_flock_category === 'LAYING';
|
||||
|
||||
const restriction = getRecordingRestriction(
|
||||
recording.is_laying,
|
||||
recording.is_transition,
|
||||
currentIsLaying
|
||||
);
|
||||
|
||||
if (restriction.isLocked) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const {
|
||||
options: stockProductOptions,
|
||||
rawData: stockProducts,
|
||||
@@ -581,15 +676,28 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
return approvedProjectFlockKandangsData.data;
|
||||
}, [approvedProjectFlockKandangsData]);
|
||||
|
||||
const isLayingCategory =
|
||||
initialValues?.project_flock?.project_flock_category === 'LAYING' ||
|
||||
projectFlockKandangLookup?.project_flock?.category === 'LAYING' ||
|
||||
projectFlockKandangDetail?.project_flock?.category === 'LAYING';
|
||||
const isLayingCategory = useMemo(() => {
|
||||
// Priority 1: initialValues (for edit/detail mode)
|
||||
if (initialValues?.is_laying !== undefined) {
|
||||
return initialValues.is_laying;
|
||||
}
|
||||
|
||||
const isGrowingCategory =
|
||||
initialValues?.project_flock?.project_flock_category === 'GROWING' ||
|
||||
projectFlockKandangLookup?.project_flock?.category === 'GROWING' ||
|
||||
projectFlockKandangDetail?.project_flock?.category === 'GROWING';
|
||||
// Priority 2: projectFlockKandangLookup flag (for add mode)
|
||||
if (projectFlockKandangLookup?.is_laying !== undefined) {
|
||||
return projectFlockKandangLookup.is_laying;
|
||||
}
|
||||
|
||||
// Priority 3: projectFlockKandangDetail (fallback for edit/detail mode)
|
||||
return (
|
||||
projectFlockKandangDetail?.project_flock?.category === 'LAYING' || false
|
||||
);
|
||||
}, [
|
||||
initialValues?.is_laying,
|
||||
projectFlockKandangLookup,
|
||||
projectFlockKandangDetail,
|
||||
]);
|
||||
|
||||
const isGrowingCategory = !isLayingCategory;
|
||||
|
||||
const recordingApprovalLines = useMemo(() => {
|
||||
if (isLayingCategory) {
|
||||
@@ -1299,6 +1407,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
setSelectedLocation(location);
|
||||
setSelectedProjectFlock(null);
|
||||
setSelectedKandang(null);
|
||||
setProductionStandards(null);
|
||||
setNextDayRecording(null);
|
||||
if (duplicateErrorShown) {
|
||||
toast.dismiss();
|
||||
setDuplicateErrorShown(false);
|
||||
@@ -1323,6 +1433,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
|
||||
setSelectedProjectFlock(projectFlock);
|
||||
setSelectedKandang(null);
|
||||
setProductionStandards(null);
|
||||
setNextDayRecording(null);
|
||||
if (duplicateErrorShown) {
|
||||
toast.dismiss();
|
||||
setDuplicateErrorShown(false);
|
||||
@@ -1343,6 +1455,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
formik.setFieldValue('kandang_id', kandangId);
|
||||
|
||||
setSelectedKandang(kandang);
|
||||
setProductionStandards(null);
|
||||
setNextDayRecording(null);
|
||||
if (duplicateErrorShown) {
|
||||
toast.dismiss();
|
||||
setDuplicateErrorShown(false);
|
||||
@@ -1879,10 +1993,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
<p className='font-semibold'>
|
||||
{type === 'add'
|
||||
? nextDayRecording
|
||||
? `Hari ke-${nextDayRecording.next_day} (Minggu ke-${Math.ceil(nextDayRecording.next_day / 7)})`
|
||||
? `Hari ke-${nextDayRecording.next_day} (Minggu ke-${calculateWeek(nextDayRecording.next_day)})`
|
||||
: '-'
|
||||
: initialValues?.day
|
||||
? `Hari ke-${initialValues.day} (Minggu ke-${Math.ceil(initialValues.day / 7)})`
|
||||
? `Hari ke-${initialValues.day} (Minggu ke-${calculateWeek(initialValues.day)})`
|
||||
: '-'}
|
||||
</p>
|
||||
</div>
|
||||
@@ -1956,18 +2070,18 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
<div>
|
||||
<span className='text-sm text-gray-600'>Kategori</span>
|
||||
<p className='font-semibold'>
|
||||
<Badge
|
||||
variant='soft'
|
||||
color={
|
||||
initialValues.project_flock
|
||||
?.project_flock_category === 'LAYING'
|
||||
? 'info'
|
||||
: 'warning'
|
||||
}
|
||||
size='sm'
|
||||
>
|
||||
{initialValues.project_flock?.project_flock_category}
|
||||
</Badge>
|
||||
{(() => {
|
||||
const category =
|
||||
initialValues.project_flock?.project_flock_category ||
|
||||
'GROWING';
|
||||
const color =
|
||||
category === 'LAYING' ? 'info' : 'warning';
|
||||
return (
|
||||
<Badge variant='soft' color={color} size='sm'>
|
||||
{category}
|
||||
</Badge>
|
||||
);
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -2103,9 +2217,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
{type === 'detail' && initialValues && (
|
||||
<div
|
||||
className={`grid gap-6 mb-6 grid-cols-1 ${
|
||||
initialValues.project_flock?.project_flock_category === 'LAYING'
|
||||
? 'xl:grid-cols-3'
|
||||
: 'xl:grid-cols-2'
|
||||
initialValues.is_laying ? 'xl:grid-cols-3' : 'xl:grid-cols-2'
|
||||
}`}
|
||||
>
|
||||
{/* FCR Section */}
|
||||
@@ -2196,8 +2308,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
{/* Egg Production Section - Only for LAYING category */}
|
||||
{type === 'detail' &&
|
||||
initialValues &&
|
||||
initialValues.project_flock?.project_flock_category ===
|
||||
'LAYING' && (
|
||||
initialValues.is_laying && (
|
||||
<div className='border border-gray-200 rounded-lg bg-white'>
|
||||
<div className='px-4 py-3 border-b border-gray-200'>
|
||||
<span className='card-title font-bold text-xl'>
|
||||
@@ -2324,6 +2435,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
setSelectedStocks([]);
|
||||
}
|
||||
}}
|
||||
disabled={!recordingRestriction.canEditStock}
|
||||
classNames={{
|
||||
wrapper: 'flex justify-center',
|
||||
checkbox: 'checkbox checkbox-sm',
|
||||
@@ -2373,6 +2485,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={!recordingRestriction.canEditStock}
|
||||
classNames={{
|
||||
wrapper: 'flex justify-center',
|
||||
checkbox: 'checkbox checkbox-sm',
|
||||
@@ -2425,7 +2538,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
isSearchable
|
||||
isDisabled={
|
||||
type === 'detail' ||
|
||||
!formik.values.project_flock_kandang_id
|
||||
!formik.values.project_flock_kandang_id ||
|
||||
!recordingRestriction.canEditStock
|
||||
}
|
||||
isClearable={type !== 'detail'}
|
||||
inputPrefix={
|
||||
@@ -2472,7 +2586,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
)
|
||||
: null
|
||||
}
|
||||
disabled={type === 'detail'}
|
||||
disabled={
|
||||
type === 'detail' ||
|
||||
!recordingRestriction.canEditStock
|
||||
}
|
||||
/>
|
||||
{getStockUsageAdornment(idx)}
|
||||
</div>
|
||||
@@ -2484,6 +2601,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
type='button'
|
||||
color='error'
|
||||
onClick={() => removeStock(idx)}
|
||||
disabled={!recordingRestriction.canEditStock}
|
||||
>
|
||||
<Icon
|
||||
icon='mdi:trash-can'
|
||||
@@ -2501,38 +2619,81 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
</div>
|
||||
{(type as 'add' | 'edit' | 'detail') !== 'detail' && (
|
||||
<div className='flex justify-center items-center mt-4 gap-4'>
|
||||
{selectedStocks.length > 0 && (
|
||||
{selectedStocks.length > 0 &&
|
||||
recordingRestriction.canEditStock && (
|
||||
<Button
|
||||
type='button'
|
||||
color='error'
|
||||
onClick={removeSelectedStocks}
|
||||
disabled={selectedStocks.length === 0}
|
||||
className='w-fit'
|
||||
>
|
||||
<Icon icon='mdi:trash-can' width={24} height={24} />
|
||||
Hapus Terpilih ({selectedStocks.length})
|
||||
</Button>
|
||||
)}
|
||||
<Tooltip
|
||||
content={
|
||||
!recordingRestriction.canEditStock
|
||||
? 'Stock tidak dapat ditambahkan pada masa transisi Laying'
|
||||
: ''
|
||||
}
|
||||
position='top'
|
||||
>
|
||||
<Button
|
||||
type='button'
|
||||
color='error'
|
||||
onClick={removeSelectedStocks}
|
||||
disabled={selectedStocks.length === 0}
|
||||
color='success'
|
||||
onClick={addStock}
|
||||
className='w-fit'
|
||||
disabled={!recordingRestriction.canEditStock}
|
||||
>
|
||||
<Icon icon='mdi:trash-can' width={24} height={24} />
|
||||
Hapus Terpilih ({selectedStocks.length})
|
||||
<Icon icon='ic:round-plus' width={24} height={24} />
|
||||
Tambah Stok
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type='button'
|
||||
color='success'
|
||||
onClick={addStock}
|
||||
className='w-fit'
|
||||
>
|
||||
<Icon icon='ic:round-plus' width={24} height={24} />
|
||||
Tambah Stok
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Transition Warning Banner -- MOVED UP -- */}
|
||||
{isTransitionPeriod && (
|
||||
<div className='alert alert-warning mb-4'>
|
||||
<Icon
|
||||
icon='material-symbols:warning-outline'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<span>
|
||||
{isLayingCategory
|
||||
? 'Masa Transisi Laying: Hanya Deplesi yang dapat diisi. Stock (Pakan/OVK) tidak dapat diinput.'
|
||||
: 'Masa Transisi Growing: Hanya Stock (Pakan/OVK) yang dapat diisi. Deplesi tidak dapat diinput.'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Locked Recording Warning */}
|
||||
{recordingRestriction.isLocked && (
|
||||
<div className='alert alert-error mb-4'>
|
||||
<Icon
|
||||
icon='material-symbols:lock-outline'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<span>{recordingRestriction.lockReason}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Depletions Table */}
|
||||
{((type as 'add' | 'edit' | 'detail') !== 'detail' ||
|
||||
(formik.values.depletions?.length ?? 0) > 0) && (
|
||||
<Card
|
||||
title='Deplesi'
|
||||
className={{
|
||||
wrapper: 'w-full mb-4 shadow',
|
||||
wrapper: cn('w-full mb-4 shadow', {
|
||||
'opacity-60':
|
||||
!recordingRestriction.canEditDepletion &&
|
||||
(type as 'add' | 'edit' | 'detail') !== 'detail',
|
||||
}),
|
||||
title: 'mb-4',
|
||||
}}
|
||||
>
|
||||
@@ -2562,6 +2723,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
setSelectedDepletions([]);
|
||||
}
|
||||
}}
|
||||
disabled={!recordingRestriction.canEditDepletion}
|
||||
classNames={{
|
||||
wrapper: 'flex justify-center',
|
||||
checkbox: 'checkbox checkbox-sm',
|
||||
@@ -2598,6 +2760,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={!recordingRestriction.canEditDepletion}
|
||||
classNames={{
|
||||
wrapper: 'flex justify-center',
|
||||
checkbox: 'checkbox checkbox-sm',
|
||||
@@ -2640,7 +2803,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
idx
|
||||
).errorMessage
|
||||
}
|
||||
isDisabled={type === 'detail'}
|
||||
isDisabled={
|
||||
type === 'detail' ||
|
||||
!recordingRestriction.canEditDepletion
|
||||
}
|
||||
className={{
|
||||
wrapper: 'w-full min-w-48',
|
||||
}}
|
||||
@@ -2679,7 +2845,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
)
|
||||
: null
|
||||
}
|
||||
disabled={type === 'detail'}
|
||||
disabled={
|
||||
type === 'detail' ||
|
||||
!recordingRestriction.canEditDepletion
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
{(type as 'add' | 'edit' | 'detail') !== 'detail' && (
|
||||
@@ -2689,6 +2858,9 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
type='button'
|
||||
color='error'
|
||||
onClick={() => removeDepletion(idx)}
|
||||
disabled={
|
||||
!recordingRestriction.canEditDepletion
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
icon='mdi:trash-can'
|
||||
@@ -2706,27 +2878,38 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
</div>
|
||||
{(type as 'add' | 'edit' | 'detail') !== 'detail' && (
|
||||
<div className='flex justify-center items-center mt-4 gap-4'>
|
||||
{selectedDepletions.length > 0 && (
|
||||
{selectedDepletions.length > 0 &&
|
||||
recordingRestriction.canEditDepletion && (
|
||||
<Button
|
||||
type='button'
|
||||
color='error'
|
||||
onClick={removeSelectedDepletions}
|
||||
disabled={selectedDepletions.length === 0}
|
||||
className='w-fit'
|
||||
>
|
||||
<Icon icon='mdi:trash-can' width={24} height={24} />
|
||||
Hapus Terpilih ({selectedDepletions.length})
|
||||
</Button>
|
||||
)}
|
||||
<Tooltip
|
||||
content={
|
||||
!recordingRestriction.canEditDepletion
|
||||
? 'Deplesi tidak dapat ditambahkan pada masa transisi Growing'
|
||||
: ''
|
||||
}
|
||||
position='top'
|
||||
>
|
||||
<Button
|
||||
type='button'
|
||||
color='error'
|
||||
onClick={removeSelectedDepletions}
|
||||
disabled={selectedDepletions.length === 0}
|
||||
color='success'
|
||||
onClick={addDepletion}
|
||||
className='w-fit'
|
||||
disabled={!recordingRestriction.canEditDepletion}
|
||||
>
|
||||
<Icon icon='mdi:trash-can' width={24} height={24} />
|
||||
Hapus Terpilih ({selectedDepletions.length})
|
||||
<Icon icon='ic:round-plus' width={24} height={24} />
|
||||
Tambah Depletion
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type='button'
|
||||
color='success'
|
||||
onClick={addDepletion}
|
||||
className='w-fit'
|
||||
>
|
||||
<Icon icon='ic:round-plus' width={24} height={24} />
|
||||
Tambah Depletion
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
export type RecordingRestriction = {
|
||||
canEditStock: boolean;
|
||||
canEditDepletion: boolean;
|
||||
canEditEgg: boolean;
|
||||
isLocked: boolean;
|
||||
lockReason?: string;
|
||||
};
|
||||
|
||||
export const getRecordingRestriction = (
|
||||
isLaying: boolean,
|
||||
isTransition: boolean,
|
||||
currentIsLaying?: boolean
|
||||
): RecordingRestriction => {
|
||||
if (currentIsLaying && !isLaying) {
|
||||
return {
|
||||
canEditStock: false,
|
||||
canEditDepletion: false,
|
||||
canEditEgg: false,
|
||||
isLocked: true,
|
||||
lockReason:
|
||||
'Recording Growing telah terkunci karena Project Flock sudah masuk fase Laying',
|
||||
};
|
||||
}
|
||||
|
||||
if (isTransition && !isLaying) {
|
||||
return {
|
||||
canEditStock: true,
|
||||
canEditDepletion: false,
|
||||
canEditEgg: false,
|
||||
isLocked: false,
|
||||
lockReason: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (!isLaying && !isTransition) {
|
||||
return {
|
||||
canEditStock: true,
|
||||
canEditDepletion: true,
|
||||
canEditEgg: false,
|
||||
isLocked: false,
|
||||
lockReason: undefined,
|
||||
};
|
||||
}
|
||||
if (isLaying && !isTransition) {
|
||||
return {
|
||||
canEditStock: true,
|
||||
canEditDepletion: true,
|
||||
canEditEgg: true,
|
||||
isLocked: false,
|
||||
lockReason: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
canEditStock: false,
|
||||
canEditDepletion: false,
|
||||
canEditEgg: false,
|
||||
isLocked: true,
|
||||
lockReason: 'Kondisi transisi tidak valid',
|
||||
};
|
||||
};
|
||||
@@ -20,6 +20,7 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [
|
||||
'lti.daily_checklist.master_data.employee',
|
||||
'lti.daily_checklist.master_data.activity',
|
||||
'lti.daily_checklist.master_data.configuration',
|
||||
'lti.daily_checklist.master_data.kandang',
|
||||
],
|
||||
submenu: [
|
||||
{
|
||||
@@ -66,6 +67,11 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [
|
||||
link: '/daily-checklist/master-data/activity',
|
||||
permission: ['lti.daily_checklist.master_data.activity'],
|
||||
},
|
||||
{
|
||||
text: 'Kandang',
|
||||
link: '/daily-checklist/master-data/kandang',
|
||||
permission: ['lti.daily_checklist.master_data.kandang'],
|
||||
},
|
||||
{
|
||||
text: 'Konfigurasi',
|
||||
link: '/daily-checklist/master-data/configuration',
|
||||
|
||||
@@ -21,6 +21,9 @@ export const ROUTE_PERMISSIONS: Record<string, string[]> = {
|
||||
'/daily-checklist/master-data/configuration/': [
|
||||
'lti.daily_checklist.master_data.configuration',
|
||||
],
|
||||
'/daily-checklist/master-data/kandang/': [
|
||||
'lti.daily_checklist.master_data.kandang',
|
||||
],
|
||||
|
||||
// Production
|
||||
// Production - Project Flock
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Check, ChevronsUpDown, X } from 'lucide-react';
|
||||
import { Check, ChevronsUpDown, X, Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/helper';
|
||||
import { Button } from '@/figma-make/components/base/button';
|
||||
import {
|
||||
@@ -29,6 +29,8 @@ interface MultiSelectProps {
|
||||
selected: string[];
|
||||
onChange: (selected: string[]) => void;
|
||||
onSearchChange?: (value: string) => void;
|
||||
onLoadMore?: () => void;
|
||||
isLoadingMore?: boolean;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
@@ -39,6 +41,8 @@ export function MultiSelect({
|
||||
selected,
|
||||
onChange,
|
||||
onSearchChange,
|
||||
onLoadMore,
|
||||
isLoadingMore,
|
||||
placeholder = 'Select items...',
|
||||
className,
|
||||
disabled,
|
||||
@@ -115,7 +119,18 @@ export function MultiSelect({
|
||||
onValueChange={onSearchChange}
|
||||
/>
|
||||
<CommandEmpty>No item found.</CommandEmpty>
|
||||
<CommandList className='max-h-[300px] overflow-y-auto'>
|
||||
<CommandList
|
||||
className='max-h-[300px] overflow-y-auto'
|
||||
onScroll={(e) => {
|
||||
const target = e.currentTarget;
|
||||
if (
|
||||
target.scrollHeight - target.scrollTop <=
|
||||
target.clientHeight + 1
|
||||
) {
|
||||
onLoadMore?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CommandGroup className='overflow-visible'>
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
@@ -134,6 +149,11 @@ export function MultiSelect({
|
||||
{option.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
{isLoadingMore && (
|
||||
<div className='py-4 flex justify-center w-full'>
|
||||
<Loader2 className='h-4 w-4 animate-spin text-muted-foreground' />
|
||||
</div>
|
||||
)}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
|
||||
@@ -55,7 +55,11 @@ function SelectContent({
|
||||
children,
|
||||
position = 'popper',
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content> & {
|
||||
onScroll?: React.UIEventHandler<HTMLDivElement>;
|
||||
}) {
|
||||
const { onScroll, ...restProps } = props;
|
||||
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
@@ -67,7 +71,7 @@ function SelectContent({
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
{...restProps}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
@@ -76,6 +80,7 @@ function SelectContent({
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1'
|
||||
)}
|
||||
onScroll={onScroll}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
|
||||
@@ -2,7 +2,16 @@
|
||||
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, X, Save, Send, Info, FilePlus, ListChecks } from 'lucide-react';
|
||||
import {
|
||||
Plus,
|
||||
X,
|
||||
Save,
|
||||
Send,
|
||||
Info,
|
||||
FilePlus,
|
||||
ListChecks,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent } from '@/figma-make/components/base/card';
|
||||
import { Button } from '@/figma-make/components/base/button';
|
||||
import { Label } from '@/figma-make/components/base/label';
|
||||
@@ -26,7 +35,6 @@ import {
|
||||
import { DatePicker } from '@/figma-make/components/base/date-picker';
|
||||
import { toast } from 'sonner';
|
||||
import { useSelect } from '@/components/input/SelectInput';
|
||||
import { KandangApi } from '@/services/api/master-data';
|
||||
import { DailyChecklistApi } from '@/services/api/daily-checklist/daily-checklist';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import useSWR from 'swr';
|
||||
@@ -43,6 +51,7 @@ import DropFileInput from '@/components/input/DropFileInput';
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { DailyChecklistKandangApi } from '@/services/api/daily-checklist/kandang';
|
||||
|
||||
// Static categories
|
||||
const CATEGORIES = [
|
||||
@@ -86,16 +95,11 @@ export function DailyChecklistContent() {
|
||||
searchParams.get('category') || ''
|
||||
);
|
||||
|
||||
const { options: kandangOptions } = useSelect(
|
||||
KandangApi.basePath,
|
||||
'id',
|
||||
'name',
|
||||
'search',
|
||||
{
|
||||
page: '1',
|
||||
limit: '100',
|
||||
}
|
||||
);
|
||||
const {
|
||||
options: kandangOptions,
|
||||
isLoadingMore: isLoadingMoreKandang,
|
||||
loadMore: loadMoreKandang,
|
||||
} = useSelect(DailyChecklistKandangApi.basePath, 'id', 'name');
|
||||
|
||||
const { data: phases } = useSWR<
|
||||
BaseApiResponse<Phase[] | undefined>,
|
||||
@@ -168,6 +172,16 @@ export function DailyChecklistContent() {
|
||||
const [documents, setDocuments] = useState<File[]>([]);
|
||||
const [deletedDocumentIds, setDeletedDocumentIds] = useState<number[]>([]);
|
||||
|
||||
const handleKandangScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
const target = e.target as HTMLDivElement;
|
||||
|
||||
if (target.scrollHeight - target.scrollTop <= target.clientHeight + 10) {
|
||||
if (!isLoadingMoreKandang) {
|
||||
loadMoreKandang();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Sync state to URL query params
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
@@ -994,7 +1008,7 @@ export function DailyChecklistContent() {
|
||||
>
|
||||
<SelectValue placeholder='Pilih kandang' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent onScroll={handleKandangScroll}>
|
||||
{kandangOptions.map((kandang) => (
|
||||
<SelectItem
|
||||
key={kandang.value}
|
||||
@@ -1003,6 +1017,12 @@ export function DailyChecklistContent() {
|
||||
{kandang.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
{isLoadingMoreKandang && (
|
||||
<div className='flex justify-center p-2'>
|
||||
<Loader2 className='h-4 w-4 animate-spin text-gray-500' />
|
||||
</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/figma-make/components/base/select';
|
||||
import { Badge } from '@/figma-make/components/base/badge';
|
||||
import { Users, AlertCircle, Info } from 'lucide-react';
|
||||
import { Users, AlertCircle, Info, Loader2 } from 'lucide-react';
|
||||
import { DateRangePicker } from '@/figma-make/components/base/date-range-picker';
|
||||
import {
|
||||
BarChart,
|
||||
@@ -36,10 +36,10 @@ import { DailyChecklistSummary } from '@/types/api/daily-checklist/daily-checkli
|
||||
import { AxiosError } from 'axios';
|
||||
import { httpClientFetcher, SWRHttpKey } from '@/services/http/client';
|
||||
import { DailyChecklistApi } from '@/services/api/daily-checklist/daily-checklist';
|
||||
import { KandangApi } from '@/services/api/master-data';
|
||||
import { useSelect } from '@/components/input/SelectInput';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { formatDate } from '@/lib/helper';
|
||||
import { DailyChecklistKandangApi } from '@/services/api/daily-checklist/kandang';
|
||||
|
||||
const KANDANG_COLORS = [
|
||||
'#0069e0', // Blue (primary)
|
||||
@@ -77,16 +77,20 @@ export function Dashboard() {
|
||||
httpClientFetcher
|
||||
);
|
||||
|
||||
const { options: kandangOptions } = useSelect(
|
||||
KandangApi.basePath,
|
||||
'id',
|
||||
'name',
|
||||
'search',
|
||||
{
|
||||
page: '1',
|
||||
limit: '100',
|
||||
const {
|
||||
options: kandangOptions,
|
||||
loadMore: loadMoreKandang,
|
||||
isLoadingMore: isLoadingMoreKandang,
|
||||
} = useSelect(DailyChecklistKandangApi.basePath, 'id', 'name');
|
||||
|
||||
const handleKandangScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
const target = e.target as HTMLDivElement;
|
||||
if (target.scrollHeight - target.scrollTop <= target.clientHeight + 10) {
|
||||
if (!isLoadingMoreKandang) {
|
||||
loadMoreKandang();
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const kandangColorMap: { [key: string]: string } = {};
|
||||
(kandangOptions || []).forEach((k, index) => {
|
||||
@@ -164,7 +168,7 @@ export function Dashboard() {
|
||||
>
|
||||
<SelectValue placeholder='Semua Kandang' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent onScroll={handleKandangScroll}>
|
||||
<SelectItem value='ALL'>Semua Kandang</SelectItem>
|
||||
{kandangOptions.map((kandang) => (
|
||||
<SelectItem
|
||||
@@ -174,6 +178,11 @@ export function Dashboard() {
|
||||
{kandang.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
{isLoadingMoreKandang && (
|
||||
<div className='flex justify-center p-2'>
|
||||
<Loader2 className='h-4 w-4 animate-spin text-gray-500' />
|
||||
</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
+30
-13
@@ -1,7 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Eye, CheckCircle, XCircle, Search, Trash2, Edit } from 'lucide-react';
|
||||
import {
|
||||
Eye,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Search,
|
||||
Trash2,
|
||||
Edit,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent } from '@/figma-make/components/base/card';
|
||||
import { Button } from '@/figma-make/components/base/button';
|
||||
import { Badge } from '@/figma-make/components/base/badge';
|
||||
@@ -34,9 +42,9 @@ import { DailyChecklist } from '@/types/api/daily-checklist/daily-checklist';
|
||||
import { cn } from '@/lib/helper';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { useSelect } from '@/components/input/SelectInput';
|
||||
import { KandangApi } from '@/services/api/master-data';
|
||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import { DailyChecklistKandangApi } from '@/services/api/daily-checklist/kandang';
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'ALL', label: 'Semua Status' },
|
||||
@@ -93,21 +101,25 @@ export function ListDailyChecklistContent() {
|
||||
}
|
||||
);
|
||||
|
||||
const { options: kandangOptions } = useSelect(
|
||||
KandangApi.basePath,
|
||||
'id',
|
||||
'name',
|
||||
'search',
|
||||
{
|
||||
page: '1',
|
||||
limit: '100',
|
||||
}
|
||||
);
|
||||
const {
|
||||
options: kandangOptions,
|
||||
isLoadingMore: isLoadingMoreKandang,
|
||||
loadMore: loadMoreKandang,
|
||||
} = useSelect(DailyChecklistKandangApi.basePath, 'id', 'name');
|
||||
|
||||
const checklistList = isResponseSuccess(checklistListRes)
|
||||
? checklistListRes.data || []
|
||||
: [];
|
||||
|
||||
const handleKandangScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
const target = e.target as HTMLDivElement;
|
||||
if (target.scrollHeight - target.scrollTop <= target.clientHeight + 10) {
|
||||
if (!isLoadingMoreKandang) {
|
||||
loadMoreKandang();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Modals
|
||||
const [showApproveModal, setShowApproveModal] = useState(false);
|
||||
const [showRejectModal, setShowRejectModal] = useState(false);
|
||||
@@ -490,7 +502,7 @@ export function ListDailyChecklistContent() {
|
||||
>
|
||||
<SelectValue placeholder='Semua Kandang' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent onScroll={handleKandangScroll}>
|
||||
<SelectItem value='ALL'>Semua Kandang</SelectItem>
|
||||
{kandangOptions.map((kandang) => (
|
||||
<SelectItem
|
||||
@@ -500,6 +512,11 @@ export function ListDailyChecklistContent() {
|
||||
{kandang.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
{isLoadingMoreKandang && (
|
||||
<div className='flex justify-center p-2'>
|
||||
<Loader2 className='h-4 w-4 animate-spin text-gray-500' />
|
||||
</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Plus, MoreVertical, Pencil, Trash2, Search } from 'lucide-react';
|
||||
import {
|
||||
Plus,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Search,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent } from '@/figma-make/components/base/card';
|
||||
import { Button } from '@/figma-make/components/base/button';
|
||||
import { Label } from '@/figma-make/components/base/label';
|
||||
@@ -49,8 +56,8 @@ import { cn } from '@/lib/helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { useSelect } from '@/components/input/SelectInput';
|
||||
import { KandangApi } from '@/services/api/master-data';
|
||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||
import { DailyChecklistKandangApi } from '@/services/api/daily-checklist/kandang';
|
||||
|
||||
export function MasterEmployeeContent() {
|
||||
const {
|
||||
@@ -85,16 +92,20 @@ export function MasterEmployeeContent() {
|
||||
keepPreviousData: true,
|
||||
}
|
||||
);
|
||||
const { options: kandangOptions } = useSelect(
|
||||
KandangApi.basePath,
|
||||
'id',
|
||||
'name',
|
||||
'search',
|
||||
{
|
||||
page: '1',
|
||||
limit: '100',
|
||||
const {
|
||||
options: kandangOptions,
|
||||
loadMore: loadMoreKandang,
|
||||
isLoadingMore: isLoadingMoreKandang,
|
||||
} = useSelect(DailyChecklistKandangApi.basePath, 'id', 'name');
|
||||
|
||||
const handleKandangScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
const target = e.target as HTMLDivElement;
|
||||
if (target.scrollHeight - target.scrollTop <= target.clientHeight + 10) {
|
||||
if (!isLoadingMoreKandang) {
|
||||
loadMoreKandang();
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
@@ -351,7 +362,7 @@ export function MasterEmployeeContent() {
|
||||
<SelectTrigger className='w-[180px] border-gray-200'>
|
||||
<SelectValue placeholder='Semua Kandang' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent onScroll={handleKandangScroll}>
|
||||
<SelectItem value='all'>Semua Kandang</SelectItem>
|
||||
{kandangOptions.map((kandang) => (
|
||||
<SelectItem
|
||||
@@ -361,6 +372,11 @@ export function MasterEmployeeContent() {
|
||||
{kandang.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
{isLoadingMoreKandang && (
|
||||
<div className='flex justify-center p-2'>
|
||||
<Loader2 className='h-4 w-4 animate-spin text-gray-500' />
|
||||
</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -471,6 +487,12 @@ export function MasterEmployeeContent() {
|
||||
kandang_ids: selected.map((id) => Number(id)),
|
||||
})
|
||||
}
|
||||
onLoadMore={() => {
|
||||
if (!isLoadingMoreKandang) {
|
||||
loadMoreKandang();
|
||||
}
|
||||
}}
|
||||
isLoadingMore={isLoadingMoreKandang}
|
||||
placeholder='Pilih kandang'
|
||||
className='mt-1.5'
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,585 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Plus, MoreVertical, Pencil, Trash2, Search } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/figma-make/components/base/card';
|
||||
import { Button } from '@/figma-make/components/base/button';
|
||||
import { Label } from '@/figma-make/components/base/label';
|
||||
import { Input } from '@/figma-make/components/base/input';
|
||||
import { Badge } from '@/figma-make/components/base/badge';
|
||||
import { MultiSelect } from '@/figma-make/components/base/multi-select';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/figma-make/components/base/select';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/figma-make/components/base/dialog';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/figma-make/components/base/alert-dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/figma-make/components/base/dropdown-menu';
|
||||
import { toast } from 'sonner';
|
||||
import useSWR from 'swr';
|
||||
import { DailyChecklistKandangApi } from '@/services/api/daily-checklist/kandang';
|
||||
import Table from '@/components/Table';
|
||||
import { DailyChecklistKandang } from '@/types/api/daily-checklist/kandang';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { cn } from '@/lib/helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { useSelect } from '@/components/input/SelectInput';
|
||||
import { KandangApi, LocationApi } from '@/services/api/master-data';
|
||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||
import { BaseDailyChecklistKandang } from '@/types/api/daily-checklist/kandang';
|
||||
import { UserApi } from '@/services/api/user';
|
||||
|
||||
export function MasterKandangContent() {
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
setPage,
|
||||
setPageSize,
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
initial: {
|
||||
search: '',
|
||||
location_id: '',
|
||||
status: '',
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
search: 'search',
|
||||
location_id: 'location_id',
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
data: dailyChecklistKandangs,
|
||||
isLoading: isLoadingDailyChecklistKandangs,
|
||||
mutate: refreshDailyChecklistKandangs,
|
||||
} = useSWR(
|
||||
`${DailyChecklistKandangApi.basePath}${getTableFilterQueryString()}`,
|
||||
DailyChecklistKandangApi.getAllFetcher,
|
||||
{
|
||||
keepPreviousData: true,
|
||||
}
|
||||
);
|
||||
const { options: locationOptions } = useSelect(
|
||||
LocationApi.basePath,
|
||||
'id',
|
||||
'name',
|
||||
'search',
|
||||
{
|
||||
page: '1',
|
||||
limit: '100',
|
||||
}
|
||||
);
|
||||
|
||||
const { options: picOptions } = useSelect(
|
||||
UserApi.basePath,
|
||||
'id',
|
||||
'name',
|
||||
'search',
|
||||
{
|
||||
page: '1',
|
||||
limit: '100',
|
||||
}
|
||||
);
|
||||
|
||||
const {
|
||||
options: kandangOptions,
|
||||
isLoadingMore: isLoadingKandangOptionsMore,
|
||||
loadMore: loadMoreKandang,
|
||||
} = useSelect(KandangApi.basePath, 'id', 'name');
|
||||
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [kandangToDelete, setKandangToDelete] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalMode, setModalMode] = useState<'create' | 'edit'>('create');
|
||||
const [kandangForm, setKandangForm] = useState({
|
||||
id: 0,
|
||||
name: '',
|
||||
location_id: 0,
|
||||
pic_id: 0,
|
||||
// recording_kandangs: [] as number[],
|
||||
});
|
||||
|
||||
const dailyChecklistKandangColumns: ColumnDef<DailyChecklistKandang>[] = [
|
||||
{
|
||||
id: 'name',
|
||||
header: 'Nama',
|
||||
accessorKey: 'name',
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
id: 'location',
|
||||
header: 'Lokasi',
|
||||
accessorKey: 'location',
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => row.original.location.name ?? '-',
|
||||
},
|
||||
{
|
||||
id: 'pic',
|
||||
header: 'PIC',
|
||||
accessorKey: 'pic',
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => row.original.pic.name ?? '-',
|
||||
},
|
||||
{
|
||||
id: 'recording_kandangs',
|
||||
header: 'Kandang Recording',
|
||||
accessorKey: 'recording_kandangs',
|
||||
enableSorting: false,
|
||||
cell: ({ row }) =>
|
||||
row.original.recording_kandangs?.length > 0
|
||||
? row.original.recording_kandangs.map((item) => item.name).join(', ')
|
||||
: '-',
|
||||
},
|
||||
{
|
||||
id: 'action',
|
||||
header: 'Aksi',
|
||||
accessorKey: 'action',
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-8 w-8 p-0 hover:bg-gray-100'
|
||||
>
|
||||
<MoreVertical className='h-4 w-4 text-gray-600' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem onClick={() => handleEdit(row.original)}>
|
||||
<Pencil className='mr-2 h-4 w-4' />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteClick(row.original.id)}
|
||||
className='text-red-600'
|
||||
>
|
||||
<Trash2 className='mr-2 h-4 w-4' />
|
||||
Hapus
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const handleAdd = () => {
|
||||
setModalMode('create');
|
||||
setKandangForm({
|
||||
id: 0,
|
||||
name: '',
|
||||
location_id: 0,
|
||||
pic_id: 0,
|
||||
// recording_kandangs: []
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleEdit = (dailyChecklistKandang: DailyChecklistKandang) => {
|
||||
setModalMode('edit');
|
||||
setKandangForm({
|
||||
id: dailyChecklistKandang.id,
|
||||
name: dailyChecklistKandang.name,
|
||||
location_id: dailyChecklistKandang.location.id,
|
||||
pic_id: dailyChecklistKandang.pic.id,
|
||||
// recording_kandangs:
|
||||
// dailyChecklistKandang.recording_kandangs.map((item) => item.id) ?? [],
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!kandangForm.name.trim()) {
|
||||
toast.error('Nama harus diisi');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!kandangForm.location_id) {
|
||||
toast.error('Lokasi wajib diisi');
|
||||
return;
|
||||
}
|
||||
|
||||
// if (!kandangForm.recording_kandangs.length) {
|
||||
// toast.error('Kandang recording wajib diisi');
|
||||
// return;
|
||||
// }
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
if (modalMode === 'create') {
|
||||
const createDailyChecklistKandangResponse =
|
||||
await DailyChecklistKandangApi.create({
|
||||
name: kandangForm.name.trim(),
|
||||
location_id: kandangForm.location_id,
|
||||
pic_id: kandangForm.pic_id,
|
||||
// recording_kandang_ids: kandangForm.recording_kandangs,
|
||||
});
|
||||
|
||||
if (isResponseError(createDailyChecklistKandangResponse)) {
|
||||
console.error(
|
||||
'Error creating kandang:',
|
||||
createDailyChecklistKandangResponse.message
|
||||
);
|
||||
toast.error('Gagal menambahkan kandang');
|
||||
return;
|
||||
}
|
||||
|
||||
refreshDailyChecklistKandangs();
|
||||
toast.success('Kandang berhasil ditambahkan');
|
||||
} else {
|
||||
const updateDailyChecklistKandangResponse =
|
||||
await DailyChecklistKandangApi.update(kandangForm.id, {
|
||||
name: kandangForm.name.trim(),
|
||||
location_id: kandangForm.location_id,
|
||||
pic_id: kandangForm.pic_id,
|
||||
// recording_kandang_ids: kandangForm.recording_kandangs,
|
||||
});
|
||||
|
||||
if (isResponseError(updateDailyChecklistKandangResponse)) {
|
||||
console.error(
|
||||
'Error updating kandang:',
|
||||
updateDailyChecklistKandangResponse.message
|
||||
);
|
||||
toast.error('Gagal menambahkan Kandang');
|
||||
return;
|
||||
}
|
||||
|
||||
refreshDailyChecklistKandangs();
|
||||
toast.success('Kandang berhasil diubah');
|
||||
}
|
||||
|
||||
setShowModal(false);
|
||||
setKandangForm({
|
||||
id: 0,
|
||||
name: '',
|
||||
location_id: 0,
|
||||
pic_id: 0,
|
||||
// recording_kandangs: [],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error saving kandang:', error);
|
||||
toast.error('Terjadi kesalahan saat menyimpan kandang');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteClick = (kandangId: number) => {
|
||||
setKandangToDelete(kandangId);
|
||||
setShowDeleteConfirm(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!kandangToDelete) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const deleteKandangResponse =
|
||||
await DailyChecklistKandangApi.delete(kandangToDelete);
|
||||
|
||||
if (isResponseError(deleteKandangResponse)) {
|
||||
console.error('Error deleting kandang:', deleteKandangResponse.message);
|
||||
toast.error('Gagal menghapus kandang');
|
||||
return;
|
||||
}
|
||||
|
||||
refreshDailyChecklistKandangs();
|
||||
toast.success('Kandang berhasil dihapus');
|
||||
setShowDeleteConfirm(false);
|
||||
setKandangToDelete(null);
|
||||
} catch (error) {
|
||||
console.error('Error deleting kandang:', error);
|
||||
toast.error('Terjadi kesalahan saat menghapus kandang');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingDailyChecklistKandangs && !dailyChecklistKandangs) {
|
||||
return (
|
||||
<div className='min-h-screen'>
|
||||
<div className='p-6'>
|
||||
<div className='mb-6'>
|
||||
<h1 className='text-2xl font-semibold text-gray-900'>
|
||||
Master Kandang
|
||||
</h1>
|
||||
<p className='text-sm text-gray-600 mt-1'>
|
||||
Master Data • <span className='text-[#0069e0]'>Kandang</span>
|
||||
</p>
|
||||
</div>
|
||||
<Card className='border-gray-200/60 shadow-sm rounded-xl bg-white'>
|
||||
<CardContent className='p-12 text-center text-gray-500'>
|
||||
Memuat data...
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='min-h-screen'>
|
||||
<div className='p-6'>
|
||||
{/* Page Title */}
|
||||
<div className='mb-6'>
|
||||
<h1 className='text-2xl font-semibold text-gray-900'>
|
||||
Master Kandang
|
||||
</h1>
|
||||
<p className='text-sm text-gray-600 mt-1'>
|
||||
Master Data • <span className='text-[#0069e0]'>Kandang</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Main Card */}
|
||||
<Card className='border-gray-200/60 shadow-sm rounded-xl bg-white'>
|
||||
<CardContent className='p-0'>
|
||||
{/* Single Toolbar Row */}
|
||||
<div className='flex flex-wrap items-center justify-between gap-4 p-6 border-b border-gray-200/60'>
|
||||
{/* LEFT: Search + Filters */}
|
||||
<div className='flex items-center gap-3 flex-wrap'>
|
||||
<div className='relative'>
|
||||
<Search className='absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4' />
|
||||
|
||||
<DebouncedTextInput
|
||||
name='search'
|
||||
placeholder='Cari kandang...'
|
||||
value={tableFilterState.search}
|
||||
onChange={(e) => updateFilter('search', e.target.value)}
|
||||
className={{
|
||||
wrapper: 'w-full sm:w-[280px] border-gray-200',
|
||||
inputWrapper: 'px-3 py-2 h-fit rounded-md',
|
||||
input: 'text-sm',
|
||||
}}
|
||||
startAdornment={
|
||||
<Search className='text-gray-400 w-4 h-4' />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={tableFilterState.location_id}
|
||||
onValueChange={(value) =>
|
||||
updateFilter('location_id', value === 'all' ? '' : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className='w-[180px] border-gray-200'>
|
||||
<SelectValue placeholder='Semua Lokasi' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='all'>Semua Lokasi</SelectItem>
|
||||
{locationOptions.map((kandang) => (
|
||||
<SelectItem
|
||||
key={kandang.value}
|
||||
value={String(kandang.value)}
|
||||
>
|
||||
{kandang.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* RIGHT: Export + Add */}
|
||||
<div className='flex items-center gap-2 flex-wrap'>
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
className='bg-[#0069e0] hover:bg-[#0052b3] text-white'
|
||||
>
|
||||
<Plus className='w-4 h-4 mr-2' />
|
||||
Tambah Kandang
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<Table<DailyChecklistKandang>
|
||||
data={
|
||||
isResponseSuccess(dailyChecklistKandangs)
|
||||
? dailyChecklistKandangs?.data
|
||||
: []
|
||||
}
|
||||
columns={dailyChecklistKandangColumns}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
onPageSizeChange={setPageSize}
|
||||
rowOptions={[10, 20, 50, 100]}
|
||||
page={
|
||||
isResponseSuccess(dailyChecklistKandangs)
|
||||
? dailyChecklistKandangs?.meta?.page
|
||||
: 0
|
||||
}
|
||||
totalItems={
|
||||
isResponseSuccess(dailyChecklistKandangs)
|
||||
? dailyChecklistKandangs?.meta?.total_results
|
||||
: 0
|
||||
}
|
||||
onPageChange={setPage}
|
||||
isLoading={isLoadingDailyChecklistKandangs}
|
||||
className={{
|
||||
containerClassName: cn({
|
||||
'w-full mb-20':
|
||||
isResponseSuccess(dailyChecklistKandangs) &&
|
||||
dailyChecklistKandangs?.data?.length === 0,
|
||||
}),
|
||||
tableWrapperClassName:
|
||||
'overflow-x-auto border border-solid border-base-content/10 rounded-none',
|
||||
headerRowClassName: 'bg-gray-50/50',
|
||||
headerColumnClassName:
|
||||
'text-left py-3.5 px-6 text-sm font-semibold text-gray-700',
|
||||
paginationClassName: 'px-4',
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Add/Edit Modal */}
|
||||
<Dialog open={showModal} onOpenChange={setShowModal}>
|
||||
<DialogContent className='sm:max-w-md bg-white rounded-xl shadow-lg'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{modalMode === 'create' ? 'Tambah Kandang' : 'Edit Kandang'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{modalMode === 'create'
|
||||
? 'Masukkan detail Kandang baru'
|
||||
: 'Ubah detail Kandang'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className='space-y-4 py-4'>
|
||||
<div>
|
||||
<Label htmlFor='nama-kandang'>
|
||||
Nama Kandang <span className='text-red-500'>*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id='nama-kandang'
|
||||
value={kandangForm.name}
|
||||
onChange={(e) =>
|
||||
setKandangForm({ ...kandangForm, name: e.target.value })
|
||||
}
|
||||
placeholder='Masukkan nama Kandang'
|
||||
className='mt-1.5'
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor='category'>
|
||||
Lokasi <span className='text-red-500'>*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={
|
||||
kandangForm.location_id ? String(kandangForm.location_id) : ''
|
||||
}
|
||||
onValueChange={(value) =>
|
||||
setKandangForm({ ...kandangForm, location_id: Number(value) })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id='category' className='mt-1.5'>
|
||||
<SelectValue placeholder='Pilih lokasi' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{locationOptions.map((cat) => (
|
||||
<SelectItem key={cat.value} value={String(cat.value)}>
|
||||
{cat.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor='pic'>
|
||||
PIC <span className='text-red-500'>*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={kandangForm.pic_id ? String(kandangForm.pic_id) : ''}
|
||||
onValueChange={(value) =>
|
||||
setKandangForm({ ...kandangForm, pic_id: Number(value) })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id='pic' className='mt-1.5'>
|
||||
<SelectValue placeholder='Pilih PIC' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{picOptions.map((cat) => (
|
||||
<SelectItem key={cat.value} value={String(cat.value)}>
|
||||
{cat.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => setShowModal(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={loading}
|
||||
className='bg-[#0069e0] hover:bg-[#0052b3] text-white'
|
||||
>
|
||||
{loading ? 'Menyimpan...' : 'Simpan'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
<AlertDialogContent className='bg-white rounded-xl shadow-lg sm:max-w-md'>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Hapus Kandang?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Data Kandang akan dihapus secara permanen.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={loading}>Batal</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={loading}
|
||||
className='bg-red-600 hover:bg-red-700 text-white'
|
||||
>
|
||||
{loading ? 'Menghapus...' : 'Hapus'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/figma-make/components/base/select';
|
||||
import { useSelect } from '@/components/input/SelectInput';
|
||||
import { AreaApi, KandangApi, LocationApi } from '@/services/api/master-data';
|
||||
import { AreaApi, LocationApi } from '@/services/api/master-data';
|
||||
import useSWR from 'swr';
|
||||
import { BaseApiResponse } from '@/types/api/api-general';
|
||||
import { DailyChecklistReport } from '@/types/api/daily-checklist/daily-checklist';
|
||||
@@ -26,7 +26,8 @@ import { ColumnDef } from '@tanstack/react-table';
|
||||
import { PhaseApi } from '@/services/api/daily-checklist/phase';
|
||||
import { EmployeeApi } from '@/services/api/daily-checklist/employee';
|
||||
import { Button } from '@/figma-make/components/base/button';
|
||||
import { Download } from 'lucide-react';
|
||||
import { Download, Loader2 } from 'lucide-react';
|
||||
import { DailyChecklistKandangApi } from '@/services/api/daily-checklist/kandang';
|
||||
|
||||
const MONTH_OPTIONS = [
|
||||
{ value: '1', label: 'Januari' },
|
||||
@@ -129,18 +130,23 @@ export function DailyChecklistReportsContent() {
|
||||
}
|
||||
);
|
||||
|
||||
const { options: kandangOptions } = useSelect(
|
||||
KandangApi.basePath,
|
||||
'id',
|
||||
'name',
|
||||
'search',
|
||||
{
|
||||
page: '1',
|
||||
limit: '100',
|
||||
area_id: tableFilterState.area_id,
|
||||
location_id: tableFilterState.location_id,
|
||||
const {
|
||||
options: kandangOptions,
|
||||
loadMore: loadMoreKandang,
|
||||
isLoadingMore: isLoadingMoreKandang,
|
||||
} = useSelect(DailyChecklistKandangApi.basePath, 'id', 'name', 'search', {
|
||||
area_id: tableFilterState.area_id,
|
||||
location_id: tableFilterState.location_id,
|
||||
});
|
||||
|
||||
const handleKandangScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
const target = e.target as HTMLDivElement;
|
||||
if (target.scrollHeight - target.scrollTop <= target.clientHeight + 10) {
|
||||
if (!isLoadingMoreKandang) {
|
||||
loadMoreKandang();
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const { options: phaseOptions } = useSelect(
|
||||
PhaseApi.basePath,
|
||||
@@ -435,7 +441,7 @@ export function DailyChecklistReportsContent() {
|
||||
>
|
||||
<SelectValue placeholder='Semua Kandang' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent onScroll={handleKandangScroll}>
|
||||
<SelectItem value='ALL'>Semua Kandang</SelectItem>
|
||||
{kandangOptions.map((kandang) => (
|
||||
<SelectItem
|
||||
@@ -445,6 +451,11 @@ export function DailyChecklistReportsContent() {
|
||||
{kandang.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
{isLoadingMoreKandang && (
|
||||
<div className='flex justify-center p-2'>
|
||||
<Loader2 className='h-4 w-4 animate-spin text-gray-500' />
|
||||
</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { BaseApiService } from '@/services/api/base';
|
||||
import {
|
||||
DailyChecklistKandang,
|
||||
CreateDailyChecklistKandangPayload,
|
||||
UpdateDailyChecklistKandangPayload,
|
||||
} from '@/types/api/daily-checklist/kandang';
|
||||
|
||||
export class DailyChecklistKandangApiService extends BaseApiService<
|
||||
DailyChecklistKandang,
|
||||
CreateDailyChecklistKandangPayload,
|
||||
UpdateDailyChecklistKandangPayload
|
||||
> {
|
||||
constructor(basePath: string = '/master-data/kandang-groups') {
|
||||
super(basePath);
|
||||
}
|
||||
}
|
||||
|
||||
export const DailyChecklistKandangApi = new DailyChecklistKandangApiService(
|
||||
'/master-data/kandang-groups'
|
||||
);
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { BaseMetadata } from '@/types/api/api-general';
|
||||
import { BaseKandang } from '@/types/api/master-data/kandang';
|
||||
import { BaseLocation } from '@/types/api/master-data/location';
|
||||
import { BaseUser } from '@/types/api/user';
|
||||
|
||||
export type BaseDailyChecklistKandang = {
|
||||
id: number;
|
||||
name: string;
|
||||
location: BaseLocation;
|
||||
recording_kandangs: Pick<BaseKandang, 'id' | 'name'>[];
|
||||
pic: BaseUser;
|
||||
};
|
||||
|
||||
export type DailyChecklistKandang = BaseMetadata & BaseDailyChecklistKandang;
|
||||
|
||||
export type CreateDailyChecklistKandangPayload = {
|
||||
name: string;
|
||||
location_id: number;
|
||||
pic_id: number;
|
||||
// recording_kandang_ids: number[];
|
||||
};
|
||||
|
||||
export type UpdateDailyChecklistKandangPayload =
|
||||
CreateDailyChecklistKandangPayload;
|
||||
+3
@@ -1,6 +1,7 @@
|
||||
import { BaseMetadata } from '@/types/api/api-general';
|
||||
import { BaseLocation } from '@/types/api/master-data/location';
|
||||
import { BaseUser } from '@/types/api/user';
|
||||
import { BaseDailyChecklistKandang } from '@/types/api/daily-checklist/kandang';
|
||||
|
||||
export type BaseKandang = {
|
||||
id: number;
|
||||
@@ -10,6 +11,7 @@ export type BaseKandang = {
|
||||
capacity: number;
|
||||
pic: BaseUser;
|
||||
project_flock_kandang_id?: number;
|
||||
kandang_group: Pick<BaseDailyChecklistKandang, 'id' | 'name'>;
|
||||
};
|
||||
|
||||
export type Kandang = BaseMetadata & BaseKandang;
|
||||
@@ -19,6 +21,7 @@ export type CreateKandangPayload = {
|
||||
location_id: number;
|
||||
capacity: number;
|
||||
pic_id: number;
|
||||
group_id: number;
|
||||
};
|
||||
|
||||
export type UpdateKandangPayload = CreateKandangPayload;
|
||||
|
||||
+2
@@ -74,6 +74,8 @@ export type ProjectFlockKandangLookup = {
|
||||
available_quantity?: number;
|
||||
population: number;
|
||||
chick_in_date: string;
|
||||
is_transition: boolean;
|
||||
is_laying: boolean;
|
||||
};
|
||||
|
||||
export type ProjectFlockAvailableQuantity = {
|
||||
|
||||
+2
-1
@@ -49,7 +49,8 @@ export type BaseRecording = {
|
||||
project_flock: ProjectFlock;
|
||||
record_datetime: string;
|
||||
day: number;
|
||||
executed_at: string;
|
||||
is_transition: boolean;
|
||||
is_laying: boolean;
|
||||
} & ProductionMetrics;
|
||||
|
||||
export type RecordingDepletion = {
|
||||
|
||||
Reference in New Issue
Block a user