feat(FE-92-94): Slicing UI detail chickin & refactor number input chickin form

This commit is contained in:
randy-ar
2025-10-25 16:27:15 +07:00
parent f0f6ec53cb
commit 1e9d02b4b7
11 changed files with 595 additions and 48 deletions
+11
View File
@@ -18,6 +18,7 @@
"react": "19.1.0",
"react-dom": "19.1.0",
"react-hot-toast": "^2.6.0",
"react-number-format": "^5.4.4",
"react-select": "^5.10.2",
"swr": "^2.3.6",
"tailwind-merge": "^3.3.1",
@@ -5811,6 +5812,16 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-number-format": {
"version": "5.4.4",
"resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.4.tgz",
"integrity": "sha512-wOmoNZoOpvMminhifQYiYSTCLUDOiUbBunrMrMjA+dV52sY+vck1S4UhR6PkgnoCquvvMSeJjErXZ4qSaWCliA==",
"license": "MIT",
"peerDependencies": {
"react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-select": {
"version": "5.10.2",
"resolved": "https://registry.npmjs.org/react-select/-/react-select-5.10.2.tgz",
+1
View File
@@ -20,6 +20,7 @@
"react": "19.1.0",
"react-dom": "19.1.0",
"react-hot-toast": "^2.6.0",
"react-number-format": "^5.4.4",
"react-select": "^5.10.2",
"swr": "^2.3.6",
"tailwind-merge": "^3.3.1",
+2 -1
View File
@@ -59,7 +59,7 @@ const AddChickin = () => {
? listProjectFlock?.data.map((projectFlock) => {
return {
value: projectFlock.id,
label: `${projectFlock?.flock.name} - ${projectFlock?.category} - Periode ${projectFlock.period}`,
label: `${projectFlock?.flock?.name} - ${projectFlock?.category} - Periode ${projectFlock.period}`,
};
})
: [];
@@ -242,6 +242,7 @@ const AddChickin = () => {
created_user: projectFlock.data.created_user,
created_at: projectFlock.data.created_at,
updated_at: projectFlock.data.updated_at,
approval: projectFlock.data.approval,
}}
afterSubmit={handleAfterSubmit}
/>
+302 -30
View File
@@ -1,24 +1,51 @@
'use client'
'use client';
import { isResponseError, isResponseSuccess } from "@/lib/api-helper";
import { ChickinApi } from "@/services/api/production";
import { useRouter, useSearchParams } from "next/navigation";
import useSWR from "swr";
import Button from '@/components/Button';
import Card from '@/components/Card';
import Modal, { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ChickinForm from '@/components/pages/production/chickin/form/ChickinForm';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { ChickinApi } from '@/services/api/production';
import { BaseApiResponse } from '@/types/api/api-general';
import {
Chickin,
ChickinApprovalPayload,
} from '@/types/api/production/chickin';
import { Icon } from '@iconify/react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useState } from 'react';
import toast from 'react-hot-toast';
import useSWR from 'swr';
const DetailChickin = () => {
const router = useRouter();
const searchParams = useSearchParams();
const chickinId = searchParams.get('chickinId');
const [isApproveLoading, setIsApproveLoading] = useState(false);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const confirmModal = useModal();
const deleteModal = useModal();
const chickinModal = useModal();
const {
data: chickin,
isLoading,
} = useSWR(
chickinId,
(id: number) => ChickinApi.getSingle(id)
mutate: refreshChickin,
} = useSWR(chickinId, (id: number) => ChickinApi.getSingle(id));
const [isApprovedDisabled, setIsApprovedDisabled] = useState(
// chickin.data?.approval.step_number == 1 ? false : true
true
);
const [isRejectedDisabled, setIsRejectedDisabled] = useState(
!isApprovedDisabled
);
const [approvalAction, setApprovalAction] = useState<'APPROVED' | 'REJECTED'>(
!isApprovedDisabled ? 'APPROVED' : 'REJECTED'
);
if(!chickinId){
if (!chickinId) {
router.back();
return (
@@ -28,46 +55,291 @@ const DetailChickin = () => {
);
}
if (
!isLoading &&
(!chickin || isResponseError(chickin))
) {
if (!isLoading && (!chickin || isResponseError(chickin))) {
router.replace('/404');
return;
}
if (!isResponseSuccess(chickin)) {
return (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
);
}
const confirmationModalClickHandler = async ({
action = 'APPROVED',
}: {
action: 'APPROVED' | 'REJECTED';
}) => {
if (chickin?.data.id === undefined) return;
setIsApproveLoading(true);
const approveChickinRes = await ChickinApi.customRequest<
BaseApiResponse<Chickin>,
ChickinApprovalPayload
>(`/approvals`, {
method: 'POST',
payload: {
action: action,
approvable_ids: [chickin.data.id],
},
});
if (isResponseSuccess(approveChickinRes)) {
if (refreshChickin) {
await refreshChickin();
}
toast.success(approveChickinRes.message as string);
}
if (isResponseError(approveChickinRes)) {
toast.error(approveChickinRes?.message as string);
}
confirmModal.closeModal();
setIsApproveLoading(false);
};
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
const deleteProjectFlockRes = await ChickinApi.delete(
chickin.data?.id as number
);
if (isResponseSuccess(deleteProjectFlockRes)) {
toast.success(deleteProjectFlockRes?.message as string);
router.push('/production/chickin');
}
if (isResponseError(deleteProjectFlockRes)) {
toast.error(deleteProjectFlockRes?.message as string);
}
setIsDeleteLoading(false);
};
return (
<>
<div className="w-full p-4 flex flex-col justify-center gap-4">
<div className='w-full p-4 flex flex-col justify-center gap-4'>
{isLoading && <span className='loading loading-spinner loading-xl' />}
{!isLoading && isResponseSuccess(chickin) && (
<>
<div className="card shadow">
<div className="card-body">
<div className="card-title">
Informasi Project Flock
{/* <div className='w-full flex flex-col sm:flex-row gap-2'>
<Button
variant='outline'
color='success'
onClick={(() => {
if (chickin?.data.id) {
setApprovalAction('APPROVED');
confirmModal.openModal();
}
})}
disabled={!chickin?.data.id || isApprovedDisabled}
className='w-full sm:w-fit'
>
<Icon icon='material-symbols:check' width={24} height={24} />
Approve
</Button>
<Button
variant='outline'
color='error'
onClick={() => {
if (chickin?.data.id) {
setApprovalAction('REJECTED');
confirmModal.openModal();
}
}}
disabled={!chickin?.data.id || isRejectedDisabled}
className='w-full sm:w-fit'
>
<Icon icon='mdi:times' width={24} height={24} />
Reject
</Button>
</div> */}
<Card
title='Informasi Umum'
variant='bordered'
className={{
wrapper: 'w-full',
}}
>
<div className='grid grid-cols-2 gap-4 mt-4'>
<div className='flex flex-col gap-2'>
<div className='font-semibold text-sm'>Flock</div>
<div className='text-sm'>
{
chickin.data.project_flock_kandang?.project_flock.flock
.name
}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<div className="font-semibold">Flock</div>
<div >{chickin.data.project_flock_kandang?.project_flock.flock.name}</div>
<div className='flex flex-col gap-2'>
<div className='font-semibold text-sm'>Area</div>
<div className='text-sm'>
{
chickin.data.project_flock_kandang?.project_flock.area
.name
}
</div>
</div>
<div className='flex flex-col gap-2'>
<div className='font-semibold text-sm'>Kategori</div>
<div className='text-sm'>
{chickin.data.project_flock_kandang?.project_flock.category}
</div>
</div>
<div className='flex flex-col gap-2'>
<div className='font-semibold text-sm'>Lokasi</div>
<div className='text-sm'>
{
chickin.data.project_flock_kandang?.project_flock.location
.name
}
</div>
</div>
<div className='flex flex-col gap-2'>
<div className='font-semibold text-sm'>Periode</div>
<div className='text-sm'>
{chickin.data.project_flock_kandang?.project_flock.period}
</div>
</div>
<div className='flex flex-col gap-2'>
<div className='font-semibold text-sm'>Kandang</div>
<div className='text-sm'>
{chickin.data.project_flock_kandang?.kandang.name}
</div>
</div>
</div>
</div>
<div className="card shadow">
<div className="card-body">
<div className="card-title">
Informasi Chickin
</Card>
<Card
title='Detail Chickin'
variant='bordered'
className={{
wrapper: 'w-full',
}}
>
<div className='grid grid-cols-2 gap-4 mt-4'>
<div className='flex flex-col gap-2'>
<div className='font-semibold text-sm'>Flock Kandang</div>
<div className='text-sm'>
{
chickin.data.project_flock_kandang?.project_flock.flock
.name
}{' '}
- {chickin.data.project_flock_kandang?.kandang.name}
</div>
</div>
<div className='flex flex-col gap-2'>
<div className='font-semibold text-sm'>Tanggal Chickin</div>
<div className='text-sm'>
{chickin.data.chick_in_date
? new Date(chickin.data.chick_in_date).toLocaleDateString(
'id-ID'
)
: '-'}
</div>
</div>
<div className='flex flex-col gap-2'>
<div className='font-semibold text-sm'>Jumlah (Ekor)</div>
<div className='text-sm'>
{chickin.data.quantity?.toLocaleString('id-ID')}
</div>
</div>
<div className='flex flex-col gap-2'>
<div className='font-semibold text-sm'>Catatan</div>
<div className='text-sm'>{chickin.data.note}</div>
</div>
</div>
</Card>
<div className='w-full flex flex-col sm:flex-row gap-2'>
<Button
color='error'
onClick={() => {
deleteModal.openModal();
}}
>
<Icon icon='mdi:times' width={24} height={24} />
Delete
</Button>
<Button color='warning'
onClick={() => {
chickinModal.openModal();
}}
>
<Icon icon='mdi:pencil-outline' width={24} height={24} />
Edit
</Button>
</div>
</>
)}
</div>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Project Flock ini (${chickin?.data.project_flock_kandang?.project_flock.flock.name} - ${chickin?.data.project_flock_kandang?.kandang.name})?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
<Modal ref={chickinModal.ref}>
<div className='flex flex-row justify-between items-center'>
<h1 className='text-xl font-semibold text-center mb-6'>
Chickin Kandang -{' '}
{chickin?.data?.project_flock_kandang &&
chickin?.data?.project_flock_kandang.kandang?.name}
</h1>
<Button
color='error'
variant='link'
onClick={chickinModal.closeModal}
>
<Icon
className='text-black'
icon='uil:times'
width={24}
height={24}
/>
</Button>
</div>
<ChickinForm
initialValues={chickin?.data}
formType='edit'
afterSubmit={() => {
refreshChickin();
chickinModal.closeModal();
}}
/>
</Modal>
<ConfirmationModal
ref={confirmModal.ref}
type={approvalAction == 'APPROVED' ? 'success' : 'error'}
text={`Apakah anda yakin ingin ${
approvalAction == 'APPROVED' ? 'approve' : 'reject'
} chickin berikut? (${
chickin?.data.project_flock_kandang?.project_flock.flock.name
} - ${chickin?.data.project_flock_kandang?.kandang.name})?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: approvalAction == 'APPROVED' ? 'success' : 'error',
isLoading: isApproveLoading,
onClick: () => {
confirmationModalClickHandler({
action: approvalAction,
});
},
}}
/>
</>
);
}
};
export default DetailChickin;
export default DetailChickin;
+178
View File
@@ -0,0 +1,178 @@
"use client";
import { HTMLAttributes, ReactNode } from "react";
import { cn } from "@/lib/helper";
import Image from "next/image";
export interface CardProps
extends Omit<HTMLAttributes<HTMLDivElement>, "className"> {
title?: string;
subtitle?: string;
image?: string;
imageAlt?: string;
imageWidth?: number;
imageHeight?: number;
actions?: ReactNode;
footer?: ReactNode;
className?: {
wrapper?: string;
image?: string;
body?: string;
title?: string;
subtitle?: string;
actions?: string;
footer?: string;
};
variant?: "default" | "compact" | "bordered" | "shadow" | "image-full";
size?: "sm" | "md" | "lg";
}
const Card = ({
title,
subtitle,
image,
imageAlt,
imageWidth,
imageHeight,
actions,
footer,
className,
variant = "default",
size = "md",
children,
...props
}: CardProps) => {
const getCardClasses = () => {
const baseClasses = "card bg-base-100";
const variantClasses = {
default: "",
compact: "card-compact",
bordered: "border border-base-300",
shadow: "shadow-xl",
"image-full": "card-side card-compact shadow-xl",
};
const sizeClasses = {
sm: "w-64",
md: "w-96",
lg: "w-[28rem]",
};
return cn(
baseClasses,
variantClasses[variant],
variant !== "image-full" ? sizeClasses[size] : "",
className?.wrapper,
);
};
const getImageDimensions = () => {
if (variant === "image-full") {
return {
width: imageWidth || 128,
height: imageHeight || 128,
};
}
const cardWidths = {
sm: 256, // w-64
md: 384, // w-96
lg: 448, // w-[28rem]
};
return {
width: imageWidth || cardWidths[size],
height: imageHeight || 192,
};
};
const getImageClasses = () => {
if (variant === "image-full") {
return cn("object-cover", className?.image);
}
return cn("w-full object-cover", className?.image);
};
const getBodyClasses = () => {
const baseClasses = "card-body";
if (variant === "compact" || variant === "image-full") {
return cn(baseClasses, "p-4", className?.body);
}
return cn(baseClasses, "p-6", className?.body);
};
const getTitleClasses = () => {
const sizeClasses = {
sm: "text-lg",
md: "text-xl",
lg: "text-2xl",
};
return cn("card-title font-bold", sizeClasses[size], className?.title);
};
const getSubtitleClasses = () => {
return cn("text-base-content/70 text-sm mt-1", className?.subtitle);
};
const getActionsClasses = () => {
return cn("card-actions justify-end mt-4", className?.actions);
};
const getFooterClasses = () => {
return cn("border-t border-base-300 mt-4 pt-4", className?.footer);
};
if (variant === "image-full" && image) {
const imageDimensions = getImageDimensions();
return (
<div className={getCardClasses()} {...props}>
<figure>
<Image
src={image}
alt={imageAlt || title || "Card image"}
width={imageDimensions.width}
height={imageDimensions.height}
className={getImageClasses()}
/>
</figure>
<div className={getBodyClasses()}>
{title && <h2 className={getTitleClasses()}>{title}</h2>}
{subtitle && <p className={getSubtitleClasses()}>{subtitle}</p>}
{children}
{actions && <div className={getActionsClasses()}>{actions}</div>}
</div>
{footer && <div className={getFooterClasses()}>{footer}</div>}
</div>
);
}
return (
<div className={getCardClasses()} {...props}>
{image && (
<figure>
<Image
src={image}
alt={imageAlt || title || "Card image"}
width={getImageDimensions().width}
height={getImageDimensions().height}
className={getImageClasses()}
/>
</figure>
)}
<div className={getBodyClasses()}>
{title && <h2 className={getTitleClasses()}>{title}</h2>}
{subtitle && <p className={getSubtitleClasses()}>{subtitle}</p>}
{children}
{actions && <div className={getActionsClasses()}>{actions}</div>}
</div>
{footer && <div className={getFooterClasses()}>{footer}</div>}
</div>
);
};
export default Card;
+53
View File
@@ -0,0 +1,53 @@
'use client';
import { ChangeEvent } from 'react';
import { NumericFormat, OnValueChange } from 'react-number-format';
import TextInput, { TextInputProps } from '@/components/input/TextInput';
interface NumberInputProps extends Omit<TextInputProps, 'type'> {
thousandSeparator?: string;
decimalSeparator?: string;
decimalScale?: number;
allowNegative?: boolean;
prefix?: string;
suffix?: string;
fixedDecimalScale?: boolean;
}
const NumberInput = ({
thousandSeparator = ',',
decimalSeparator = '.',
decimalScale = 5,
allowNegative = true,
onChange,
...restProps
}: NumberInputProps) => {
const valueChangeHandler: OnValueChange = (
numberFormatValues,
sourceInfo
) => {
const newChangeEvent = sourceInfo.event as
| ChangeEvent<HTMLInputElement>
| undefined;
if (newChangeEvent) {
newChangeEvent.target.value = numberFormatValues.value;
onChange?.(newChangeEvent);
}
};
return (
<NumericFormat
thousandSeparator={thousandSeparator}
decimalSeparator={decimalSeparator}
customInput={TextInput}
onValueChange={valueChangeHandler}
decimalScale={decimalScale}
allowNegative={allowNegative}
{...restProps}
/>
);
};
export default NumberInput;
@@ -11,8 +11,8 @@ import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import { TableRowSizeSelector } from '@/components/table/TableRowSizeSelector';
import { ROWS_OPTIONS } from '@/config/constant';
import { isResponseSuccess } from '@/lib/api-helper';
import { cn } from '@/lib/helper';
import { ChickinApi, ProjectFlockApi } from '@/services/api/production';
import { cn, formatNumber } from '@/lib/helper';
import { ChickinApi } from '@/services/api/production';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { Chickin } from '@/types/api/production/chickin';
import { Icon } from '@iconify/react';
@@ -124,6 +124,13 @@ const ChickinTable = () => {
{
accessorFn: (row) => row.quantity,
header: 'Jumlah Chickin',
cell: (props) => {
if (props.row.original.quantity) {
return formatNumber(props.row.original.quantity);
} else {
return '-';
}
}
},
{
accessorFn: (row) => row.chick_in_date,
@@ -159,7 +166,7 @@ const ChickinTable = () => {
deleteModal.openModal();
};
const editClickHandler = () => {
const editClickHandler = () => {
setSelectedChickin(props.row.original);
chickinModal.openModal();
};
@@ -279,7 +286,7 @@ const RowOptionsMenu = ({
'p-2.5 mr-2 flex flex-col gap-1 bg-base-100 rounded-box z-10 border border-black/10 shadow'
)}
>
{/* <Button
<Button
href={`/production/chickin/detail?chickinId=${props.row.original.id}`}
variant='ghost'
color='primary'
@@ -287,7 +294,7 @@ const RowOptionsMenu = ({
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button> */}
</Button>
<Button
variant='ghost'
color='warning'
@@ -20,6 +20,7 @@ import toast from 'react-hot-toast';
import { Icon } from '@iconify/react';
import TextArea from '@/components/input/TextArea';
import TextInput from '@/components/input/TextInput';
import NumberInput from '@/components/input/NumberInput';
interface ChickinFormProps {
formType?: 'add' | 'detail' | 'edit';
@@ -46,7 +47,10 @@ const ChickinForm = ({
return {
chick_in_date: formatDateForInput(initialValues?.chick_in_date) ?? '',
note: initialValues?.note ?? '',
quantity: initialValues?.quantity ?? initialValues?.project_flock_kandang?.available_quantity ?? 0,
quantity:
initialValues?.quantity ??
initialValues?.project_flock_kandang?.available_quantity ??
0,
};
}, [initialValues]);
@@ -71,10 +75,7 @@ const ChickinForm = ({
payload: UpdateChickinPayload,
afterSubmit: (() => void) | undefined
) => {
const res = await ChickinApi.update(
payload.project_flock_kandang_id as number,
payload
);
const res = await ChickinApi.update(payload.id, payload);
if (isResponseError(res)) {
setChickinFormErrorMessage(res.message);
return;
@@ -95,7 +96,10 @@ const ChickinForm = ({
// reset error message
setChickinFormErrorMessage('');
if (initialValues?.project_flock_kandang?.id == undefined) {
if (
initialValues?.project_flock_kandang?.id == undefined ||
(formType == 'edit' && initialValues?.id == undefined)
) {
return;
}
@@ -105,9 +109,11 @@ const ChickinForm = ({
project_flock_kandang_id: initialValues?.project_flock_kandang?.id,
note: values.note,
quantity: values.quantity,
id: initialValues.id ?? 0,
};
// cek type form yang disubmit
console.log(formType);
switch (formType) {
case 'add':
handleCreate(payload, afterSubmit);
@@ -146,12 +152,12 @@ const ChickinForm = ({
}
errorMessage={formik.errors.chick_in_date}
/>
<TextInput
<NumberInput
value={formik.values.quantity}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
name='quantity'
label='Jumlah Chickin'
label='Jumlah (Ekor)'
required
isError={
(formik.touched.quantity && Boolean(formik.errors.quantity)) ||
@@ -162,7 +168,6 @@ const ChickinForm = ({
? 'Masukan Persediaan Day Old Chick terlebih dahulu.'
: formik.errors.quantity
}
type='number'
readOnly
/>
<TextArea
@@ -144,7 +144,6 @@ const ProjectFlockTable = () => {
const deleteModal = useModal();
const confirmModal = useModal();
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [selectedFlocks, setSelectedFlocks] = useState<ProjectFlock[]>([]);
const [isApproveLoading, setIsApproveLoading] = useState(false);
// Fetch Data
+13
View File
@@ -13,6 +13,19 @@ export const cn = (...inputs: ClassValue[]) => {
return twMerge(clsx(inputs));
};
export const formatNumber = (
value: number | bigint | Intl.StringNumericLiteral,
locale = 'en-US',
minimumFractionDigits = 0,
maximumFractionDigits = 2
) => {
return new Intl.NumberFormat(locale, {
minimumFractionDigits,
maximumFractionDigits,
}).format(value);
};
export const formatCurrency = (
value: number | bigint | Intl.StringNumericLiteral,
currency = 'USD',
+9 -2
View File
@@ -1,4 +1,4 @@
import { BaseMetadata } from "@/types/api/api-general";
import { BaseApproval, BaseMetadata } from "@/types/api/api-general";
import { ProjectFlockKandang } from "@/types/api/production/project-flock-kandang";
export type BaseChickin = {
@@ -7,6 +7,7 @@ export type BaseChickin = {
quantity?: number;
note?: string;
project_flock_kandang?: ProjectFlockKandang;
approval: BaseApproval;
}
export type Chickin = BaseMetadata & BaseChickin;
@@ -18,5 +19,11 @@ export type CreateChickinPayload = {
quantity?: number;
}
export type UpdateChickinPayload = CreateChickinPayload;
export type UpdateChickinPayload = CreateChickinPayload & {
id: number;
};
export type ChickinApprovalPayload = {
action: 'APPROVED' | 'REJECTED';
approvable_ids: number[];
};