diff --git a/.husky/pre-commit b/.husky/pre-commit index 66ff6a67..e7bb3165 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,2 +1,3 @@ +npm run format npm run lint npm run build diff --git a/package-lock.json b/package-lock.json index 33b7c640..2cac4bc7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "moment": "^2.30.1", "next": "15.5.3", "react": "19.1.0", + "react-day-picker": "^9.11.1", "react-dom": "19.1.0", "react-hot-toast": "^2.6.0", "react-number-format": "^5.4.4", @@ -196,6 +197,12 @@ "node": ">=6.9.0" } }, + "node_modules/@date-fns/tz": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz", + "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==", + "license": "MIT" + }, "node_modules/@emnapi/core": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.6.0.tgz", @@ -2873,6 +2880,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/date-fns-jalali": { + "version": "4.1.0-0", + "resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz", + "integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -5749,6 +5772,27 @@ "node": ">=0.10.0" } }, + "node_modules/react-day-picker": { + "version": "9.11.1", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.11.1.tgz", + "integrity": "sha512-l3ub6o8NlchqIjPKrRFUCkTUEq6KwemQlfv3XZzzwpUeGwmDJ+0u0Upmt38hJyd7D/vn2dQoOoLV/qAp0o3uUw==", + "license": "MIT", + "dependencies": { + "@date-fns/tz": "^1.4.1", + "date-fns": "^4.1.0", + "date-fns-jalali": "^4.1.0-0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/react-dom": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", diff --git a/package.json b/package.json index 10fe9598..033c2963 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "moment": "^2.30.1", "next": "15.5.3", "react": "19.1.0", + "react-day-picker": "^9.11.1", "react-dom": "19.1.0", "react-hot-toast": "^2.6.0", "react-number-format": "^5.4.4", diff --git a/src/app/production/transfer-to-laying/detail/edit/page.tsx b/src/app/production/transfer-to-laying/detail/edit/page.tsx index 9003dbba..d5498e08 100644 --- a/src/app/production/transfer-to-laying/detail/edit/page.tsx +++ b/src/app/production/transfer-to-laying/detail/edit/page.tsx @@ -8,91 +8,6 @@ import TransferToLayingForm from '@/components/pages/production/transfer-to-layi import { TransferToLayingApi } from '@/services/api/production/transfer-to-laying'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; -import { TransferToLaying } from '@/types/api/production/transfer-to-laying'; - -// TODO: delete dummy data -const DUMMY_TRANSFER_TO_LAYING_EDIT: TransferToLaying = { - id: 1, - transfer_date: '2025-10-14', - flock_source: { - id: 1, - name: 'Flock asal test', - }, - flock_destination: { - id: 2, - name: 'Flock tujuan destination', - }, - quantity: 10, - kandangs: [ - { - kandang: { - id: 1, - name: 'Kandang test', - status: 'ACTIVE', - location: { - id: 1, - name: 'test location', - address: 'test address 1', - area: { id: 1, name: 'test area 1' }, - }, - pic: { - id: 1, - id_user: 2, - email: 'test@gmail.com', - name: 'test', - }, - created_user: { - id: 1, - id_user: 2, - email: 'test@gmail.com', - name: 'test', - }, - created_at: '14-10-2025', - updated_at: '14-10-2025', - }, - quantity: 8, - }, - { - kandang: { - id: 1, - name: 'Kandang test 2', - status: 'ACTIVE', - location: { - id: 1, - name: 'test location', - address: 'test address 1', - area: { id: 1, name: 'test area 1' }, - }, - pic: { - id: 1, - id_user: 2, - email: 'test@gmail.com', - name: 'test', - }, - created_user: { - id: 1, - id_user: 2, - email: 'test@gmail.com', - name: 'test', - }, - created_at: '14-10-2025', - updated_at: '14-10-2025', - }, - quantity: 2, - }, - ], - reason: 'Test alasan', - - created_user: { - id: 1, - id_user: 2, - email: 'test@gmail.com', - name: 'test', - }, - created_at: '14-10-2025', - updated_at: '14-10-2025', -}; - const TransferToLayingEdit = () => { const router = useRouter(); const searchParams = useSearchParams(); @@ -114,33 +29,33 @@ const TransferToLayingEdit = () => { ); } - // TODO: remove dummy data and integrate with real API if ( !isLoadingTransferToLaying && - (!transferToLaying || - (isResponseError(transferToLaying) && !DUMMY_TRANSFER_TO_LAYING_EDIT)) + (!transferToLaying || isResponseError(transferToLaying)) ) { router.replace('/404'); return; } + if ( + isResponseSuccess(transferToLaying) && + transferToLaying.data.approval.step_number === 2 + ) { + router.replace('/production/transfer-to-laying'); + return; + } + return (
{isLoadingTransferToLaying && ( )} - {/* {!isLoadingTransferToLaying && isResponseSuccess(transferToLaying) && ( + {!isLoadingTransferToLaying && isResponseSuccess(transferToLaying) && ( - )} */} - - {/* TODO: remove this dummy data and integrate to real API */} - + )}
); }; diff --git a/src/app/production/transfer-to-laying/detail/page.tsx b/src/app/production/transfer-to-laying/detail/page.tsx index de5426c8..9ff6ed5e 100644 --- a/src/app/production/transfer-to-laying/detail/page.tsx +++ b/src/app/production/transfer-to-laying/detail/page.tsx @@ -8,91 +8,6 @@ import TransferToLayingForm from '@/components/pages/production/transfer-to-layi import { TransferToLayingApi } from '@/services/api/production/transfer-to-laying'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; -import { TransferToLaying } from '@/types/api/production/transfer-to-laying'; - -// TODO: delete dummy data -const DUMMY_TRANSFER_TO_LAYING_DETAIL: TransferToLaying = { - id: 1, - transfer_date: '2025-10-14', - flock_source: { - id: 1, - name: 'Flock asal test', - }, - flock_destination: { - id: 2, - name: 'Flock tujuan destination', - }, - quantity: 10, - kandangs: [ - { - kandang: { - id: 1, - name: 'Kandang test', - status: 'ACTIVE', - location: { - id: 1, - name: 'test location', - address: 'test address 1', - area: { id: 1, name: 'test area 1' }, - }, - pic: { - id: 1, - id_user: 2, - email: 'test@gmail.com', - name: 'test', - }, - created_user: { - id: 1, - id_user: 2, - email: 'test@gmail.com', - name: 'test', - }, - created_at: '14-10-2025', - updated_at: '14-10-2025', - }, - quantity: 8, - }, - { - kandang: { - id: 1, - name: 'Kandang test 2', - status: 'ACTIVE', - location: { - id: 1, - name: 'test location', - address: 'test address 1', - area: { id: 1, name: 'test area 1' }, - }, - pic: { - id: 1, - id_user: 2, - email: 'test@gmail.com', - name: 'test', - }, - created_user: { - id: 1, - id_user: 2, - email: 'test@gmail.com', - name: 'test', - }, - created_at: '14-10-2025', - updated_at: '14-10-2025', - }, - quantity: 2, - }, - ], - reason: 'Test alasan', - - created_user: { - id: 1, - id_user: 2, - email: 'test@gmail.com', - name: 'test', - }, - created_at: '14-10-2025', - updated_at: '14-10-2025', -}; - const TransferToLayingDetail = () => { const router = useRouter(); const searchParams = useSearchParams(); @@ -114,11 +29,9 @@ const TransferToLayingDetail = () => { ); } - // TODO: remove dummy data and integrate with real API if ( !isLoadingTransferToLaying && - (!transferToLaying || - (isResponseError(transferToLaying) && !DUMMY_TRANSFER_TO_LAYING_DETAIL)) + (!transferToLaying || isResponseError(transferToLaying)) ) { router.replace('/404'); return; @@ -129,18 +42,13 @@ const TransferToLayingDetail = () => { {isLoadingTransferToLaying && ( )} - {/* {!isLoadingTransferToLaying && isResponseSuccess(transferToLaying) && ( + + {!isLoadingTransferToLaying && isResponseSuccess(transferToLaying) && ( - )} */} - - {/* TODO: remove this dummy data and integrate to real API */} - + )} ); }; diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index a84c1827..a242b1e4 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -1,6 +1,13 @@ 'use client'; -import { ReactNode, RefObject, useCallback, useRef, useState } from 'react'; +import { + ReactNode, + RefObject, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; import { cn } from '@/lib/helper'; export const useModal = () => { @@ -8,31 +15,34 @@ export const useModal = () => { const [open, setOpen] = useState(false); const openModal = useCallback(() => { + if (!ref.current) return; + ref.current.show(); setOpen(true); - - ref.current?.showModal(); }, []); const closeModal = useCallback(() => { + if (!ref.current) return; + ref.current.close(); setOpen(false); - ref.current?.close(); }, []); const toggle = useCallback(() => { - if (open) { - closeModal(); - } else { - openModal(); - } + open ? closeModal() : openModal(); }, [open, closeModal, openModal]); - if (ref.current) { - ref.current.addEventListener('close', () => { - closeModal(); - }); - } + useEffect(() => { + const dialog = ref.current; + if (!dialog) return; - return { ref, open, setOpen, openModal, closeModal, toggle } as const; + const handleClose = () => setOpen(false); + dialog.addEventListener('close', handleClose); + + return () => { + dialog.removeEventListener('close', handleClose); + }; + }, []); + + return { ref, open, openModal, closeModal, toggle } as const; }; interface ModalProps { @@ -46,15 +56,19 @@ interface ModalProps { } const Modal = ({ ref, children, closeOnBackdrop, className }: ModalProps) => { - return ( - -
{children}
+ const handleBackdropClick = (e: React.MouseEvent) => { + if (closeOnBackdrop && e.target === ref.current) { + ref.current?.close(); + } + }; - {closeOnBackdrop && ( -
- -
- )} + return ( + +
{children}
); }; diff --git a/src/components/Table.tsx b/src/components/Table.tsx index d3498e33..b02dd3b5 100644 --- a/src/components/Table.tsx +++ b/src/components/Table.tsx @@ -13,6 +13,7 @@ import { FilterFn, SortingState, OnChangeFn, + Row, } from '@tanstack/react-table'; import { rankItem } from '@tanstack/match-sorter-utils'; import { Icon } from '@iconify/react'; @@ -50,6 +51,7 @@ export interface TableProps { manualSorting?: boolean; rowSelection?: Record; setRowSelection?: OnChangeFn>; + enableRowSelection?: boolean | ((row: Row) => boolean); } const DUMMY_SKELETON_DATA = [{}, {}, {}, {}, {}]; @@ -90,6 +92,7 @@ const Table = ({ manualSorting = false, rowSelection, setRowSelection, + enableRowSelection, }: TableProps) => { const isServerSideTable = totalItems !== undefined && @@ -150,6 +153,10 @@ const Table = ({ tableOptions.getRowId = (row) => (row as { id: string }).id; } + if (enableRowSelection !== undefined) { + tableOptions.enableRowSelection = enableRowSelection; + } + const table = useReactTable(tableOptions); const { setPageSize } = table; diff --git a/src/components/input/DateInput.tsx b/src/components/input/DateInput.tsx index 6e2f1d77..7317b038 100644 --- a/src/components/input/DateInput.tsx +++ b/src/components/input/DateInput.tsx @@ -1,14 +1,23 @@ 'use client'; -import { ChangeEventHandler, FocusEventHandler, ReactNode } from 'react'; - -import { cn } from '@/lib/helper'; +import { + ChangeEventHandler, + FocusEventHandler, + useEffect, + useState, +} from 'react'; +import { cn, formatDate } from '@/lib/helper'; +import Modal, { useModal } from '@/components/Modal'; +import { DateRange, DayPicker, Matcher } from 'react-day-picker'; +import 'react-day-picker/dist/style.css'; +import Button from '@/components/Button'; +import { Icon } from '@iconify/react'; export interface DateInputProps { label?: string; bottomLabel?: string; name: string; - value?: string; + value?: string | { from?: string; to?: string }; placeholder?: string; min?: string; max?: string; @@ -24,9 +33,8 @@ export interface DateInputProps { readOnly?: boolean; required?: boolean; isLoading?: boolean; + isRange?: boolean; errorMessage?: string; - startAdornment?: ReactNode; - endAdornment?: ReactNode; onChange?: ChangeEventHandler; onBlur?: FocusEventHandler; } @@ -36,22 +44,144 @@ const DateInput = ({ bottomLabel, name, value, - placeholder, + placeholder = 'dd/mm/yyyy', min, max, className, - isError, - isValid, - errorMessage, - startAdornment, - endAdornment, + isError: externalError, + isValid: externalValid, + errorMessage: externalErrorMessage, disabled = false, required = false, onChange, onBlur, readOnly = false, isLoading = false, + isRange = false, }: DateInputProps) => { + const [internalError, setInternalError] = useState(null); + const [selected, setSelected] = useState(); + const [selectedRange, setSelectedRange] = useState<{ + from?: Date; + to?: Date; + }>({}); + const [displayValue, setDisplayValue] = useState(''); + + const minDate = min + ? new Date(min.split('/').reverse().join('-')) + : undefined; + const maxDate = max + ? new Date(max.split('/').reverse().join('-')) + : undefined; + + const calendarModal = useModal(); + + // --- Sync value props --- + useEffect(() => { + if (!value) return; + if (isRange && typeof value === 'object') { + const from = value.from ? new Date(value.from) : undefined; + const to = value.to ? new Date(value.to) : undefined; + setSelectedRange({ from, to }); + setDisplayValue( + `${from ? formatDate(from, 'DD/MM/YYYY') : ''} ${ + to ? '- ' + formatDate(to, 'DD/MM/YYYY') : '' + }` + ); + } else if (typeof value === 'string') { + const iso = value.includes('/') + ? value.split('/').reverse().join('-') + : value; + const date = new Date(iso); + setSelected(date); + setDisplayValue(formatDate(iso, 'DD/MM/YYYY')); + } + }, [value, isRange]); + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + if (!disabled && !readOnly) calendarModal.openModal(); + }; + + const handleBlur: FocusEventHandler = (e) => { + onBlur?.(e); + }; + + const handleSelectSingle = (selectedDate?: Date) => { + if (!selectedDate) return; + if (minDate && selectedDate < minDate) { + setInternalError(`Tanggal tidak boleh sebelum ${min}`); + return; + } + if (maxDate && selectedDate > maxDate) { + setInternalError(`Tanggal tidak boleh setelah ${max}`); + return; + } + setInternalError(null); + setSelected(selectedDate); + const formattedDisplay = formatDate(selectedDate, 'DD/MM/YYYY'); + const formattedISO = formatDate(selectedDate, 'YYYY-MM-DD'); + setDisplayValue(formattedDisplay); + + const syntheticEvent = { + target: { name, value: formattedISO }, + } as unknown as React.ChangeEvent; + onChange?.(syntheticEvent); + calendarModal.closeModal(); + }; + + const handleSelectRange = (range?: { from?: Date; to?: Date }) => { + if (!range) return; + setSelectedRange(range); + + const fromStr = range.from ? formatDate(range.from, 'DD/MM/YYYY') : ''; + const toStr = range.to ? formatDate(range.to, 'DD/MM/YYYY') : ''; + setDisplayValue(`${fromStr}${toStr ? ' - ' + toStr : ''}`); + + // Jika kedua tanggal sudah terpilih + if (range.from && range.to) { + if (minDate && range.from < minDate) { + setInternalError(`Tanggal mulai tidak boleh sebelum ${min}`); + return; + } + if (maxDate && range.to > maxDate) { + setInternalError(`Tanggal akhir tidak boleh setelah ${max}`); + return; + } + + setInternalError(null); + const syntheticEvent = { + target: { + name, + value: { + from: formatDate(range.from, 'YYYY-MM-DD'), + to: formatDate(range.to, 'YYYY-MM-DD'), + }, + }, + } as unknown as React.ChangeEvent; + onChange?.(syntheticEvent); + } + }; + + const handleResetDate = () => { + setSelected(undefined); + setSelectedRange({}); + setDisplayValue(''); + const syntheticEvent = { + target: { name, value: isRange ? { from: '', to: '' } : '' }, + } as unknown as React.ChangeEvent; + onChange?.(syntheticEvent); + calendarModal.closeModal(); + }; + + const handleSaveDate = () => { + if (internalError) return; + calendarModal.closeModal(); + }; + + const finalIsError = externalError || !!internalError; + const finalErrorMessage = internalError || externalErrorMessage; + return (
{label} {required && ( - <> - {' '} - - * - - + + * + )} )}
- {startAdornment && startAdornment} - - {(isLoading || endAdornment) && ( + {isLoading && (
- {isLoading && } - {endAdornment && endAdornment} +
)} + + handleClick(e as unknown as React.MouseEvent) + } + />
- {!isError && bottomLabel && ( + {!finalIsError && bottomLabel && (

{bottomLabel}

)} - {isError && errorMessage && ( -

{errorMessage}

+ {finalIsError && finalErrorMessage && ( +

{finalErrorMessage}

)} + + + {isRange ? ( + {displayValue}
} + disabled={ + [ + minDate ? { before: minDate } : undefined, + maxDate ? { after: maxDate } : undefined, + ].filter(Boolean) as Matcher[] + } + /> + ) : ( + + )} +
+ {isRange && ( + + Tekan dua kali untuk memilih tanggal awal + + )} + +
+ + {isRange && ( + + )} +
+
+ ); }; diff --git a/src/components/modal/ConfirmationModal.tsx b/src/components/modal/ConfirmationModal.tsx index 04c221e6..683345f5 100644 --- a/src/components/modal/ConfirmationModal.tsx +++ b/src/components/modal/ConfirmationModal.tsx @@ -9,7 +9,7 @@ import Button from '@/components/Button'; import { cn } from '@/lib/helper'; import { Color } from '@/types/theme'; -interface ConfirmationModalProps { +export interface ConfirmationModalProps { ref: RefObject; type?: 'info' | 'success' | 'error'; text?: string; @@ -30,6 +30,7 @@ interface ConfirmationModalProps { modal?: string; modalBox?: string; }; + children?: React.ReactNode; } const ConfirmationModal = ({ @@ -40,6 +41,7 @@ const ConfirmationModal = ({ primaryButton, secondaryButton, className, + children, }: ConfirmationModalProps) => { const closeModalHandler = () => { ref.current?.close(); @@ -90,6 +92,8 @@ const ConfirmationModal = ({ {text ?? 'Apakah anda yakin ingin melakukan hal ini?'}

+ {children &&
{children}
} +
{secondaryButton && secondaryButton.text && (