Ver a proveniência

UPDATE OPEN INVENTORY FOR ITEMS WITH NO INVENTORY

reset-do-picking-order
kelvin.yau há 5 dias
ascendente
cometimento
de65686192
6 ficheiros alterados com 393 adições e 151 eliminações
  1. +376
    -147
      src/components/InventorySearch/InventorySearch.tsx
  2. +4
    -1
      src/i18n/en/inventory.json
  3. +3
    -1
      src/i18n/en/items.json
  4. +2
    -1
      src/i18n/zh/common.json
  5. +5
    -1
      src/i18n/zh/inventory.json
  6. +3
    -0
      src/i18n/zh/items.json

+ 376
- 147
src/components/InventorySearch/InventorySearch.tsx Ver ficheiro

@@ -1,16 +1,36 @@
"use client";
import { InventoryLotLineResult, InventoryResult } from "@/app/api/inventory";
import { useTranslation } from "react-i18next";
import SearchBox, { Criterion } from "../SearchBox";
import { useCallback, useEffect, useMemo, useState } from "react";
import { isEqual, orderBy, uniq, uniqBy } from "lodash";
import SearchResults, { Column } from "../SearchResults";
import { CheckCircleOutline, DoDisturb } from "@mui/icons-material";
import InventoryTable from "./InventoryTable";
import { defaultPagingController } from "../SearchResults/SearchResults";
import InventoryLotLineTable from "./InventoryLotLineTable";
import { SearchInventory, SearchInventoryLotLine, fetchInventories, fetchInventoryLotLines } from "@/app/api/inventory/actions";
import { PrinterCombo } from "@/app/api/settings/printer";
'use client';
import { InventoryLotLineResult, InventoryResult } from '@/app/api/inventory';
import { useTranslation } from 'react-i18next';
import SearchBox, { Criterion } from '../SearchBox';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { uniq, uniqBy } from 'lodash';
import InventoryTable from './InventoryTable';
import { defaultPagingController } from '../SearchResults/SearchResults';
import InventoryLotLineTable from './InventoryLotLineTable';
import {
SearchInventory,
SearchInventoryLotLine,
fetchInventories,
fetchInventoryLotLines,
} from '@/app/api/inventory/actions';
import { PrinterCombo } from '@/app/api/settings/printer';
import { ItemCombo, fetchItemsWithDetails } from '@/app/api/settings/item/actions';
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
TextField,
Box,
CircularProgress,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Radio,
} from '@mui/material';

