added page acceptance

This commit is contained in:
Samandar Turg'unboev
2025-07-12 18:11:24 +05:00
parent 62a5c062d1
commit 6b48f507a4
17 changed files with 1213 additions and 26 deletions

View File

@@ -0,0 +1,746 @@
'use client';
import ActionPopMenu from '@/components/common/ActionPopMenu';
import { type ColumnData, MyTable } from '@/components/common/MyTable';
import BaseButton from '@/components/ui-kit/BaseButton';
import BaseInput from '@/components/ui-kit/BaseInput';
import { selectDefaultStyles } from '@/components/ui-kit/BaseReactSelect';
import { useAuthContext } from '@/context/auth-context';
import type { BoxStatus, IBox, PrintStatus } from '@/data/box/box.model';
import { box_requests } from '@/data/box/box.requests';
import type { Product, UpdateProductBodyType } from '@/data/item/item.mode';
import { item_requests } from '@/data/item/item.requests';
import { party_requests } from '@/data/party/party.requests';
import { DEFAULT_PAGE_SIZE, pageLinks } from '@/helpers/constants';
import useInput from '@/hooks/useInput';
import { useMyNavigation } from '@/hooks/useMyNavigation';
import { useMyTranslation } from '@/hooks/useMyTranslation';
import useRequest from '@/hooks/useRequest';
import { useModalStore } from '@/modalStorage/modalSlice';
import { useBoxIdStore } from '@/modalStorage/partyId';
import { notifyUnknownError } from '@/services/notification';
import { CheckCircle, FilterListOff, Print, RemoveRedEye, Search } from '@mui/icons-material';
import { Box, Button, CircularProgress, FormControl, MenuItem, Select, Stack, Typography } from '@mui/material';
import { CloseIcon } from 'next/dist/client/components/react-dev-overlay/internal/icons/CloseIcon';
import { useRouter, useSearchParams } from 'next/navigation';
import type React from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import AsyncSelect from 'react-select/async';
import { useReactToPrint } from 'react-to-print';
import BoxesPrintList from '../boxes-print/BoxesPrintList';
import CustomModal from '../customModal/CustomModal';
type Props = {};
const style = {
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 400,
bgcolor: 'background.paper',
border: '2px solid #000',
boxShadow: 24,
display: 'flex',
flexDirection: 'column',
gap: '10px',
p: 4,
};
const DashboardAcceptancePage = () => {
// Redux for modal state
const { isOpen: isModalOpen, openModal, closeModal } = useModalStore();
const t = useMyTranslation();
const navigation = useMyNavigation();
const { isAdmin } = useAuthContext();
const router = useRouter();
// State management
const [page, setPage] = useState(1);
const [pageSize] = useState(DEFAULT_PAGE_SIZE);
const { value: keyword, onChange: handleKeyword, setValue: setKeyword } = useInput('');
const [boxStatusFilter, setBoxStatusFilter] = useState<BoxStatus | undefined>(undefined);
const [trackId, setTrackId] = useState<string>();
const [partyFilter, setPartyFilter] = useState<{ label: string; value: number } | undefined>(undefined);
const [boxFilter, setBoxFilter] = useState<{ label: string; value: number } | undefined>(undefined);
const [deleteIds, setDeleteIds] = useState<number[]>([]);
const [downloadIds, setDownloadIds] = useState<number[]>([]);
const [changeStatusIds, setChangeStatusIds] = useState<number[]>([]);
const [boxAmounts, setBoxAmounts] = useState<Record<number, { totalAmount: number; totalAccepted: number }>>({});
const [printStatuses, setPrintStatuses] = useState<Record<number, string>>({});
const [hasMore, setHasMore] = useState(true);
const [isFetching, setIsFetching] = useState(false);
const [allData, setAllData] = useState<IBox[]>([]);
const searchParams = useSearchParams();
const boxId = searchParams.get('boxId');
const { boxesIdAccepted, setBoxIdAccepted } = useBoxIdStore();
// Print related state
const [selectedBoxForPrint, setSelectedBoxForPrint] = useState<IBox | null>(null);
const [selectedBoxDetails, setSelectedBoxDetails] = useState<any>(null);
const printRef = useRef<HTMLDivElement>(null);
const tableContainerRef = useRef<HTMLDivElement>(null);
// Print functionality
const handlePrint = useReactToPrint({
contentRef: printRef,
onAfterPrint: () => {
setSelectedBoxForPrint(null);
setSelectedBoxDetails(null);
},
});
// Fetch party options
const { data: defaultPartyOptions } = useRequest(() => party_requests.getAll({}), {
enabled: true,
selectData(data) {
return data.data.data.data.map(p => ({ value: p.id, label: p.name }));
},
placeholderData: [],
});
const partyOptions = (inputValue: string) => {
return party_requests.getAll({ partyName: inputValue }).then(res => {
return res.data.data.data.map(p => ({ label: p.name, value: p.id }));
});
};
// Print box handler
const onPrintBox = async (boxData: IBox) => {
try {
const response = await box_requests.find({ packetId: boxData.id });
const boxOne = response.data.data;
const detailedBoxData = {
id: +boxData.id,
box_name: boxOne.packet.name,
net_weight: +boxOne.packet.brutto,
box_weight: +boxOne.packet.boxWeight,
box_type: boxOne.packet.boxType,
box_size: boxOne.packet.volume,
passportName: boxOne.packet.passportName,
status: boxOne.packet.status,
packetId: boxData.id,
partyId: +boxOne.packet.partyId,
partyName: boxOne.packet.partyName,
passportId: boxOne.client?.passportId,
client_id: boxOne.packet?.cargoId,
clientName: boxOne.client?.passportName,
products_list: boxOne.items.map((item: any) => ({
id: item.id,
price: item.price,
cargoId: item.cargoId,
trekId: item.trekId,
name: item.name,
nameRu: item.nameRu,
amount: +item.amount,
acceptedNumber: item.acceptedNumber,
weight: +item.weight,
})),
};
setSelectedBoxDetails(detailedBoxData);
setTimeout(() => {
handlePrint();
}, 100);
} catch (error) {}
};
// Memoized options
const boxStatusOptions = useMemo(() => {
const p = ['READY_TO_INVOICE'] as BoxStatus[];
if (isAdmin) {
p.push('READY');
}
return p;
}, [isAdmin]);
const printOptions = useMemo(() => {
const p = ['false'] as PrintStatus[];
if (isAdmin) {
p.push('false');
}
return p;
}, [isAdmin]);
// Main data fetching query with optimized refresh handling
const getBoxesQuery = useRequest(
() =>
box_requests.getAll({
page: page,
cargoId: keyword,
partyId: partyFilter?.value,
status: boxStatusFilter,
direction: 'desc',
sort: 'id',
}),
{
dependencies: [page, boxStatusFilter, keyword, partyFilter],
selectData(data) {
return data.data.data;
},
onSuccess: data => {
// Only update data if the page or filters have changed
if (page === 1) {
setAllData(data.data.data.data);
} else {
// Prevent duplicate data
setAllData(prev => {
const existingIds = new Set(prev.map(box => box.id));
const newData = data.data.data.data.filter(box => !existingIds.has(box.id));
return [...prev, ...newData];
});
}
setHasMore(data.data.data.data.length === pageSize);
setIsFetching(false);
},
}
);
// Secondary list query
const getListQuery = useRequest(
() =>
item_requests.getAll({
page: page,
trekId: trackId,
packetId: boxFilter?.value,
partyId: partyFilter?.value,
}),
{
dependencies: [page, trackId, boxFilter?.value, partyFilter?.value],
selectData(data) {
return data.data.data;
},
}
);
// Form state for item amounts
const [values, setValues] = useState<{ [trackId: string]: number | '' }>({});
const handleAmountChange = (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>, max: number, trackId: string) => {
const val = Number(event.target.value);
if (val >= 1 && val <= max) {
setValues(prev => ({ ...prev, [trackId]: val }));
} else if (event.target.value === '') {
setValues(prev => ({ ...prev, [trackId]: '' }));
}
};
const loading = getBoxesQuery.loading;
// Page change handler
const handleChange = useCallback(
(newPage: number) => {
if (!isFetching && hasMore) {
setIsFetching(true);
setPage(newPage);
}
},
[isFetching, hasMore]
);
// Reset all filters
const resetFilter = useCallback(() => {
setPage(1);
setKeyword('');
setBoxStatusFilter(undefined);
setPartyFilter(undefined);
setAllData([]);
}, []);
// Box options for select
const { data: defaultBoxOptions, refetch } = useRequest(
() =>
box_requests.getAll({
partyId: partyFilter?.value,
}),
{
enabled: !!partyFilter,
selectData(data) {
return data.data.data.data.map(p => ({ value: p.id, label: p.name }));
},
placeholderData: [],
dependencies: [partyFilter],
}
);
useEffect(() => {
if (boxId && defaultPartyOptions && defaultPartyOptions.length > 0) {
const selected = defaultPartyOptions.find(p => p.value === Number(boxId));
if (selected) {
setPartyFilter(selected);
}
}
}, [boxId, defaultPartyOptions]);
useEffect(() => {
if (boxesIdAccepted) {
navigation.push(`${pageLinks.dashboard.acceptance.index}?boxId=${boxesIdAccepted}`);
}
}, [boxesIdAccepted]);
const onChangePrint = async (id: number, newStatus: PrintStatus) => {
if (changeStatusIds.includes(id)) return;
try {
setChangeStatusIds(p => [...p, id]);
const res = await box_requests.find({ packetId: id });
await box_requests.update({
cargoId: String(res.data.data.packet.cargoId),
items: res.data.data.items,
print: newStatus === 'true' ? true : false,
packetId: String(res.data.data.packet.id),
passportId: res.data.data.client.passportId,
status: res.data.data.packet.status,
});
setPrintStatuses(prev => ({
...prev,
[id]: newStatus,
}));
} catch (error) {
} finally {
setChangeStatusIds(prev => prev.filter(i => i !== id));
}
};
// Filter changes with debouncing
useEffect(() => {
const debounceTimer = setTimeout(() => {
if (page === 1) {
getBoxesQuery.refetch();
} else {
setPage(1); // This will trigger the refetch automatically
}
}, 350);
return () => clearTimeout(debounceTimer);
}, [keyword, partyFilter?.value, boxFilter?.value]);
// Fetch box amounts only when needed
useEffect(() => {
if (allData.length === 0 || loading) return;
const controller = new AbortController();
const fetchAmounts = async () => {
const result: Record<number, { totalAmount: number; totalAccepted: number }> = {};
try {
await Promise.all(
allData.map(async box => {
if (boxAmounts[box.id]) return;
try {
const res = await box_requests.find({ packetId: box.id });
const boxData = res.data.data;
const total = boxData.items.reduce(
(acc, item) => {
acc.totalAmount = boxData.packet.totalItems ?? 0;
if (item.acceptedNumber && item.acceptedNumber > 0) {
acc.totalAccepted += 1;
}
return acc;
},
{ totalAmount: 0, totalAccepted: 0 }
);
result[box.id] = total;
} catch (e) {
if (!controller.signal.aborted) {
console.error(e);
}
}
})
);
if (Object.keys(result).length > 0) {
setBoxAmounts(prev => ({ ...prev, ...result }));
}
} catch (error) {
console.error(error);
}
};
fetchAmounts();
return () => {
controller.abort();
};
}, [allData, loading]);
// Optimized scroll handler with throttling
const handleScroll = useCallback(() => {
if (!tableContainerRef.current || isFetching || !hasMore) return;
const { scrollTop, scrollHeight, clientHeight } = tableContainerRef.current;
const scrollThreshold = 100; // pixels from bottom to trigger load
if (scrollHeight - (scrollTop + clientHeight) < scrollThreshold) {
handleChange(page + 1);
}
}, [page, isFetching, hasMore, handleChange]);
// Scroll event listener with cleanup
useEffect(() => {
const container = tableContainerRef.current;
if (!container) return;
const throttledScroll = throttle(handleScroll, 200);
container.addEventListener('scroll', throttledScroll);
return () => {
container.removeEventListener('scroll', throttledScroll);
};
}, [handleScroll]);
// Box options for select
const boxOptions = (inputValue: string) => {
return box_requests
.getAll({
cargoId: inputValue,
partyId: partyFilter?.value,
})
.then(res => {
return res.data.data.data.map(p => ({ label: p.name, value: p.id }));
});
};
// Reset box filter when party changes
useEffect(() => {
setBoxFilter(undefined);
}, [partyFilter]);
// Table columns definition
const columns: ColumnData<IBox>[] = useMemo(
() => [
{
label: t('No'),
width: 100,
renderCell(data, rowIndex) {
return rowIndex + 1;
},
},
{
dataKey: 'partyName',
label: t('party_name'),
width: 120,
},
{
dataKey: 'name',
label: t('name'),
width: 120,
},
{
dataKey: 'packetNetWeight',
label: t('weight'),
width: 120,
},
{
dataKey: 'totalItems',
label: t('count_of_items'),
width: 120,
renderCell: data => {
const total = boxAmounts[data.id];
if (!total) return <Typography>...</Typography>;
const isCompleted = total.totalAmount === total.totalAccepted && total.totalAmount > 0;
return (
<Stack direction='row' alignItems='center' spacing={1}>
<Typography>
{data.totalItems} / {total.totalAccepted}
</Typography>
{isCompleted && <CheckCircle sx={{ color: '#22c55e', fontSize: 16 }} />}
</Stack>
);
},
},
{
dataKey: 'totalNetWeight',
label: t('party_weight'),
width: 120,
},
{
dataKey: 'cargoId',
label: t('cargo_id'),
width: 120,
},
{
dataKey: 'passportName',
label: t('client'),
width: 120,
},
{
dataKey: 'id',
label: t('print'),
width: 120,
renderHeaderCell() {
return (
<Stack direction={'row'} alignItems={'center'}>
<span>{t('print')}</span>
</Stack>
);
},
renderCell(data) {
const total = boxAmounts[data.id];
const isCompleted = total?.totalAccepted === total?.totalAmount && total?.totalAmount > 0;
return (
<Button onClick={() => onPrintBox(data)}>
<Print className='h-3 w-3 mr-1' />
</Button>
);
},
},
{
dataKey: 'status',
label: t('status'),
width: 240,
renderHeaderCell() {
return (
<Stack direction={'row'} alignItems={'center'}>
<span>{t('print_status')}</span>
</Stack>
);
},
renderCell(data) {
const currentValue = printStatuses[data.id] || (data.print ? 'true' : 'false');
return (
<FormControl
sx={{ m: 1, border: 'none', minWidth: 120, background: data.print ? '#3489E4' : '#DF2F99' }}
size='small'
>
<Select
labelId={`print-status-${data.id}`}
id={`print-status-${data.id}`}
sx={{ color: 'white', border: 'none' }}
value={currentValue}
onChange={async e => {
const newValue = e.target.value as PrintStatus;
onChangePrint(data.id, newValue);
}}
>
<MenuItem value='true'>Chop etildi</MenuItem>
<MenuItem value='false'>Chop etilmadi</MenuItem>
</Select>
</FormControl>
);
},
},
{
label: '',
width: 100,
numeric: true,
renderCell(data) {
return (
<ActionPopMenu
buttons={[
{
icon: <RemoveRedEye sx={{ path: { color: '#3489E4' } }} />,
label: t('view_packet'),
onClick: () => {
navigation.push(pageLinks.dashboard.boxes.detail(data.id));
},
},
]}
/>
);
},
},
],
[t, boxAmounts, printStatuses, navigation, onPrintBox, onChangePrint]
);
// Form handling for items
const [items, setItems] = useState<Product>();
const [loaer, setLoading] = useState(false);
const {
register,
control,
handleSubmit,
watch,
setValue,
formState: { errors },
} = useForm<Product>({
defaultValues: {
trekId: items?.trekId,
name: items?.name,
nameRu: items?.nameRu,
amount: items?.amount,
weight: items?.weight,
acceptedNumber: Number(values),
},
});
const updateItems = async (item: Product, acceptedNumber: number) => {
try {
setLoading(true);
const updateBody: UpdateProductBodyType = {
itemId: item.id,
acceptedNumber: item.amount,
amount: item.amount,
name: item.name,
nameRu: item.nameRu,
trekId: item.trekId,
weight: item.weight,
};
await item_requests.update(updateBody);
getListQuery.refetch();
setValues(prev => ({ ...prev, [item.trekId]: '' }));
setTrackId('');
} catch (error) {
notifyUnknownError(error);
} finally {
setLoading(false);
}
};
return (
<Box>
<CustomModal open={isModalOpen} onClose={closeModal} title={t('product_inspection')}>
<Box sx={style}>
<Typography id='modal-modal-title' variant='h6' component='h2'>
{t('product_inspection')}
</Typography>
<Typography id='modal-modal-description' sx={{ mt: 2 }}>
{t('enter_product')}
</Typography>
<AsyncSelect
isClearable
value={partyFilter}
onChange={(newValue: any) => {
setPartyFilter(newValue);
setPage(1);
}}
styles={selectDefaultStyles}
noOptionsMessage={() => t('not_found')}
loadingMessage={() => t('loading')}
defaultOptions={defaultPartyOptions!}
loadOptions={partyOptions}
placeholder={t('filter_party_name')}
/>
<AsyncSelect
isClearable
value={boxFilter}
onChange={(newValue: any) => {
setBoxFilter(newValue);
setPage(1);
navigation.push(pageLinks.dashboard.boxes.detail(newValue.value));
}}
styles={selectDefaultStyles}
noOptionsMessage={() => t('enter_box_name_to_find')}
loadingMessage={() => t('loading')}
defaultOptions={defaultBoxOptions!}
loadOptions={boxOptions}
placeholder={t('filter_box_name')}
/>
<Button onClick={() => closeModal()} sx={{ position: 'absolute', right: '10px' }}>
<CloseIcon />
</Button>
</Box>
</CustomModal>
<Stack direction={'row'} mb={3} spacing={3}>
<Button onClick={() => openModal()}>{t('product_inspection')}</Button>
</Stack>
<Box
width={1}
mb={3}
sx={{
padding: '28px',
borderRadius: '16px',
backgroundColor: '#fff',
}}
>
<Stack mb={3.5} direction={'row'} justifyContent={'space-between'} alignItems={'center'}>
<Typography
sx={{
fontSize: '20px',
lineHeight: '24px',
fontWeight: 600,
textTransform: 'capitalize',
color: '#000',
}}
>
{t('packet')}
</Typography>
<Stack direction={'row'} alignItems={'center'} spacing={2}>
<BaseInput
InputProps={{
startAdornment: <Search color='primary' />,
}}
placeholder={t('filter_packet_name')}
value={keyword}
onChange={e => {
setKeyword(e.target.value);
}}
/>
<AsyncSelect
isClearable
value={partyFilter}
onChange={(newValue: any) => {
setPartyFilter(newValue);
if (newValue) {
setBoxIdAccepted(newValue.value);
navigation.push(`${pageLinks.dashboard.acceptance.index}?boxId=${newValue.value}`);
} else {
setBoxIdAccepted(undefined);
navigation.push(`${pageLinks.dashboard.acceptance.index}`);
}
setPage(1);
}}
styles={selectDefaultStyles}
noOptionsMessage={() => t('not_found')}
loadingMessage={() => t('loading')}
defaultOptions={defaultPartyOptions!}
loadOptions={partyOptions}
placeholder={t('filter_party_name')}
/>
<BaseButton colorVariant='gray' startIcon={<FilterListOff />} size='small' onClick={resetFilter}>
{t('reset_filter')}
</BaseButton>
</Stack>
</Stack>
<Box
mb={6}
ref={tableContainerRef}
sx={{
height: 'calc(100vh - 300px)',
overflowY: 'auto',
'&::-webkit-scrollbar': {
width: '6px',
},
'&::-webkit-scrollbar-thumb': {
backgroundColor: '#888',
borderRadius: '3px',
},
}}
>
<MyTable columns={columns} data={allData} loading={loading && page === 1} />
{isFetching && page > 1 && (
<Box sx={{ display: 'flex', justifyContent: 'center', padding: '20px' }}>
<CircularProgress size={24} />
</Box>
)}
</Box>
</Box>
{selectedBoxDetails && (
<div style={{ display: 'none' }}>
<BoxesPrintList ref={printRef} boxData={selectedBoxDetails} />
</div>
)}
</Box>
);
};
// Simple throttle implementation
function throttle<T extends (...args: any[]) => any>(func: T, limit: number): T {
let inThrottle: boolean;
return function (this: any, ...args: any[]) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => (inThrottle = false), limit);
}
} as T;
}
export default DashboardAcceptancePage;