diff --git a/package-lock.json b/package-lock.json index e1f28d3e..b82b5c06 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", @@ -195,6 +196,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", @@ -2872,6 +2879,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", @@ -5732,6 +5755,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 b371e4e7..10e8de5c 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,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/chickin/add/kandang/layout.tsx b/src/app/production/chickin/add/kandang/layout.tsx new file mode 100644 index 00000000..7220dfa1 --- /dev/null +++ b/src/app/production/chickin/add/kandang/layout.tsx @@ -0,0 +1,11 @@ +import SuspenseHelper from '@/components/helper/SuspenseHelper'; + +const Layout = ({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) => { + return {children}; +}; + +export default Layout; diff --git a/src/app/production/chickin/add/kandang/page.tsx b/src/app/production/chickin/add/kandang/page.tsx new file mode 100644 index 00000000..6f624672 --- /dev/null +++ b/src/app/production/chickin/add/kandang/page.tsx @@ -0,0 +1,15 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; + +export default function AddChickinKandang() { + const router = useRouter(); + const searchParams = useSearchParams(); + const kandangId = searchParams.get('kandangId'); + + return ( +
+

Tambah Chickin untuk Kandang ID: {kandangId}

+
+ ); +} diff --git a/src/app/production/chickin/add/page.tsx b/src/app/production/chickin/add/page.tsx index a52dbe10..2f68e86c 100644 --- a/src/app/production/chickin/add/page.tsx +++ b/src/app/production/chickin/add/page.tsx @@ -2,10 +2,12 @@ import Badge from '@/components/Badge'; import Button from '@/components/Button'; +import Card from '@/components/Card'; import SelectInput, { OptionType } from '@/components/input/SelectInput'; import Modal, { useModal } from '@/components/Modal'; import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ChickinForm from '@/components/pages/production/chickin/form/ChickinForm'; +import PillBadge from '@/components/PillBadge'; import Table from '@/components/Table'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { cn } from '@/lib/helper'; @@ -13,10 +15,12 @@ import { ProjectFlockApi } from '@/services/api/production'; import { useTableFilter } from '@/services/hooks/useTableFilter'; import { BaseApiResponse } from '@/types/api/api-general'; import { Kandang } from '@/types/api/master-data/kandang'; +import { ProjectFlock } from '@/types/api/production/project-flock'; import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang'; import { Icon } from '@iconify/react'; import { useRouter, useSearchParams } from 'next/navigation'; import { useState } from 'react'; +import { is } from 'react-day-picker/locale'; import useSWR from 'swr'; @@ -113,21 +117,27 @@ const AddChickin = () => { <>
- +
+ +

+ Daftar Kandang Project Flock +