interface Props {
inventories: InventoryResult[];
@@ -35,7 +55,7 @@ type SearchQuery = Partial<
type SearchParamNames = keyof SearchQuery;

const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => {
const { t } = useTranslation(["inventory", "common"]);
const { t } = useTranslation(['inventory', 'common', 'item']);

// Inventory
const [filteredInventories, setFilteredInventories] = useState<InventoryResult[]>([]);
@@ -48,32 +68,42 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => {
const [inventoryLotLinesPagingController, setInventoryLotLinesPagingController] = useState(defaultPagingController)
const [inventoryLotLinesTotalCount, setInventoryLotLinesTotalCount] = useState(0)

const defaultInputs = useMemo(() => ({
itemId: "",
itemCode: "",
itemName: "",
itemType: "",
onHandQty: "",
onHoldQty: "",
unavailableQty: "",
availableQty: "",
currencyName: "",
status: "",
baseUom: "",
uomShortDesc: "",
latestMarketUnitPrice: "",
latestMupUpdatedDate: "",
}), [])
// Opening inventory (pure opening stock for items without existing inventory)
const [openingItems, setOpeningItems] = useState<ItemCombo[]>([]);
const [openingModalOpen, setOpeningModalOpen] = useState(false);
const [openingSelectedItem, setOpeningSelectedItem] = useState<ItemCombo | null>(null);
const [openingLoading, setOpeningLoading] = useState(false);
const [openingSearchText, setOpeningSearchText] = useState('');

const defaultInputs = useMemo(
() => ({
itemId: '',
itemCode: '',
itemName: '',
itemType: '',
onHandQty: '',
onHoldQty: '',
unavailableQty: '',
availableQty: '',
currencyName: '',
status: '',
baseUom: '',
uomShortDesc: '',
latestMarketUnitPrice: '',
latestMupUpdatedDate: '',
}),
[],
);
const [inputs, setInputs] = useState<Record<SearchParamNames, string>>(defaultInputs);

const searchCriteria: Criterion<SearchParamNames>[] = useMemo(
() => [
{ label: t("Code"), paramName: "itemCode", type: "text" },
{ label: t("Name"), paramName: "itemName", type: "text" },
{ label: t('Code'), paramName: 'itemCode', type: 'text' },
{ label: t('Name'), paramName: 'itemName', type: 'text' },
{
label: t("Type"),
paramName: "itemType",
type: "select",
label: t('Type'),
paramName: 'itemType',
type: 'select',
options: uniq(inventories.map((i) => i.itemType)),
},
// {
@@ -83,112 +113,112 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => {
// options: uniq(inventories.map((i) => i.status)),
// },
],
[t],
[t, inventories],
);

// Inventory
const refetchInventoryData = useCallback(async (
query: Record<SearchParamNames, string>,
actionType: "reset" | "search" | "paging" | "init",
pagingController: typeof defaultPagingController,
) => {
console.log("%c Action Type 1.", "color:red", actionType)
// Avoid loading data again
if (actionType === "paging" && pagingController === defaultPagingController) {
return
}
console.log("%c Action Type 2.", "color:blue", actionType)

const params: SearchInventory = {
code: query?.itemCode ?? '',
name: query?.itemName ?? '',
type: query?.itemType.toLowerCase() === "all" ? '' : query?.itemType ?? '',
pageNum: pagingController.pageNum - 1,
pageSize: pagingController.pageSize
}
const refetchInventoryData = useCallback(
async (
query: Record<SearchParamNames, string>,
actionType: 'reset' | 'search' | 'paging' | 'init',
pagingController: typeof defaultPagingController,
) => {
console.log('%c Action Type 1.', 'color:red', actionType);
// Avoid loading data again
if (actionType === 'paging' && pagingController === defaultPagingController) {
return;
}
console.log('%c Action Type 2.', 'color:blue', actionType);

const params: SearchInventory = {
code: query?.itemCode ?? '',
name: query?.itemName ?? '',
type: query?.itemType.toLowerCase() === 'all' ? '' : query?.itemType ?? '',
pageNum: pagingController.pageNum - 1,
pageSize: pagingController.pageSize,
};

const response = await fetchInventories(params)

if (response) {
setInventoriesTotalCount(response.total);
switch (actionType) {
case "init":
case "reset":
case "search":
setFilteredInventories(() => response.records);
break;
case "paging":
setFilteredInventories((fi) =>
// orderBy(
uniqBy([...fi, ...response.records], "id")
// , ["id"], ["desc"])
);
const response = await fetchInventories(params);

if (response) {
setInventoriesTotalCount(response.total);
switch (actionType) {
case 'init':
case 'reset':
case 'search':
setFilteredInventories(() => response.records);
break;
case 'paging':
setFilteredInventories((fi) =>
uniqBy([...fi, ...response.records], 'id'),
);
}
}
}
}, [])
},
[],
);

useEffect(() => {
refetchInventoryData(defaultInputs, "init", defaultPagingController)
}, [])
refetchInventoryData(defaultInputs, 'init', defaultPagingController);
}, [defaultInputs, refetchInventoryData]);

useEffect(() => {
// if (!isEqual(inventoriesPagingController, defaultPagingController)) {
refetchInventoryData(inputs, "paging", inventoriesPagingController)
refetchInventoryData(inputs, 'paging', inventoriesPagingController)
// }
}, [inventoriesPagingController])

// Inventory Lot Line
const refetchInventoryLotLineData = useCallback(async (
itemId: number | null,
actionType: "reset" | "search" | "paging",
pagingController: typeof defaultPagingController,
) => {
if (!itemId) {
setSelectedInventory(null)
setInventoryLotLinesTotalCount(0);
setFilteredInventoryLotLines([])
return
}
const refetchInventoryLotLineData = useCallback(
async (
itemId: number | null,
actionType: 'reset' | 'search' | 'paging',
pagingController: typeof defaultPagingController,
) => {
if (!itemId) {
setSelectedInventory(null);
setInventoryLotLinesTotalCount(0);
setFilteredInventoryLotLines([]);
return;
}

// Avoid loading data again
if (actionType === "paging" && pagingController === defaultPagingController) {
return
}
// Avoid loading data again
if (actionType === 'paging' && pagingController === defaultPagingController) {
return;
}

const params: SearchInventoryLotLine = {
itemId: itemId,
pageNum: pagingController.pageNum - 1,
pageSize: pagingController.pageSize
}
const params: SearchInventoryLotLine = {
itemId,
pageNum: pagingController.pageNum - 1,
pageSize: pagingController.pageSize,
};

const response = await fetchInventoryLotLines(params)
if (response) {
setInventoryLotLinesTotalCount(response.total);
switch (actionType) {
case "reset":
case "search":
setFilteredInventoryLotLines(() => response.records);
break;
case "paging":
setFilteredInventoryLotLines((fi) =>
// orderBy(
uniqBy([...fi, ...response.records], "id"),
// ["id"], ["desc"])
);
const response = await fetchInventoryLotLines(params);
if (response) {
setInventoryLotLinesTotalCount(response.total);
switch (actionType) {
case 'reset':
case 'search':
setFilteredInventoryLotLines(() => response.records);
break;
case 'paging':
setFilteredInventoryLotLines((fi) => uniqBy([...fi, ...response.records], 'id'));
}
}
}
}, [])
},
[],
);

useEffect(() => {
// if (!isEqual(inventoryLotLinesPagingController, defaultPagingController)) {
refetchInventoryLotLineData(selectedInventory?.itemId ?? null, "paging", inventoryLotLinesPagingController)
refetchInventoryLotLineData(selectedInventory?.itemId ?? null, 'paging', inventoryLotLinesPagingController)
// }
}, [inventoryLotLinesPagingController])

// Reset
const onReset = useCallback(() => {
refetchInventoryData(defaultInputs, "reset", defaultPagingController);
refetchInventoryLotLineData(null, "reset", defaultPagingController);
refetchInventoryData(defaultInputs, 'reset', defaultPagingController);
refetchInventoryLotLineData(null, 'reset', defaultPagingController);
// setFilteredInventories(inventories);

setInputs(() => defaultInputs)
@@ -197,48 +227,147 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => {
}, []);

// Click Row
const onInventoryRowClick = useCallback((item: InventoryResult) => {
refetchInventoryLotLineData(item.itemId, "search", defaultPagingController)

setSelectedInventory(item)
setInventoryLotLinesPagingController(() => defaultPagingController)
}, [])
const onInventoryRowClick = useCallback(
(item: InventoryResult) => {
refetchInventoryLotLineData(item.itemId, 'search', defaultPagingController);
setSelectedInventory(item);
setInventoryLotLinesPagingController(() => defaultPagingController);
},
[refetchInventoryLotLineData],
);

// On Search
const onSearch = useCallback((query: Record<SearchParamNames, string>) => {
refetchInventoryData(query, "search", defaultPagingController)
refetchInventoryLotLineData(null, "search", defaultPagingController);
const onSearch = useCallback(
(query: Record<SearchParamNames, string>) => {
refetchInventoryData(query, 'search', defaultPagingController);
refetchInventoryLotLineData(null, 'search', defaultPagingController);

setInputs(() => query)
setInventoriesPagingController(() => defaultPagingController)
setInventoryLotLinesPagingController(() => defaultPagingController)
}, [refetchInventoryData])
setInputs(() => query);
setInventoriesPagingController(() => defaultPagingController);
setInventoryLotLinesPagingController(() => defaultPagingController);
},
[refetchInventoryData, refetchInventoryLotLineData],
);

console.log('', 'color: #666', inventoriesPagingController);

const handleOpenOpeningInventoryModal = useCallback(() => {
setOpeningSelectedItem(null);
setOpeningItems([]);
setOpeningSearchText('');
setOpeningModalOpen(true);
}, []);

const handleOpeningSearch = useCallback(async () => {
const trimmed = openingSearchText.trim();
if (!trimmed) {
setOpeningItems([]);
return;
}

console.log("", "color: #666", inventoriesPagingController)
setOpeningLoading(true);
try {
const searchParams: Record<string, any> = {
pageSize: 50,
pageNum: 1,
};

// Heuristic: if input contains space, treat as name; otherwise treat as code.
if (trimmed.includes(' ')) {
searchParams.name = trimmed;
} else {
searchParams.code = trimmed;
}

const response = await fetchItemsWithDetails(searchParams);

let records: any[] = [];
if (response && typeof response === 'object') {
const anyRes = response as any;
if (Array.isArray(anyRes.records)) {
records = anyRes.records;
} else if (Array.isArray(anyRes)) {
records = anyRes;
}
}

const combos: ItemCombo[] = records.map((item: any) => ({
id: item.id,
label: `${item.code} - ${item.name}`,
uomId: item.uomId,
uom: item.uom,
uomDesc: item.uomDesc,
group: item.group,
currentStockBalance: item.currentStockBalance,
}));

setOpeningItems(combos);
} catch (e) {
console.error('Failed to search items for opening inventory:', e);
setOpeningItems([]);
} finally {
setOpeningLoading(false);
}
}, [openingSearchText]);

const handleConfirmOpeningInventory = useCallback(() => {
if (!openingSelectedItem) {
setOpeningModalOpen(false);
return;
}

// Try to split label into code and name if possible: "CODE - Name"
const rawLabel = openingSelectedItem.label ?? '';
const [codePart, ...nameParts] = rawLabel.split(' - ');
const itemCode = codePart?.trim() || rawLabel;
const itemName = nameParts.join(' - ').trim() || itemCode;

const syntheticInventory: InventoryResult = {
id: 0,
itemId: Number(openingSelectedItem.id),
itemCode,
itemName,
itemType: 'Material',
onHandQty: 0,
onHoldQty: 0,
unavailableQty: 0,
availableQty: 0,
uomCode: openingSelectedItem.uom,
uomUdfudesc: openingSelectedItem.uomDesc,
uomShortDesc: openingSelectedItem.uom,
qtyPerSmallestUnit: 1,
baseUom: openingSelectedItem.uom,
price: 0,
currencyName: '',
status: 'active',
latestMarketUnitPrice: undefined,
latestMupUpdatedDate: undefined,
};

// Use this synthetic inventory to drive the stock adjustment UI
setSelectedInventory(syntheticInventory);
setFilteredInventoryLotLines([]);
setInventoryLotLinesPagingController(() => defaultPagingController);
setOpeningModalOpen(false);
}, [openingSelectedItem]);

return (
<>
<SearchBox
criteria={searchCriteria}
onSearch={(query) => {
onSearch(query)
// console.log(query)
// console.log(inventories)
// setInputs(() => query)
// refetchInventoryData(query, "search", defaultPagingController)
// setFilteredInventories(
// inventories.filter(
// (i) =>
// i.itemCode.toLowerCase().includes(query.itemCode.toLowerCase()) &&
// i.itemName.toLowerCase().includes(query.itemName.toLowerCase()) &&
// (query.itemType == "All" ||
// i.itemType.toLowerCase().includes(query.itemType.toLowerCase())) &&
// (query.status == "All" ||
// i.status.toLowerCase().includes(query.status.toLowerCase())),
// ),
// );
onSearch(query);
}}
onReset={onReset}
extraActions={
<Button
variant="outlined"
color="secondary"
onClick={handleOpenOpeningInventoryModal}
>
{t('Add entry for items without inventory')}
</Button>
}
/>
<InventoryTable
inventories={filteredInventories}
@@ -255,12 +384,112 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => {
inventory={selectedInventory}
printerCombo={printerCombo ?? []}
onStockTransferSuccess={() =>
refetchInventoryLotLineData(selectedInventory?.itemId ?? null, "search", inventoryLotLinesPagingController)
refetchInventoryLotLineData(
selectedInventory?.itemId ?? null,
'search',
inventoryLotLinesPagingController,
)
}
onStockAdjustmentSuccess={() =>
refetchInventoryLotLineData(selectedInventory?.itemId ?? null, "search", inventoryLotLinesPagingController)
refetchInventoryLotLineData(
selectedInventory?.itemId ?? null,
'search',
inventoryLotLinesPagingController,
)
}
/>

<Dialog
open={openingModalOpen}
onClose={() => setOpeningModalOpen(false)}
fullWidth
maxWidth="md"
>
<DialogTitle>{t('Add entry for items without inventory')}</DialogTitle>
<DialogContent sx={{ pt: 2 }}>
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
<TextField
label={t('Item')}
fullWidth
value={openingSearchText}
onChange={(e) => setOpeningSearchText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleOpeningSearch();
}
}}
sx={{ flex: 2 }}
/>
<Button
variant="contained"
onClick={handleOpeningSearch}
disabled={openingLoading}
sx={{ flex: 1 }}
>
{openingLoading ? <CircularProgress size={20} /> : t('common:Search')}
</Button>
</Box>

{openingItems.length === 0 && !openingLoading ? (
<Box sx={{ py: 1, color: 'text.secondary', fontSize: 14 }}>
{openingSearchText
? t('No data')
: t('Enter item code or name to search')}
</Box>
) : (
<Table size="small">
<TableHead>
<TableRow>
<TableCell />
<TableCell>{t('Code')}</TableCell>
<TableCell>{t('Name')}</TableCell>
<TableCell>{t('UoM')}</TableCell>
<TableCell align="right">{t('現有庫存')}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{openingItems.map((it) => {
const [code, ...nameParts] = (it.label ?? '').split(' - ');
const name = nameParts.join(' - ');
const selected = openingSelectedItem?.id === it.id;
return (
<TableRow
key={it.id}
hover
selected={selected}
onClick={() => setOpeningSelectedItem(it)}
sx={{ cursor: 'pointer' }}
>
<TableCell padding="checkbox">
<Radio checked={selected} />
</TableCell>
<TableCell>{code}</TableCell>
<TableCell>{name}</TableCell>
<TableCell>{it.uomDesc || it.uom}</TableCell>
<TableCell align="right">
{it.currentStockBalance != null ? it.currentStockBalance : '-'}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setOpeningModalOpen(false)}>
{t('common:Cancel')}
</Button>
<Button
variant="contained"
onClick={handleConfirmOpeningInventory}
disabled={!openingSelectedItem}
>
{t('common:Confirm')}
</Button>
</DialogActions>
</Dialog>
</>
);
};


+ 4
- 1
src/i18n/en/inventory.json Ver ficheiro

@@ -1,4 +1,7 @@
{
"Average unit price": "Average unit price",
"Latest market unit price": "Latest market unit price"
"Latest market unit price": "Latest market unit price",
"Add entry for items without inventory": "Add entry for items without inventory",
"Enter item code or name to search": "Enter item code or name to search",
"Current Stock": "Current Stock"
}

+ 3
- 1
src/i18n/en/items.json Ver ficheiro

@@ -16,5 +16,7 @@
"QC Checklist": "QC Checklist",
"QC Type": "QC Type",
"IPQC": "IPQC",
"EPQC": "EPQC"
"EPQC": "EPQC",
"Item": "Item",
"Code or name": "Code or name"
}

+ 2
- 1
src/i18n/zh/common.json Ver ficheiro

@@ -567,5 +567,6 @@
"Upload row errors": "以下行有問題:",
"item(s) updated": "個項目已更新。",
"Average unit price": "平均單位價格",
"Latest market unit price": "最新市場價格"
"Latest market unit price": "最新市場價格",
"Current Stock": "現有庫存"
}

+ 5
- 1
src/i18n/zh/inventory.json Ver ficheiro

@@ -268,6 +268,10 @@
"Confirm remove": "確認移除",
"Adjusted Qty": "調整後倉存",
"Average unit price": "平均單位價格",
"Latest market unit price": "最新市場價格"
"Latest market unit price": "最新市場價格",
"Add entry for items without inventory": "為無庫存貨品新增倉存",
"Enter item code or name to search": "輸入貨品編號或名稱以搜索",
"Current Stock": "現有庫存",
"No Data": "沒有數據"

}

+ 3
- 0
src/i18n/zh/items.json Ver ficheiro

@@ -52,4 +52,7 @@
"QC Type": "質檢種類",
"IPQC": "IPQC",
"EPQC": "EPQC"
,
"Item": "貨品",
"Code or name": "編號或名稱"
}

Carregando…
Cancelar
Guardar