Compare commits

...

57 Commits

Author SHA1 Message Date
Rivaldi A N S fa32995802 Merge branch 'feat/FE/US-76/TASK-129-130-137-integrate-api-daily-recording-growing' into 'feat/FE/US-76/daily-recording-growing'
[FEAT/FE][US#76/TASK#129-130-137] Integration API for Feature Daily Recording Growing

See merge request mbugroup/lti-web-client!39
2025-10-29 10:11:10 +00:00
rstubryan c832c4adeb fix(resolve): resolve merge issue 2025-10-29 15:56:57 +07:00
rstubryan eda3f0f1be chore(FE-Storyless): update Prettier to version 3.6.2 and remove .prettierrc from .gitignore 2025-10-29 15:52:34 +07:00
rstubryan c7b04c5bc6 feat(FE-137): integrate flock periods data fetching in RecordingForm for accurate recording validation 2025-10-28 10:58:37 +07:00
rstubryan c37950a230 refactor(FE-137): optimize recordedProjectFlockIds calculation to filter today's recordings 2025-10-28 10:44:08 +07:00
rstubryan 7da95b80b0 refactor(FE-Storyless): conditionally handle onChange prop in SelectInput for better flexibility 2025-10-28 08:58:01 +07:00
rstubryan c74ed18a16 refactor(FE-137): enable clearable option for select inputs in RecordingForm 2025-10-27 14:15:48 +07:00
rstubryan 15e6372c30 feat(FE-137): add product flag badges to RecordingForm for enhanced visibility 2025-10-27 13:03:37 +07:00
rstubryan 6dd3593f70 feat(FE-137): integrate Badge component to display project flock period in RecordingForm 2025-10-27 12:57:00 +07:00
rstubryan 5d376f8783 refactor(FE-137): remove unnecessary padding from SelectInput for improved layout 2025-10-27 12:56:38 +07:00
rstubryan 304d14a6fe refactor(FE-137): remove 'Periode' column from RecordingTable for cleaner display 2025-10-27 11:57:46 +07:00
rstubryan 0b0ecd3bc4 refactor(FE-137): replace stock availability text with Badge component in MovementForm 2025-10-27 11:25:15 +07:00
rstubryan 58369b8ffa refactor(FE-137): simplify stock display in MovementForm and RecordingForm, enhance input handling in SelectInput 2025-10-27 11:05:06 +07:00
rstubryan 943c0e05b9 refactor(FE-137): conditionally render location SelectInput in RecordingForm based on type 2025-10-27 06:50:48 +07:00
rstubryan 9143248e1d refactor(FE-137): remove redundant status column from RecordingTable 2025-10-27 06:28:51 +07:00
rstubryan 4b9d0d2064 refactor(FE-137): enhance RecordingForm validation to prevent duplicate project flock entries 2025-10-27 06:18:27 +07:00
rstubryan c8f596ad2a refactor(FE-137): update RecordingForm to improve project flock handling and label formatting 2025-10-27 05:54:14 +07:00
rstubryan 135fc2d5d3 feat(FE-114): update MovementForm and RecordingForm to use inputPrefix and inputSuffix for improved input handling 2025-10-25 14:24:51 +07:00
rstubryan 189c152745 feat(FE-114): add inputPrefix and inputSuffix props for enhanced input customization 2025-10-25 14:24:23 +07:00
rstubryan a0556ea1f4 refactor(FE-114): add currency prefix and unit suffix to delivery cost and body weight inputs 2025-10-25 13:53:53 +07:00
rstubryan 81ce36e326 refactor(FE-137): remove ID column from RecordingTable for cleaner presentation 2025-10-25 13:41:18 +07:00
rstubryan d7ce8c667a refactor(FE-114): simplify input handling in MovementForm and RecordingForm by removing unnecessary value normalization 2025-10-25 11:26:38 +07:00
rstubryan 6290199074 feat(FE-Storyless): integrate NumberInput and PatternInput components with react-number-format for enhanced input handling 2025-10-25 10:49:07 +07:00
rstubryan 896a0c6de2 refactor(FE-64): integrate product and supplier selection with API data fetching in MovementForm 2025-10-24 21:10:03 +07:00
rstubryan 9c5dc0dbb5 refactor(FE-137): integrate approve and reject functionality in RecordingForm with loading states and modal confirmations 2025-10-24 20:44:15 +07:00
rstubryan 81003eac63 feat(FE-137): enhance stock product selection in RecordingForm with initial values support 2025-10-24 20:37:11 +07:00
rstubryan e322e0d078 feat(FE-137): update RECORDING_FLAG_OPTIONS values for consistency in constant.ts 2025-10-24 20:29:33 +07:00
rstubryan 17e6eef0c5 feat(FE-137): add approve and reject functionality in RecordingForm with confirmation modals 2025-10-24 18:02:41 +07:00
rstubryan 6114d706ad feat(FE-137): disable input field in RecordingForm when type is 'detail' 2025-10-24 14:13:21 +07:00
rstubryan d14fa2ed2b feat(FE-137): integrate advanced filtering options in RecordingTable with dropdowns for area, location, and kandang 2025-10-24 13:53:20 +07:00
rstubryan 537fc617ff feat(FE-137): implement bulk approval and rejection functionality in RecordingTable with user feedback 2025-10-24 13:40:27 +07:00
rstubryan 7a6a35568f feat(FE-137): enhance RecordingTable to support recording deletion with user feedback and refresh functionality 2025-10-24 13:32:46 +07:00
rstubryan d2c485fdf0 feat(FE-114,137): implement stock validation in RecordingForm to manage usage limits and enhance user feedback 2025-10-24 12:45:07 +07:00
rstubryan 0c49978033 feat(FE-114,137): enhance RecordingForm to handle stock usage and depletion total changes with improved input handling 2025-10-24 12:26:33 +07:00
rstubryan 00de4782e7 feat(FE-137): simplify RecordingTable by removing unused columns and enhancing data clarity 2025-10-24 12:14:47 +07:00
rstubryan c546bd6b3c feat(FE-137): refactor RecordingTable to remove unused types and streamline data fetching 2025-10-24 11:37:25 +07:00
rstubryan 258324f092 feat(US-137): update RecordingTable to enhance data display and add new columns for project details 2025-10-24 11:36:14 +07:00
rstubryan 12a69b7c6c feat(FE-137): integrate SWR for fetching recordings and update table to display API data 2025-10-24 11:35:11 +07:00
rstubryan b148a09e84 feat(US-137): update API endpoints and default values in RecordingForm for production environment 2025-10-24 11:27:32 +07:00
rstubryan adc995dbe7 feat(US-114): enhance auto-calculation logic in RecordingForm to handle manual edits 2025-10-24 11:00:14 +07:00
rstubryan 9cbc703a63 feat(FE-114): integrate row selection functionality in RecordingTable and Table components 2025-10-24 10:18:56 +07:00
rstubryan 41e6848d75 refactor(FE-114): remove optional product_warehouse_id validation from RecordingForm schema 2025-10-24 10:08:38 +07:00
rstubryan ca5b236565 refactor(FE-114): enforce required usage amount in RecordingForm validation 2025-10-24 10:00:35 +07:00
rstubryan 714072aea1 fix(merge): resolve merge conflict 2025-10-24 09:57:38 +07:00
rstubryan a9f0696b38 refactor(FE-114): auto-populate notes with product name and enhance tooltip visibility in RecordingForm 2025-10-24 09:50:12 +07:00
rstubryan c30fcd81b2 refactor(FE-114): simplify CreateRecordingPayload structure and update validation in RecordingForm 2025-10-24 08:53:41 +07:00
rstubryan 7f5ae94706 feat(FE-114): integrate product stock fetching and selection in RecordingForm 2025-10-23 22:59:41 +07:00
rstubryan 6060ec0f7e feat(FE-114): prevent auto-calculation override during manual average weight editing in RecordingForm 2025-10-23 22:02:12 +07:00
rstubryan ef249fee12 feat(FE-114): add average weight calculation and input handling in RecordingForm 2025-10-23 21:54:06 +07:00
rstubryan 71df86c8df feat(FE-114): integrate location and project flock selection in RecordingForm 2025-10-23 21:34:40 +07:00
rstubryan d61c0ab844 feat(FE-114): integrate date time handling in RecordingForm for on-time status 2025-10-23 20:59:20 +07:00
rstubryan b653cc1dab refactor(FE-114): replace button elements with Button component for consistency and improved styling 2025-10-23 20:44:59 +07:00
rstubryan 392e211181 refactor(FE-Storyless): replace img with Image component for optimized loading 2025-10-23 19:54:17 +07:00
rstubryan cebe738beb refactor(FE-114): enhance type safety and improve checkbox input handling 2025-10-23 19:52:38 +07:00
rstubryan 6e5875a7b7 refactor(FE-Storyless): add flock_id, area_id, fcr_id, location_id, and kandang_ids to project-flock type definition 2025-10-23 19:52:21 +07:00
rstubryan db8cb56984 fix(merge): resolve conflict on merge 2025-10-23 18:24:02 +07:00
rstubryan 22f1a32e1b feat(FE-137): integrate API for daily recording with enhanced data structure and validation 2025-10-23 11:59:22 +07:00
17 changed files with 2716 additions and 2260 deletions
-3
View File
@@ -40,8 +40,5 @@ yarn-error.log*
*.tsbuildinfo
next-env.d.ts
# prettier
.prettierrc
# idea
.idea
+17
View File
@@ -39,6 +39,7 @@
"eslint": "^9",
"eslint-config-next": "15.5.3",
"husky": "^9.1.7",
"prettier": "3.6.2",
"tailwindcss": "^4",
"typescript": "^5"
}
@@ -5669,6 +5670,22 @@
"node": ">= 0.8.0"
}
},
"node_modules/prettier": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+1
View File
@@ -41,6 +41,7 @@
"eslint": "^9",
"eslint-config-next": "15.5.3",
"husky": "^9.1.7",
"prettier": "3.6.2",
"tailwindcss": "^4",
"typescript": "^5"
}
+3 -2
View File
@@ -6,6 +6,7 @@ import {
} from 'react';
import { cn } from '@/lib/helper';
import Image from 'next/image';
export interface CardProps extends Omit<HTMLAttributes<HTMLDivElement>, 'className'> {
title?: string;
@@ -108,7 +109,7 @@ const Card = ({
return (
<div className={getCardClasses()} {...props}>
<figure>
<img
<Image
src={image}
alt={imageAlt || title || 'Card image'}
className={getImageClasses()}
@@ -129,7 +130,7 @@ const Card = ({
<div className={getCardClasses()} {...props}>
{image && (
<figure>
<img
<Image
src={image}
alt={imageAlt || title || 'Card image'}
className={getImageClasses()}
+65 -15
View File
@@ -9,6 +9,11 @@ interface FormActionsProps<T> {
editUrl?: string;
onDelete?: () => void;
disableSubmit?: boolean;
onApprove?: () => void;
onReject?: () => void;
isApproveLoading?: boolean;
isRejectLoading?: boolean;
showApproveReject?: boolean;
}
export const FormActions = <T,>({
@@ -17,25 +22,32 @@ export const FormActions = <T,>({
editUrl,
onDelete,
disableSubmit = false,
onApprove,
onReject,
isApproveLoading = false,
isRejectLoading = false,
showApproveReject = false,
}: FormActionsProps<T>) => {
return (
<div className='flex flex-row justify-between gap-2 flex-wrap'>
{type !== 'add' && onDelete && (
{type !== 'add' && (
<div className='flex flex-row justify-start gap-2'>
<Button
type='button'
color='error'
onClick={onDelete}
className='px-4'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={24}
height={24}
className='justify-start text-sm'
/>
Delete
</Button>
{onDelete && (
<Button
type='button'
color='error'
onClick={onDelete}
className='px-4'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={24}
height={24}
className='justify-start text-sm'
/>
Delete
</Button>
)}
{type !== 'edit' && editUrl && (
<Button
type='button'
@@ -52,6 +64,44 @@ export const FormActions = <T,>({
Edit
</Button>
)}
{type === 'detail' && showApproveReject && (onApprove || onReject) && (
<>
{onApprove && (
<Button
type='button'
color='success'
onClick={onApprove}
className='px-4'
isLoading={isApproveLoading}
>
<Icon
icon='material-symbols:check-circle-outline'
width={24}
height={24}
className='justify-start text-sm'
/>
Approve
</Button>
)}
{onReject && (
<Button
type='button'
color='error'
onClick={onReject}
className='px-4'
isLoading={isRejectLoading}
>
<Icon
icon='material-symbols:cancel-outline'
width={24}
height={24}
className='justify-start text-sm'
/>
Reject
</Button>
)}
</>
)}
</div>
)}
{type !== 'detail' && (
+10 -10
View File
@@ -1,10 +1,10 @@
"use client";
'use client';
import { ChangeEvent, ReactNode } from "react";
import { NumericFormat, OnValueChange } from "react-number-format";
import TextInput, { TextInputProps } from "@/components/input/TextInput";
import { ChangeEvent, ReactNode } from 'react';
import { NumericFormat, OnValueChange } from 'react-number-format';
import TextInput, { TextInputProps } from '@/components/input/TextInput';
interface NumberInputProps extends Omit<TextInputProps, "type"> {
interface NumberInputProps extends Omit<TextInputProps, 'type'> {
thousandSeparator?: string;
decimalSeparator?: string;
decimalScale?: number;
@@ -17,8 +17,8 @@ interface NumberInputProps extends Omit<TextInputProps, "type"> {
}
const NumberInput = ({
thousandSeparator = ",",
decimalSeparator = ".",
thousandSeparator = ',',
decimalSeparator = '.',
decimalScale = 5,
allowNegative = true,
onChange,
@@ -28,7 +28,7 @@ const NumberInput = ({
}: NumberInputProps) => {
const valueChangeHandler: OnValueChange = (
numberFormatValues,
sourceInfo,
sourceInfo
) => {
const newChangeEvent = sourceInfo.event as
| ChangeEvent<HTMLInputElement>
@@ -49,8 +49,8 @@ const NumberInput = ({
onValueChange={valueChangeHandler}
decimalScale={decimalScale}
allowNegative={allowNegative}
startAdornment={inputPrefix}
endAdornment={inputSuffix}
inputPrefix={inputPrefix}
inputSuffix={inputSuffix}
{...restProps}
/>
);
+60
View File
@@ -0,0 +1,60 @@
'use client';
import { ChangeEvent } from 'react';
import { PatternFormat, OnValueChange } from 'react-number-format';
import TextInput, { TextInputProps } from '@/components/input/TextInput';
interface PatternInputProps extends Omit<TextInputProps, 'type'> {
type?: 'password' | 'tel' | 'text' | undefined;
/** Format pattern, e.g. "##/##/####", "(###) ###-####", "####-####-####" */
format: string;
/** Mask character for empty slots, e.g. "_" */
mask?: string;
/** Allow showing mask even when value is empty */
allowEmptyFormatting?: boolean;
patternChar?: string;
}
const PatternInput = ({
type = 'text',
format,
mask = '_',
allowEmptyFormatting = false,
patternChar = '#',
onChange,
...restProps
}: PatternInputProps) => {
const valueChangeHandler: OnValueChange = (
patternFormatValues,
sourceInfo
) => {
const newChangeEvent = sourceInfo.event as
| ChangeEvent<HTMLInputElement>
| undefined;
if (newChangeEvent) {
newChangeEvent.target.value = patternFormatValues.value;
onChange?.(newChangeEvent);
}
};
return (
<PatternFormat
type={type}
format={format}
mask={mask}
allowEmptyFormatting={allowEmptyFormatting}
patternChar={patternChar}
customInput={TextInput}
onValueChange={valueChangeHandler}
{...restProps}
/>
);
};
export default PatternInput;
+25 -1
View File
@@ -53,6 +53,7 @@ interface SelectInputBaseProps<T = OptionType> {
openMenu?: boolean;
delay?: number;
onInputChange?: (search: string) => void;
startAdornment?: ReactNode;
}
interface SelectInputProps<T = OptionType> extends SelectInputBaseProps<T> {
@@ -87,6 +88,7 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
delay = 300,
createables = false,
onInputChange,
startAdornment,
} = props;
const [internalInputValue, setInternalInputValue] = useState('');
@@ -149,7 +151,7 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
<SelectComponent<T, boolean, GroupBase<T>>
instanceId='select'
value={value ?? (isMulti ? [] : null)}
onChange={handleChange}
onChange={onChange ? handleChange : undefined}
options={options}
menuIsOpen={openMenu}
inputValue={internalInputValue}
@@ -210,6 +212,28 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
components={{
...components,
...(optionComponent ? { Option: optionComponent } : {}),
...(startAdornment ? {
Control: ({ children, innerRef, innerProps, menuIsOpen, isFocused, isDisabled }) => (
<div
ref={innerRef}
{...innerProps}
className={cn(
'w-full min-h-12! rounded-lg! border bg-white transition-shadow cursor-pointer! flex items-center',
{
'border-red-500! ring-2 ring-red-200': isError,
'border-indigo-500 ring-2 ring-indigo-200': isFocused,
'border-gray-300': !isError && !isFocused,
'bg-gray-100 text-gray-400 cursor-not-allowed': isDisabled,
}
)}
>
<div className='flex-1 px-4! gap-1 flex items-center'>
{startAdornment}
{children}
</div>
</div>
),
} : {}),
}}
menuPortalTarget={
typeof document !== 'undefined' ? document.body : undefined
+111 -29
View File
@@ -31,6 +31,8 @@ export interface TextInputProps {
errorMessage?: string;
startAdornment?: ReactNode;
endAdornment?: ReactNode;
inputPrefix?: ReactNode;
inputSuffix?: ReactNode;
onChange?: ChangeEventHandler<HTMLInputElement>;
onBlur?: FocusEventHandler<HTMLInputElement>;
}
@@ -48,6 +50,8 @@ const TextInput = ({
errorMessage,
startAdornment,
endAdornment,
inputPrefix,
inputSuffix,
disabled = false,
required = false,
onChange,
@@ -85,39 +89,117 @@ const TextInput = ({
</label>
)}
<div
className={cn(
'input h-12 px-4 py-2 text-base font-normal leading-6 w-full rounded outline-none! transition-all duration-200',
{
'border-error': isError,
'border-success!': isValid,
},
className?.inputWrapper
)}
>
{startAdornment && startAdornment}
{inputPrefix || inputSuffix ? (
<div className='relative flex'>
{inputPrefix && (
<div
className={cn(
'inline-flex items-center px-4 py-2 border border-r-0 rounded-l-md transition-all duration-200',
{
'bg-gray-100 border-gray-300': !disabled,
'bg-gray-50 border-gray-200': disabled,
}
)}
>
{inputPrefix}
</div>
)}
<input
type={type}
id={name}
name={name}
placeholder={placeholder}
value={value}
onChange={onChange}
onBlur={onBlur}
disabled={disabled}
className={cn('grow', className?.input)}
readOnly={readOnly}
/>
<div
className={cn(
'input h-12 text-base font-normal leading-6 flex-1 rounded-lg! outline-none! transition-all duration-200 flex items-center bg-white',
{
'border-error': isError,
'border-success!': isValid,
'rounded-l-none!': inputPrefix,
'rounded-r-none!': inputSuffix,
'input-disabled': disabled,
'cursor-not-allowed': disabled,
'bg-gray-50': disabled,
},
className?.inputWrapper
)}
>
{startAdornment && startAdornment}
{(isLoading || endAdornment) && (
<div className='flex flex-row gap-2'>
{isLoading && <span className='loading loading-spinner' />}
<input
type={type}
id={name}
name={name}
placeholder={placeholder}
value={value}
onChange={onChange}
onBlur={onBlur}
disabled={disabled}
className={cn(
'grow bg-transparent outline-none',
{
'cursor-not-allowed': disabled,
'text-gray-500': disabled,
},
className?.input
)}
readOnly={readOnly}
/>
{endAdornment && endAdornment}
{(isLoading || endAdornment) && (
<div className='flex flex-row gap-2'>
{isLoading && <span className='loading loading-spinner' />}
{endAdornment && endAdornment}
</div>
)}
</div>
)}
</div>
{inputSuffix && (
<div
className={cn(
'inline-flex items-center px-4 py-2 border border-l-0 rounded-r-md transition-all duration-200',
{
'bg-gray-100 border-gray-300': !disabled,
'bg-gray-50 border-gray-200': disabled,
}
)}
>
{inputSuffix}
</div>
)}
</div>
) : (
<div
className={cn(
'input h-12 px-4 py-2 text-base font-normal leading-6 w-full rounded-lg! outline-none! transition-all duration-200 bg-white',
{
'border-error': isError,
'border-success!': isValid,
},
className?.inputWrapper
)}
>
{startAdornment && startAdornment}
<input
type={type}
id={name}
name={name}
placeholder={placeholder}
value={value}
onChange={onChange}
onBlur={onBlur}
disabled={disabled}
className={cn('grow', className?.input)}
readOnly={readOnly}
/>
{(isLoading || endAdornment) && (
<div className='flex flex-row gap-2'>
{isLoading && <span className='loading loading-spinner' />}
{endAdornment && endAdornment}
</div>
)}
</div>
)}
{!isError && bottomLabel && (
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,7 @@
'use client';
import { useCallback, useMemo, useState } from 'react';
import useSWR from 'swr';
import { Icon } from '@iconify/react';
import { SortingState } from '@tanstack/react-table';
import { cn } from '@/lib/helper';
@@ -8,7 +9,9 @@ import { useModal } from '@/components/Modal';
import Button from '@/components/Button';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import { OptionType } from '@/components/input/SelectInput';
import SelectInput from '@/components/input/SelectInput';
import { ROWS_OPTIONS } from '@/config/constant';
import CheckboxInput from '@/components/input/CheckboxInput';
import { TableToolbar } from '@/components/table/TableToolbar';
import { TableRowSizeSelector } from '@/components/table/TableRowSizeSelector';
import Table from '@/components/Table';
@@ -16,105 +19,14 @@ import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import { type CellContext } from '@tanstack/react-table';
import { type Recording } from '@/types/api/production/recording';
const dummyRecordings: Recording[] = [
{
id: 1,
flock: {
id: 1,
name: 'Flock Recording 1',
created_at: '2024-01-01',
updated_at: '2024-01-01',
created_user: {
id: 1,
id_user: 1,
email: 'admin@example.com',
name: 'Admin',
},
},
recording_date: '2024-01-01',
location: {
id: 1,
name: 'Location 1',
address: 'Jl. Contoh No. 1',
area: {
id: 1,
name: 'Area 1',
},
created_at: '2024-01-01',
updated_at: '2024-01-01',
created_user: {
id: 1,
id_user: 1,
email: 'admin@example.com',
name: 'Admin',
},
},
coop: {
id: 1,
name: 'Coop 1',
status: 'ACTIVE',
location: {
id: 1,
name: 'Location 1',
address: 'Jl. Contoh No. 1',
area: {
id: 1,
name: 'Area 1',
},
},
pic: {
id: 1,
id_user: 1,
email: 'pic@example.com',
name: 'PIC User',
},
created_at: '2024-01-01',
updated_at: '2024-01-01',
created_user: {
id: 1,
id_user: 1,
email: 'admin@example.com',
name: 'Admin',
},
},
feed_data: [
{
feed_name: 'Feed 1',
feed_qty: 100,
feed_stock: 500,
},
],
body_weight: [
{
chicken_weight: 2.5,
chicken_count: 1000,
average_chicken_weight: 2.5,
},
],
vaccination: [
{
vaccine_name: 'Vaccine 1',
total_stock: 200,
used_stock: 150,
},
],
mortality: [
{
condition: 'NORMAL',
count: 5,
},
],
created_at: '2024-01-01',
updated_at: '2024-01-01',
created_user: {
id: 1,
id_user: 1,
email: 'admin@example.com',
name: 'Admin',
},
},
];
import { type BaseApiResponse } from '@/types/api/api-general';
import { RecordingApi } from '@/services/api/production';
import { AreaApi } from '@/services/api/master-data';
import { LocationApi } from '@/services/api/master-data';
import { KandangApi } from '@/services/api/master-data';
import { isResponseSuccess, isResponseError } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import toast from 'react-hot-toast';
const RowOptionsMenu = ({
type = 'dropdown',
@@ -173,12 +85,34 @@ const RowOptionsMenu = ({
};
const RecordingTable = () => {
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: '',
areaFilter: '',
locationFilter: '',
kandangFilter: '',
periodFilter: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
search: 'search',
areaFilter: 'area_id',
locationFilter: 'location_id',
kandangFilter: 'kandang_id',
periodFilter: 'period',
},
});
const [sorting, setSorting] = useState<SortingState>([]);
const [selectedRecordings, setSelectedRecordings] = useState<number[]>([]);
const [, setSelectedRecording] = useState<Recording | undefined>(undefined);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const [selectedRecording, setSelectedRecording] = useState<Recording | undefined>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isBulkApproveLoading, setIsBulkApproveLoading] = useState(false);
const [isBulkRejectLoading, setIsBulkRejectLoading] = useState(false);
@@ -187,12 +121,81 @@ const RecordingTable = () => {
const bulkApproveModal = useModal();
const bulkRejectModal = useModal();
// State for dropdown search
const [locationSelectInputValue, setLocationSelectInputValue] = useState('');
const [areaSelectInputValue, setAreaSelectInputValue] = useState('');
const [kandangSelectInputValue, setKandangSelectInputValue] = useState('');
const [selectedArea, setSelectedArea] = useState<OptionType | null>(null);
const [selectedLocation, setSelectedLocation] = useState<OptionType | null>(null);
const [selectedKandang, setSelectedKandang] = useState<OptionType | null>(null);
const {
data: recordings,
isLoading,
mutate: refreshRecordings,
} = useSWR(
`${RecordingApi.basePath}${getTableFilterQueryString()}`,
RecordingApi.getAllFetcher
);
// Fetch data for dropdowns
const areaUrl = `${AreaApi.basePath}?${new URLSearchParams({
search: areaSelectInputValue,
limit: '100',
}).toString()}`;
const {
data: areas,
isLoading: isLoadingAreas,
} = useSWR(areaUrl, AreaApi.getAllFetcher);
const locationUrl = `${LocationApi.basePath}?${new URLSearchParams({
search: locationSelectInputValue,
area_id: selectedArea != null ? selectedArea.value.toString() : '',
limit: '100',
}).toString()}`;
const {
data: locations,
isLoading: isLoadingLocations,
} = useSWR(locationUrl, LocationApi.getAllFetcher);
const kandangUrl = `${KandangApi.basePath}?${new URLSearchParams({
search: kandangSelectInputValue,
location_id:
selectedLocation != null ? selectedLocation.value.toString() : '',
limit: '100',
}).toString()}`;
const {
data: kandangs,
isLoading: isLoadingKandang,
} = useSWR(kandangUrl, KandangApi.getAllFetcher);
// Data to Options Mapping
const optionsArea = isResponseSuccess(areas)
? areas?.data.map((area) => ({
value: area.id,
label: area.name,
}))
: [];
const optionsLocation = isResponseSuccess(locations)
? locations?.data.map((location) => ({
value: location.id,
label: location.name,
}))
: [];
const optionsKandang = isResponseSuccess(kandangs)
? kandangs?.data.map((kandang) => ({
value: kandang.id,
label: kandang.name,
}))
: [];
const searchChangeHandler = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value);
updateFilter('search', e.target.value);
setPage(1);
},
[]
[updateFilter, setPage]
);
const pageSizeChangeHandler = useCallback(
@@ -201,52 +204,80 @@ const RecordingTable = () => {
setPageSize(newVal.value as number);
setPage(1);
},
[]
[setPageSize, setPage]
);
const paginatedData = useMemo(() => {
const filteredData = dummyRecordings.filter(
(recording) =>
recording.flock.name.toLowerCase().includes(search.toLowerCase()) ||
recording.location.name.toLowerCase().includes(search.toLowerCase()) ||
recording.coop.name.toLowerCase().includes(search.toLowerCase())
);
const start = (page - 1) * pageSize;
return filteredData.slice(start, start + pageSize);
}, [page, pageSize, search]);
if (!recordings || recordings.status !== 'success') return [];
return recordings.data;
}, [recordings]);
const selectedRowIds = Object.keys(rowSelection).map((item) => parseInt(item));
const bulkApproveHandler = async () => {
setIsBulkApproveLoading(true);
console.log(
'Approved recordings:',
paginatedData.filter((_, idx) => selectedRecordings.includes(idx))
);
setTimeout(() => {
setIsBulkApproveLoading(false);
setSelectedRecordings([]);
const approveResponse = await RecordingApi.customRequest<
BaseApiResponse<Recording[]>
>('approvals', {
method: 'POST',
payload: {
action: 'APPROVED',
approvable_ids: selectedRowIds,
notes: 'Bulk Approved',
},
});
if (isResponseSuccess(approveResponse)) {
await refreshRecordings();
setRowSelection({});
bulkApproveModal.closeModal();
}, 1000);
toast.success(`Successfully approved ${selectedRowIds.length} recordings!`);
}
if (isResponseError(approveResponse)) {
toast.error(approveResponse?.message as string);
bulkApproveModal.closeModal();
}
setIsBulkApproveLoading(false);
};
const bulkRejectHandler = async () => {
setIsBulkRejectLoading(true);
console.log(
'Rejected recordings:',
paginatedData.filter((_, idx) => selectedRecordings.includes(idx))
);
setTimeout(() => {
setIsBulkRejectLoading(false);
setSelectedRecordings([]);
const rejectResponse = await RecordingApi.customRequest<
BaseApiResponse<Recording[]>
>('approvals', {
method: 'POST',
payload: {
action: 'REJECTED',
approvable_ids: selectedRowIds,
notes: 'Bulk Rejected',
},
});
if (isResponseSuccess(rejectResponse)) {
refreshRecordings();
setRowSelection({});
bulkRejectModal.closeModal();
}, 1000);
toast.success(`Successfully rejected ${selectedRowIds.length} recordings!`);
}
if (isResponseError(rejectResponse)) {
toast.error(rejectResponse?.message as string);
bulkRejectModal.closeModal();
}
setIsBulkRejectLoading(false);
};
const singleDeleteHandler = async () => {
setIsDeleteLoading(true);
setTimeout(() => {
setIsDeleteLoading(false);
singleDeleteModal.closeModal();
}, 1000);
await RecordingApi.delete(selectedRecording?.id as number);
refreshRecordings();
singleDeleteModal.closeModal();
toast.success('Successfully delete Recording!');
setIsDeleteLoading(false);
};
return (
@@ -258,21 +289,189 @@ const RecordingTable = () => {
label: 'Tambah Recording',
}}
search={{
value: search,
value: tableFilterState.search,
onChange: searchChangeHandler,
placeholder: 'Cari Recording',
}}
/>
<TableRowSizeSelector
value={pageSize}
value={tableFilterState.pageSize}
onChange={pageSizeChangeHandler}
options={ROWS_OPTIONS}
/>
{/* Filter Dropdowns - Desktop */}
<div className='hidden sm:grid sm:grid-cols-4 gap-4 mt-4'>
<SelectInput
label='Area'
placeholder='Pilih Area'
options={optionsArea}
value={selectedArea}
onChange={(selected) => {
const selectedValue = selected as OptionType | null;
setSelectedArea(selectedValue);
setSelectedLocation(null);
setSelectedKandang(null);
updateFilter('areaFilter', selectedValue ? selectedValue.value.toString() : '');
updateFilter('locationFilter', '');
updateFilter('kandangFilter', '');
setPage(1);
}}
className={{ wrapper: 'w-full' }}
onInputChange={(value) => setAreaSelectInputValue(value)}
isLoading={isLoadingAreas}
isClearable
/>
<SelectInput
label='Lokasi'
placeholder='Pilih Lokasi'
options={optionsLocation}
value={selectedLocation}
onChange={(selected) => {
const selectedValue = selected as OptionType | null;
setSelectedLocation(selectedValue);
setSelectedKandang(null);
updateFilter('locationFilter', selectedValue ? selectedValue.value.toString() : '');
updateFilter('kandangFilter', '');
setPage(1);
}}
className={{ wrapper: 'w-full' }}
onInputChange={(value) => setLocationSelectInputValue(value)}
isLoading={isLoadingLocations}
isClearable
isDisabled={!selectedArea}
/>
<SelectInput
label='Kandang'
placeholder='Pilih Kandang'
options={optionsKandang}
value={selectedKandang}
onChange={(selected) => {
const selectedValue = selected as OptionType | null;
setSelectedKandang(selectedValue);
updateFilter('kandangFilter', selectedValue ? selectedValue.value.toString() : '');
setPage(1);
}}
className={{ wrapper: 'w-full' }}
onInputChange={(value) => setKandangSelectInputValue(value)}
isLoading={isLoadingKandang}
isClearable
isDisabled={!selectedLocation}
/>
<SelectInput
label='Periode'
placeholder='Pilih Periode'
options={[
{ value: '1', label: 'Periode 1' },
{ value: '2', label: 'Periode 2' },
{ value: '3', label: 'Periode 3' },
]}
value={
tableFilterState.periodFilter
? { value: tableFilterState.periodFilter, label: `Periode ${tableFilterState.periodFilter}` }
: null
}
onChange={(selected) => {
const selectedValue = selected as OptionType | null;
updateFilter('periodFilter', selectedValue ? selectedValue.value.toString() : '');
setPage(1);
}}
className={{ wrapper: 'w-full' }}
isClearable
/>
</div>
{/* Filter Dropdowns - Mobile */}
<div className='sm:hidden flex flex-col gap-3 mt-4'>
<SelectInput
label='Area'
placeholder='Pilih Area'
options={optionsArea}
value={selectedArea}
onChange={(selected) => {
const selectedValue = selected as OptionType | null;
setSelectedArea(selectedValue);
setSelectedLocation(null);
setSelectedKandang(null);
updateFilter('areaFilter', selectedValue ? selectedValue.value.toString() : '');
updateFilter('locationFilter', '');
updateFilter('kandangFilter', '');
setPage(1);
}}
className={{ wrapper: 'w-full' }}
onInputChange={(value) => setAreaSelectInputValue(value)}
isLoading={isLoadingAreas}
isClearable
/>
<SelectInput
label='Lokasi'
placeholder='Pilih Lokasi'
options={optionsLocation}
value={selectedLocation}
onChange={(selected) => {
const selectedValue = selected as OptionType | null;
setSelectedLocation(selectedValue);
setSelectedKandang(null);
updateFilter('locationFilter', selectedValue ? selectedValue.value.toString() : '');
updateFilter('kandangFilter', '');
setPage(1);
}}
className={{ wrapper: 'w-full' }}
onInputChange={(value) => setLocationSelectInputValue(value)}
isLoading={isLoadingLocations}
isClearable
isDisabled={!selectedArea}
/>
<SelectInput
label='Kandang'
placeholder='Pilih Kandang'
options={optionsKandang}
value={selectedKandang}
onChange={(selected) => {
const selectedValue = selected as OptionType | null;
setSelectedKandang(selectedValue);
updateFilter('kandangFilter', selectedValue ? selectedValue.value.toString() : '');
setPage(1);
}}
className={{ wrapper: 'w-full' }}
onInputChange={(value) => setKandangSelectInputValue(value)}
isLoading={isLoadingKandang}
isClearable
isDisabled={!selectedLocation}
/>
<SelectInput
label='Periode'
placeholder='Pilih Periode'
options={[
{ value: '1', label: 'Periode 1' },
{ value: '2', label: 'Periode 2' },
{ value: '3', label: 'Periode 3' },
]}
value={
tableFilterState.periodFilter
? { value: tableFilterState.periodFilter, label: `Periode ${tableFilterState.periodFilter}` }
: null
}
onChange={(selected) => {
const selectedValue = selected as OptionType | null;
updateFilter('periodFilter', selectedValue ? selectedValue.value.toString() : '');
setPage(1);
}}
className={{ wrapper: 'w-full' }}
isClearable
/>
</div>
</div>
{/* Bulk action buttons */}
<div className={'flex justify-end items-center'}>
{selectedRecordings.length > 0 && (
{selectedRowIds.length > 0 && (
<div className='flex gap-2 mb-4'>
<Button
type='button'
@@ -285,7 +484,7 @@ const RecordingTable = () => {
width={20}
height={20}
/>
Approve ({selectedRecordings.length})
Approve ({selectedRowIds.length})
</Button>
<Button
type='button'
@@ -298,7 +497,7 @@ const RecordingTable = () => {
width={20}
height={20}
/>
Reject ({selectedRecordings.length})
Reject ({selectedRowIds.length})
</Button>
</div>
)}
@@ -306,7 +505,7 @@ const RecordingTable = () => {
<ConfirmationModal
ref={bulkApproveModal.ref}
type='success'
text={`Apakah anda yakin ingin menyetujui ${selectedRecordings.length} data Recording yang dipilih?`}
text={`Apakah anda yakin ingin menyetujui ${selectedRowIds.length} data Recording yang dipilih?`}
secondaryButton={{
text: 'Tidak',
}}
@@ -321,7 +520,7 @@ const RecordingTable = () => {
<ConfirmationModal
ref={bulkRejectModal.ref}
type='error'
text={`Apakah anda yakin ingin menolak ${selectedRecordings.length} data Recording yang dipilih?`}
text={`Apakah anda yakin ingin menolak ${selectedRowIds.length} data Recording yang dipilih?`}
secondaryButton={{
text: 'Tidak',
}}
@@ -339,75 +538,83 @@ const RecordingTable = () => {
columns={[
{
id: 'select',
accessorKey: 'id',
header: ({ table }) => (
<input
type='checkbox'
className='checkbox'
checked={
table.getRowModel().rows.length > 0 &&
table
.getRowModel()
.rows.every((row) => selectedRecordings.includes(row.index))
}
onChange={(e) => {
if (e.target.checked) {
setSelectedRecordings(
table.getRowModel().rows.map((row) => row.index)
);
} else {
setSelectedRecordings([]);
}
}}
/>
<div className='w-full flex flex-row justify-center'>
<CheckboxInput
name='allRow'
checked={table.getIsAllRowsSelected()}
indeterminate={table.getIsSomeRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
</div>
),
cell: ({ row }) => (
<input
type='checkbox'
className='checkbox'
checked={selectedRecordings.includes(row.index)}
onChange={(e) => {
if (e.target.checked) {
setSelectedRecordings([...selectedRecordings, row.index]);
} else {
setSelectedRecordings(
selectedRecordings.filter((i) => i !== row.index)
);
}
}}
/>
<div>
<CheckboxInput
name='row'
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
indeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()}
/>
</div>
),
},
{
header: '#',
cell: (props) => pageSize * (page - 1) + props.row.index + 1,
cell: (props) => tableFilterState.pageSize * (tableFilterState.page - 1) + props.row.index + 1,
},
{
accessorKey: 'flock.name',
header: 'Flock',
header: 'Nama Project',
cell: (props) => `Project ${props.row.original.project_flock_kandang_id}`,
},
{
accessorKey: 'recording_date',
header: 'Tanggal Recording',
header: 'Umur (hari)',
cell: (props) => props.row.original.day,
},
{
accessorKey: 'record_date',
header: 'Waktu Recording',
cell: (props) =>
new Date(props.row.original.recording_date).toLocaleDateString(),
new Date(props.row.original.record_date).toLocaleDateString(),
},
{
accessorKey: 'location.name',
header: 'Lokasi',
header: 'Populasi Awal',
cell: (props) => props.row.original.total_chick?.toLocaleString() || '-',
},
{
accessorKey: 'coop.name',
header: 'Kandang',
header: 'BW',
cell: (props) => props.row.original.avg_daily_gain?.toFixed(2) || '-',
},
{
accessorKey: 'mortality',
header: 'Total Mortality',
header: 'Pakan',
cell: (props) => props.row.original.cum_intake?.toLocaleString() || '-',
},
{
header: 'FCR',
cell: (props) => props.row.original.fcr_value?.toFixed(2) || '-',
},
{
accessorKey: 'total_depletion',
header: 'Total Deplesi',
cell: (props) => props.row.original.total_depletion,
},
{
header: 'Deplesi (%)',
cell: (props) => props.row.original.daily_depletion_rate?.toFixed(2) || '-',
},
{
header: 'Populasi Akhir',
cell: (props) => (props.row.original.total_chick - props.row.original.total_depletion)?.toLocaleString() || '-',
},
{
header: 'Ketepatan Waktu',
cell: (props) => props.row.original.ontime ? 'Tepat Waktu' : 'Terlambat',
},
{
header: 'Tanggal Submit',
cell: (props) =>
props.row.original.mortality.reduce(
(acc, curr) => acc + curr.count,
0
),
new Date(props.row.original.created_at).toLocaleString(),
},
{
header: 'Aksi',
@@ -452,13 +659,15 @@ const RecordingTable = () => {
},
},
]}
pageSize={pageSize}
page={page}
totalItems={dummyRecordings.length}
pageSize={tableFilterState.pageSize}
page={recordings?.status === 'success' ? recordings.meta?.page : tableFilterState.page}
totalItems={recordings?.status === 'success' ? recordings.meta?.total_results : 0}
onPageChange={setPage}
isLoading={false}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
className={{
containerClassName: cn({
'mb-20': paginatedData.length === 0,
@@ -477,7 +686,7 @@ const RecordingTable = () => {
<ConfirmationModal
ref={singleDeleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Recording ini?`}
text={`Apakah anda yakin ingin menghapus data Recording ini (ID: ${selectedRecording?.id})?`}
secondaryButton={{
text: 'Tidak',
}}
@@ -1,212 +1,222 @@
import * as Yup from 'yup';
import { RECORDING_FLAG_OPTIONS } from '@/config/constant';
import { Recording } from '@/types/api/production/recording';
import {
Recording,
CreateRecordingPayload,
} from '@/types/api/production/recording';
export const RecordingFormSchema = Yup.object({
flock: Yup.object({
project_flock_kandang: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
flock_id: Yup.number()
project_flock_kandang_id: Yup.number()
.default(0)
.typeError('Flock wajib diisi!')
.typeError('Project Flock Kandang wajib diisi!')
.test(
'is-valid-flock',
'Flock wajib diisi!',
'is-valid-project-flock-kandang',
'Project Flock Kandang wajib diisi!',
(value) => value !== undefined && value !== null && value > 0
)
.required('Flock wajib diisi!'),
location: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
location_id: Yup.number()
.default(0)
.typeError('Lokasi wajib diisi!')
.required('Project Flock Kandang wajib diisi!')
.test(
'is-valid-location',
'Lokasi wajib diisi!',
(value) => value !== undefined && value !== null && value > 0
)
.required('Lokasi wajib diisi!'),
coop: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
coop_id: Yup.number()
.default(0)
.typeError('Kandang wajib diisi!')
.test(
'is-valid-coop',
'Kandang wajib diisi!',
(value) => value !== undefined && value !== null && value > 0
)
.required('Kandang wajib diisi!'),
recording_date: Yup.date()
.required('Tanggal recording wajib diisi')
.typeError('Format tanggal tidak valid'),
feed_data: Yup.array()
'not-already-recorded',
'Project Flock ini sudah direcord hari ini!',
function(value) {
const recordedProjectFlockIds = this.options.context?.recordedProjectFlockIds as Set<number>;
const formType = this.options.context?.type as 'add' | 'edit' | 'detail';
if (formType !== 'add') return true;
if (value && recordedProjectFlockIds?.has(value)) {
return false;
}
return true;
}
),
body_weights: Yup.array()
.of(
Yup.object({
feed_id: Yup.string().required('Nama pakan wajib diisi!'),
feed_qty: Yup.mixed<number | ''>().notRequired(),
feed_stock: Yup.number()
.required('Jumlah pakan yang digunakan wajib diisi!')
.min(1, 'Jumlah pakan minimal 1!')
.typeError('Jumlah pakan yang digunakan harus berupa angka!')
.test(
'is-not-exceed-qty',
'Jumlah pakan yang digunakan tidak boleh melebihi stok tersedia!',
function (value) {
const { feed_qty } = this.parent;
if (value === undefined) return true;
if (
feed_qty === undefined ||
feed_qty === '' ||
typeof feed_qty !== 'number'
)
return true;
return value <= feed_qty;
}
),
})
)
.min(1, 'Minimal harus ada 1 data pakan!')
.required('Data pakan wajib diisi!'),
body_weight: Yup.array()
.of(
Yup.object({
chicken_weight: Yup.number()
weight: Yup.number()
.required('Berat ayam wajib diisi!')
.min(1, 'Berat ayam minimal 1 gram!')
.typeError('Berat ayam harus berupa angka!'),
chicken_count: Yup.number()
qty: Yup.number()
.required('Jumlah ayam wajib diisi!')
.min(1, 'Jumlah ayam minimal 1 ekor!')
.typeError('Jumlah ayam harus berupa angka!'),
average_chicken_weight: Yup.number()
.required('Rata-rata berat ayam wajib diisi!')
.min(1, 'Rata-rata berat ayam minimal 1 gram!')
.typeError('Rata-rata berat ayam harus berupa angka!'),
.typeError('Jumlah ayam harus berupa angka!')
.default(1),
average_weight: Yup.number()
.optional()
.min(0, 'Rata-rata berat tidak boleh negatif!')
.typeError('Rata-rata berat harus berupa angka!')
.default(0),
})
)
.min(1, 'Minimal harus ada 1 data bobot badan!')
.required('Data bobot badan wajib diisi!'),
vaccination: Yup.array()
stocks: Yup.array()
.of(
Yup.object({
vaccine_id: Yup.string().required('Nama vaksin wajib diisi!'),
total_stock: Yup.mixed<number | ''>().notRequired(),
used_stock: Yup.number()
.required('Jumlah vaksin yang digunakan wajib diisi!')
.min(1, 'Jumlah vaksin minimal 1!')
.typeError('Jumlah vaksin yang digunakan harus berupa angka!')
.test(
'is-not-exceed-total',
'Jumlah vaksin yang digunakan tidak boleh melebihi stok tersedia!',
function (value) {
const { total_stock } = this.parent;
if (value === undefined) return true;
if (
total_stock === undefined ||
total_stock === '' ||
typeof total_stock !== 'number'
)
return true;
return value <= total_stock;
}
),
product_warehouse_id: Yup.number()
.required('Produk wajib diisi!')
.min(1, 'Produk wajib diisi!')
.typeError('Produk harus berupa angka!'),
usage_amount: Yup.number()
.required('Jumlah penggunaan wajib diisi!')
.min(0, 'Jumlah penggunaan tidak boleh negatif!')
.typeError('Jumlah penggunaan harus berupa angka!'),
notes: Yup.string().optional(),
})
)
.min(1, 'Minimal harus ada 1 data vaksinasi!')
.required('Data vaksinasi wajib diisi!'),
mortality: Yup.array()
.min(1, 'Minimal harus ada 1 data stok!')
.required('Data stok wajib diisi!'),
depletions: Yup.array()
.of(
Yup.object({
condition: Yup.mixed<string>()
total: Yup.number()
.required('Jumlah depletions wajib diisi!')
.min(1, 'Jumlah depletions minimal 1!')
.typeError('Jumlah depletions harus berupa angka!'),
notes: Yup.string()
.required('Kondisi depletions wajib diisi!')
.oneOf(
RECORDING_FLAG_OPTIONS.map((opt) => opt.value),
'Kondisi tidak valid!'
RECORDING_FLAG_OPTIONS.map((option) => option.value),
'Kondisi depletions tidak valid!'
)
.required('Kondisi wajib diisi!'),
count: Yup.number()
.required('Jumlah mortalitas wajib diisi!')
.min(1, 'Jumlah mortalitas minimal 1 ekor!')
.typeError('Jumlah mortalitas harus berupa angka!'),
.typeError('Kondisi depletions harus berupa teks!')
.min(1, 'Kondisi depletions wajib diisi!'),
})
)
.min(1, 'Minimal harus ada 1 data mortalitas!')
.required('Data mortalitas wajib diisi!'),
.min(1, 'Minimal harus ada 1 data depletions!')
.required('Data depletions wajib diisi!'),
});
export const UpdateRecordingFormSchema = RecordingFormSchema;
export const UpdateRecordingFormSchema = Yup.object({
project_flock_kandang: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
project_flock_kandang_id: Yup.number()
.default(0)
.typeError('Project Flock Kandang wajib diisi!')
.test(
'is-valid-project-flock-kandang',
'Project Flock Kandang wajib diisi!',
(value) => value !== undefined && value !== null && value > 0
)
.required('Project Flock Kandang wajib diisi!'),
body_weights: Yup.array()
.of(
Yup.object({
weight: Yup.number()
.required('Berat ayam wajib diisi!')
.min(1, 'Berat ayam minimal 1 gram!')
.typeError('Berat ayam harus berupa angka!'),
qty: Yup.number()
.required('Jumlah ayam wajib diisi!')
.min(1, 'Jumlah ayam minimal 1 ekor!')
.typeError('Jumlah ayam harus berupa angka!')
.default(1),
average_weight: Yup.number()
.optional()
.min(0, 'Rata-rata berat tidak boleh negatif!')
.typeError('Rata-rata berat harus berupa angka!')
.default(0),
})
)
.min(1, 'Minimal harus ada 1 data bobot badan!')
.required('Data bobot badan wajib diisi!'),
stocks: Yup.array()
.of(
Yup.object({
product_warehouse_id: Yup.number()
.required('Produk wajib diisi!')
.min(1, 'Produk wajib diisi!')
.typeError('Produk harus berupa angka!'),
usage_amount: Yup.number()
.required('Jumlah penggunaan wajib diisi!')
.min(0, 'Jumlah penggunaan tidak boleh negatif!')
.typeError('Jumlah penggunaan harus berupa angka!'),
notes: Yup.string().optional(),
})
)
.min(1, 'Minimal harus ada 1 data stok!')
.required('Data stok wajib diisi!'),
depletions: Yup.array()
.of(
Yup.object({
total: Yup.number()
.required('Jumlah depletions wajib diisi!')
.min(1, 'Jumlah depletions minimal 1!')
.typeError('Jumlah depletions harus berupa angka!'),
notes: Yup.string()
.required('Kondisi depletions wajib diisi!')
.oneOf(
RECORDING_FLAG_OPTIONS.map((option) => option.value),
'Kondisi depletions tidak valid!'
)
.typeError('Kondisi depletions harus berupa teks!')
.min(1, 'Kondisi depletions wajib diisi!'),
})
)
.min(1, 'Minimal harus ada 1 data depletions!')
.required('Data depletions wajib diisi!'),
});
export type RecordingFormValues = Yup.InferType<typeof RecordingFormSchema>;
type RecordingFormData = Partial<Recording> & {
body_weights?: CreateRecordingPayload['body_weights'];
stocks?: CreateRecordingPayload['stocks'];
depletions?: CreateRecordingPayload['depletions'];
};
export const getRecordingFormInitialValues = (
initialValues?: Recording
initialValues?: RecordingFormData
): RecordingFormValues => ({
flock: initialValues?.flock
project_flock_kandang: initialValues?.project_flock_kandang_id
? {
value: initialValues.flock.id,
label: initialValues.flock.name,
value: initialValues.project_flock_kandang_id,
label: `Project Flock #${initialValues.project_flock_kandang_id}`,
}
: null,
flock_id: initialValues?.flock?.id ?? 0,
location: initialValues?.location
? {
value: initialValues.location.id,
label: initialValues.location.name,
}
: null,
location_id: initialValues?.location?.id ?? 0,
coop: initialValues?.coop
? {
value: initialValues.coop.id,
label: initialValues.coop.name,
}
: null,
coop_id: initialValues?.coop?.id ?? 0,
recording_date: initialValues?.recording_date
? new Date(initialValues.recording_date)
: new Date(),
feed_data: initialValues?.feed_data
? initialValues.feed_data.map((feed) => ({
feed_id: feed.feed_name,
feed_qty: feed.feed_qty,
feed_stock: feed.feed_stock,
}))
: [
{
feed_id: '',
feed_qty: '',
feed_stock: 0,
},
],
body_weight: initialValues?.body_weight ?? [
project_flock_kandang_id: initialValues?.project_flock_kandang_id ?? 0,
body_weights: initialValues?.body_weights?.map(
(bw: NonNullable<CreateRecordingPayload['body_weights']>[0]) => ({
weight: bw.weight,
qty: bw.qty,
average_weight: bw.qty > 0 ? Math.round(bw.weight / bw.qty) : 0,
})
) ?? [
{
chicken_weight: 0,
chicken_count: 0,
average_chicken_weight: 0,
weight: 0,
qty: 0,
average_weight: 0,
},
],
vaccination: initialValues?.vaccination
? initialValues.vaccination.map((vaccine) => ({
vaccine_id: vaccine.vaccine_name,
total_stock: vaccine.total_stock,
used_stock: vaccine.used_stock,
}))
: [
{
vaccine_id: '',
total_stock: '',
used_stock: 0,
},
],
mortality: initialValues?.mortality ?? [
stocks: initialValues?.stocks?.map(
(stock: NonNullable<CreateRecordingPayload['stocks']>[0]) => ({
product_warehouse_id: stock.product_warehouse_id,
usage_amount: stock.usage_amount,
notes: stock.notes,
})
) ?? [
{
condition: '',
count: 0,
product_warehouse_id: 0,
usage_amount: 0,
notes: '',
},
],
depletions: initialValues?.depletions?.map(
(depletion: NonNullable<CreateRecordingPayload['depletions']>[0]) => ({
product_warehouse_id: depletion.product_warehouse_id,
total: depletion.total,
notes: depletion.notes,
})
) ?? [
{
product_warehouse_id: 0,
total: 0,
notes: '',
},
],
});
File diff suppressed because it is too large Load Diff
@@ -24,7 +24,7 @@ export const useRecordingFormHandlers = (initialValuesId?: number) => {
return;
}
toast.success(res?.message as string);
router.push('/flock/recording');
router.push('/production/recording');
},
[router]
);
@@ -38,7 +38,7 @@ export const useRecordingFormHandlers = (initialValuesId?: number) => {
}
toast.success(res?.message as string);
router.refresh();
router.push('/flock/recording');
router.push('/production/recording');
},
[router]
);
@@ -55,7 +55,7 @@ export const useRecordingFormHandlers = (initialValuesId?: number) => {
deleteModal.closeModal();
toast.success('Successfully delete Recording!');
setIsDeleteLoading(false);
router.push('/flock/recording');
router.push('/production/recording');
}, [deleteModal, initialValuesId, router]);
return {
+3 -3
View File
@@ -221,7 +221,7 @@ export const SUPPLIER_FLAG_OPTIONS = [
];
export const RECORDING_FLAG_OPTIONS = [
{ label: 'Ayam Afkir', value: 'Ayam Afkir' },
{ label: 'Ayam Culling', value: 'Ayam Culling' },
{ label: 'Ayam Mati', value: 'Ayam Mati' },
{ label: 'Ayam Afkir', value: 'Afkir' },
{ label: 'Ayam Culling', value: 'Culling' },
{ label: 'Ayam Mati', value: 'Mati' },
];
+2 -2
View File
@@ -24,9 +24,9 @@ export const RecordingApi = new BaseApiService<
Recording,
CreateRecordingPayload,
UpdateRecordingPayload
>('/flock/recordings');
>('/production/recordings');
export const ChickinApi = new BaseApiService<
Chickin,
CreateChickinPayload,
UpdateChickinPayload
>('/production/chickins');
>('/production/chickins');
+33 -48
View File
@@ -1,60 +1,45 @@
import { BaseMetadata } from '@/types/api/api-general';
import { Location } from '@/types/api/master-data/location';
import { Kandang } from '@/types/api/master-data/kandang';
import { Flock } from '@/types/api/master-data/flock';
import { BaseMetadata, User } from '@/types/api/api-general';
export type ProductionMetrics = {
total_depletion: number;
cum_depletion_rate: number;
daily_gain: number;
avg_daily_gain: number;
cum_intake: number;
fcr_value: number;
total_chick: number;
daily_depletion_rate: number;
cum_depletion: number;
};
export type BaseRecording = {
id: number;
flock: Flock;
recording_date: string;
location: Location;
coop: Kandang;
feed_data: {
feed_name: string;
feed_qty: number;
feed_stock: number;
}[];
body_weight: {
chicken_weight: number;
chicken_count: number;
average_chicken_weight: number;
}[];
vaccination: {
vaccine_name: string;
total_stock: number;
used_stock: number;
}[];
mortality: {
condition: string;
count: number;
}[];
};
project_flock_kandang_id: number;
record_datetime: string;
record_date: string;
status: number;
ontime: boolean;
day: number;
created_user: User;
} & ProductionMetrics;
export type Recording = BaseMetadata & BaseRecording;
export type CreateRecordingPayload = {
flock_id: number;
recording_date: string;
location_id: number;
coop_id: number;
feed_data: {
feed_id: string;
feed_qty: number;
feed_stock: number;
project_flock_kandang_id: number;
body_weights: {
weight: number;
qty: number;
}[];
body_weight: {
chicken_weight: number;
chicken_count: number;
average_chicken_weight: number;
stocks?: {
product_warehouse_id: number;
usage_amount: number;
notes: string;
}[];
vaccination: {
vaccine_id: string;
total_stock: number;
used_stock: number;
}[];
mortality: {
condition: string;
count: number;
depletions?: {
product_warehouse_id?: number;
total: number;
notes: string;
}[];
};