+
+
{ setSearchProjectFlock(val); @@ -164,78 +174,203 @@ const AddChickin = () => {
- - emptyContent={ -
- {projectFlockId && isResponseError(projectFlock) ? ( - - {projectFlock.message} - - ) : ( - - Pilih project flock terlebih dahulu... - - )} -
- } - data={ - isResponseSuccess(projectFlock) ? projectFlock.data?.kandangs : [] - } - columns={[ - { - header: '#', - cell: (props) => - tableFilterState.pageSize * (tableFilterState.page - 1) + - props.row.index + - 1, - }, - { - accessorKey: 'name', - header: 'Nama Kandang', - }, - { - header: 'Aksi', - cell: (props) => { - return ( - <> - - - ); - }, - }, - ]} - page={undefined} + + > + + emptyContent={ +
+ {projectFlockId && isResponseError(projectFlock) ? ( + + {projectFlock.message} + + ) : ( + + Pilih project flock terlebih dahulu... + + )} +
+ } + data={isResponseSuccess(projectFlock) ? [projectFlock.data] : []} + columns={[ + { + header: 'Area', + accessorKey: 'area.name', + }, + { + header: 'Lokasi', + accessorKey: 'location.name', + }, + { + header: 'Nama Flock', + accessorKey: 'flock.name', + }, + { + header: 'Kategori', + accessorKey: 'category', + }, + { + header: 'Status', + accessorKey: 'status', + cell: (props) => { + return props.row.original.approval.step_name ? ( + { + switch ( + props.row.original.approval.step_name.toUpperCase() + ) { + case 'AKTIF': + return 'red'; + case 'PENGAJUAN': + return 'green'; + default: + return 'gray'; + } + })()} + content={props.row.original.approval.step_name + .toLowerCase() + .replace(/_/g, ' ') + .replace(/\b\w/g, (char) => char.toUpperCase())} + /> + ) : ( + '-' + ); + }, + }, + { + header: 'Periode', + accessorKey: 'period', + }, + { + header: 'FCR Layer', + accessorKey: 'fcr.name', + }, + ]} + page={undefined} + className={{ + containerClassName: cn({ + 'mb-20': + isResponseSuccess(projectFlock) && + projectFlock.data?.kandangs?.length === 0, + }), + tableWrapperClassName: 'overflow-x-auto min-h-full!', + tableClassName: 'font-inter w-full table-auto min-h-full!', + headerRowClassName: 'border-b border-b-gray-200', + headerColumnClassName: + 'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', + bodyRowClassName: 'border-b border-b-gray-200', + bodyColumnClassName: + 'px-6 py-3 last:flex last:flex-row last:justify-end', + paginationClassName: 'hidden', + }} + /> +
+ + + emptyContent={ +
+ {projectFlockId && isResponseError(projectFlock) ? ( + + {projectFlock.message} + + ) : ( + + Pilih project flock terlebih dahulu... + + )} +
+ } + data={ + isResponseSuccess(projectFlock) + ? projectFlock.data?.kandangs + : [] + } + columns={[ + { + header: '#', + cell: (props) => + tableFilterState.pageSize * (tableFilterState.page - 1) + + props.row.index + + 1, + }, + { + accessorFn: () => + isResponseSuccess(projectFlock) + ? projectFlock.data.area.name + : '', + header: 'Area', + }, + { + accessorFn: () => + isResponseSuccess(projectFlock) + ? projectFlock.data.location.name + : '', + header: 'Lokasi', + }, + { + accessorKey: 'name', + header: 'Kandang', + }, + { + accessorKey: 'capacity', + header: 'Kapasitas', + }, + { + accessorKey: 'pic.name', + header: 'Penanggung Jawab', + }, + { + header: 'Aksi', + cell: (props) => { + return ( + <> + + + ); + }, + }, + ]} + page={undefined} + className={{ + containerClassName: cn({ + 'mb-20': + isResponseSuccess(projectFlock) && + projectFlock.data?.kandangs?.length === 0, + }), + tableWrapperClassName: 'overflow-x-auto min-h-full!', + tableClassName: 'font-inter w-full table-auto min-h-full!', + headerRowClassName: 'border-b border-b-gray-200', + headerColumnClassName: + 'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', + bodyRowClassName: 'border-b border-b-gray-200', + bodyColumnClassName: + 'px-6 py-3 last:flex last:flex-row last:justify-end', + paginationClassName: 'hidden', + }} + /> +
diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index a84c1827..958d88dd 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,35 @@ export const useModal = () => { const [open, setOpen] = useState(false); const openModal = useCallback(() => { + if (!ref.current) return; + ref.current.showModal(); 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(); - }); - } + // Gunakan useEffect agar event listener tidak didaftarkan berulang kali + 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 +57,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/input/DateInput.tsx b/src/components/input/DateInput.tsx index 2b91b7ae..e483a861 100644 --- a/src/components/input/DateInput.tsx +++ b/src/components/input/DateInput.tsx @@ -4,29 +4,21 @@ import { ChangeEventHandler, FocusEventHandler, ReactNode, + useEffect, useState, } from 'react'; -import { cn } from '@/lib/helper'; - -const formatToISO = (dateStr: string): string | null => { - const parts = dateStr.split('/'); - if (parts.length !== 3) return null; - const [day, month, year] = parts; - if (!day || !month || !year) return null; - return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`; -}; - -const formatToLocal = (isoDate: string): string => { - if (!isoDate) return ''; - const [year, month, day] = isoDate.split('-'); - return `${day}/${month}/${year}`; -}; +import { cn, formatDate } from '@/lib/helper'; +import Modal, { useModal } from '../Modal'; +import { DateRange, DayPicker, Matcher } from 'react-day-picker'; +import 'react-day-picker/dist/style.css'; +import Button from '../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; @@ -42,9 +34,8 @@ export interface DateInputProps { readOnly?: boolean; required?: boolean; isLoading?: boolean; + isRange?: boolean; errorMessage?: string; - startAdornment?: ReactNode; - endAdornment?: ReactNode; onChange?: ChangeEventHandler; onBlur?: FocusEventHandler; } @@ -54,51 +45,139 @@ const DateInput = ({ bottomLabel, name, value, - placeholder, + placeholder = 'dd/mm/yyyy', min, max, className, isError: externalError, isValid: externalValid, errorMessage: externalErrorMessage, - startAdornment, - endAdornment, 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 minISO = min ? formatToISO(min) ?? undefined : undefined; - const maxISO = max ? formatToISO(max) ?? undefined : undefined; + const minDate = min + ? new Date(min.split('/').reverse().join('-')) + : undefined; + const maxDate = max + ? new Date(max.split('/').reverse().join('-')) + : undefined; - const valueISO = - value && value.includes('/') ? formatToISO(value) ?? '' : value ?? ''; + const calendarModal = useModal(); - const handleChange: ChangeEventHandler = (e) => { - const selectedDate = e.target.value; - const isoMin = minISO; - const isoMax = maxISO; - - if (isoMin && selectedDate < isoMin) { - setInternalError(`Tanggal tidak boleh sebelum ${min}`); - } else if (isoMax && selectedDate > isoMax) { - setInternalError(`Tanggal tidak boleh setelah ${max}`); - } else { - setInternalError(null); + // --- 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 event = { - ...e, - target: { - ...e.target, - value: formatToLocal(selectedDate), - }, - }; - onChange?.(event as React.ChangeEvent); + 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; @@ -122,49 +201,53 @@ const DateInput = ({ > {label} {required && ( - <> - {' '} - - * - - + + * + )} )}
- {startAdornment && startAdornment} - - {(isLoading || endAdornment) && ( + {isLoading && (
- {isLoading && } - {endAdornment && endAdornment} +
)} + + handleClick(e as unknown as React.MouseEvent) + } + />
{!finalIsError && bottomLabel && ( @@ -173,6 +256,74 @@ const DateInput = ({ {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/pages/production/project-flock/ProjectFlockTable.tsx b/src/components/pages/production/project-flock/ProjectFlockTable.tsx index 2958a3f5..73282123 100644 --- a/src/components/pages/production/project-flock/ProjectFlockTable.tsx +++ b/src/components/pages/production/project-flock/ProjectFlockTable.tsx @@ -44,54 +44,56 @@ const RowOptionsMenu = ({ 'dropdown-content': type === 'dropdown', 'mt-2': type === 'collapse', }, - 'p-2.5 mr-2 flex flex-col gap-1 bg-base-100 rounded-box z-10 border border-black/10 shadow' + 'p-2.5 mr-2 bg-base-100 rounded-box z-10 border border-black/10 shadow' )} > - - {props.row.original.approval.step_name === 'Aktif' && ( +
- )} - {props.row.original.approval.step_name === 'Pengajuan' && ( + {props.row.original.approval.step_name === 'Aktif' && ( + + )} + {props.row.original.approval.step_name === 'Pengajuan' && ( + + )} - )} - +
); };