Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into dev/restu

This commit is contained in:
rstubryan
2026-02-27 11:21:34 +07:00
35 changed files with 2565 additions and 1832 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ import InventoryAdjustmentTable from '@/components/pages/inventory/adjustment/In
const InventoryAdjustment = () => {
return (
<section className='w-full p-4'>
<section className='w-full'>
<InventoryAdjustmentTable />
</section>
);
+1 -1
View File
@@ -2,7 +2,7 @@ import RecordingTable from '@/components/pages/production/recording/RecordingTab
const Recording = () => {
return (
<section className='w-full overflow-x-hidden'>
<section className='w-full'>
<RecordingTable />
</section>
);
+41 -7
View File
@@ -3,15 +3,51 @@ import { getFilledFormikValuesCount } from '@/lib/formik-helper';
import { cn } from '@/lib/helper';
import { Icon } from '@iconify/react';
import { FormikValues } from 'formik';
import { useMemo } from 'react';
export type ButtonFilterProps = ButtonProps & {
values: FormikValues;
onClick: () => void;
excludeFields?: string[];
fieldGroups?: string[][];
};
// 'bg-gradient-to-t from-blue-50 to-blue-100 border-blue-500 text-blue-600 hover:from-blue-100 hover:to-blue-200
const ButtonFilter = ({ values, onClick, ...props }: ButtonFilterProps) => {
const ButtonFilter = ({
values,
onClick,
excludeFields = [],
fieldGroups = [],
...props
}: ButtonFilterProps) => {
const activeCount = useMemo(() => {
const filteredValues: FormikValues = {};
Object.keys(values).forEach((key) => {
if (!excludeFields.includes(key)) {
filteredValues[key] = values[key];
}
});
let count = getFilledFormikValuesCount(filteredValues);
fieldGroups.forEach((group) => {
const groupFields = group.filter(
(field) => !excludeFields.includes(field)
);
const filledGroupFields = groupFields.filter(
(field) => filteredValues[field]
);
if (
filledGroupFields.length === groupFields.length &&
groupFields.length > 1
) {
count -= groupFields.length - 1;
}
});
return count;
}, [values, excludeFields, fieldGroups]);
return (
<Button
{...props}
@@ -21,7 +57,7 @@ const ButtonFilter = ({ values, onClick, ...props }: ButtonFilterProps) => {
className={cn(
'rounded-lg max-h-10 font-semibold text-sm gap-1.5',
'text-sm text-base-content/50 border border-base-content/10 shadow-button-soft',
getFilledFormikValuesCount(values) > 0
activeCount > 0
? 'border-primary-gradient text-primary rounded-lg!'
: 'rounded-lg',
props.className
@@ -31,14 +67,12 @@ const ButtonFilter = ({ values, onClick, ...props }: ButtonFilterProps) => {
icon='heroicons:funnel'
width={20}
height={20}
className={
getFilledFormikValuesCount(values) > 0 ? 'text-blue-600' : ''
}
className={activeCount > 0 ? 'text-blue-600' : ''}
/>
Filter
{getFilledFormikValuesCount(values) > 0 && (
{activeCount > 0 && (
<span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
{getFilledFormikValuesCount(values)}
{activeCount}
</span>
)}
</Button>
+8 -2
View File
@@ -134,14 +134,20 @@ const DropFileInput: React.FC<DropFileInputProps> = ({
{!isError && bottomLabel && (
<p
className={cn('w-full text-sm opacity-60', className?.bottomLabel)}
className={cn(
'w-full mt-1.5 text-xs opacity-60',
className?.bottomLabel
)}
>
{bottomLabel}
</p>
)}
{isError && (
<p
className={cn('w-full text-sm text-error', className?.errorMessage)}
className={cn(
'w-full mt-1.5 text-xs text-error',
className?.errorMessage
)}
>
{errorMessage}
</p>
+2 -2
View File
@@ -144,12 +144,12 @@ export const RadioGroup = ({
{/* Label bawah */}
{!isError && bottomLabel && (
<p className='text-sm opacity-60'>{bottomLabel}</p>
<p className='mt-1.5 text-xs opacity-60'>{bottomLabel}</p>
)}
{/* Pesan error */}
{isError && errorMessage && (
<p className='text-sm text-error'>{errorMessage}</p>
<p className='mt-1.5 text-xs text-error'>{errorMessage}</p>
)}
</div>
</RadioGroupContext.Provider>
+4 -2
View File
@@ -488,9 +488,11 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
/>
)}
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
{isError && (
<p className='w-full mt-1.5 text-xs text-error'>{errorMessage}</p>
)}
{!isError && bottomLabel && (
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
<p className='w-full mt-1.5 text-xs opacity-60'>{bottomLabel}</p>
)}
</div>
);
+4 -2
View File
@@ -159,9 +159,11 @@ const TagInput: React.FC<TagInputProps> = ({
{/* Bottom label or error message */}
{!isError && bottomLabel && (
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
<p className='w-full mt-1.5 text-xs opacity-60'>{bottomLabel}</p>
)}
{isError && (
<p className='w-full mt-1.5 text-xs text-error'>{errorMessage}</p>
)}
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
</div>
);
};
+7 -35
View File
@@ -31,6 +31,7 @@ import {
ClosingFilterType,
} from '@/components/pages/closing/filter/ClosingFilter';
import ClosingTableSkeleton from '@/components/pages/closing/skeleton/ClosingTableSkeleton';
import ButtonFilter from '@/components/helper/ButtonFilter';
const RowOptionsMenu = ({
props,
@@ -158,6 +159,7 @@ const ClosingsTable = () => {
onReset: () => {
updateFilter('location_id', '');
updateFilter('project_status', '');
filterModal.closeModal();
},
});
@@ -287,23 +289,6 @@ const ClosingsTable = () => {
);
}, [formik.values.project_status, projectStatusOptions]);
// ===== ACTIVE FILTERS COUNT =====
const activeFiltersCount = useMemo(() => {
let count = 0;
if (tableFilterState.location_id) {
count += 1;
}
if (tableFilterState.project_status) {
count += 1;
}
return count;
}, [tableFilterState.location_id, tableFilterState.project_status]);
const hasFilters = activeFiltersCount > 0;
// ===== SEARCH CHANGE HANDLER =====
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
@@ -352,25 +337,12 @@ const ClosingsTable = () => {
}}
/>
<Button
variant='outline'
color='none'
<ButtonFilter
values={tableFilterState}
excludeFields={['page', 'pageSize', 'search']}
onClick={handleFilterModalOpen}
className={cn(
'px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft transition-all',
{
'border-primary-gradient text-primary': hasFilters,
}
)}
>
<Icon icon='heroicons:funnel' width={20} height={20} />
Filter
{hasFilters && (
<span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
{activeFiltersCount}
</span>
)}
</Button>
className='px-3 py-2.5'
/>
</div>
</div>
+277 -331
View File
@@ -16,41 +16,39 @@ import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Button from '@/components/Button';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
import RealizationStatusBadge from '@/components/pages/expense/RealizationStatusBadge';
import ExpenseStatusBadge from '@/components/pages/expense/ExpenseStatusBadge';
import CheckboxInput from '@/components/input/CheckboxInput';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import DateInput from '@/components/input/DateInput';
import RequirePermission from '@/components/helper/RequirePermission';
import ButtonFilter from '@/components/helper/ButtonFilter';
import ExpensesFilterModal from '@/components/pages/expense/filter/ExpensesFilterModal';
import { Expense } from '@/types/api/expense';
import { ExpenseApi } from '@/services/api/expense';
import { cn, formatCurrency, formatDate } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { LocationApi, SupplierApi } from '@/services/api/master-data';
import { Location } from '@/types/api/master-data/location';
import { Supplier } from '@/types/api/master-data/supplier';
import { BaseApiResponse } from '@/types/api/api-general';
const RowOptionsMenu = ({
type = 'dropdown',
popoverPosition = 'bottom',
props,
deleteClickHandler,
}: {
type: 'dropdown' | 'collapse';
popoverPosition: 'bottom' | 'top';
props: CellContext<Expense, unknown>;
approveClickHandler: () => void;
rejectClickHandler: () => void;
deleteClickHandler: () => void;
}) => {
const popoverId = `expense#${props.row.original.id}`;
const popoverAnchorName = `--anchor-expense#${props.row.original.id}`;
const closePopover = () => {
document.getElementById(popoverId)?.hidePopover();
};
const showEditButton = props.row.original.latest_approval
? props.row.original.latest_approval.step_number !== 6 &&
(props.row.original.latest_approval.step_number === 1 ||
@@ -59,81 +57,95 @@ const RowOptionsMenu = ({
props.row.original.latest_approval.step_number === 4)
: false;
// TODO: apply RBAC
const showRealizationButton = props.row.original.latest_approval
? props.row.original.latest_approval.action !== 'REJECTED' &&
props.row.original.latest_approval.step_number === 4
: false;
return (
<RowOptionsMenuWrapper type={type}>
<div className='w-full max-h-40 overflow-auto flex flex-col gap-1'>
<RequirePermission permissions='lti.expense.detail'>
<Button
href={`/expense/detail/?expenseId=${props.row.original.id}`}
variant='ghost'
color='primary'
className='justify-start text-sm'
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
</RequirePermission>
<div className='relative'>
<PopoverButton
tabIndex={0}
variant='ghost'
color='none'
popoverTarget={popoverId}
anchorName={popoverAnchorName}
>
<Icon icon='material-symbols:more-vert' width={16} height={16} />
</PopoverButton>
{showEditButton && (
<RequirePermission permissions='lti.expense.update'>
<PopoverContent
id={popoverId}
anchorName={popoverAnchorName}
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
>
<div className='flex flex-col bg-base-100 rounded-xl'>
<RequirePermission permissions='lti.expense.detail'>
<Button
href={`/expense/detail/edit/?expenseId=${props.row.original.id}`}
href={`/expense/detail/?expenseId=${props.row.original.id}`}
variant='ghost'
color='warning'
className='justify-start text-sm'
color='none'
className='p-3 justify-start text-sm font-semibold w-full'
onClick={closePopover}
>
<Icon
icon='material-symbols:edit-outline'
width={16}
height={16}
/>
Edit
<Icon icon='heroicons:eye' width={20} height={20} />
Detail
</Button>
</RequirePermission>
)}
{showRealizationButton && (
<RequirePermission permissions='lti.expense.create.realization'>
{showEditButton && (
<RequirePermission permissions='lti.expense.update'>
<Button
href={`/expense/detail/edit/?expenseId=${props.row.original.id}`}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full'
onClick={closePopover}
>
<Icon icon='mdi:pencil-outline' width={20} height={20} />
Edit
</Button>
</RequirePermission>
)}
{showRealizationButton && (
<RequirePermission permissions='lti.expense.create.realization'>
<Button
href={`/expense/realization/?expenseId=${props.row.original.id}`}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full'
onClick={closePopover}
>
<Icon
icon='material-symbols:money-bag-rounded'
width={20}
height={20}
className='text-info'
/>
Realisasi
</Button>
</RequirePermission>
)}
<RequirePermission permissions='lti.expense.delete'>
<Button
href={`/expense/realization/?expenseId=${props.row.original.id}`}
onClick={() => {
deleteClickHandler();
closePopover();
}}
variant='ghost'
color='info'
className='justify-start text-sm text-info focus-visible:text-info-content hover:text-info-content'
color='error'
className='p-3 justify-start text-sm font-semibold w-full focus-visible:text-error-content hover:text-error-content'
>
<Icon
icon='material-symbols:money-bag-rounded'
width={16}
height={16}
/>
Realisasi
<Icon icon='mdi:delete-outline' width={20} height={20} />
Delete
</Button>
</RequirePermission>
)}
<RequirePermission permissions='lti.expense.delete'>
<Button
onClick={deleteClickHandler}
variant='ghost'
color='error'
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={16}
height={16}
className='justify-start text-sm'
/>
Delete
</Button>
</RequirePermission>
</div>
</RowOptionsMenuWrapper>
</div>
</PopoverContent>
</div>
);
};
@@ -179,6 +191,9 @@ const ExpensesTable = () => {
const approveModal = useModal();
const rejectModal = useModal();
// ===== FILTER MODAL STATE =====
const filterModal = useModal();
const [selectedExpense, setSelectedExpense] = useState<Expense | undefined>(
undefined
);
@@ -340,31 +355,7 @@ const ExpensesTable = () => {
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 3;
const approveClickHandler = () => {
setSelectedExpense(props.row.original);
// Set row selection
setRowSelection({
[String(props.row.original.id)]: true,
});
setApprovalNotes('');
approveModal.openModal();
};
const rejectClickHandler = () => {
setSelectedExpense(props.row.original);
// Set row selection
setRowSelection({
[String(props.row.original.id)]: true,
});
setApprovalNotes('');
rejectModal.openModal();
};
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedExpense(props.row.original);
@@ -372,31 +363,11 @@ const ExpensesTable = () => {
};
return (
<>
{currentPageSize > 3 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu
type='dropdown'
props={props}
approveClickHandler={approveClickHandler}
rejectClickHandler={rejectClickHandler}
deleteClickHandler={deleteClickHandler}
/>
</RowDropdownOptions>
)}
{currentPageSize <= 3 && (
<RowCollapseOptions>
<RowOptionsMenu
type='collapse'
props={props}
approveClickHandler={approveClickHandler}
rejectClickHandler={rejectClickHandler}
deleteClickHandler={deleteClickHandler}
/>
</RowCollapseOptions>
)}
</>
<RowOptionsMenu
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
props={props}
deleteClickHandler={deleteClickHandler}
/>
);
},
},
@@ -535,51 +506,32 @@ const ExpensesTable = () => {
setIsRejectLoading(false);
};
const {
setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const [selectedLocation, setSelectedLocation] = useState<OptionType | null>(
null
);
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
setSelectedLocation(val as OptionType);
updateFilter(
'locationId',
val ? ((val as OptionType).value as string) : ''
);
};
const {
setInputValue: setVendorInputValue,
options: vendorOptions,
isLoadingOptions: isLoadingVendorOptions,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
const [selectedVendor, setSelectedVendor] = useState<OptionType | null>(null);
const vendorChangeHandler = (val: OptionType | OptionType[] | null) => {
setSelectedVendor(val as OptionType);
updateFilter('vendorId', val ? ((val as OptionType).value as string) : '');
};
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
};
const transactionDateChangeHandler: ChangeEventHandler<HTMLInputElement> = (
e
) => {
updateFilter('transactionDate', e.target.value);
// ===== FILTER MODAL HANDLERS =====
const handleFilterModalOpen = () => {
filterModal.openModal();
};
const realizationDateChangeHandler: ChangeEventHandler<HTMLInputElement> = (
e
) => {
updateFilter('realizationDate', e.target.value);
const handleFilterSubmit = (values: {
transaction_date?: string | null;
realization_date?: string | null;
location_id?: string | null;
vendor_id?: string | null;
}) => {
updateFilter('transactionDate', values.transaction_date || '');
updateFilter('realizationDate', values.realization_date || '');
updateFilter('locationId', values.location_id || '');
updateFilter('vendorId', values.vendor_id || '');
};
const handleFilterReset = () => {
updateFilter('transactionDate', '');
updateFilter('realizationDate', '');
updateFilter('locationId', '');
updateFilter('vendorId', '');
};
// track sorting
@@ -595,188 +547,176 @@ const ExpensesTable = () => {
return (
<>
<div className='w-full p-0 sm:p-4'>
<div className='flex flex-col gap-2 mb-4'>
<div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-4'>
<div className='w-full sm:w-fit flex flex-col sm:flex-row self-start gap-2'>
<RequirePermission permissions='lti.expense.create'>
<div className='w-full'>
<div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
{/* Action Buttons */}
<div className='w-fit flex flex-row gap-3 flex-wrap'>
<RequirePermission permissions='lti.expense.create'>
<Button
href='/expense/add'
color='primary'
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-xl shadow-button-soft'
>
<Icon icon='heroicons:plus' width={20} height={20} />
Add Expense
</Button>
</RequirePermission>
{selectedRowIds.length > 0 && (
<>
<hr className='w-px h-full border-none bg-base-content/10 sm:block hidden' />
<RequirePermission permissions='lti.expense.approve.head_area'>
<Button
href='/expense/add'
color='primary'
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
variant='outline'
color='none'
onClick={bulkApproveClickHandler}
disabled={!isAllSelectedRowLatestApprovalOnHeadArea}
className='px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
>
<Icon icon='heroicons:plus' width={20} height={20} />
Add Expense
<Icon
icon='lucide-lab:farm'
width={20}
height={20}
className='text-success'
/>
Approve Head Area
</Button>
</RequirePermission>
{selectedRowIds.length > 0 && (
<>
<RequirePermission permissions='lti.expense.approve.head_area'>
<Button
variant='outline'
color='info'
onClick={bulkApproveClickHandler}
disabled={!isAllSelectedRowLatestApprovalOnHeadArea}
className='w-full sm:w-fit'
>
<Icon icon='lucide-lab:farm' width={24} height={24} />
Approve Head Area
</Button>
</RequirePermission>
<RequirePermission permissions='lti.expense.approve.unit_vice_president'>
<Button
variant='outline'
color='none'
onClick={bulkApproveClickHandler}
disabled={
!isAllSelectedRowLatestApprovalOnUnitVicePresident
}
className='px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
>
<Icon
icon='tdesign:money'
width={20}
height={20}
className='text-success'
/>
Approve Unit Vice President
</Button>
</RequirePermission>
<RequirePermission permissions='lti.expense.approve.unit_vice_president'>
<Button
variant='outline'
color='success'
onClick={bulkApproveClickHandler}
disabled={
!isAllSelectedRowLatestApprovalOnUnitVicePresident
}
className='w-full sm:w-fit'
>
<Icon icon='tdesign:money' width={24} height={24} />
Approve Unit Vice President
</Button>
</RequirePermission>
<RequirePermission permissions='lti.expense.approve.finance'>
<Button
variant='outline'
color='none'
onClick={bulkApproveClickHandler}
disabled={!isAllSelectedRowLatestApprovalOnFinance}
className='px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
>
<Icon
icon='tdesign:money'
width={20}
height={20}
className='text-success'
/>
Approve Finance
</Button>
</RequirePermission>
<RequirePermission permissions='lti.expense.approve.finance'>
<Button
variant='outline'
color='success'
onClick={bulkApproveClickHandler}
disabled={!isAllSelectedRowLatestApprovalOnFinance}
className='w-full sm:w-fit'
>
<Icon icon='tdesign:money' width={24} height={24} />
Approve Finance
</Button>
</RequirePermission>
<RequirePermission
permissions={[
'lti.expense.approve.head_area',
'lti.expense.approve.unit_vice_president',
'lti.expense.approve.finance',
]}
>
<Button
variant='outline'
color='none'
onClick={bulkRejectClickHandler}
disabled={
!isAllSelectedRowLatestApprovalOnHeadArea &&
!isAllSelectedRowLatestApprovalOnUnitVicePresident &&
!isAllSelectedRowLatestApprovalOnFinance
}
className='px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
>
<Icon
icon='material-symbols:close'
width={20}
height={20}
className='text-error'
/>
Reject
</Button>
</RequirePermission>
</>
)}
</div>
<RequirePermission
permissions={[
'lti.expense.approve.head_area',
'lti.expense.approve.unit_vice_president',
'lti.expense.approve.finance',
]}
>
<Button
variant='outline'
color='error'
onClick={bulkRejectClickHandler}
disabled={
!isAllSelectedRowLatestApprovalOnHeadArea &&
!isAllSelectedRowLatestApprovalOnUnitVicePresident &&
!isAllSelectedRowLatestApprovalOnFinance
}
className='w-full sm:w-fit'
>
<Icon
icon='material-symbols:close'
width={24}
height={24}
/>
Reject
</Button>
</RequirePermission>
</>
)}
</div>
</div>
{/* Search and Filter */}
<div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
<DebouncedTextInput
name='search'
placeholder='Search'
value={tableFilterState.search ?? ''}
onChange={searchChangeHandler}
startAdornment={
<Icon
icon='heroicons:magnifying-glass'
width={20}
height={20}
/>
}
className={{
wrapper: 'w-full min-w-24 max-w-3xs',
inputWrapper: 'rounded-xl! shadow-button-soft',
input:
'placeholder:font-semibold placeholder:text-base-content/50',
}}
/>
<div className='grid grid-cols-12 justify-end gap-2'>
<DateInput
required
label='Tanggal Transaksi'
name='transaction_date'
placeholder='Masukkan tanggal transaksi'
value={tableFilterState.transactionDate}
onChange={transactionDateChangeHandler}
className={{
wrapper: 'col-span-12 sm:col-span-3',
}}
/>
<DateInput
required
label='Tanggal Realisasi'
name='realization_date'
placeholder='Masukkan tanggal realisasi'
value={tableFilterState.realizationDate}
onChange={realizationDateChangeHandler}
className={{
wrapper: 'col-span-12 sm:col-span-3',
}}
/>
<SelectInput
label='Lokasi'
options={locationOptions}
isLoading={isLoadingLocationOptions}
value={selectedLocation}
onChange={locationChangeHandler}
onInputChange={setLocationInputValue}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-3',
}}
/>
<SelectInput
label='Vendor'
options={vendorOptions}
isLoading={isLoadingVendorOptions}
value={selectedVendor}
onChange={vendorChangeHandler}
onInputChange={setVendorInputValue}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-3',
}}
/>
<DebouncedTextInput
name='search'
placeholder='Cari Biaya Operasional'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'col-span-12 max-w-52 justify-self-end' }}
/>
</div>
<ButtonFilter
values={tableFilterState}
excludeFields={[
'page',
'pageSize',
'search',
'nameSort',
'userId',
]}
onClick={handleFilterModalOpen}
className='px-3 py-2.5'
/>
</div>
</div>
<Table<Expense>
data={isResponseSuccess(expenses) ? expenses?.data : []}
columns={expensesColumns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(expenses) ? expenses?.meta?.page : 0}
totalItems={
isResponseSuccess(expenses) ? expenses?.meta?.total_results : 0
}
onPageChange={setPage}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
enableRowSelection={tableEnableRowSelectionHandler}
className={{
containerClassName: cn({
'mb-20':
isResponseSuccess(expenses) && expenses?.data?.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',
}}
/>
{/* Table Section */}
<div className='flex flex-col mb-4'>
<Table<Expense>
data={isResponseSuccess(expenses) ? expenses?.data : []}
columns={expensesColumns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(expenses) ? expenses?.meta?.page : 0}
totalItems={
isResponseSuccess(expenses) ? expenses?.meta?.total_results : 0
}
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
enableRowSelection={tableEnableRowSelectionHandler}
className={{
containerClassName: cn('p-3 mb-0', {
'w-full':
isResponseSuccess(expenses) && expenses?.data?.length === 0,
}),
headerColumnClassName: 'text-nowrap',
}}
/>
</div>
</div>
<ConfirmationModal
@@ -831,6 +771,12 @@ const ExpensesTable = () => {
onClick: confirmationModalRejectClickHandler,
}}
/>
<ExpensesFilterModal
ref={filterModal.ref}
onSubmit={handleFilterSubmit}
onReset={handleFilterReset}
/>
</>
);
};
@@ -0,0 +1,28 @@
import * as yup from 'yup';
export type ExpensesFilterType = {
transaction_date: string | null;
realization_date: string | null;
location_id: string | null;
vendor_id: string | null;
};
export const ExpensesFilterSchema = yup.object({
transaction_date: yup.string().nullable(),
realization_date: yup
.string()
.nullable()
.test(
'is-greater-or-equal-transaction',
'Tanggal realisasi tidak boleh sebelum tanggal transaksi',
function (value) {
const { transaction_date } = this.parent;
if (!transaction_date || !value) return true;
return new Date(value) >= new Date(transaction_date);
}
),
location_id: yup.string().nullable(),
vendor_id: yup.string().nullable(),
});
export type ExpensesFilterValues = yup.InferType<typeof ExpensesFilterSchema>;
@@ -0,0 +1,206 @@
'use client';
import { RefObject } from 'react';
import { useFormik } from 'formik';
import { Icon } from '@iconify/react';
import Modal from '@/components/Modal';
import Button from '@/components/Button';
import DateInput from '@/components/input/DateInput';
import SelectInput from '@/components/input/SelectInput';
import { OptionType, useSelect } from '@/components/input/SelectInput';
import { LocationApi, SupplierApi } from '@/services/api/master-data';
import { Location } from '@/types/api/master-data/location';
import { Supplier } from '@/types/api/master-data/supplier';
import {
ExpensesFilterSchema,
ExpensesFilterValues,
} from '@/components/pages/expense/filter/ExpensesFilter';
interface ExpensesFilterModalProps {
ref: RefObject<HTMLDialogElement | null>;
initialValues?: ExpensesFilterValues;
onSubmit?: (values: Partial<ExpensesFilterValues>) => void;
onReset?: () => void;
}
const ExpensesFilterModal = ({
ref,
initialValues,
onSubmit,
onReset,
}: ExpensesFilterModalProps) => {
const closeModalHandler = () => {
ref.current?.close();
};
const {
setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const {
setInputValue: setVendorInputValue,
options: vendorOptions,
isLoadingOptions: isLoadingVendorOptions,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
const formik = useFormik<ExpensesFilterValues>({
initialValues: initialValues || {
transaction_date: null,
realization_date: null,
location_id: null,
vendor_id: null,
},
validationSchema: ExpensesFilterSchema,
onSubmit: async (values) => {
onSubmit?.(values);
closeModalHandler();
},
onReset: () => {
onReset?.();
closeModalHandler();
},
});
const locationValue = formik.values.location_id
? locationOptions.find(
(opt) => String(opt.value) === formik.values.location_id
) || null
: null;
const vendorValue = formik.values.vendor_id
? vendorOptions.find(
(opt) => String(opt.value) === formik.values.vendor_id
) || null
: null;
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
const locationId =
val && !Array.isArray(val) ? (String(val.value) as string) : null;
formik.setFieldValue('location_id', locationId);
};
const vendorChangeHandler = (val: OptionType | OptionType[] | null) => {
const vendorId =
val && !Array.isArray(val) ? (String(val.value) as string) : null;
formik.setFieldValue('vendor_id', vendorId);
};
return (
<Modal
ref={ref}
className={{
modalBox: 'p-0 rounded-xl xl:max-w-4/12 max-w-sm',
}}
>
<form
onSubmit={formik.handleSubmit}
onReset={formik.handleReset}
className='w-full flex flex-col'
>
{/* Modal Header */}
<div className='p-4 flex items-center justify-between gap-2 border-b border-base-content/10'>
<div className='flex items-center gap-2 text-primary'>
<Icon icon='heroicons:funnel' width={20} height={20} />
<h3 className='text-sm font-medium'>Filter Data</h3>
</div>
<Button
type='button'
variant='ghost'
color='none'
onClick={closeModalHandler}
className='p-0 text-base-content/50 hover:text-base-content'
>
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
{/* Modal Body */}
<div className='p-4 flex flex-col gap-1.5'>
<DateInput
label='Tanggal Transaksi'
name='transaction_date'
placeholder='Masukkan tanggal transaksi'
value={formik.values.transaction_date || ''}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={
formik.touched.transaction_date &&
!!formik.errors.transaction_date
}
/>
<DateInput
label='Tanggal Realisasi'
name='realization_date'
placeholder='Masukkan tanggal realisasi'
value={formik.values.realization_date || ''}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={
formik.touched.realization_date &&
!!formik.errors.realization_date
}
/>
{formik.touched.realization_date &&
formik.errors.realization_date && (
<span className='text-xs text-error'>
{formik.errors.realization_date}
</span>
)}
<SelectInput
label='Lokasi'
placeholder='Pilih Lokasi'
options={locationOptions}
value={locationValue}
onChange={locationChangeHandler}
onInputChange={setLocationInputValue}
isLoading={isLoadingLocationOptions}
isClearable
isSearchable={true}
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Vendor'
placeholder='Pilih Vendor'
options={vendorOptions}
value={vendorValue}
onChange={vendorChangeHandler}
onInputChange={setVendorInputValue}
isLoading={isLoadingVendorOptions}
isClearable
isSearchable={true}
className={{ wrapper: 'w-full' }}
/>
</div>
{/* Modal Footer */}
<div className='p-4 flex justify-between gap-4 border-t border-base-content/10 bg-gray-50'>
<Button
type='reset'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
>
Reset Filter
</Button>
<Button
type='submit'
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
disabled={!formik.isValid || formik.isSubmitting}
>
Apply Filter
</Button>
</div>
</form>
</Modal>
);
};
export default ExpensesFilterModal;
+21 -27
View File
@@ -42,7 +42,6 @@ import {
FinanceTableFilterSchema,
FinanceTableFilterValues,
} from './FinanceTableFilter.schema';
import SelectInputRadio from '@/components/input/SelectInputRadio';
const RowOptionsMenu = ({
popoverPosition = 'bottom',
@@ -234,6 +233,7 @@ const FinanceTable = () => {
const [selectedFinance, setSelectedFinance] = useState<Finance | null>(null);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [dateErrorShown, setDateErrorShown] = useState(false);
const [hasDateError, setHasDateError] = useState(false);
// ===== Formik for Filter =====
const filterFormik = useFormik<FinanceTableFilterValues>({
@@ -423,10 +423,7 @@ const FinanceTable = () => {
const endDateObj = new Date(endDate);
if (endDateObj < startDate) {
filterFormik.setFieldError(
'end_date',
'Tanggal akhir tidak boleh masa lampau'
);
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
@@ -434,12 +431,14 @@ const FinanceTable = () => {
setDateErrorShown(true);
}
} else {
filterFormik.setFieldError('end_date', undefined);
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
}
} else {
setHasDateError(false);
}
};
@@ -454,10 +453,7 @@ const FinanceTable = () => {
const endDate = new Date(value);
if (endDate < startDateObj) {
filterFormik.setFieldError(
'end_date',
'Tanggal akhir tidak boleh masa lampau'
);
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
@@ -468,7 +464,7 @@ const FinanceTable = () => {
}
}
filterFormik.setFieldError('end_date', undefined);
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
@@ -647,7 +643,7 @@ const FinanceTable = () => {
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
>
<Icon icon='mdi:bank-transfer-in' width={20} height={20} />
Add Injection (Saldo Bank)
Injection Saldo Bank
</Button>
</RequirePermission>
<RequirePermission permissions='lti.finance.initial_balances.create'>
@@ -657,7 +653,7 @@ const FinanceTable = () => {
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
>
<Icon icon='mdi:cash-register' width={20} height={20} />
Add Initial Balance
Saldo Awal
</Button>
</RequirePermission>
<RequirePermission permissions='lti.finance.payments.create'>
@@ -667,7 +663,7 @@ const FinanceTable = () => {
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
>
<Icon icon='heroicons:plus' width={20} height={20} />
Add Finance
Tambah
</Button>
</RequirePermission>
</div>
@@ -765,10 +761,7 @@ const FinanceTable = () => {
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<form
onSubmit={filterFormik.handleSubmit}
onReset={filterFormik.handleReset}
>
<form onSubmit={filterFormik.handleSubmit} onReset={filterFormik.handleReset}>
<div className='p-4 flex flex-col gap-1.5'>
<SelectInput
options={FINANCE_TRANSACTION_TYPE_OPTIONS}
@@ -823,7 +816,7 @@ const FinanceTable = () => {
isMulti
className={{ wrapper: 'w-full' }}
/>
<SelectInputRadio
<SelectInput
options={sortByOptions}
label='Urutkan Berdasarkan'
placeholder='Pilih Urutan'
@@ -836,22 +829,23 @@ const FinanceTable = () => {
name='start_date'
label='Periode Tanggal (Mulai)'
value={filterFormik.values.start_date}
errorMessage={filterFormik.errors.start_date}
onChange={startDateChangeHandler}
errorMessage={
filterFormik.errors.end_date
? filterFormik.errors.end_date
: undefined
isError={
filterFormik.touched.start_date &&
Boolean(filterFormik.errors.start_date)
}
/>
<DateInput
name='end_date'
label='Periode Tanggal (Akhir)'
value={filterFormik.values.end_date}
errorMessage={filterFormik.errors.end_date}
onChange={endDateChangeHandler}
errorMessage={
filterFormik.errors.end_date
? filterFormik.errors.end_date
: undefined
isError={
(filterFormik.touched.end_date &&
Boolean(filterFormik.errors.end_date)) ||
hasDateError
}
/>
</div>
@@ -1,20 +1,18 @@
'use client';
import Badge from '@/components/Badge';
import { useCallback, useEffect, useMemo, useState } from 'react';
import useSWR from 'swr';
import { Icon } from '@iconify/react';
import { ColumnDef, ColumnSort, SortingState } from '@tanstack/react-table';
import Button from '@/components/Button';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import Table from '@/components/Table';
import RequirePermission from '@/components/helper/RequirePermission';
import { ROWS_OPTIONS } from '@/config/constant';
import { isResponseSuccess } from '@/lib/api-helper';
import { cn } from '@/lib/helper';
import { cn, formatNumber, formatDate, formatCurrency } from '@/lib/helper';
import { InventoryAdjustmentApi } from '@/services/api/inventory';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { InventoryAdjustment } from '@/types/api/inventory/adjustment';
import { Icon } from '@iconify/react';
import { ColumnDef, ColumnSort, SortingState } from '@tanstack/react-table';
import { useCallback, useEffect, useState } from 'react';
import useSWR from 'swr';
import StatusBadge from '@/components/helper/StatusBadge';
const InventoryAdjustmentTable = () => {
const {
@@ -41,80 +39,106 @@ const InventoryAdjustmentTable = () => {
},
});
// Fetch Data
const { data: inventoryAdjustments, isLoading } = useSWR(
`${InventoryAdjustmentApi.basePath}${getTableFilterQueryString()}`,
InventoryAdjustmentApi.getAllFetcher
);
// State
const [sorting, setSorting] = useState<SortingState>([]);
// Columns
const inventoryAdjustmentsColumns: ColumnDef<InventoryAdjustment>[] = [
{
header: '#',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
id: 'product_name',
header: 'Nama Produk',
accessorFn: (row) => row.product_warehouse?.product?.name ?? '-',
},
{
id: 'warehouse_name',
header: 'Gudang',
accessorFn: (row) => row.product_warehouse?.warehouse?.name ?? '-',
},
{
id: 'created_at',
header: 'Tanggal',
accessorFn: (row) =>
new Date(row.created_at).toLocaleDateString('id-ID', {
day: '2-digit',
month: 'short',
year: 'numeric',
}),
},
{
id: 'quantity',
header: 'Kuantitas',
accessorFn: (row) => formatNumber(String(row.increase + row.decrease)),
},
{
id: 'transaction_type',
header: 'Tipe Transaksi',
accessorFn: (row) => {
if (row.increase > 0) return 'Peningkatan';
if (row.decrease > 0) return 'Penurunan';
return '-';
const inventoryAdjustmentsColumns: ColumnDef<InventoryAdjustment>[] = useMemo(
() => [
{
id: 'adj_number',
header: 'No. Referensi',
accessorFn: (row) => row.adj_number ?? '-',
},
cell: (props) => {
const type = props.row.original.increase;
const label = type > 0 ? 'Peningkatan' : type <= 0 ? 'Penurunan' : '-';
return (
<Badge variant='soft' color={type > 0 ? 'success' : 'error'}>
{label}
</Badge>
);
{
id: 'location',
header: 'Lokasi',
accessorFn: (row) => row.location?.name ?? '-',
},
},
{
id: 'created_by',
header: 'Oleh',
accessorFn: (row) => row.created_user?.name ?? '-',
},
];
{
id: 'project_flock',
header: 'Flock',
accessorFn: (row) => row.project_flock?.flock_name ?? '-',
},
{
id: 'warehouse_name',
header: 'Gudang',
accessorFn: (row) => row.product_warehouse?.warehouse?.name ?? '-',
},
{
id: 'product_name',
header: 'Nama Produk',
accessorFn: (row) => row.product_warehouse?.product?.name ?? '-',
},
{
id: 'quantity',
header: 'Kuantitas',
accessorFn: (row) => row.qty ?? '-',
cell: (row) => {
const value = row.row.original.increase + row.row.original.decrease;
return <div className='text-center'>{formatNumber(value)}</div>;
},
},
{
id: 'price',
header: 'Harga',
accessorFn: (row) => (row.price ? formatCurrency(row.price) : '-'),
},
{
id: 'grand_total',
header: 'Grand Total',
accessorFn: (row) =>
row.grand_total ? formatCurrency(row.grand_total) : '-',
},
{
id: 'transaction_type',
header: 'Tipe Transaksi',
accessorFn: (row) => row.transaction_subtype ?? '-',
cell: (row) => {
const subtype = row.row.original.transaction_subtype;
const increase = row.row.original.increase;
// Handler
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
setPageSize(newVal.value as number);
};
const subtypeLabelMap: Record<string, string> = {
PURCHASE_IN: 'Pembelian',
MARKETING_OUT: 'Penjualan',
RECORDING_STOCK_OUT: 'Recording Stock',
RECORDING_DEPLETION_OUT: 'Recording Depletion',
RECORDING_EGG_IN: 'Recording Egg',
ADJUSTMENT_OUT: 'Penyesuaian',
};
const label = subtypeLabelMap[subtype] || subtype || '-';
return (
<StatusBadge
color={
increase > 0 ? 'success' : increase <= 0 ? 'error' : 'neutral'
}
text={label}
className={{
badge: 'whitespace-nowrap',
}}
/>
);
},
},
{
id: 'created_at',
header: 'Tanggal',
accessorFn: (row) =>
row.created_at ? formatDate(row.created_at, 'DD MMM YYYY') : '-',
},
{
id: 'created_by',
header: 'Oleh',
accessorFn: (row) => row.created_user?.name ?? '-',
},
],
[tableFilterState.pageSize, tableFilterState.page]
);
const updateSortingFilter = useCallback(
(
@@ -130,7 +154,6 @@ const InventoryAdjustmentTable = () => {
[updateFilter]
);
// Effect
useEffect(() => {
const productCategorySortFilter = sorting.find(
(sortItem) => sortItem.id === 'productCategory'
@@ -149,89 +172,60 @@ const InventoryAdjustmentTable = () => {
updateSortingFilter('stockSort', stockSortFilter);
}, [sorting, updateSortingFilter]);
// Utils Function
const formatNumber = (value: string) => {
const numericValue = value.replace(/[^0-9.]/g, '');
const [integer, decimal] = numericValue.split('.');
const formattedInteger = integer.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return decimal ? `${formattedInteger}.${decimal}` : formattedInteger;
};
// Render
return (
<>
<div className='w-full p-0 sm:p-4'>
<div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'>
<div className='w-full flex flex-row'>
<RequirePermission permissions='lti.inventory.create'>
<Button
href='/inventory/adjustment/add'
variant='outline'
color='primary'
className='w-full sm:w-fit'
>
<Icon icon='ic:round-plus' width={24} height={24} />
Tambah
</Button>
</RequirePermission>
</div>
<div className='flex flex-row justify-end'>
<SelectInput
label='Baris'
options={ROWS_OPTIONS}
value={{
label: String(tableFilterState.pageSize),
value: tableFilterState.pageSize,
}}
onChange={pageSizeChangeHandler}
className={{ wrapper: 'min-w-28' }}
/>
</div>
</div>
<Table<InventoryAdjustment>
data={
isResponseSuccess(inventoryAdjustments)
? inventoryAdjustments?.data
: []
}
columns={inventoryAdjustmentsColumns}
pageSize={tableFilterState.pageSize}
page={
isResponseSuccess(inventoryAdjustments)
? inventoryAdjustments?.meta?.page
: 0
}
totalItems={
isResponseSuccess(inventoryAdjustments)
? inventoryAdjustments?.meta?.total_results
: 0
}
onPageChange={setPage}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: cn({
'mb-20':
isResponseSuccess(inventoryAdjustments) &&
inventoryAdjustments?.data?.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',
}}
/>
<div className='w-full'>
{/* Header Section */}
<div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
<div className='w-fit flex flex-row gap-3 flex-wrap'>
<RequirePermission permissions='lti.inventory.create'>
<Button
href='/inventory/adjustment/add'
color='primary'
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
>
<Icon icon='heroicons:plus' width={20} height={20} />
Add Adjustment
</Button>
</RequirePermission>
</div>
</div>
</>
{/* Table Section */}
<div className='flex flex-col mb-4'>
<Table<InventoryAdjustment>
data={
isResponseSuccess(inventoryAdjustments)
? inventoryAdjustments?.data
: []
}
columns={inventoryAdjustmentsColumns}
pageSize={tableFilterState.pageSize}
page={
isResponseSuccess(inventoryAdjustments)
? inventoryAdjustments?.meta?.page
: 0
}
totalItems={
isResponseSuccess(inventoryAdjustments)
? inventoryAdjustments?.meta?.total_results
: 0
}
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: cn('p-3 mb-0', {
'w-full':
isResponseSuccess(inventoryAdjustments) &&
inventoryAdjustments?.data?.length === 0,
}),
headerColumnClassName: 'text-nowrap',
}}
/>
</div>
</div>
);
};
@@ -1,55 +1,106 @@
import * as Yup from 'yup';
import { OptionType } from '@/components/input/SelectInput';
export const InventoryAdjustmentFormSchema = Yup.object({
product_category: Yup.mixed<OptionType>()
.nullable()
.test(
'is-valid-option',
'Kategori Produk wajib diisi!',
(value) => value !== null && value !== undefined
export type InventoryAdjustmentFormSchemaType = {
location: {
value: number;
label: string;
} | null;
location_id: number;
project_flock: {
value: number;
label: string;
} | null;
project_flock_id: number;
kandang: {
value: number;
label: string;
} | null;
kandang_id: number;
project_flock_kandang: {
value: number;
label: string;
} | null;
project_flock_kandang_id: number;
product: {
value: number;
label: string;
} | null;
product_id: number;
transaction_type: string;
transaction_subtype: string;
qty: number | string;
price: number | string;
notes: string;
};
export const InventoryAdjustmentFormSchema: Yup.ObjectSchema<InventoryAdjustmentFormSchemaType> =
Yup.object({
location: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
location_id: Yup.number()
.min(1, 'Lokasi wajib diisi!')
.required('Lokasi wajib diisi!')
.typeError('Lokasi wajib diisi!'),
project_flock: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
project_flock_id: Yup.number()
.min(1, 'Project flock wajib diisi!')
.required('Project flock wajib diisi!')
.typeError('Project flock wajib diisi!'),
kandang: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
kandang_id: Yup.number()
.min(1, 'Kandang wajib diisi!')
.required('Kandang wajib diisi!')
.typeError('Kandang wajib diisi!'),
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!'),
product: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
product_id: Yup.number()
.min(1, 'Produk wajib diisi!')
.required('Produk wajib diisi!')
.typeError('Produk wajib diisi!'),
transaction_type: Yup.string()
.min(1, 'Tipe transaksi wajib diisi!')
.oneOf(
['PEMBELIAN', 'PENJUALAN', 'RECORDING'],
'Tipe transaksi tidak valid'
)
.required('Tipe transaksi wajib diisi')
.typeError('Tipe transaksi wajib diisi!'),
transaction_subtype: Yup.string().required(
'Sub tipe transaksi wajib diisi'
),
product_category_id: Yup.number().nullable(),
product: Yup.mixed<OptionType>()
.nullable()
.test(
'is-valid-option',
'Produk wajib diisi!',
(value) => value !== null && value !== undefined
),
product_id: Yup.number()
.nullable()
.required('Produk wajib diisi!')
.min(1, 'Produk wajib diisi!'),
warehouse: Yup.mixed<OptionType>()
.nullable()
.test(
'is-valid-option',
'Warehouse wajib diisi!',
(value) => value !== null && value !== undefined
),
warehouse_id: Yup.number()
.nullable()
.required('Warehouse wajib diisi!')
.min(1, 'Warehouse wajib diisi!'),
transaction_type: Yup.string()
.oneOf(['increase', 'decrease'], 'Tipe transaksi tidak valid')
.nullable()
.required('Tipe transaksi wajib diisi'),
quantity: Yup.number()
.typeError('Kuantitas harus berupa angka')
.min(1, 'Minimal kuantitas adalah 1')
.required('Kuantitas wajib diisi'),
note: Yup.string().required('Catatan wajib diisi!'),
});
qty: Yup.number()
.typeError('Kuantitas harus berupa angka')
.min(1, 'Minimal kuantitas adalah 1')
.required('Kuantitas wajib diisi'),
price: Yup.number()
.typeError('Harga harus berupa angka')
.min(0, 'Minimal harga adalah 0')
.required('Harga wajib diisi'),
notes: Yup.string().required('Catatan wajib diisi!'),
});
export type InventoryAdjustmentFormValues = Yup.InferType<
typeof InventoryAdjustmentFormSchema
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
'use client';
import { ChangeEventHandler, useState } from 'react';
import { ChangeEventHandler, useMemo, useState } from 'react';
import useSWR from 'swr';
import { SortingState, CellContext, ColumnDef } from '@tanstack/react-table';
@@ -11,37 +11,62 @@ import { MovementApi } from '@/services/api/inventory';
import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
import { OptionType } from '@/components/input/SelectInput';
import Button from '@/components/Button';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput from '@/components/input/SelectInput';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import RequirePermission from '@/components/helper/RequirePermission';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
const RowOptionsMenu = ({
type = 'dropdown',
popoverPosition = 'bottom',
props,
}: {
type: 'dropdown' | 'collapse';
popoverPosition: 'bottom' | 'top';
props: CellContext<Movement, unknown>;
}) => (
<RowOptionsMenuWrapper type={type}>
<RequirePermission permissions='lti.inventory.transfer.detail'>
<Button
href={`/inventory/movement/detail/?movementId=${props.row.original.id}`}
}) => {
const popoverId = `movement#${props.row.original.id}`;
const popoverAnchorName = `--anchor-movement#${props.row.original.id}`;
const closePopover = () => {
document.getElementById(popoverId)?.hidePopover();
};
return (
<div className='relative'>
<PopoverButton
tabIndex={0}
variant='ghost'
color='primary'
className='justify-start text-sm'
color='none'
popoverTarget={popoverId}
anchorName={popoverAnchorName}
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
</RequirePermission>
</RowOptionsMenuWrapper>
);
<Icon icon='material-symbols:more-vert' width={16} height={16} />
</PopoverButton>
<PopoverContent
id={popoverId}
anchorName={popoverAnchorName}
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
>
<div className='flex flex-col bg-base-100 rounded-xl'>
<RequirePermission permissions='lti.inventory.transfer.detail'>
<Button
href={`/inventory/movement/detail/?movementId=${props.row.original.id}`}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full'
onClick={closePopover}
>
<Icon icon='heroicons:eye' width={20} height={20} />
Detail
</Button>
</RequirePermission>
</div>
</PopoverContent>
</div>
);
};
const MovementTable = () => {
const {
@@ -71,121 +96,108 @@ const MovementTable = () => {
updateFilter('search', e.target.value);
};
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
setPageSize(newVal.value as number);
setPage(1);
};
const movementColumns: ColumnDef<Movement>[] = [
{
header: '#',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorFn: (row) => row.source_warehouse?.name,
header: 'Gudang Asal',
},
{
accessorFn: (row) => row.destination_warehouse?.name,
header: 'Gudang Tujuan',
},
{
accessorKey: 'transfer_reason',
header: 'Catatan',
},
{
accessorKey: 'transfer_date',
header: 'Tanggal',
cell: (props) =>
new Date(props.row.original.transfer_date).toLocaleDateString('id-ID'),
},
{
accessorFn: (row) => {
const totalCost = row.deliveries?.reduce(
(sum, d) => sum + (d.shipping_cost_total || 0),
0
);
return totalCost?.toLocaleString('id-ID');
const movementColumns: ColumnDef<Movement>[] = useMemo(
() => [
{
header: 'No',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
header: 'Biaya Pengiriman',
},
{
header: 'Aksi',
cell: (props) => {
const currentPageSize = props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu type='dropdown' props={props} />
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptionsMenu type='collapse' props={props} />
</RowCollapseOptions>
)}
</>
);
{
accessorFn: (row) => row.source_warehouse?.name,
header: 'Gudang Asal',
},
},
];
{
accessorFn: (row) => row.destination_warehouse?.name,
header: 'Gudang Tujuan',
},
{
accessorKey: 'transfer_reason',
header: 'Catatan',
},
{
accessorKey: 'transfer_date',
header: 'Tanggal',
cell: (props) =>
new Date(props.row.original.transfer_date).toLocaleDateString(
'id-ID'
),
},
{
accessorFn: (row) => {
const totalCost = row.deliveries?.reduce(
(sum, d) => sum + (d.shipping_cost_total || 0),
0
);
return totalCost?.toLocaleString('id-ID');
},
header: 'Biaya Pengiriman',
},
{
header: 'Aksi',
cell: (props: CellContext<Movement, unknown>) => {
const currentPageSize =
props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
return (
<RowOptionsMenu
props={props}
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
/>
);
},
},
],
[tableFilterState.pageSize, tableFilterState.page]
);
return (
<>
<div className='w-full p-0 sm:p-4'>
<div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'>
<div className='w-full flex flex-row gap-2'>
<RequirePermission permissions='lti.inventory.transfer.create'>
<Button
href='/inventory/movement/add'
color='primary'
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
>
<Icon icon='heroicons:plus' width={20} height={20} />
Add Movement
</Button>
</RequirePermission>
</div>
<DebouncedTextInput
name='search'
placeholder='Cari Movement'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
</div>
<div className='flex justify-end gap-4'>
<SelectInput
label='Baris'
options={ROWS_OPTIONS}
value={{
label: String(tableFilterState.pageSize),
value: tableFilterState.pageSize,
}}
onChange={pageSizeChangeHandler}
className={{
wrapper:
'col-span-6 sm:col-span-4 max-w-28 sm:justify-self-end',
}}
/>
</div>
<div className='w-full'>
{/* Header Section */}
<div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
{/* Action Buttons */}
<div className='w-fit flex flex-row gap-3 flex-wrap'>
<RequirePermission permissions='lti.inventory.transfer.create'>
<Button
href='/inventory/movement/add'
color='primary'
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
>
<Icon icon='heroicons:plus' width={20} height={20} />
Add Movement
</Button>
</RequirePermission>
</div>
{/* Search */}
<div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
<DebouncedTextInput
name='search'
placeholder='Cari Movement'
value={tableFilterState.search ?? ''}
onChange={searchChangeHandler}
startAdornment={
<Icon icon='heroicons:magnifying-glass' width={20} height={20} />
}
className={{
wrapper: 'w-full min-w-24 max-w-3xs',
inputWrapper: 'rounded-xl! shadow-button-soft',
input:
'placeholder:font-semibold placeholder:text-base-content/50',
}}
/>
</div>
</div>
{/* Table Section */}
<div className='flex flex-col mb-4'>
<Table<Movement>
data={isResponseSuccess(movements) ? movements?.data : []}
columns={movementColumns}
@@ -195,26 +207,20 @@ const MovementTable = () => {
isResponseSuccess(movements) ? movements?.meta?.total_results : 0
}
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: cn({
'mb-20':
containerClassName: cn('p-3 mb-0', {
'w-full':
isResponseSuccess(movements) && movements?.data?.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',
headerColumnClassName: 'text-nowrap',
}}
/>
</div>
</>
</div>
);
};
@@ -2,13 +2,8 @@
import Button from '@/components/Button';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import Table from '@/components/Table';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import RequirePermission from '@/components/helper/RequirePermission';
import { ROWS_OPTIONS } from '@/config/constant';
import { isResponseSuccess } from '@/lib/api-helper';
import { cn, formatCurrency, formatNumber } from '@/lib/helper';
import { InventoryProductApi } from '@/services/api/inventory';
@@ -18,28 +13,59 @@ import { Icon } from '@iconify/react';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import { ChangeEventHandler, useMemo, useState } from 'react';
import useSWR from 'swr';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
const RowOptionsMenu = ({
type = 'dropdown',
popoverPosition = 'bottom',
props,
}: {
type: 'dropdown' | 'collapse';
popoverPosition: 'bottom' | 'top';
props: CellContext<InventoryProduct, unknown>;
}) => (
<RowOptionsMenuWrapper type={type}>
<RequirePermission permissions='lti.inventory.product_stock.detail'>
<Button
href={`/inventory/product/detail?inventoryProductId=${props.row.original.id}`}
}) => {
const popoverId = `product#${props.row.original.id}`;
const popoverAnchorName = `--anchor-product#${props.row.original.id}`;
const closePopover = () => {
document.getElementById(popoverId)?.hidePopover();
};
return (
<div className='relative'>
<PopoverButton
tabIndex={0}
variant='ghost'
color='primary'
className='justify-start text-sm'
color='none'
popoverTarget={popoverId}
anchorName={popoverAnchorName}
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
</RequirePermission>
</RowOptionsMenuWrapper>
);
<Icon icon='material-symbols:more-vert' width={16} height={16} />
</PopoverButton>
<PopoverContent
id={popoverId}
anchorName={popoverAnchorName}
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
>
<div className='flex flex-col bg-base-100 rounded-xl'>
<RequirePermission permissions='lti.inventory.product_stock.detail'>
<Button
href={`/inventory/product/detail?inventoryProductId=${props.row.original.id}`}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full'
onClick={closePopover}
>
<Icon icon='heroicons:eye' width={20} height={20} />
Detail
</Button>
</RequirePermission>
</div>
</PopoverContent>
</div>
);
};
const InventoryProductTable = () => {
const {
@@ -69,16 +95,10 @@ const InventoryProductTable = () => {
updateFilter('search', e.target.value);
};
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
setPageSize(newVal.value as number);
setPage(1);
};
const columns: ColumnDef<InventoryProduct>[] = useMemo(
() => [
{
header: '#',
header: 'No',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
@@ -125,7 +145,7 @@ const InventoryProductTable = () => {
},
{
header: 'Aksi',
cell: (props) => {
cell: (props: CellContext<InventoryProduct, unknown>) => {
const currentPageSize =
props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
@@ -135,58 +155,57 @@ const InventoryProductTable = () => {
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu type='dropdown' props={props} />
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptionsMenu type='collapse' props={props} />
</RowCollapseOptions>
)}
</>
<RowOptionsMenu
props={props}
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
/>
);
},
},
],
[]
[tableFilterState.pageSize, tableFilterState.page]
);
return (
<>
<div className='w-full p-0 sm:p-4'>
<div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'>
<div className='w-full flex flex-row gap-2'></div>
</div>
<div className='flex justify-between items-end gap-4'>
<DebouncedTextInput
name='search'
placeholder='Cari Produk'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
<SelectInput
label='Baris'
options={ROWS_OPTIONS}
value={{
label: String(tableFilterState.pageSize),
value: tableFilterState.pageSize,
}}
onChange={pageSizeChangeHandler}
className={{
wrapper:
'col-span-6 sm:col-span-4 max-w-28 sm:justify-self-end',
}}
/>
</div>
<div className='w-full'>
{/* Header Section */}
<div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
{/* Action Buttons */}
<div className='w-fit flex flex-row gap-3 flex-wrap'>
<RequirePermission permissions='lti.inventory.product_stock.create'>
<Button
href='/inventory/product/add'
color='primary'
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
>
<Icon icon='heroicons:plus' width={20} height={20} />
Add Product
</Button>
</RequirePermission>
</div>
{/* Search */}
<div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
<DebouncedTextInput
name='search'
placeholder='Cari Produk'
value={tableFilterState.search ?? ''}
onChange={searchChangeHandler}
startAdornment={
<Icon icon='heroicons:magnifying-glass' width={20} height={20} />
}
className={{
wrapper: 'w-full min-w-24 max-w-3xs',
inputWrapper: 'rounded-xl! shadow-button-soft',
input:
'placeholder:font-semibold placeholder:text-base-content/50',
}}
/>
</div>
</div>
{/* Table Section */}
<div className='flex flex-col mb-4'>
<Table<InventoryProduct>
data={
isResponseSuccess(inventoryProducts) ? inventoryProducts?.data : []
@@ -204,27 +223,21 @@ const InventoryProductTable = () => {
: 0
}
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: cn({
'mb-20':
containerClassName: cn('p-3 mb-0', {
'w-full':
isResponseSuccess(inventoryProducts) &&
inventoryProducts?.data?.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',
headerColumnClassName: 'text-nowrap',
}}
/>
</div>
</>
</div>
);
};
@@ -30,6 +30,7 @@ import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
import StatusBadge from '@/components/helper/StatusBadge';
import MarketingFilterModal from '@/components/pages/marketing/MarketingFilter';
import ButtonFilter from '@/components/helper/ButtonFilter';
const RowsOptionsMenu = ({
props,
@@ -214,32 +215,6 @@ const MarketingTable = () => {
updateFilter('customer_id', '');
};
// ===== ACTIVE FILTERS COUNT =====
const activeFiltersCount = useMemo(() => {
let count = 0;
// Product filter
if (tableFilterState.product_ids) {
count += 1;
}
// Status filter
if (tableFilterState.status) {
count += 1;
}
// Customer filter
if (tableFilterState.customer_id) {
count += 1;
}
return count;
}, [
tableFilterState.product_ids,
tableFilterState.status,
tableFilterState.customer_id,
]);
const approveClickHandler = () => {
setApproveAction('APPROVED');
confirmationModal.openModal();
@@ -588,28 +563,14 @@ const MarketingTable = () => {
)}
</div>
<div className='flex flex-row gap-3'>
<Button
variant='outline'
color='none'
<ButtonFilter
values={tableFilterState}
excludeFields={['page', 'pageSize', 'search']}
onClick={() => {
filterModal.openModal();
}}
className={cn(
'px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft transition-all',
{
'border-primary-gradient text-primary':
activeFiltersCount > 0,
}
)}
>
<Icon icon='heroicons:funnel' width={20} height={20} />
Filter
{activeFiltersCount > 0 && (
<span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
{activeFiltersCount}
</span>
)}
</Button>
className='px-3 py-2.5'
/>
<Dropdown
align='end'
direction='bottom'
@@ -42,6 +42,7 @@ import {
} from './filter/ProjectFlockFilter';
import Modal from '@/components/Modal';
import SelectInputRadio from '@/components/input/SelectInputRadio';
import ButtonFilter from '@/components/helper/ButtonFilter';
const RowOptionsMenu = ({
props,
@@ -346,25 +347,6 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
);
}, [formik.values.period, periodOptions]);
// ===== ACTIVE FILTERS COUNT =====
const activeFiltersCount = useMemo(() => {
let count = 0;
if (tableFilterState.area_id) count += 1;
if (tableFilterState.location_id) count += 1;
if (tableFilterState.kandang_id) count += 1;
if (tableFilterState.category) count += 1;
if (tableFilterState.period) count += 1;
return count;
}, [
tableFilterState.area_id,
tableFilterState.location_id,
tableFilterState.kandang_id,
tableFilterState.category,
tableFilterState.period,
]);
const hasFilters = activeFiltersCount > 0;
// ===== FILTER DEPENDENCY HANDLERS =====
const handleFilterAreaChange = (area: OptionType | null) => {
const areaId = area?.value ? String(area.value) : undefined;
@@ -961,25 +943,12 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
}}
/>
<Button
variant='outline'
color='none'
<ButtonFilter
values={tableFilterState}
excludeFields={['page', 'pageSize', 'search']}
onClick={handleFilterModalOpen}
className={cn(
'px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft transition-all',
{
'border-primary-gradient text-primary': hasFilters,
}
)}
>
<Icon icon='heroicons:funnel' width={20} height={20} />
Filter
{hasFilters && (
<span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
{activeFiltersCount}
</span>
)}
</Button>
className='px-3 py-2.5'
/>
<Dropdown
align='end'
@@ -45,6 +45,7 @@ import StatusBadge from '@/components/helper/StatusBadge';
import CheckboxInput from '@/components/input/CheckboxInput';
import { useUiStore } from '@/stores/ui/ui.store';
import { Color } from '@/types/theme';
import ButtonFilter from '@/components/helper/ButtonFilter';
// ===== STATUS BADGE UTILITIES =====
const statusTextMap: Record<string, string> = {
@@ -511,36 +512,6 @@ const RecordingTable = () => {
);
}, [formik.values.kandang_id, kandangOptions]);
// ===== ACTIVE FILTERS COUNT =====
const activeFiltersCount = useMemo(() => {
let count = 0;
if (tableFilterState.areaFilter) {
count += 1;
}
if (tableFilterState.locationFilter) {
count += 1;
}
if (tableFilterState.kandangFilter) {
count += 1;
}
if (tableFilterState.projectFlockKandangFilter) {
count += 1;
}
return count;
}, [
tableFilterState.areaFilter,
tableFilterState.locationFilter,
tableFilterState.kandangFilter,
tableFilterState.projectFlockKandangFilter,
]);
const hasFilters = activeFiltersCount > 0;
// ===== HANDLE FILTER MODAL OPEN =====
const handleFilterModalOpen = () => {
filterModal.openModal();
@@ -1264,30 +1235,17 @@ const RecordingTable = () => {
}}
/>
<Button
variant='outline'
color='none'
<ButtonFilter
values={tableFilterState}
excludeFields={['page', 'pageSize', 'search']}
onClick={handleFilterModalOpen}
className={cn(
'px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft transition-all',
{
'border-primary-gradient text-primary': hasFilters,
}
)}
>
<Icon icon='heroicons:funnel' width={20} height={20} />
Filter
{hasFilters && (
<span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
{activeFiltersCount}
</span>
)}
</Button>
className='px-3 py-2.5'
/>
</div>
</div>
{/* Table Section */}
<div className='flex flex-col mb-4 -mx-4 px-4'>
<div className='flex flex-col mb-4'>
{isLoading ? (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
@@ -1438,7 +1396,11 @@ const RecordingTable = () => {
<Button
type='submit'
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
disabled={!formik.isValid || formik.isSubmitting}
disabled={
!formik.isValid ||
formik.isSubmitting ||
!formik.values.kandang_id
}
>
Apply Filter
</Button>
@@ -1,7 +1,8 @@
'use client';
import { RefObject } from 'react';
import { RefObject, useState, useEffect } from 'react';
import { useFormik } from 'formik';
import toast from 'react-hot-toast';
import { Icon } from '@iconify/react';
import Modal from '@/components/Modal';
@@ -13,6 +14,10 @@ import { OptionType, useSelect } from '@/components/input/SelectInput';
import { ProjectFlockApi } from '@/services/api/production';
import { Flock } from '@/types/api/master-data/flock';
import { TransferToLayingFilter } from '@/types/api/production/transfer-to-laying';
import {
TransferToLayingFilterSchema,
TransferToLayingFilterValues,
} from '@/components/pages/production/transfer-to-laying/filter/TransferToLayingFilter';
interface TransferToLayingFilterModal {
ref: RefObject<HTMLDialogElement | null>;
@@ -29,6 +34,36 @@ const TransferToLayingFilterModal = ({
ref.current?.close();
};
// ===== DATE ERROR STATE =====
const [dateErrorShown, setDateErrorShown] = useState(false);
const [hasDateError, setHasDateError] = useState(false);
// ===== CLEANUP TOAST ON UNMOUNT =====
useEffect(() => {
return () => {
if (dateErrorShown) {
toast.dismiss();
}
};
}, [dateErrorShown]);
// ===== CLEANUP TOAST WHEN MODAL CLOSES =====
useEffect(() => {
const dialogElement = ref.current;
const handleModalClose = () => {
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
};
dialogElement?.addEventListener('close', handleModalClose);
return () => {
dialogElement?.removeEventListener('close', handleModalClose);
};
}, [ref, dateErrorShown]);
// Flock Source
const {
setInputValue: setFlockSourceInputValue,
@@ -49,13 +84,7 @@ const TransferToLayingFilterModal = ({
category: 'LAYING',
});
const formik = useFormik<{
startDate: string;
endDate: string;
flockSource: { value: number; label: string }[];
flockDestination: { value: number; label: string }[];
status: { value: number; label: string }[];
}>({
const formik = useFormik<TransferToLayingFilterValues>({
initialValues: {
startDate: '',
endDate: '',
@@ -63,15 +92,22 @@ const TransferToLayingFilterModal = ({
flockDestination: [],
status: [],
},
validationSchema: TransferToLayingFilterSchema,
onSubmit: async (values) => {
const formattedValues = {
...values,
flockSource: values.flockSource.map((item) => item.value),
flockDestination: values.flockDestination.map((item) => item.value),
status: values.status.map((item) => item.value),
flockSource: values.flockSource
? (values.flockSource as OptionType[]).map((item) => item.value)
: [],
flockDestination: values.flockDestination
? (values.flockDestination as OptionType[]).map((item) => item.value)
: [],
status: values.status
? (values.status as OptionType[]).map((item) => item.value)
: [],
};
onSubmit?.(formattedValues);
onSubmit?.(formattedValues as TransferToLayingFilter);
closeModalHandler();
},
onReset: () => {
@@ -81,17 +117,17 @@ const TransferToLayingFilterModal = ({
});
const flockSourceChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('flockSource', val as OptionType[]);
formik.setFieldValue('flockSource', val);
};
const flockDestinationChangeHandler = (
val: OptionType | OptionType[] | null
) => {
formik.setFieldValue('flockDestination', val as OptionType[]);
formik.setFieldValue('flockDestination', val);
};
const statusChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('status', val as OptionType[]);
formik.setFieldValue('status', val);
};
return (
@@ -132,24 +168,84 @@ const TransferToLayingFilterModal = ({
<DateInput
name='startDate'
placeholder='Tanggal Awal'
value={formik.values.startDate}
onChange={formik.handleChange}
value={formik.values.startDate || ''}
errorMessage={formik.errors.startDate}
onChange={(e) => {
const value = e.target.value;
formik.setFieldValue('startDate', value);
if (value && formik.values.endDate) {
const startDate = new Date(value);
const endDateObj = new Date(formik.values.endDate);
if (endDateObj < startDate) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
});
setDateErrorShown(true);
}
} else {
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
}
} else {
setHasDateError(false);
}
}}
onBlur={formik.handleBlur}
isError={
formik.touched.startDate && Boolean(formik.errors.startDate)
}
/>
<hr className='w-full max-w-3 h-px border-base-content/10' />
<DateInput
name='endDate'
placeholder='Tanggal Akhir'
value={formik.values.endDate}
onChange={formik.handleChange}
value={formik.values.endDate || ''}
errorMessage={formik.errors.endDate}
onChange={(e) => {
const value = e.target.value;
formik.setFieldValue('endDate', value);
if (value && formik.values.startDate) {
const startDateObj = new Date(formik.values.startDate);
const endDate = new Date(value);
if (endDate < startDateObj) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
});
setDateErrorShown(true);
}
return;
}
}
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
}}
onBlur={formik.handleBlur}
isError={
(formik.touched.endDate && Boolean(formik.errors.endDate)) ||
hasDateError
}
/>
</div>
<SelectInputCheckbox
label='Flock Asal'
placeholder='Flock Asal'
value={formik.values.flockSource}
value={formik.values.flockSource as OptionType[]}
onChange={flockSourceChangeHandler}
options={flockSourceOptions}
isLoading={isLoadingFlockSourceOptions}
@@ -160,7 +256,7 @@ const TransferToLayingFilterModal = ({
<SelectInputCheckbox
label='Flock Tujuan'
placeholder='Flock Tujuan'
value={formik.values.flockDestination}
value={formik.values.flockDestination as OptionType[]}
onChange={flockDestinationChangeHandler}
options={flockDestinationOptions}
isLoading={isLoadingFlockDestinationOptions}
@@ -176,7 +272,7 @@ const TransferToLayingFilterModal = ({
{ value: 'APPROVED', label: 'Disetujui' },
{ value: 'REJECTED', label: 'Ditolak' },
]}
value={formik.values.status}
value={formik.values.status as OptionType[]}
onChange={statusChangeHandler}
/>
</div>
@@ -1,6 +1,6 @@
'use client';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import { ChangeEventHandler, useEffect, useState } from 'react';
import useSWR from 'swr';
import {
CellContext,
@@ -17,7 +17,6 @@ import { useModal } from '@/components/Modal';
import CheckboxInput from '@/components/input/CheckboxInput';
import RequirePermission from '@/components/helper/RequirePermission';
import PopoverButton from '@/components/popover/PopoverButton';
import Badge from '@/components/Badge';
import PopoverContent from '@/components/popover/PopoverContent';
import Dropdown from '@/components/Dropdown';
import StatusBadge from '@/components/helper/StatusBadge';
@@ -34,6 +33,7 @@ import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { Color } from '@/types/theme';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import ButtonFilter from '@/components/helper/ButtonFilter';
const RowOptionsMenu = ({
props,
@@ -159,30 +159,6 @@ const TransferToLayingsTable = () => {
TransferToLayingApi.getAllFetcher
);
const filterCount = useMemo(() => {
let count = 0;
if (tableFilterState.startDate && tableFilterState.endDate) {
count += 1;
}
if (tableFilterState.flockSource.length > 0) {
count += 1;
}
if (tableFilterState.flockDestination.length > 0) {
count += 1;
}
if (tableFilterState.status.length > 0) {
count += 1;
}
return count;
}, [tableFilterState]);
const isFilterActive = filterCount > 0;
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
useState(false);
@@ -559,30 +535,19 @@ const TransferToLayingsTable = () => {
}}
/>
<Button
variant='outline'
color='none'
<ButtonFilter
values={tableFilterState}
excludeFields={[
'page',
'pageSize',
'search',
'filter_by',
'sort_by',
]}
fieldGroups={[['startDate', 'endDate']]}
onClick={filterModal.openModal}
className={cn(
'px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft transition-all',
{
'border-primary-gradient text-primary': isFilterActive,
}
)}
>
<Icon icon='heroicons:funnel' width={20} height={20} />
Filter
{isFilterActive && (
<Badge
className={{
badge:
'p-1.5 bg-[#FF3535] text-xs text-base-100 border border-base-300 rounded-lg',
}}
>
{filterCount}
</Badge>
)}
</Button>
className='px-3 py-2.5'
/>
<Dropdown
align='end'
@@ -0,0 +1,33 @@
import * as yup from 'yup';
export type TransferToLayingFilterType = {
startDate: string | null;
endDate: string | null;
flockSource: number[];
flockDestination: number[];
status: string[];
};
export const TransferToLayingFilterSchema = yup.object({
startDate: yup.string().optional().nullable(),
endDate: yup
.string()
.optional()
.nullable()
.test(
'is-greater-than-start',
'Tanggal akhir tidak boleh masa lampau',
function (value) {
const { startDate } = this.parent;
if (!startDate || !value) return true;
return new Date(value) >= new Date(startDate);
}
),
flockSource: yup.array().optional().nullable(),
flockDestination: yup.array().optional().nullable(),
status: yup.array().optional().nullable(),
});
export type TransferToLayingFilterValues = yup.InferType<
typeof TransferToLayingFilterSchema
>;
@@ -48,6 +48,7 @@ import {
import { generateUniformityPDF } from '@/components/pages/production/uniformity/export/UniformityExportPDF';
import { generateUniformityExcel } from '@/components/pages/production/uniformity/export/UniformityExportExcel';
import Dropdown from '@/components/Dropdown';
import ButtonFilter from '@/components/helper/ButtonFilter';
import { useFormik } from 'formik';
import {
UniformityTableFilterSchema,
@@ -192,16 +193,28 @@ const UniformityTable = () => {
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: '',
start_date: '',
end_date: '',
location_id: '',
project_flock_id: '',
kandang_id: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
search: 'search',
start_date: 'start_date',
end_date: 'end_date',
location_id: 'location_id',
project_flock_id: 'project_flock_id',
kandang_id: 'kandang_id',
},
});
@@ -233,12 +246,14 @@ const UniformityTable = () => {
const [filterKandang, setFilterKandang] = useState<OptionType | null>(null);
const [filterProjectFlockKandangId, setFilterProjectFlockKandangId] =
useState<number | undefined>(undefined);
const [filterStartDate, setFilterStartDate] = useState('');
const [filterEndDate, setFilterEndDate] = useState('');
const [filterProjectFlockLocationId, setFilterProjectFlockLocationId] =
useState<string>('');
const [, setFilterErrors] = useState<Record<string, string>>({});
// ===== DATE ERROR STATE =====
const [dateErrorShown, setDateErrorShown] = useState(false);
const [hasDateError, setHasDateError] = useState(false);
const {
setInputValue: setFilterLocationInputValue,
options: filterLocationOptions,
@@ -319,8 +334,8 @@ const UniformityTable = () => {
// ===== FORMIK FILTER =====
const filterFormik = useFormik<UniformityTableFilterValues>({
initialValues: {
start_date: filterStartDate,
end_date: filterEndDate,
start_date: tableFilterState.start_date,
end_date: tableFilterState.end_date,
location: filterLocation,
project_flock: filterProjectFlock,
project_flock_kandang_id: filterProjectFlockKandangId,
@@ -329,8 +344,21 @@ const UniformityTable = () => {
validationSchema: UniformityTableFilterSchema,
enableReinitialize: true,
onSubmit: async (values) => {
setFilterStartDate(values.start_date);
setFilterEndDate(values.end_date);
updateFilter('start_date', values.start_date);
updateFilter('end_date', values.end_date);
updateFilter(
'location_id',
values.location?.value ? String(values.location.value) : ''
);
updateFilter(
'project_flock_id',
values.project_flock?.value ? String(values.project_flock.value) : ''
);
updateFilter(
'kandang_id',
values.kandang?.value ? String(values.kandang.value) : ''
);
setFilterLocation(values.location ?? null);
setFilterProjectFlock(values.project_flock ?? null);
setFilterKandang(values.kandang ?? null);
@@ -356,11 +384,11 @@ const UniformityTable = () => {
filterProjectFlockKandangId.toString()
);
}
if (filterStartDate) {
queryParams.append('start_date', filterStartDate);
if (tableFilterState.start_date) {
queryParams.append('start_date', tableFilterState.start_date);
}
if (filterEndDate) {
queryParams.append('end_date', filterEndDate);
if (tableFilterState.end_date) {
queryParams.append('end_date', tableFilterState.end_date);
}
queryParams.append('with_chart', 'true');
}
@@ -379,8 +407,8 @@ const UniformityTable = () => {
}, [
isSubmitted,
filterProjectFlockKandangId,
filterStartDate,
filterEndDate,
tableFilterState.start_date,
tableFilterState.end_date,
getTableFilterQueryString,
]);
@@ -456,30 +484,17 @@ const UniformityTable = () => {
setFilterProjectFlock(null);
setFilterKandang(null);
setFilterProjectFlockKandangId(undefined);
setFilterStartDate('');
setFilterEndDate('');
setFilterErrors({});
updateFilter('start_date', '');
updateFilter('end_date', '');
updateFilter('location_id', '');
updateFilter('project_flock_id', '');
updateFilter('kandang_id', '');
filterFormik.resetForm();
}, [filterFormik]);
const handleFilterStartDateChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setFilterStartDate(value);
filterFormik.setFieldValue('start_date', value);
},
[filterFormik]
);
const handleFilterEndDateChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setFilterEndDate(value);
filterFormik.setFieldValue('end_date', value);
},
[filterFormik]
);
filterModal.closeModal();
}, [filterFormik, updateFilter]);
const selectedRowIds = useMemo(() => {
return Object.keys(rowSelection)
@@ -662,11 +677,11 @@ const UniformityTable = () => {
filterProjectFlockKandangId.toString()
);
}
if (filterStartDate) {
queryParams.append('start_date', filterStartDate);
if (tableFilterState.start_date) {
queryParams.append('start_date', tableFilterState.start_date);
}
if (filterEndDate) {
queryParams.append('end_date', filterEndDate);
if (tableFilterState.end_date) {
queryParams.append('end_date', tableFilterState.end_date);
}
queryParams.append('limit', '100');
queryParams.append('page', '1');
@@ -677,7 +692,11 @@ const UniformityTable = () => {
const response = await UniformityApi.getAllFetcher(url);
return isResponseSuccess(response) ? response.data : null;
}, [filterProjectFlockKandangId, filterStartDate, filterEndDate]);
}, [
filterProjectFlockKandangId,
tableFilterState.start_date,
tableFilterState.end_date,
]);
const handleExportExcel = useCallback(async () => {
setIsExcelExportLoading(true);
@@ -698,8 +717,8 @@ const UniformityTable = () => {
location_name: locationName,
project_flock_name: projectFlockName,
kandang_name: kandangName,
start_date: filterStartDate,
end_date: filterEndDate,
start_date: tableFilterState.start_date,
end_date: tableFilterState.end_date,
});
toast.success('Excel berhasil dibuat dan diunduh.');
@@ -713,8 +732,8 @@ const UniformityTable = () => {
filterLocation,
filterProjectFlock,
filterKandang,
filterStartDate,
filterEndDate,
tableFilterState.start_date,
tableFilterState.end_date,
]);
const handleExportPDF = useCallback(async () => {
@@ -736,8 +755,8 @@ const UniformityTable = () => {
location_name: locationName,
project_flock_name: projectFlockName,
kandang_name: kandangName,
start_date: filterStartDate,
end_date: filterEndDate,
start_date: tableFilterState.start_date,
end_date: tableFilterState.end_date,
});
toast.success('PDF berhasil dibuat dan diunduh.');
@@ -751,8 +770,8 @@ const UniformityTable = () => {
filterLocation,
filterProjectFlock,
filterKandang,
filterStartDate,
filterEndDate,
tableFilterState.start_date,
tableFilterState.end_date,
]);
useEffect(() => {
@@ -778,6 +797,23 @@ const UniformityTable = () => {
}
}, [uniformities, rowSelection]);
useEffect(() => {
return () => {
if (dateErrorShown) {
toast.dismiss();
}
};
}, [dateErrorShown]);
useEffect(() => {
return () => {
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
};
}, [filterModal.open, dateErrorShown]);
// ===== TABLE COLUMNS DEFINITION =====
const uniformityColumns: ColumnDef<Uniformity>[] = useMemo(
() => [
@@ -883,37 +919,6 @@ const UniformityTable = () => {
[]
);
// ===== CALCULATE FILTER COUNT =====
const filterCount = useMemo(() => {
let count = 0;
if (filterStartDate && filterEndDate) {
count += 1;
}
if (filterLocation) {
count += 1;
}
if (filterProjectFlock) {
count += 1;
}
if (filterKandang) {
count += 1;
}
return count;
}, [
filterStartDate,
filterEndDate,
filterLocation,
filterProjectFlock,
filterKandang,
]);
const isFilterActive = filterCount > 0;
return (
<>
<div className='@container w-full'>
@@ -932,30 +937,13 @@ const UniformityTable = () => {
</div>
<div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
<Button
variant='outline'
color='none'
<ButtonFilter
values={tableFilterState}
excludeFields={['page', 'pageSize', 'search']}
fieldGroups={[['start_date', 'end_date']]}
onClick={filterModal.openModal}
className={cn(
'px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft transition-all',
{
'border-primary-gradient text-primary': isFilterActive,
}
)}
>
<Icon icon='heroicons:funnel' width={20} height={20} />
Filter
{isFilterActive && (
<Badge
className={{
badge:
'p-1.5 bg-[#FF3535] text-xs text-base-100 border border-base-300 rounded-lg',
}}
>
{filterCount}
</Badge>
)}
</Button>
className='px-3 py-2.5'
/>
<Dropdown
align='end'
@@ -1279,7 +1267,38 @@ const UniformityTable = () => {
placeholder='Tanggal Mulai'
value={filterFormik.values.start_date}
errorMessage={filterFormik.errors.start_date}
onChange={handleFilterStartDateChange}
onChange={(e) => {
const value = e.target.value;
filterFormik.setFieldValue('start_date', value);
if (value && filterFormik.values.end_date) {
const startDate = new Date(value);
const endDateObj = new Date(
filterFormik.values.end_date
);
if (endDateObj < startDate) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error(
'Tanggal akhir tidak boleh masa lampau',
{
duration: Infinity,
}
);
setDateErrorShown(true);
}
} else {
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
}
} else {
setHasDateError(false);
}
}}
isError={
filterFormik.touched.start_date &&
Boolean(filterFormik.errors.start_date)
@@ -1291,11 +1310,38 @@ const UniformityTable = () => {
placeholder='Tanggal Akhir'
value={filterFormik.values.end_date}
errorMessage={filterFormik.errors.end_date}
onChange={handleFilterEndDateChange}
isError={
filterFormik.touched.end_date &&
Boolean(filterFormik.errors.end_date)
}
onChange={(e) => {
const value = e.target.value;
filterFormik.setFieldValue('end_date', value);
if (value && filterFormik.values.start_date) {
const startDateObj = new Date(
filterFormik.values.start_date
);
const endDate = new Date(value);
if (endDate < startDateObj) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error(
'Tanggal akhir tidak boleh masa lampau',
{
duration: Infinity,
}
);
setDateErrorShown(true);
}
return;
}
}
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
}}
isError={hasDateError}
/>
</div>
</div>
+115 -137
View File
@@ -12,10 +12,9 @@ import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Button from '@/components/Button';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import RequirePermission from '@/components/helper/RequirePermission';
import StatusBadge from '@/components/helper/StatusBadge';
@@ -69,59 +68,72 @@ const getStatusBadgeColor = (status: string): Color => {
return statusBadgeColorMap[status] || 'neutral';
};
// ===== INTERFACES =====
interface RowOptionsMenuProps {
type: 'dropdown' | 'collapse';
props: CellContext<Purchase, unknown>;
deleteClickHandler: () => void;
}
// ===== ROW OPTIONS MENU =====
const RowOptionsMenu = ({
type = 'dropdown',
popoverPosition = 'bottom',
props,
deleteClickHandler,
}: RowOptionsMenuProps) => {
}: {
popoverPosition: 'bottom' | 'top';
props: CellContext<Purchase, unknown>;
deleteClickHandler: () => void;
}) => {
const popoverId = `purchase#${props.row.original.id}`;
const popoverAnchorName = `--anchor-purchase#${props.row.original.id}`;
const closePopover = () => {
document.getElementById(popoverId)?.hidePopover();
};
return (
<RowOptionsMenuWrapper type={type}>
<RequirePermission permissions='lti.purchase.detail'>
<Button
href={`/purchase/detail/?purchaseId=${props.row.original.id}`}
variant='ghost'
color='primary'
className='justify-start text-sm'
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
</RequirePermission>
<div className='relative'>
<PopoverButton
tabIndex={0}
variant='ghost'
color='none'
popoverTarget={popoverId}
anchorName={popoverAnchorName}
>
<Icon icon='material-symbols:more-vert' width={16} height={16} />
</PopoverButton>
{/*<Button*/}
{/* href={`/purchase/detail/edit/?purchaseId=${props.row.original.id}`}*/}
{/* variant='ghost'*/}
{/* color='warning'*/}
{/* className='justify-start text-sm'*/}
{/*>*/}
{/* <Icon icon='material-symbols:edit-outline' width={16} height={16} />*/}
{/* Edit*/}
{/*</Button>*/}
<PopoverContent
id={popoverId}
anchorName={popoverAnchorName}
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
>
<div className='flex flex-col bg-base-100 rounded-xl'>
<RequirePermission permissions='lti.purchase.detail'>
<Button
href={`/purchase/detail/?purchaseId=${props.row.original.id}`}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full'
onClick={closePopover}
>
<Icon icon='heroicons:eye' width={20} height={20} />
Detail
</Button>
</RequirePermission>
<RequirePermission permissions='lti.purchase.delete'>
<Button
onClick={deleteClickHandler}
variant='ghost'
color='error'
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={16}
height={16}
className='justify-start text-sm'
/>
Delete
</Button>
</RequirePermission>
</RowOptionsMenuWrapper>
<RequirePermission permissions='lti.purchase.delete'>
<Button
onClick={() => {
deleteClickHandler();
closePopover();
}}
variant='ghost'
color='error'
className='p-3 justify-start text-sm font-semibold w-full focus-visible:text-error-content hover:text-error-content'
>
<Icon icon='mdi:delete-outline' width={20} height={20} />
Delete
</Button>
</RequirePermission>
</div>
</PopoverContent>
</div>
);
};
@@ -346,27 +358,11 @@ const PurchaseTable = () => {
};
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu
type='dropdown'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptionsMenu
type='collapse'
props={props}
deleteClickHandler={deleteClickHandler}
/>
</RowCollapseOptions>
)}
</>
<RowOptionsMenu
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
props={props}
deleteClickHandler={deleteClickHandler}
/>
);
},
},
@@ -405,22 +401,22 @@ const PurchaseTable = () => {
return (
<>
<div className='w-full p-0 sm:p-4'>
<div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col xl:flex-row justify-between items-end xl:items-center gap-2'>
<div className='w-full flex flex-row gap-2'>
<RequirePermission permissions='lti.purchase.create'>
<Button
href='/purchase/add'
color='primary'
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
>
<Icon icon='heroicons:plus' width={20} height={20} />
Add Purchase
</Button>
</RequirePermission>
</div>
<div className='w-full'>
<div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
<div className='w-fit flex flex-row gap-3 flex-wrap'>
<RequirePermission permissions='lti.purchase.create'>
<Button
href='/purchase/add'
color='primary'
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-xl shadow-button-soft'
>
<Icon icon='heroicons:plus' width={20} height={20} />
Add Purchase
</Button>
</RequirePermission>
</div>
<div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
<DebouncedTextInput
name='search'
placeholder='Search'
@@ -441,59 +437,41 @@ const PurchaseTable = () => {
}}
/>
</div>
<div className='flex flex-wrap justify-end gap-4'>
<SelectInput
label='Baris'
options={ROWS_OPTIONS}
value={{
label: String(tableFilterState.pageSize),
value: tableFilterState.pageSize,
}}
onChange={pageSizeChangeHandler}
className={{
wrapper: 'w-full sm:w-24',
}}
/>
</div>
</div>
<Table<Purchase>
data={
isResponseSuccess(purchaseRequests) ? purchaseRequests?.data : []
}
columns={purchaseColumns}
pageSize={tableFilterState.pageSize}
page={
isResponseSuccess(purchaseRequests)
? purchaseRequests?.meta?.page
: 0
}
totalItems={
isResponseSuccess(purchaseRequests)
? purchaseRequests?.meta?.total_results
: 0
}
onPageChange={setPage}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: cn({
'mb-20':
isResponseSuccess(purchaseRequests) &&
purchaseRequests?.data?.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',
}}
/>
{/* Table Section */}
<div className='flex flex-col mb-4'>
<Table<Purchase>
data={
isResponseSuccess(purchaseRequests) ? purchaseRequests?.data : []
}
columns={purchaseColumns}
pageSize={tableFilterState.pageSize}
page={
isResponseSuccess(purchaseRequests)
? purchaseRequests?.meta?.page
: 0
}
totalItems={
isResponseSuccess(purchaseRequests)
? purchaseRequests?.meta?.total_results
: 0
}
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: cn('p-3', {
'w-full mb-20':
isResponseSuccess(purchaseRequests) &&
purchaseRequests?.data?.length === 0,
}),
headerColumnClassName: 'text-nowrap',
}}
/>
</div>
</div>
{/* ===== MODAL COMPONENTS ===== */}
@@ -15,7 +15,7 @@ import {
import ExpenseStatusBadge from '@/components/pages/expense/ExpenseStatusBadge';
import RealizationStatusBadge from '@/components/pages/expense/RealizationStatusBadge';
import Table from '@/components/Table';
import { cn, formatCurrency, formatDate } from '@/lib/helper';
import { formatCurrency, formatDate } from '@/lib/helper';
import { ReportExpense } from '@/types/api/report/report-expense';
import { ReportExpenseApi } from '@/services/api/report';
import { isResponseSuccess } from '@/lib/api-helper';
@@ -38,6 +38,7 @@ import { Nonstock } from '@/types/api/master-data/nonstock';
import { ColumnDef } from '@tanstack/react-table';
import { httpClient } from '@/services/http/client';
import { BaseApiResponse } from '@/types/api/api-general';
import ButtonFilter from '@/components/helper/ButtonFilter';
interface ReportExpenseTabProps {
tabId: string;
@@ -144,6 +145,7 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
setFilterParams({});
setIsSubmitted(false);
setPage(1);
filterModal.closeModal();
},
});
@@ -169,20 +171,6 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
[formik.values.category]
);
// ===== ACTIVE FILTERS COUNT =====
const activeFiltersCount = useMemo(() => {
let count = 0;
if (filterParams.location_id) count += 1;
if (filterParams.supplier_id) count += 1;
if (filterParams.kandang_id) count += 1;
if (filterParams.nonstock_id) count += 1;
if (filterParams.realization_date) count += 1;
if (filterParams.category) count += 1;
return count;
}, [filterParams]);
const hasFilters = activeFiltersCount > 0;
// ===== DATA FETCHING =====
const { data: reportExpenseResponse, isLoading } = useSWR(
isSubmitted
@@ -312,25 +300,12 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
setTabActions(
tabId,
<div className='flex flex-row gap-3'>
<Button
variant='outline'
color='none'
<ButtonFilter
values={formik.values}
onClick={() => filterModal.openModal()}
className={cn(
'px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft transition-all',
{
'border-primary-gradient text-primary': hasFilters,
}
)}
>
<Icon icon='heroicons:funnel' width={20} height={20} />
Filter
{hasFilters && (
<span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
{activeFiltersCount}
</span>
)}
</Button>
variant='outline'
className='px-3 py-2.5'
/>
<Dropdown
align='end'
@@ -387,8 +362,7 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
);
}, [
tabId,
hasFilters,
activeFiltersCount,
formik.values,
isAnyExportLoading,
handleExportExcel,
handleExportPDF,
@@ -11,7 +11,19 @@ export type DebtSupplierFilterType = {
export const DebtSupplierFilterSchema: yup.ObjectSchema<DebtSupplierFilterType> =
yup.object({
startDate: yup.string().optional().notRequired(),
endDate: yup.string().optional().notRequired(),
endDate: yup
.string()
.optional()
.notRequired()
.test(
'is-greater-than-start',
'Tanggal akhir tidak boleh masa lampau',
function (value) {
const startDate = this.parent.startDate;
if (!startDate || !value) return true;
return new Date(value) >= new Date(startDate);
}
),
supplierIds: yup
.array()
.of(
@@ -38,6 +38,7 @@ import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
import CustomerSupplierSkeleton from '@/components/pages/report/finance/skeleton/CustomerSupplierSkeleton';
import { OptionType } from '@/components/table/TableRowSizeSelector';
import { Color } from '@/types/theme';
import ButtonFilter from '@/components/helper/ButtonFilter';
interface CustomerPaymentTabProps {
tabId: string;
@@ -118,6 +119,7 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
toast.dismiss();
setDateErrorShown(false);
}
filterModal.closeModal();
},
});
@@ -213,30 +215,6 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
);
}, [formik.values.filter_by]);
// ===== ACTIVE FILTERS COUNT =====
const activeFiltersCount = useMemo(() => {
let count = 0;
// Date filter (start_date + end_date = 1 filter)
if (filterParams.start_date || filterParams.end_date) {
count += 1;
}
// Customer filter
if (filterParams.customer_ids) {
count += 1;
}
// Filter by type filter (hanya dihitung jika ada nilai yang dipilih)
if (filterParams.filter_by) {
count += 1;
}
return count;
}, [filterParams]);
const hasFilters = activeFiltersCount > 0;
// ===== DATA FETCHING =====
const { data: customerPayment, isLoading } = useSWR(
isSubmitted
@@ -380,25 +358,13 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
setTabActions(
tabId,
<div className='flex flex-row gap-3'>
<Button
variant='outline'
color='none'
<ButtonFilter
values={formik.values}
fieldGroups={[['start_date', 'end_date']]}
onClick={handleFilterModalOpen}
className={cn(
'px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft transition-all',
{
'border-primary-gradient text-primary': hasFilters,
}
)}
>
<Icon icon='heroicons:funnel' width={20} height={20} />
Filter
{hasFilters && (
<span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
{activeFiltersCount}
</span>
)}
</Button>
variant='outline'
className='px-3 py-2.5'
/>
<Dropdown
align='end'
@@ -455,8 +421,7 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
);
}, [
tabId,
hasFilters,
activeFiltersCount,
formik.values,
isAnyExportLoading,
handleExportExcel,
handleExportPdf,
@@ -880,19 +845,29 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
<DateInput
name='start_date'
value={formik.values.start_date || ''}
errorMessage={formik.errors.start_date}
onChange={handleStartDateChange}
className={{ wrapper: 'w-full' }}
isNestedModal
isError={
formik.touched.start_date &&
Boolean(formik.errors.start_date)
}
/>
<hr className='w-full max-w-3 h-px border-base-content/10' />
<DateInput
name='end_date'
value={formik.values.end_date || ''}
errorMessage={formik.errors.end_date}
onChange={handleEndDateChange}
className={{ wrapper: 'w-full' }}
isNestedModal
isError={hasDateError}
isError={
(formik.touched.end_date &&
Boolean(formik.errors.end_date)) ||
hasDateError
}
/>
</div>
</div>
@@ -87,6 +87,10 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
});
const [isSubmitted, setIsSubmitted] = useState(false);
// ===== DATE ERROR STATE =====
const [dateErrorShown, setDateErrorShown] = useState(false);
const [hasDateError, setHasDateError] = useState(false);
const filterModal = useModal();
const {
@@ -106,6 +110,7 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
const handleFilterModalOpen = () => {
filterModal.openModal();
formik.validateForm();
};
// ===== FORMIK SETUP =====
@@ -137,6 +142,7 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
filter_by: undefined,
});
setIsSubmitted(false);
filterModal.closeModal();
},
});
@@ -274,6 +280,7 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
<div className='flex flex-row gap-3'>
<ButtonFilter
values={formik.values}
fieldGroups={[['startDate', 'endDate']]}
onClick={handleFilterModalOpen}
variant='outline'
className='px-3 py-2.5'
@@ -348,6 +355,23 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
};
}, [tabId, clearTabActions]);
useEffect(() => {
return () => {
if (dateErrorShown) {
toast.dismiss();
}
};
}, [dateErrorShown]);
useEffect(() => {
return () => {
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
};
}, [filterModal.open, dateErrorShown]);
const getTableColumns = (supplier?: DebtSupplier): ColumnDef<DebtRow>[] => [
{
id: 'no',
@@ -722,7 +746,31 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
name='startDate'
value={formik.values.startDate || ''}
onChange={(e) => {
formik.setFieldValue('startDate', e.target.value || null);
const value = e.target.value;
formik.setFieldValue('startDate', value || null);
if (value && formik.values.endDate) {
const startDate = new Date(value);
const endDateObj = new Date(formik.values.endDate);
if (endDateObj < startDate) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
});
setDateErrorShown(true);
}
} else {
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
}
} else {
setHasDateError(false);
}
}}
className={{ wrapper: 'w-full' }}
isError={
@@ -736,10 +784,36 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
name='endDate'
value={formik.values.endDate || ''}
onChange={(e) => {
formik.setFieldValue('endDate', e.target.value || null);
const value = e.target.value;
formik.setFieldValue('endDate', value || null);
if (value && formik.values.startDate) {
const startDateObj = new Date(formik.values.startDate);
const endDate = new Date(value);
if (endDate < startDateObj) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
});
setDateErrorShown(true);
}
return;
}
}
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
}}
className={{ wrapper: 'w-full' }}
isError={formik.touched.endDate && !!formik.errors.endDate}
isError={
(formik.touched.endDate && !!formik.errors.endDate) ||
hasDateError
}
errorMessage={formik.errors.endDate}
isNestedModal
/>
@@ -32,6 +32,7 @@ import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import SelectInputRadio from '@/components/input/SelectInputRadio';
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
import PurchasePerSupplierSkeleton from '@/components/pages/report/logistic-stock/skeleton/PurchasePerSupplierSkeleton';
import ButtonFilter from '@/components/helper/ButtonFilter';
interface PurchasesPerSupplierTabProps {
tabId: string;
@@ -146,6 +147,7 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => {
toast.dismiss();
setDateErrorShown(false);
}
filterModal.closeModal();
},
});
@@ -253,43 +255,6 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => {
);
}, [formik.values.sort_by, sortByOptions]);
// ===== ACTIVE FILTERS COUNT =====
const activeFiltersCount = useMemo(() => {
let count = 0;
if (filterParams.start_date || filterParams.end_date) {
count += 1;
}
if (filterParams.area_id) {
count += 1;
}
if (filterParams.supplier_id) {
count += 1;
}
if (filterParams.product_id) {
count += 1;
}
if (filterParams.product_category_id) {
count += 1;
}
if (filterParams.filter_by) {
count += 1;
}
if (filterParams.sort_by) {
count += 1;
}
return count;
}, [filterParams]);
const hasFilters = activeFiltersCount > 0;
// ===== DATA FETCHING =====
const { data: purchasePerSupplier, isLoading } = useSWR(
isSubmitted
@@ -486,25 +451,13 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => {
setTabActions(
tabId,
<div className='flex flex-row gap-3'>
<Button
variant='outline'
color='none'
<ButtonFilter
values={formik.values}
fieldGroups={[['start_date', 'end_date']]}
onClick={handleFilterModalOpen}
className={cn(
'px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft transition-all',
{
'border-primary-gradient text-primary': hasFilters,
}
)}
>
<Icon icon='heroicons:funnel' width={20} height={20} />
Filter
{hasFilters && (
<span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
{activeFiltersCount}
</span>
)}
</Button>
variant='outline'
className='px-3 py-2.5'
/>
<Dropdown
align='end'
@@ -561,8 +514,7 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => {
);
}, [
tabId,
hasFilters,
activeFiltersCount,
formik.values,
isAnyExportLoading,
filterModal.open,
setTabActions,
@@ -891,18 +843,28 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => {
<DateInput
name='start_date'
value={formik.values.start_date || ''}
errorMessage={formik.errors.start_date}
onChange={handleStartDateChange}
className={{ wrapper: 'w-full' }}
isNestedModal
isError={
formik.touched.start_date &&
Boolean(formik.errors.start_date)
}
/>
<hr className='w-full max-w-3 h-px border-base-content/10' />
<DateInput
name='end_date'
value={formik.values.end_date || ''}
errorMessage={formik.errors.end_date}
onChange={handleEndDateChange}
className={{ wrapper: 'w-full' }}
isNestedModal
isError={hasDateError}
isError={
(formik.touched.end_date &&
Boolean(formik.errors.end_date)) ||
hasDateError
}
/>
</div>
</div>
@@ -47,6 +47,7 @@ import {
MARKETING_TYPE_OPTIONS,
} from '@/config/constant';
import Badge from '@/components/Badge';
import ButtonFilter from '@/components/helper/ButtonFilter';
interface DailyMarketingTabProps {
tabId: string;
@@ -79,6 +80,10 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
// ===== FILTER STATE =====
const [filterParams, setFilterParams] = useState<FilterParams>({});
// ===== DATE ERROR STATE =====
const [dateErrorShown, setDateErrorShown] = useState(false);
const [hasDateError, setHasDateError] = useState(false);
const filterModal = useModal();
// ===== OPTIONS =====
@@ -137,6 +142,7 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
onReset: () => {
setFilterParams({});
setIsSubmitted(false);
filterModal.closeModal();
},
});
@@ -202,47 +208,6 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
);
}, [formik.values.marketing_type]);
// ===== ACTIVE FILTERS COUNT =====
const activeFiltersCount = useMemo(() => {
let count = 0;
if (filterParams.area_id) {
count += 1;
}
if (filterParams.location_id) {
count += 1;
}
if (filterParams.warehouse_id) {
count += 1;
}
if (filterParams.customer_id) {
count += 1;
}
if (filterParams.start_date || filterParams.end_date) {
count += 1;
}
if (filterParams.filter_by) {
count += 1;
}
if (filterParams.marketing_type) {
count += 1;
}
if (filterParams.sort_by) {
count += 1;
}
return count;
}, [filterParams]);
const hasFilters = activeFiltersCount > 0;
// ===== DATA FETCHING =====
const { data: dailyMarketings, isLoading } = useSWR(
isSubmitted
@@ -412,30 +377,13 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
}}
/>
<Button
variant='outline'
color='none'
<ButtonFilter
values={formik.values}
fieldGroups={[['start_date', 'end_date']]}
onClick={handleFilterModalOpen}
className={cn(
'px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft transition-all',
{
'border-primary-gradient text-primary': hasFilters,
}
)}
>
<Icon icon='heroicons:funnel' width={20} height={20} />
Filter
{hasFilters && (
<Badge
className={{
badge:
'p-1.5 bg-[#FF3535] text-xs text-base-100 border border-base-300 rounded-lg',
}}
>
{activeFiltersCount}
</Badge>
)}
</Button>
variant='outline'
className='px-3 py-2.5'
/>
<Dropdown
align='end'
@@ -493,8 +441,7 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
}, [
tabId,
searchValue,
hasFilters,
activeFiltersCount,
formik.values,
isAnyExportLoading,
filterModal.open,
setTabActions,
@@ -506,6 +453,23 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
};
}, [tabId, clearTabActions]);
useEffectHook(() => {
return () => {
if (dateErrorShown) {
toast.dismiss();
}
};
}, [dateErrorShown]);
useEffectHook(() => {
return () => {
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
};
}, [filterModal.open, dateErrorShown]);
const getTableColumns = (): ColumnDef<DailyMarketingRow>[] => {
const tableColumns: ColumnDef<DailyMarketingRow>[] = [
{
@@ -849,34 +813,76 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
placeholder='Pilih Tanggal Awal'
value={formik.values.start_date || ''}
onChange={(e) => {
formik.setFieldValue('start_date', e.target.value || null);
const value = e.target.value;
formik.setFieldValue('start_date', value || null);
if (value && formik.values.end_date) {
const startDate = new Date(value);
const endDateObj = new Date(formik.values.end_date);
if (endDateObj < startDate) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
});
setDateErrorShown(true);
}
} else {
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
}
} else {
setHasDateError(false);
}
}}
className={{ wrapper: 'w-full' }}
errorMessage={formik.errors.start_date}
isError={
!!formik.errors.start_date && formik.touched.start_date
}
/>
{formik.errors.start_date && formik.touched.start_date && (
<div className='text-error text-xs mt-1'>
{formik.errors.start_date}
</div>
)}
<DateInput
name='end_date'
label='Tanggal Akhir'
placeholder='Pilih Tanggal Akhir'
value={formik.values.end_date || ''}
onChange={(e) => {
formik.setFieldValue('end_date', e.target.value || null);
const value = e.target.value;
formik.setFieldValue('end_date', value || null);
if (value && formik.values.start_date) {
const startDateObj = new Date(formik.values.start_date);
const endDate = new Date(value);
if (endDate < startDateObj) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
});
setDateErrorShown(true);
}
return;
}
}
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
}}
className={{ wrapper: 'w-full' }}
isError={!!formik.errors.end_date && formik.touched.end_date}
errorMessage={formik.errors.end_date}
isError={
(formik.errors.end_date && formik.touched.end_date) ||
hasDateError
}
/>
{formik.errors.end_date && formik.touched.end_date && (
<div className='text-error text-xs mt-1'>
{formik.errors.end_date}
</div>
)}
</div>
</div>
@@ -17,6 +17,7 @@ import {
} from '@/types/api/report/hpp-per-kandang';
import { isResponseSuccess } from '@/lib/api-helper';
import Button from '@/components/Button';
import ButtonFilter from '@/components/helper/ButtonFilter';
import Dropdown from '@/components/Dropdown';
import { generateHppPerKandangPDF } from '@/components/pages/report/marketing/export/HppPerkandangExportPDF';
import { generateHppPerKandangExcel } from '@/components/pages/report/marketing/export/HppPerkandangExportXLSX';
@@ -136,6 +137,7 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
toast.dismiss();
setDateErrorShown(false);
}
filterModal.closeModal();
},
});
@@ -233,43 +235,6 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
);
}, [formik.values.show_unrecorded, showUnrecordedOptions]);
// ===== ACTIVE FILTERS COUNT =====
const activeFiltersCount = useMemo(() => {
let count = 0;
if (filterParams.period) {
count += 1;
}
if (filterParams.area_id) {
count += 1;
}
if (filterParams.location_id) {
count += 1;
}
if (filterParams.kandang_id) {
count += 1;
}
if (filterParams.weight_min || filterParams.weight_max) {
count += 1;
}
if (filterParams.show_unrecorded !== undefined) {
count += 1;
}
if (filterParams.sort_by) {
count += 1;
}
return count;
}, [filterParams]);
const hasFilters = activeFiltersCount > 0;
// ===== DATA FETCHING =====
const { data: hppPerKandang, isLoading } = useSWR(
isSubmitted
@@ -486,25 +451,12 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
setTabActions(
tabId,
<div className='flex flex-row gap-3'>
<Button
variant='outline'
color='none'
<ButtonFilter
values={formik.values}
onClick={handleFilterModalOpen}
className={cn(
'px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft transition-all',
{
'border-primary-gradient text-primary': hasFilters,
}
)}
>
<Icon icon='heroicons:funnel' width={20} height={20} />
Filter
{hasFilters && (
<span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
{activeFiltersCount}
</span>
)}
</Button>
variant='outline'
className='px-3 py-2.5'
/>
<Dropdown
align='end'
@@ -561,8 +513,7 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
);
}, [
tabId,
hasFilters,
activeFiltersCount,
formik.values,
isAnyExportLoading,
filterModal.open,
setTabActions,
@@ -7,6 +7,7 @@ import toast from 'react-hot-toast';
import { Icon } from '@iconify/react';
import Button from '@/components/Button';
import ButtonFilter from '@/components/helper/ButtonFilter';
import Dropdown from '@/components/dropdown/Dropdown';
import SelectInput, { useSelect } from '@/components/input/SelectInput';
import ProductionResultProjectFlockKandangTable from '@/components/pages/report/production-result/tab/ProductionResultProjectFlockKandangTable';
@@ -237,6 +238,7 @@ const ProductionResultContent = ({ tabId }: ProductionResultTabProps) => {
setFilterParams({});
setIsSubmitted(false);
setPage(1);
filterModal.closeModal();
},
});
@@ -324,20 +326,6 @@ const ProductionResultContent = ({ tabId }: ProductionResultTabProps) => {
[formik.values.kandang_id]
);
// ===== ACTIVE FILTERS COUNT =====
const activeFiltersCount = useMemo(() => {
let count = 0;
if (filterParams.area_id) count += 1;
if (filterParams.location_id) count += 1;
if (filterParams.project_flock_id) count += 1;
if (filterParams.project_flock_kandang_id) count += 1;
return count;
}, [filterParams]);
const hasFilters = activeFiltersCount > 0;
// ===== DATA FETCHING =====
const { data: projectFlockKandangsData, isLoading } = useSWR<
BaseApiResponse<ProjectFlockKandang[]>
@@ -539,25 +527,12 @@ const ProductionResultContent = ({ tabId }: ProductionResultTabProps) => {
setTabActions(
tabId,
<div className='flex flex-row gap-3'>
<Button
variant='outline'
color='none'
<ButtonFilter
values={filterParams}
onClick={() => filterModal.openModal()}
className={cn(
'px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft transition-all',
{
'border-primary-gradient text-primary': hasFilters,
}
)}
>
<Icon icon='heroicons:funnel' width={20} height={20} />
Filter
{hasFilters && (
<span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
{activeFiltersCount}
</span>
)}
</Button>
variant='outline'
className='px-3 py-2.5'
/>
<Dropdown
align='end'
@@ -614,8 +589,7 @@ const ProductionResultContent = ({ tabId }: ProductionResultTabProps) => {
);
}, [
tabId,
hasFilters,
activeFiltersCount,
filterParams,
isAnyExportLoading,
exportToExcelHandler,
exportToPdfHandler,
+16
View File
@@ -545,3 +545,19 @@ export const MARKETING_DATE_FILTER_TYPE_OPTIONS = [
value: 'so_date',
},
];
export const TRANSACTION_TYPE_OPTIONS = [
{ label: 'Pembelian', value: 'PEMBELIAN' },
{ label: 'Penjualan', value: 'PENJUALAN' },
{ label: 'Recording', value: 'RECORDING' },
];
export const TRANSACTION_SUBTYPE_OPTIONS = {
PEMBELIAN: { label: 'Pembelian', value: 'PURCHASE_IN' },
PENJUALAN: { label: 'Penjualan', value: 'MARKETING_OUT' },
RECORDING: [
{ label: 'Recording Stock', value: 'RECORDING_STOCK_OUT' },
{ label: 'Recording Depletion', value: 'RECORDING_DEPLETION_OUT' },
{ label: 'Recording Egg', value: 'RECORDING_EGG_IN' },
],
} as const;
+19 -15
View File
@@ -1,31 +1,35 @@
import { Product } from '@/types/api/master-data/product';
import { Warehouse } from '@/types/api/master-data/warehouse';
import { BaseMetadata } from '@/types/api/api-general';
import { Location } from '@/types/api/master-data/location';
import { ProjectFlock } from '@/types/api/project-flock';
export type BaseInventoryAdjustment = {
id: number;
adj_number: string;
transaction_type: string;
transaction_subtype: string;
function_code: string;
qty: number;
price: number;
grand_total: number;
increase: number;
decrease: number;
note: string;
notes: string;
location: Location;
project_flock: ProjectFlock;
product_warehouse_id: number;
product_warehouse: {
id: number;
quantity: number;
product_id: number;
warehouse_id: number;
product: Product;
warehouse: Warehouse;
};
product_warehouse: ProductWarehouse;
project_flock_kandang_id?: number;
};
export type InventoryAdjustment = BaseMetadata & BaseInventoryAdjustment;
export type CreateInventoryAdjustmentPayload = {
project_flock_kandang_id: number;
product_id: number;
warehouse_id: number;
transaction_type: string;
quantity: number;
note: string;
transaction_subtype: string;
qty: number;
price: number;
notes: string;
};
export type UpdateInventoryAdjustmentPayload =