FPSMS-frontend
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

248 строки
7.9 KiB

  1. 'use client';
  2. import { InventoryResult } from '@/app/api/inventory';
  3. import { fetchInventoryQtyLots, InventoryQtyLotRow } from '@/app/api/inventory/actions';
  4. import {
  5. Box,
  6. Button,
  7. CircularProgress,
  8. Dialog,
  9. DialogContent,
  10. DialogTitle,
  11. Table,
  12. TableBody,
  13. TableCell,
  14. TableHead,
  15. TableRow,
  16. Typography,
  17. } from '@mui/material';
  18. import { useEffect, useRef, useState } from 'react';
  19. import { useTranslation } from 'react-i18next';
  20. const formatQty = (value: number) => {
  21. if (!Number.isFinite(value)) return '0';
  22. return Number.isInteger(value)
  23. ? value.toLocaleString()
  24. : value.toLocaleString(undefined, { maximumFractionDigits: 2 });
  25. };
  26. type Bucket = 'jo' | 'do' | 'other' | 'unavailable';
  27. const reasonLabel = (t: (key: string) => string, row: InventoryQtyLotRow) => {
  28. const label =
  29. row.reason === 'expired'
  30. ? t('Expired lot')
  31. : row.reason === 'joHold'
  32. ? t('Jo hold qty')
  33. : row.reason === 'doHold'
  34. ? t('Do hold qty')
  35. : row.reason === 'otherHold' || row.reason === 'onHold'
  36. ? t('Other hold qty')
  37. : t('Unavailable lot');
  38. return row.detail ? `${label}:${row.detail}` : label;
  39. };
  40. const sumSource = (rows: InventoryQtyLotRow[], source: string) =>
  41. rows.reduce((sum, row) => (row.bucket === 'onHold' && row.source === source ? sum + Number(row.qty || 0) : sum), 0);
  42. const InventoryQtyStory: React.FC<{ inventory: InventoryResult }> = ({ inventory }) => {
  43. const { t } = useTranslation('inventory');
  44. const onHand = Number(inventory.onHandQty ?? 0);
  45. const onHold = Number(inventory.onHoldQty ?? 0);
  46. const unavailable = Number(inventory.unavailableQty ?? 0);
  47. const available = Number(inventory.availableQty ?? onHand - onHold - unavailable);
  48. const shortUom = inventory.uomShortDesc || '';
  49. const [open, setOpen] = useState(false);
  50. const [bucket, setBucket] = useState<Bucket>('unavailable');
  51. const [loading, setLoading] = useState(false);
  52. const [rows, setRows] = useState<InventoryQtyLotRow[]>([]);
  53. const [splitReady, setSplitReady] = useState(false);
  54. const requestRef = useRef(0);
  55. useEffect(() => {
  56. setOpen(false);
  57. setRows([]);
  58. setSplitReady(onHold === 0);
  59. if (onHold === 0 || inventory.itemId == null) return;
  60. const requestId = ++requestRef.current;
  61. fetchInventoryQtyLots(inventory.itemId)
  62. .then((res) => {
  63. if (requestId !== requestRef.current) return;
  64. setRows(res ?? []);
  65. setSplitReady(true);
  66. })
  67. .catch(() => {
  68. if (requestId !== requestRef.current) return;
  69. setRows([]);
  70. setSplitReady(false);
  71. });
  72. }, [inventory.itemId, onHold]);
  73. const joHold = splitReady ? sumSource(rows, 'jo') : 0;
  74. const doHold = splitReady ? sumSource(rows, 'do') : 0;
  75. const otherHold = splitReady ? sumSource(rows, 'other') : 0;
  76. const openDetail = (next: Bucket) => {
  77. const qty =
  78. next === 'jo' ? joHold : next === 'do' ? doHold : next === 'other' ? otherHold : unavailable;
  79. if (qty === 0) return;
  80. setBucket(next);
  81. setOpen(true);
  82. if (rows.length > 0 || next !== 'unavailable') {
  83. setLoading(false);
  84. return;
  85. }
  86. const requestId = ++requestRef.current;
  87. setLoading(true);
  88. fetchInventoryQtyLots(inventory.itemId)
  89. .then((res) => {
  90. if (requestId !== requestRef.current) return;
  91. setRows(res ?? []);
  92. })
  93. .catch(() => {
  94. if (requestId !== requestRef.current) return;
  95. setRows([]);
  96. })
  97. .finally(() => {
  98. if (requestId === requestRef.current) setLoading(false);
  99. });
  100. };
  101. const visibleRows = rows.filter((row) =>
  102. bucket === 'unavailable' ? row.bucket === 'unavailable' : row.bucket === 'onHold' && row.source === bucket,
  103. );
  104. const title =
  105. bucket === 'jo'
  106. ? t('Jo hold qty')
  107. : bucket === 'do'
  108. ? t('Do hold qty')
  109. : bucket === 'other'
  110. ? t('Other hold qty')
  111. : t('Unavailable Qty');
  112. return (
  113. <Box sx={{ py: 1 }}>
  114. <Box sx={{ display: 'flex', flexWrap: 'wrap', alignItems: 'baseline', columnGap: 0.75, rowGap: 0.5 }}>
  115. <Typography variant="body2" component="span">
  116. {`${t('On Hand Qty')} ${formatQty(onHand)} −`}
  117. </Typography>
  118. {splitReady ? (
  119. <>
  120. <QtyLink
  121. label={`${t('Jo hold qty')} ${formatQty(joHold)}`}
  122. enabled={joHold !== 0}
  123. onClick={() => openDetail('jo')}
  124. />
  125. <Typography variant="body2" component="span">−</Typography>
  126. <QtyLink
  127. label={`${t('Do hold qty')} ${formatQty(doHold)}`}
  128. enabled={doHold !== 0}
  129. onClick={() => openDetail('do')}
  130. />
  131. {otherHold !== 0 && (
  132. <>
  133. <Typography variant="body2" component="span">−</Typography>
  134. <QtyLink
  135. label={`${t('Other hold qty')} ${formatQty(otherHold)}`}
  136. enabled
  137. onClick={() => openDetail('other')}
  138. />
  139. </>
  140. )}
  141. </>
  142. ) : (
  143. <QtyLink
  144. label={`${t('On Hold Qty')} ${formatQty(onHold)}`}
  145. enabled={false}
  146. onClick={() => undefined}
  147. />
  148. )}
  149. <Typography variant="body2" component="span">−</Typography>
  150. <QtyLink
  151. label={`${t('Unavailable Qty')} ${formatQty(unavailable)}`}
  152. enabled={unavailable !== 0}
  153. onClick={() => openDetail('unavailable')}
  154. />
  155. <Typography variant="body2" component="span">
  156. {'= '}
  157. <Box component="span" sx={{ fontWeight: 700 }}>
  158. {`${t('Available Qty')} ${formatQty(available)}${shortUom}`}
  159. </Box>
  160. </Typography>
  161. </Box>
  162. <Dialog open={open} onClose={() => setOpen(false)} maxWidth="md" fullWidth>
  163. <DialogTitle>{`${title} · ${t('Qty lot detail')}`}</DialogTitle>
  164. <DialogContent>
  165. {loading ? (
  166. <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
  167. <CircularProgress size={28} />
  168. </Box>
  169. ) : visibleRows.length === 0 ? (
  170. <Typography sx={{ py: 2 }}>{t('No qty lots')}</Typography>
  171. ) : (
  172. <Table size="small">
  173. <TableHead>
  174. <TableRow>
  175. <TableCell>{t('Lot No')}</TableCell>
  176. <TableCell>{t('Expiry Date')}</TableCell>
  177. <TableCell align="right">{t('Qty')}</TableCell>
  178. <TableCell>{t('Warehouse')}</TableCell>
  179. <TableCell>{t('Why')}</TableCell>
  180. </TableRow>
  181. </TableHead>
  182. <TableBody>
  183. {visibleRows.map((row) => (
  184. <TableRow key={`${row.bucket}-${row.source}-${row.lotLineId}-${row.detail}`}>
  185. <TableCell sx={{ whiteSpace: 'nowrap' }}>{row.lotNo}</TableCell>
  186. <TableCell sx={{ whiteSpace: 'nowrap' }}>{row.expiryDate}</TableCell>
  187. <TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>{formatQty(row.qty)}</TableCell>
  188. <TableCell sx={{ whiteSpace: 'nowrap' }}>{row.warehouseCode}</TableCell>
  189. <TableCell>{reasonLabel(t, row)}</TableCell>
  190. </TableRow>
  191. ))}
  192. </TableBody>
  193. </Table>
  194. )}
  195. </DialogContent>
  196. </Dialog>
  197. </Box>
  198. );
  199. };
  200. const QtyLink = ({
  201. label,
  202. enabled,
  203. onClick,
  204. }: {
  205. label: string;
  206. enabled: boolean;
  207. onClick: () => void;
  208. }) => {
  209. if (!enabled) {
  210. return (
  211. <Typography variant="body2" component="span">
  212. {label}
  213. </Typography>
  214. );
  215. }
  216. return (
  217. <Button
  218. variant="text"
  219. onClick={onClick}
  220. sx={{
  221. minWidth: 0,
  222. p: 0,
  223. textTransform: 'none',
  224. fontWeight: 400,
  225. fontSize: '0.875rem',
  226. lineHeight: 1.43,
  227. verticalAlign: 'baseline',
  228. }}
  229. >
  230. {label}
  231. </Button>
  232. );
  233. };
  234. export default InventoryQtyStory;