FPSMS-frontend
Non puoi selezionare più di 25 argomenti Gli argomenti devono iniziare con una lettera o un numero, possono includere trattini ('-') e possono essere lunghi fino a 35 caratteri.
 
 

485 righe
16 KiB

  1. "use client";
  2. import React, { useCallback, useEffect, useMemo, useState } from "react";
  3. import {
  4. Box,
  5. Button,
  6. Dialog,
  7. DialogActions,
  8. DialogContent,
  9. DialogTitle,
  10. Grid,
  11. Stack,
  12. Table,
  13. TableBody,
  14. TableCell,
  15. TableContainer,
  16. TableHead,
  17. TableRow,
  18. TextField,
  19. Typography,
  20. IconButton,
  21. CircularProgress,
  22. } from "@mui/material";
  23. import { useTranslation } from "react-i18next";
  24. import { Add, Delete, Edit } from "@mui/icons-material";
  25. import SearchBox, { Criterion } from "../SearchBox/SearchBox";
  26. import SearchResults, { Column } from "../SearchResults/SearchResults";
  27. import {
  28. saveItemQcCategoryMapping,
  29. deleteItemQcCategoryMapping,
  30. getItemQcCategoryMappings,
  31. fetchQcCategoriesForAll,
  32. fetchItemsForAll,
  33. getItemByCode,
  34. getCategoryType,
  35. updateCategoryType,
  36. } from "@/app/api/settings/qcItemAll/actions";
  37. import {
  38. QcCategoryResult,
  39. ItemsResult,
  40. } from "@/app/api/settings/qcItemAll";
  41. import { ItemQcCategoryMappingInfo } from "@/app/api/settings/qcItemAll";
  42. import {
  43. deleteDialog,
  44. errorDialogWithContent,
  45. submitDialog,
  46. successDialog,
  47. } from "../Swal/CustomAlerts";
  48. type SearchQuery = Partial<Omit<QcCategoryResult, "id">>;
  49. type SearchParamNames = keyof SearchQuery;
  50. const Tab0ItemQcCategoryMapping: React.FC = () => {
  51. const { t } = useTranslation("qcItemAll");
  52. const [qcCategories, setQcCategories] = useState<QcCategoryResult[]>([]);
  53. const [filteredQcCategories, setFilteredQcCategories] = useState<QcCategoryResult[]>([]);
  54. const [selectedCategory, setSelectedCategory] = useState<QcCategoryResult | null>(null);
  55. const [mappings, setMappings] = useState<ItemQcCategoryMappingInfo[]>([]);
  56. const [openDialog, setOpenDialog] = useState(false);
  57. const [openAddDialog, setOpenAddDialog] = useState(false);
  58. const [itemCode, setItemCode] = useState<string>("");
  59. const [validatedItem, setValidatedItem] = useState<ItemsResult | null>(null);
  60. const [itemCodeError, setItemCodeError] = useState<string>("");
  61. const [validatingItemCode, setValidatingItemCode] = useState<boolean>(false);
  62. const [selectedType, setSelectedType] = useState<string>("IQC");
  63. const [loading, setLoading] = useState(true);
  64. const [categoryType, setCategoryType] = useState<string>("IQC");
  65. const [savingCategoryType, setSavingCategoryType] = useState(false);
  66. useEffect(() => {
  67. const loadData = async () => {
  68. setLoading(true);
  69. try {
  70. // Only load categories list (same as Tab 2) - fast!
  71. const categories = await fetchQcCategoriesForAll();
  72. setQcCategories(categories || []);
  73. setFilteredQcCategories(categories || []);
  74. } catch (error) {
  75. console.error("Tab0: Error loading data:", error);
  76. setQcCategories([]);
  77. setFilteredQcCategories([]);
  78. if (error instanceof Error) {
  79. errorDialogWithContent(t("Error"), error.message, t);
  80. }
  81. } finally {
  82. setLoading(false);
  83. }
  84. };
  85. loadData();
  86. }, []);
  87. const handleViewMappings = useCallback(async (category: QcCategoryResult) => {
  88. setSelectedCategory(category);
  89. const [mappingData, typeFromApi] = await Promise.all([
  90. getItemQcCategoryMappings(category.id),
  91. getCategoryType(category.id),
  92. ]);
  93. setMappings(mappingData);
  94. setCategoryType(typeFromApi ?? "IQC"); // 方案 A: no mappings -> default IQC
  95. setOpenDialog(true);
  96. }, []);
  97. const handleAddMapping = useCallback(() => {
  98. if (!selectedCategory) return;
  99. setItemCode("");
  100. setValidatedItem(null);
  101. setItemCodeError("");
  102. setSelectedType(categoryType);
  103. setOpenAddDialog(true);
  104. }, [selectedCategory, categoryType]);
  105. const handleItemCodeChange = useCallback(async (code: string) => {
  106. setItemCode(code);
  107. setValidatedItem(null);
  108. setItemCodeError("");
  109. if (!code || code.trim() === "") {
  110. return;
  111. }
  112. if (code.trim().length !== 6) {
  113. return;
  114. }
  115. setValidatingItemCode(true);
  116. try {
  117. const item = await getItemByCode(code.trim());
  118. if (item) {
  119. setValidatedItem(item);
  120. setItemCodeError("");
  121. } else {
  122. setValidatedItem(null);
  123. setItemCodeError(t("Item code not found"));
  124. }
  125. } catch (error) {
  126. setValidatedItem(null);
  127. setItemCodeError(t("Error validating item code"));
  128. } finally {
  129. setValidatingItemCode(false);
  130. }
  131. }, [t]);
  132. const handleSaveMapping = useCallback(async () => {
  133. if (!selectedCategory || !validatedItem) return;
  134. await submitDialog(async () => {
  135. try {
  136. await saveItemQcCategoryMapping(
  137. validatedItem.id as number,
  138. selectedCategory.id,
  139. selectedType
  140. //categoryType
  141. );
  142. // Close add dialog first
  143. setOpenAddDialog(false);
  144. setItemCode("");
  145. setValidatedItem(null);
  146. setItemCodeError("");
  147. // Reload mappings to update the view
  148. const mappingData = await getItemQcCategoryMappings(selectedCategory.id);
  149. setMappings(mappingData);
  150. // Show success message after closing dialogs
  151. await successDialog(t("Submit Success"), t);
  152. // Keep the view dialog open to show updated data
  153. } catch (error: unknown) {
  154. let message: string;
  155. if (error && typeof error === "object" && "message" in error) {
  156. message = String((error as { message?: string }).message);
  157. } else {
  158. message = String(error);
  159. }
  160. // 嘗試從 message 裡解析出後端 FailureRes.error
  161. try {
  162. const jsonStart = message.indexOf("{");
  163. if (jsonStart >= 0) {
  164. const jsonPart = message.slice(jsonStart);
  165. const parsed = JSON.parse(jsonPart);
  166. if (parsed.error) {
  167. message = parsed.error;
  168. }
  169. }
  170. } catch {
  171. // 解析失敗就維持原本的 message
  172. }
  173. let displayMessage = message;
  174. if (displayMessage.includes("already has type") && displayMessage.includes("linked to QcCategory")) {
  175. const match = displayMessage.match(/type "([^"]+)" linked to QcCategory[:\s]+(.+?)(?:\.|One item)/);
  176. const type = match?.[1] ?? "";
  177. const categoryName = match?.[2]?.trim() ?? "";
  178. displayMessage = t("Item already has type \"{{type}}\" in QcCategory \"{{category}}\". One item can only have each type in one QcCategory.", {
  179. type,
  180. category: categoryName,
  181. });
  182. }
  183. errorDialogWithContent(t("Submit Error"), displayMessage || t("Submit Error"), t);
  184. }
  185. }, t);
  186. }, [selectedCategory, validatedItem, selectedType, t]);
  187. const handleDeleteMapping = useCallback(
  188. async (mappingId: number) => {
  189. if (!selectedCategory) return;
  190. deleteDialog(async () => {
  191. try {
  192. await deleteItemQcCategoryMapping(mappingId);
  193. await successDialog(t("Delete Success"), t);
  194. // Reload mappings
  195. const mappingData = await getItemQcCategoryMappings(selectedCategory.id);
  196. setMappings(mappingData);
  197. // No need to reload categories list - it doesn't change
  198. } catch (error) {
  199. errorDialogWithContent(t("Delete Error"), String(error), t);
  200. }
  201. }, t);
  202. },
  203. [selectedCategory, t]
  204. );
  205. const typeOptions = ["IQC", "IPQC", "EPQC"];
  206. function formatTypeDisplay(value: unknown): string {
  207. if (value == null) return "null";
  208. if (typeof value === "string") return value;
  209. if (typeof value === "object" && value !== null && "type" in value) {
  210. const v = (value as { type?: unknown }).type;
  211. if (typeof v === "string") return v;
  212. if (v != null && typeof v === "object") return "null"; // 避免 [object Object]
  213. return "null";
  214. }
  215. return "null";
  216. }
  217. const searchCriteria: Criterion<SearchParamNames>[] = useMemo(
  218. () => [
  219. { label: t("Code"), paramName: "code", type: "text" },
  220. { label: t("Name"), paramName: "name", type: "text" },
  221. ],
  222. [t]
  223. );
  224. const onReset = useCallback(() => {
  225. setFilteredQcCategories(qcCategories);
  226. }, [qcCategories]);
  227. const columnWidthSx = (width = "10%") => {
  228. return { width: width, whiteSpace: "nowrap" };
  229. };
  230. const columns = useMemo<Column<QcCategoryResult>[]>(
  231. () => [
  232. { name: "code", label: t("Qc Category Code"), sx: columnWidthSx("20%") },
  233. { name: "name", label: t("Qc Category Name"), sx: columnWidthSx("40%") },
  234. {
  235. name: "type",
  236. label: t("Type"),
  237. sx: columnWidthSx("10%"),
  238. renderCell: (row) => {
  239. const t = row.type;
  240. if (t == null) return " "; // 原来是 "null"
  241. if (typeof t === "string") return t;
  242. if (typeof t === "object" && t !== null && "type" in t) {
  243. const v = (t as { type?: unknown }).type;
  244. return typeof v === "string" ? v : " "; // 原来是 "null"
  245. }
  246. return " "; // 原来是 "null"
  247. },
  248. },
  249. {
  250. name: "id",
  251. label: t("Actions"),
  252. onClick: (category) => handleViewMappings(category),
  253. buttonIcon: <Edit />,
  254. buttonIcons: {} as any,
  255. sx: columnWidthSx("10%"),
  256. },
  257. ],
  258. [t, handleViewMappings]
  259. );
  260. if (loading) {
  261. return (
  262. <Box sx={{ display: "flex", justifyContent: "center", alignItems: "center", minHeight: "200px" }}>
  263. <CircularProgress />
  264. </Box>
  265. );
  266. }
  267. return (
  268. <Box>
  269. <SearchBox
  270. criteria={searchCriteria}
  271. onSearch={(query) => {
  272. setFilteredQcCategories(
  273. qcCategories.filter(
  274. (qc) =>
  275. (!query.code || qc.code.toLowerCase().includes(query.code.toLowerCase())) &&
  276. (!query.name || qc.name.toLowerCase().includes(query.name.toLowerCase()))
  277. )
  278. );
  279. }}
  280. onReset={onReset}
  281. />
  282. <SearchResults<QcCategoryResult>
  283. items={filteredQcCategories}
  284. columns={columns}
  285. />
  286. {/* View Mappings Dialog */}
  287. <Dialog open={openDialog} onClose={() => setOpenDialog(false)} maxWidth="md" fullWidth sx={{ zIndex: 1000 }}>
  288. <DialogTitle>
  289. {t("Mapping Details")} - {selectedCategory?.name}
  290. </DialogTitle>
  291. <DialogContent>
  292. <Stack direction="row" alignItems="center" spacing={2} sx={{ mb: 1 }}>
  293. </Stack>
  294. <Stack
  295. direction="row"
  296. alignItems="center"
  297. spacing={2}
  298. sx={{ mb: 1, minHeight: 40 }}
  299. >
  300. <Typography
  301. variant="body2"
  302. sx={{ display: "flex", alignItems: "center", minHeight: 40 }}
  303. >
  304. {t("Category Type")}
  305. </Typography>
  306. <TextField
  307. select
  308. size="small"
  309. sx={{ minWidth: 120 }}
  310. value={categoryType}
  311. onChange={(e) => setCategoryType(e.target.value)}
  312. SelectProps={{ native: true }}
  313. >
  314. {typeOptions.map((opt) => (
  315. <option key={opt} value={opt}>{opt}</option>
  316. ))}
  317. </TextField>
  318. <Button
  319. variant="outlined"
  320. size="small"
  321. disabled={savingCategoryType}
  322. onClick={async () => {
  323. if (!selectedCategory) return;
  324. setSavingCategoryType(true);
  325. try {
  326. await updateCategoryType(selectedCategory.id, categoryType);
  327. setQcCategories(prev =>
  328. prev.map(cat =>
  329. cat.id === selectedCategory.id ? { ...cat, type: categoryType } : cat
  330. )
  331. );
  332. setFilteredQcCategories(prev =>
  333. prev.map(cat =>
  334. cat.id === selectedCategory.id ? { ...cat, type: categoryType } : cat
  335. )
  336. );
  337. // 2) 同步 selectedCategory,讓 Dialog 標題旁邊的資料也一致
  338. setSelectedCategory(prev =>
  339. prev && prev.id === selectedCategory.id ? { ...prev, type: categoryType } : prev
  340. );
  341. await successDialog(t("Submit Success"), t);
  342. const mappingData = await getItemQcCategoryMappings(selectedCategory.id);
  343. setMappings(mappingData);
  344. } catch (e) {
  345. errorDialogWithContent(t("Submit Error"), String(e), t);
  346. } finally {
  347. setSavingCategoryType(false);
  348. }
  349. }}
  350. >
  351. {t("Save")}
  352. </Button>
  353. <Box sx={{ display: "flex", justifyContent: "flex-end" }}>
  354. <Button
  355. variant="contained"
  356. startIcon={<Add />}
  357. onClick={handleAddMapping}
  358. >
  359. {t("Add Mapping")}
  360. </Button>
  361. </Box>
  362. </Stack>
  363. <Stack spacing={2} sx={{ mt: 1 }}>
  364. <TableContainer>
  365. <Table>
  366. <TableHead>
  367. <TableRow>
  368. <TableCell>{t("Item Code")}</TableCell>
  369. <TableCell>{t("Item Name")}</TableCell>
  370. <TableCell>{t("Type")}</TableCell>
  371. <TableCell>{t("Actions")}</TableCell>
  372. </TableRow>
  373. </TableHead>
  374. <TableBody>
  375. {mappings.length === 0 ? (
  376. <TableRow>
  377. <TableCell colSpan={4} align="center">
  378. {t("No mappings found")}
  379. </TableCell>
  380. </TableRow>
  381. ) : (
  382. mappings.map((mapping) => (
  383. <TableRow key={mapping.id}>
  384. <TableCell>{mapping.itemCode}</TableCell>
  385. <TableCell>{mapping.itemName}</TableCell>
  386. <TableCell>
  387. {formatTypeDisplay(mapping.type)}
  388. </TableCell>
  389. <TableCell>
  390. <IconButton
  391. color="error"
  392. size="small"
  393. onClick={() => handleDeleteMapping(mapping.id)}
  394. >
  395. <Delete />
  396. </IconButton>
  397. </TableCell>
  398. </TableRow>
  399. ))
  400. )}
  401. </TableBody>
  402. </Table>
  403. </TableContainer>
  404. </Stack>
  405. </DialogContent>
  406. <DialogActions>
  407. <Button onClick={() => setOpenDialog(false)}>{t("Cancel")}</Button>
  408. </DialogActions>
  409. </Dialog>
  410. {/* Add Mapping Dialog */}
  411. <Dialog open={openAddDialog} onClose={() => setOpenAddDialog(false)} maxWidth="sm" fullWidth sx={{ zIndex: 1000 }}>
  412. <DialogTitle>{t("Add Mapping")}</DialogTitle>
  413. <DialogContent>
  414. <Stack spacing={2} sx={{ mt: 2 }}>
  415. <TextField
  416. label={t("Item Code")}
  417. value={itemCode}
  418. onChange={(e) => handleItemCodeChange(e.target.value)}
  419. error={!!itemCodeError}
  420. helperText={itemCodeError || (validatedItem ? `${validatedItem.code} - ${validatedItem.name}` : t("Enter item code to validate"))}
  421. fullWidth
  422. disabled={validatingItemCode}
  423. InputProps={{
  424. endAdornment: validatingItemCode ? <CircularProgress size={20} /> : null,
  425. }}
  426. />
  427. <TextField
  428. select
  429. label={t("Select Type")}
  430. value={selectedType}
  431. onChange={(e) => setSelectedType(e.target.value)}
  432. SelectProps={{
  433. native: true,
  434. }}
  435. fullWidth
  436. >
  437. {typeOptions.map((type) => (
  438. <option key={type} value={type}>
  439. {type}
  440. </option>
  441. ))}
  442. </TextField>
  443. </Stack>
  444. </DialogContent>
  445. <DialogActions>
  446. <Button onClick={() => setOpenAddDialog(false)}>{t("Cancel")}</Button>
  447. <Button
  448. variant="contained"
  449. onClick={handleSaveMapping}
  450. disabled={!validatedItem}
  451. >
  452. {t("Save")}
  453. </Button>
  454. </DialogActions>
  455. </Dialog>
  456. </Box>
  457. );
  458. };
  459. export default Tab0ItemQcCategoryMapping;