| @@ -1,7 +1,7 @@ | |||||
| import { Metadata } from "next"; | import { Metadata } from "next"; | ||||
| import { getServerI18n, I18nProvider } from "@/i18n"; | import { getServerI18n, I18nProvider } from "@/i18n"; | ||||
| import PageTitleBar from "@/components/PageTitleBar"; | import PageTitleBar from "@/components/PageTitleBar"; | ||||
| import BomWeightingScoreTable from "@/components/BomWeightingScoreTable"; | |||||
| import BomWeightingTabs from "@/components/BomWeightingTabs"; | |||||
| import { fetchBomWeightingScores } from "@/app/api/settings/bomWeighting"; | import { fetchBomWeightingScores } from "@/app/api/settings/bomWeighting"; | ||||
| export const metadata: Metadata = { | export const metadata: Metadata = { | ||||
| @@ -16,7 +16,7 @@ const BomWeightingScorePage: React.FC = async () => { | |||||
| <> | <> | ||||
| <PageTitleBar title={t("BOM Weighting Score List")} className="mb-4" /> | <PageTitleBar title={t("BOM Weighting Score List")} className="mb-4" /> | ||||
| <I18nProvider namespaces={["common"]}> | <I18nProvider namespaces={["common"]}> | ||||
| <BomWeightingScoreTable bomWeightingScores={bomWeightingScores} /> | |||||
| <BomWeightingTabs bomWeightingScores={bomWeightingScores} /> | |||||
| </I18nProvider> | </I18nProvider> | ||||
| </> | </> | ||||
| ); | ); | ||||
| @@ -0,0 +1,13 @@ | |||||
| "use client"; | |||||
| import axiosInstance from "@/app/(main)/axios/axiosInstance"; | |||||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||||
| import type { BomScoreResult } from "./index"; | |||||
| export const fetchBomScoresClient = async (): Promise<BomScoreResult[]> => { | |||||
| const response = await axiosInstance.get<BomScoreResult[]>( | |||||
| `${NEXT_PUBLIC_API_URL}/bom/scores`, | |||||
| ); | |||||
| return response.data; | |||||
| }; | |||||
| @@ -3,22 +3,34 @@ import { BASE_API_URL } from "@/config/api"; | |||||
| import { cache } from "react"; | import { cache } from "react"; | ||||
| export interface BomCombo { | export interface BomCombo { | ||||
| id: number; | |||||
| value: number; | |||||
| label: string; | |||||
| outputQty: number; | |||||
| outputQtyUom: string; | |||||
| description: string; | |||||
| id: number; | |||||
| value: number; | |||||
| label: string; | |||||
| outputQty: number; | |||||
| outputQtyUom: string; | |||||
| description: string; | |||||
| } | } | ||||
| export const preloadBomCombo = (() => { | |||||
| fetchBomCombo() | |||||
| }) | |||||
| export interface BomScoreResult { | |||||
| id: number; | |||||
| code: string; | |||||
| name: string; | |||||
| baseScore: number | string | { value?: number; [key: string]: any }; | |||||
| } | |||||
| export const preloadBomCombo = () => { | |||||
| fetchBomCombo(); | |||||
| }; | |||||
| export const fetchBomCombo = cache(async () => { | export const fetchBomCombo = cache(async () => { | ||||
| return serverFetchJson<BomCombo[]>(`${BASE_API_URL}/bom/combo`, { | |||||
| next: { tags: ["bomCombo"] }, | |||||
| }) | |||||
| }) | |||||
| return serverFetchJson<BomCombo[]>(`${BASE_API_URL}/bom/combo`, { | |||||
| next: { tags: ["bomCombo"] }, | |||||
| }); | |||||
| }); | |||||
| export const fetchBomScores = cache(async () => { | |||||
| return serverFetchJson<BomScoreResult[]>(`${BASE_API_URL}/bom/scores`, { | |||||
| next: { tags: ["boms"] }, | |||||
| }); | |||||
| }); | |||||
| @@ -0,0 +1,16 @@ | |||||
| "use client"; | |||||
| import axiosInstance from "@/app/(main)/axios/axiosInstance"; | |||||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||||
| export interface BomScoreRecalcResponse { | |||||
| updatedCount: number; | |||||
| } | |||||
| export const recalcBomScoresClient = async (): Promise<BomScoreRecalcResponse> => { | |||||
| const response = await axiosInstance.post<BomScoreRecalcResponse>( | |||||
| `${NEXT_PUBLIC_API_URL}/bom/scores/recalculate`, | |||||
| ); | |||||
| return response.data; | |||||
| }; | |||||
| @@ -0,0 +1,82 @@ | |||||
| "use client"; | |||||
| import React, { useMemo } from "react"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| import { BomScoreResult } from "@/app/api/bom"; | |||||
| import { GridColDef, GridValueFormatterParams } from "@mui/x-data-grid"; | |||||
| import StyledDataGrid from "../StyledDataGrid"; | |||||
| import Paper from "@mui/material/Paper"; | |||||
| interface Props { | |||||
| boms: BomScoreResult[]; | |||||
| } | |||||
| const BomScoreTable: React.FC<Props> = ({ boms }) => { | |||||
| const { t } = useTranslation("common"); | |||||
| const columns = useMemo<GridColDef<BomScoreResult>[]>( | |||||
| () => [ | |||||
| { | |||||
| field: "code", | |||||
| headerName: t("Code"), | |||||
| flex: 1, | |||||
| minWidth: 150, | |||||
| }, | |||||
| { | |||||
| field: "name", | |||||
| headerName: t("Name"), | |||||
| flex: 1.5, | |||||
| minWidth: 220, | |||||
| }, | |||||
| { | |||||
| field: "baseScore", | |||||
| headerName: t("Base Score"), | |||||
| flex: 1, | |||||
| minWidth: 140, | |||||
| sortable: false, | |||||
| valueFormatter: (params: GridValueFormatterParams) => { | |||||
| const v = params.value; | |||||
| if (v == null) return ""; | |||||
| let num: number | null = null; | |||||
| if (typeof v === "number") { | |||||
| num = v; | |||||
| } else if (typeof v === "string") { | |||||
| num = parseFloat(v); | |||||
| } else if (typeof v === "object" && v !== null) { | |||||
| const obj = v as any; | |||||
| if (typeof obj.value === "number") { | |||||
| num = obj.value; | |||||
| } else if (typeof obj.toString === "function") { | |||||
| num = parseFloat(obj.toString()); | |||||
| } | |||||
| } | |||||
| if (num == null || Number.isNaN(num)) return ""; | |||||
| return num.toFixed(2); | |||||
| }, | |||||
| }, | |||||
| ], | |||||
| [t], | |||||
| ); | |||||
| return ( | |||||
| <Paper variant="outlined" sx={{ overflow: "hidden" }}> | |||||
| <StyledDataGrid | |||||
| rows={boms} | |||||
| columns={columns} | |||||
| getRowId={(row) => row.id} | |||||
| autoHeight | |||||
| disableRowSelectionOnClick | |||||
| hideFooterPagination={true} | |||||
| sx={{ | |||||
| "& .MuiDataGrid-columnHeaderTitle": { fontSize: 15 }, | |||||
| "& .MuiDataGrid-cell": { fontSize: 16 }, | |||||
| }} | |||||
| /> | |||||
| </Paper> | |||||
| ); | |||||
| }; | |||||
| export default BomScoreTable; | |||||
| @@ -0,0 +1,2 @@ | |||||
| export { default } from "./BomScoreTable"; | |||||
| @@ -4,18 +4,15 @@ import React, { useMemo, useState, useCallback, useEffect } from "react"; | |||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { BomWeightingScoreResult } from "@/app/api/settings/bomWeighting"; | import { BomWeightingScoreResult } from "@/app/api/settings/bomWeighting"; | ||||
| import { updateBomWeightingScoreClient } from "@/app/api/settings/bomWeighting/client"; | import { updateBomWeightingScoreClient } from "@/app/api/settings/bomWeighting/client"; | ||||
| import { GridColDef, GridValueGetterParams, GridValueFormatterParams, GridRenderCellParams } from "@mui/x-data-grid"; | |||||
| import { recalcBomScoresClient } from "@/app/api/bom/recalculateClient"; | |||||
| import { GridColDef, GridValueFormatterParams } from "@mui/x-data-grid"; | |||||
| import StyledDataGrid from "../StyledDataGrid"; | import StyledDataGrid from "../StyledDataGrid"; | ||||
| import Paper from "@mui/material/Paper"; | import Paper from "@mui/material/Paper"; | ||||
| import IconButton from "@mui/material/IconButton"; | |||||
| import EditNote from "@mui/icons-material/EditNote"; | |||||
| import Dialog from "@mui/material/Dialog"; | |||||
| import DialogTitle from "@mui/material/DialogTitle"; | |||||
| import DialogContent from "@mui/material/DialogContent"; | |||||
| import DialogActions from "@mui/material/DialogActions"; | |||||
| import TextField from "@mui/material/TextField"; | import TextField from "@mui/material/TextField"; | ||||
| import Button from "@mui/material/Button"; | import Button from "@mui/material/Button"; | ||||
| import { successDialog } from "../Swal/CustomAlerts"; | |||||
| import { successDialog, warningDialog } from "../Swal/CustomAlerts"; | |||||
| import Box from "@mui/material/Box"; | |||||
| import Stack from "@mui/material/Stack"; | |||||
| interface Props { | interface Props { | ||||
| bomWeightingScores: BomWeightingScoreResult[]; | bomWeightingScores: BomWeightingScoreResult[]; | ||||
| @@ -24,98 +21,137 @@ interface Props { | |||||
| const BomWeightingScoreTable: React.FC<Props> & { Loading?: React.FC } = ({ bomWeightingScores: initialBomWeightingScores }) => { | const BomWeightingScoreTable: React.FC<Props> & { Loading?: React.FC } = ({ bomWeightingScores: initialBomWeightingScores }) => { | ||||
| const { t } = useTranslation("common"); | const { t } = useTranslation("common"); | ||||
| const [bomWeightingScores, setBomWeightingScores] = useState(initialBomWeightingScores); | const [bomWeightingScores, setBomWeightingScores] = useState(initialBomWeightingScores); | ||||
| const [editDialogOpen, setEditDialogOpen] = useState(false); | |||||
| const [editingItem, setEditingItem] = useState<BomWeightingScoreResult | null>(null); | |||||
| const [isEditMode, setIsEditMode] = useState(false); | |||||
| const [isSaving, setIsSaving] = useState(false); | const [isSaving, setIsSaving] = useState(false); | ||||
| const [formData, setFormData] = useState({ | |||||
| name: "", | |||||
| range: "", | |||||
| weighting: "", | |||||
| remarks: "", | |||||
| }); | |||||
| const [draftWeightingById, setDraftWeightingById] = useState<Record<number, string>>({}); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| setBomWeightingScores(initialBomWeightingScores); | setBomWeightingScores(initialBomWeightingScores); | ||||
| }, [initialBomWeightingScores]); | }, [initialBomWeightingScores]); | ||||
| const handleEditClick = useCallback((row: BomWeightingScoreResult) => { | |||||
| let weightingValue = 0; | |||||
| if (row.weighting != null) { | |||||
| if (typeof row.weighting === "object" && row.weighting !== null) { | |||||
| const obj = row.weighting as any; | |||||
| weightingValue = typeof obj.value === "number" ? obj.value : parseFloat(String(row.weighting)) || 0; | |||||
| } else { | |||||
| weightingValue = typeof row.weighting === "number" ? row.weighting : parseFloat(String(row.weighting)) || 0; | |||||
| } | |||||
| const extractWeightingNumber = useCallback((row: BomWeightingScoreResult): number => { | |||||
| const w = row.weighting; | |||||
| if (w == null) return 0; | |||||
| if (typeof w === "number") return w; | |||||
| if (typeof w === "string") return parseFloat(w) || 0; | |||||
| if (typeof w === "object" && w !== null) { | |||||
| const obj = w as any; | |||||
| if (typeof obj.value === "number") return obj.value; | |||||
| if (typeof obj.toString === "function") return parseFloat(obj.toString()) || 0; | |||||
| } | } | ||||
| setEditingItem(row); | |||||
| setFormData({ | |||||
| name: row.name || "", | |||||
| range: String(row.range || ""), | |||||
| weighting: String(weightingValue), | |||||
| remarks: row.remarks || "", | |||||
| }); | |||||
| setEditDialogOpen(true); | |||||
| return parseFloat(String(w)) || 0; | |||||
| }, []); | }, []); | ||||
| const handleCloseDialog = useCallback(() => { | |||||
| setEditDialogOpen(false); | |||||
| setEditingItem(null); | |||||
| setFormData({ name: "", range: "", weighting: "", remarks: "" }); | |||||
| const enterEditMode = useCallback(() => { | |||||
| const nextDraft: Record<number, string> = {}; | |||||
| for (const row of bomWeightingScores) { | |||||
| nextDraft[row.id] = String(extractWeightingNumber(row)); | |||||
| } | |||||
| setDraftWeightingById(nextDraft); | |||||
| setIsEditMode(true); | |||||
| }, [bomWeightingScores, extractWeightingNumber]); | |||||
| const cancelEditMode = useCallback(() => { | |||||
| setIsEditMode(false); | |||||
| setDraftWeightingById({}); | |||||
| }, []); | }, []); | ||||
| const handleSave = useCallback(async (e?: React.MouseEvent) => { | const handleSave = useCallback(async (e?: React.MouseEvent) => { | ||||
| e?.preventDefault(); | e?.preventDefault(); | ||||
| e?.stopPropagation(); | e?.stopPropagation(); | ||||
| if (!editingItem || isSaving) return; | |||||
| if (!isEditMode || isSaving) return; | |||||
| setIsSaving(true); | |||||
| try { | try { | ||||
| const updated = await updateBomWeightingScoreClient({ | |||||
| id: editingItem.id, | |||||
| name: editingItem.name, // Keep original name | |||||
| range: parseInt(formData.range, 10), | |||||
| weighting: parseFloat(formData.weighting), | |||||
| remarks: editingItem.remarks || undefined, // Keep original remarks | |||||
| }); | |||||
| // Update local state immediately | |||||
| setBomWeightingScores((prev) => | |||||
| prev.map((item) => (item.id === editingItem.id ? updated : item)) | |||||
| ); | |||||
| // Close dialog first, then show success message | |||||
| handleCloseDialog(); | |||||
| await successDialog(t("Update Success"), t); | |||||
| let sum = 0; | |||||
| for (const row of bomWeightingScores) { | |||||
| const raw = draftWeightingById[row.id] ?? String(extractWeightingNumber(row)); | |||||
| const n = parseFloat(raw); | |||||
| if (Number.isNaN(n)) { | |||||
| await warningDialog("權重必須為數字", t); | |||||
| return; | |||||
| } | |||||
| sum += n; | |||||
| } | |||||
| const EPS = 1e-6; | |||||
| if (Math.abs(sum - 1) > EPS) { | |||||
| await warningDialog(`權重總和必須等於 1(目前總和: ${sum.toFixed(4)})`, t); | |||||
| return; | |||||
| } | |||||
| setIsSaving(true); | |||||
| const updates = bomWeightingScores | |||||
| .map((row) => { | |||||
| const nextWeightingStr = draftWeightingById[row.id]; | |||||
| if (nextWeightingStr == null) return null; | |||||
| const prevWeighting = extractWeightingNumber(row); | |||||
| const nextWeighting = parseFloat(nextWeightingStr); | |||||
| if (Number.isNaN(nextWeighting)) return null; | |||||
| // Only update changed values (weighting only; range locked) | |||||
| if (nextWeighting === prevWeighting) return null; | |||||
| return { | |||||
| row, | |||||
| nextWeighting, | |||||
| }; | |||||
| }) | |||||
| .filter(Boolean) as Array<{ row: BomWeightingScoreResult; nextWeighting: number }>; | |||||
| let updatedCount: number | null = null; | |||||
| if (updates.length > 0) { | |||||
| const updatedRows = await Promise.all( | |||||
| updates.map(({ row, nextWeighting }) => | |||||
| updateBomWeightingScoreClient({ | |||||
| id: row.id, | |||||
| name: row.name, | |||||
| range: row.range, | |||||
| weighting: nextWeighting, | |||||
| remarks: row.remarks || undefined, | |||||
| }), | |||||
| ), | |||||
| ); | |||||
| const updatedById = new Map(updatedRows.map((r) => [r.id, r])); | |||||
| setBomWeightingScores((prev) => | |||||
| prev.map((r) => updatedById.get(r.id) ?? r), | |||||
| ); | |||||
| // After weighting changes, trigger BOM baseScore recalculation on the server | |||||
| try { | |||||
| const result = await recalcBomScoresClient(); | |||||
| updatedCount = result?.updatedCount ?? null; | |||||
| } catch (recalcError) { | |||||
| console.error("Failed to recalculate BOM base scores:", recalcError); | |||||
| // We don't block the main save flow if recalculation fails | |||||
| } | |||||
| } | |||||
| cancelEditMode(); | |||||
| // Show success message, with extra info about how many BOM base scores were recalculated (if available) | |||||
| if (updatedCount != null) { | |||||
| await successDialog( | |||||
| `${t("Update Success")}(已重新計算 ${updatedCount} 筆 BOM 基礎分)`, | |||||
| t, | |||||
| ); | |||||
| } else { | |||||
| await successDialog(t("Update Success"), t); | |||||
| } | |||||
| } catch (error: any) { | } catch (error: any) { | ||||
| console.error("Error updating bom weighting score:", error); | console.error("Error updating bom weighting score:", error); | ||||
| // Show error message to user | |||||
| const errorMessage = error?.response?.data?.message || error?.message || t("Update Failed") || "Update failed. Please try again."; | const errorMessage = error?.response?.data?.message || error?.message || t("Update Failed") || "Update failed. Please try again."; | ||||
| alert(errorMessage); | alert(errorMessage); | ||||
| } finally { | } finally { | ||||
| setIsSaving(false); | setIsSaving(false); | ||||
| } | } | ||||
| }, [editingItem, formData, t, handleCloseDialog, isSaving]); | |||||
| }, [isEditMode, isSaving, bomWeightingScores, draftWeightingById, extractWeightingNumber, cancelEditMode, t]); | |||||
| const columns = useMemo<GridColDef<BomWeightingScoreResult>[]>( | const columns = useMemo<GridColDef<BomWeightingScoreResult>[]>( | ||||
| () => [ | () => [ | ||||
| { | |||||
| field: "actions", | |||||
| headerName: t("Edit"), | |||||
| width: 100, | |||||
| sortable: false, | |||||
| renderCell: (params: GridRenderCellParams<BomWeightingScoreResult>) => ( | |||||
| <IconButton | |||||
| size="small" | |||||
| onClick={() => handleEditClick(params.row)} | |||||
| color="primary" | |||||
| > | |||||
| <EditNote fontSize="small" /> | |||||
| </IconButton> | |||||
| ), | |||||
| }, | |||||
| { | { | ||||
| field: "name", | field: "name", | ||||
| headerName: t("Name"), | headerName: t("Name"), | ||||
| @@ -132,38 +168,60 @@ const BomWeightingScoreTable: React.FC<Props> & { Loading?: React.FC } = ({ bomW | |||||
| field: "weighting", | field: "weighting", | ||||
| headerName: t("Weighting"), | headerName: t("Weighting"), | ||||
| flex: 1, | flex: 1, | ||||
| valueGetter: (params: GridValueGetterParams<BomWeightingScoreResult>) => { | |||||
| const weighting = params.row.weighting; | |||||
| if (weighting == null || weighting === undefined) return null; | |||||
| if (typeof weighting === "object" && weighting !== null) { | |||||
| const obj = weighting as any; | |||||
| if (typeof obj.value === "number") { | |||||
| return obj.value; | |||||
| } | |||||
| if (typeof obj.toString === "function") { | |||||
| return parseFloat(obj.toString()); | |||||
| } | |||||
| const numValue = parseFloat(String(weighting)); | |||||
| return isNaN(numValue) ? null : numValue; | |||||
| } | |||||
| const numValue = typeof weighting === "number" ? weighting : parseFloat(String(weighting)); | |||||
| return isNaN(numValue) ? null : numValue; | |||||
| }, | |||||
| sortable: false, | |||||
| valueFormatter: (params: GridValueFormatterParams) => { | valueFormatter: (params: GridValueFormatterParams) => { | ||||
| const value = params.value; | const value = params.value; | ||||
| if (value == null || value === undefined) return ""; | if (value == null || value === undefined) return ""; | ||||
| return typeof value === "number" ? value.toFixed(2) : ""; | return typeof value === "number" ? value.toFixed(2) : ""; | ||||
| }, | }, | ||||
| renderCell: (params) => { | |||||
| if (!isEditMode) { | |||||
| const value = extractWeightingNumber(params.row); | |||||
| return value.toFixed(2); | |||||
| } | |||||
| const current = draftWeightingById[params.row.id] ?? String(extractWeightingNumber(params.row)); | |||||
| return ( | |||||
| <TextField | |||||
| size="small" | |||||
| type="number" | |||||
| inputProps={{ step: "0.01" }} | |||||
| value={current} | |||||
| onClick={(e) => e.stopPropagation()} | |||||
| onChange={(e) => { | |||||
| const next = e.target.value; | |||||
| setDraftWeightingById((prev) => ({ ...prev, [params.row.id]: next })); | |||||
| }} | |||||
| sx={{ width: "100%" }} | |||||
| /> | |||||
| ); | |||||
| }, | |||||
| }, | }, | ||||
| ], | ], | ||||
| [t, handleEditClick], | |||||
| [t, isEditMode, draftWeightingById, extractWeightingNumber], | |||||
| ); | ); | ||||
| return ( | return ( | ||||
| <> | <> | ||||
| <Paper variant="outlined" sx={{ overflow: "hidden" }}> | <Paper variant="outlined" sx={{ overflow: "hidden" }}> | ||||
| <Box sx={{ p: 2, pb: 0 }}> | |||||
| <Stack direction="row" justifyContent="flex-end" spacing={1}> | |||||
| {isEditMode ? ( | |||||
| <> | |||||
| <Button variant="outlined" onClick={cancelEditMode} disabled={isSaving}> | |||||
| {t("Cancel")} | |||||
| </Button> | |||||
| <Button variant="contained" onClick={handleSave} disabled={isSaving}> | |||||
| {isSaving ? t("Saving") || "Saving..." : t("Save")} | |||||
| </Button> | |||||
| </> | |||||
| ) : ( | |||||
| <Button variant="contained" onClick={enterEditMode}> | |||||
| {t("Edit")} | |||||
| </Button> | |||||
| )} | |||||
| </Stack> | |||||
| </Box> | |||||
| <StyledDataGrid | <StyledDataGrid | ||||
| rows={bomWeightingScores} | rows={bomWeightingScores} | ||||
| columns={columns} | columns={columns} | ||||
| @@ -171,51 +229,12 @@ const BomWeightingScoreTable: React.FC<Props> & { Loading?: React.FC } = ({ bomW | |||||
| autoHeight | autoHeight | ||||
| disableRowSelectionOnClick | disableRowSelectionOnClick | ||||
| hideFooterPagination={true} | hideFooterPagination={true} | ||||
| sx={{ | |||||
| "& .MuiDataGrid-columnHeaderTitle": { fontSize: 15 }, | |||||
| "& .MuiDataGrid-cell": { fontSize: 16 }, | |||||
| }} | |||||
| /> | /> | ||||
| </Paper> | </Paper> | ||||
| <Dialog open={editDialogOpen} onClose={handleCloseDialog} maxWidth="sm" fullWidth> | |||||
| <DialogTitle>{t("Edit BOM Weighting Score")}</DialogTitle> | |||||
| <DialogContent> | |||||
| <TextField | |||||
| fullWidth | |||||
| label={t("Name")} | |||||
| value={formData.name} | |||||
| margin="normal" | |||||
| disabled | |||||
| /> | |||||
| <TextField | |||||
| fullWidth | |||||
| label={t("Range")} | |||||
| type="number" | |||||
| value={formData.range} | |||||
| onChange={(e) => setFormData({ ...formData, range: e.target.value })} | |||||
| margin="normal" | |||||
| required | |||||
| /> | |||||
| <TextField | |||||
| fullWidth | |||||
| label={t("Weighting")} | |||||
| type="number" | |||||
| inputProps={{ step: "0.01" }} | |||||
| value={formData.weighting} | |||||
| onChange={(e) => setFormData({ ...formData, weighting: e.target.value })} | |||||
| margin="normal" | |||||
| required | |||||
| /> | |||||
| </DialogContent> | |||||
| <DialogActions> | |||||
| <Button onClick={handleCloseDialog} disabled={isSaving}>{t("Cancel")}</Button> | |||||
| <Button | |||||
| onClick={handleSave} | |||||
| variant="contained" | |||||
| color="primary" | |||||
| disabled={isSaving} | |||||
| > | |||||
| {isSaving ? t("Saving") || "Saving..." : t("Save")} | |||||
| </Button> | |||||
| </DialogActions> | |||||
| </Dialog> | |||||
| </> | </> | ||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -0,0 +1,132 @@ | |||||
| "use client"; | |||||
| import React, { useState, useEffect } from "react"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| import { BomWeightingScoreResult } from "@/app/api/settings/bomWeighting"; | |||||
| import type { BomScoreResult } from "@/app/api/bom"; | |||||
| import { fetchBomScoresClient } from "@/app/api/bom/client"; | |||||
| import BomWeightingScoreTable from "@/components/BomWeightingScoreTable"; | |||||
| import BomScoreTable from "@/components/BomScoreTable"; | |||||
| import Tabs from "@mui/material/Tabs"; | |||||
| import Tab from "@mui/material/Tab"; | |||||
| import Box from "@mui/material/Box"; | |||||
| import Paper from "@mui/material/Paper"; | |||||
| interface Props { | |||||
| bomWeightingScores: BomWeightingScoreResult[]; | |||||
| } | |||||
| const BomWeightingTabs: React.FC<Props> = ({ bomWeightingScores }) => { | |||||
| const { t } = useTranslation("common"); | |||||
| const [tab, setTab] = useState(0); | |||||
| const [bomScores, setBomScores] = useState<BomScoreResult[] | null>(null); | |||||
| const [loadingScores, setLoadingScores] = useState(false); | |||||
| const [loadError, setLoadError] = useState<string | null>(null); | |||||
| useEffect(() => { | |||||
| if (tab !== 1) return; | |||||
| const load = async () => { | |||||
| try { | |||||
| setLoadingScores(true); | |||||
| setLoadError(null); | |||||
| console.log("Fetching BOM scores from /bom/scores..."); | |||||
| const data = await fetchBomScoresClient(); | |||||
| console.log("BOM scores received:", data); | |||||
| setBomScores(data || []); | |||||
| } catch (err: any) { | |||||
| console.error("Failed to load BOM scores:", err); | |||||
| const errorMsg = | |||||
| err?.response?.data?.message || err?.message || t("Update Failed") || "Load failed"; | |||||
| setLoadError(errorMsg); | |||||
| setBomScores([]); | |||||
| } finally { | |||||
| setLoadingScores(false); | |||||
| } | |||||
| }; | |||||
| void load(); | |||||
| }, [tab, t]); | |||||
| return ( | |||||
| <> | |||||
| <Paper | |||||
| variant="outlined" | |||||
| sx={{ | |||||
| mb: 2, | |||||
| borderBottomLeftRadius: 0, | |||||
| borderBottomRightRadius: 0, | |||||
| }} | |||||
| > | |||||
| <Tabs | |||||
| value={tab} | |||||
| onChange={(_, v) => setTab(v)} | |||||
| indicatorColor="primary" | |||||
| textColor="primary" | |||||
| sx={{ | |||||
| pl: 2, | |||||
| minHeight: 44, | |||||
| }} | |||||
| > | |||||
| <Tab | |||||
| label={t("Material Weighting")} | |||||
| value={0} | |||||
| sx={{ textTransform: "none", fontSize: 16, px: 3, minHeight: 44 }} | |||||
| /> | |||||
| <Tab | |||||
| label={t("Material Score")} | |||||
| value={1} | |||||
| sx={{ textTransform: "none", fontSize: 16, px: 3, minHeight: 44 }} | |||||
| /> | |||||
| </Tabs> | |||||
| </Paper> | |||||
| <Box> | |||||
| {tab === 0 && ( | |||||
| <BomWeightingScoreTable bomWeightingScores={bomWeightingScores} /> | |||||
| )} | |||||
| {tab === 1 && ( | |||||
| loadingScores ? ( | |||||
| <Paper | |||||
| variant="outlined" | |||||
| sx={{ | |||||
| p: 3, | |||||
| borderTopLeftRadius: 0, | |||||
| borderTopRightRadius: 0, | |||||
| }} | |||||
| > | |||||
| <div className="text-slate-700">{t("Loading")}</div> | |||||
| </Paper> | |||||
| ) : loadError ? ( | |||||
| <Paper | |||||
| variant="outlined" | |||||
| sx={{ | |||||
| p: 3, | |||||
| borderTopLeftRadius: 0, | |||||
| borderTopRightRadius: 0, | |||||
| }} | |||||
| > | |||||
| <div className="text-slate-700 text-red-600">{loadError}</div> | |||||
| </Paper> | |||||
| ) : bomScores && bomScores.length > 0 ? ( | |||||
| <BomScoreTable boms={bomScores} /> | |||||
| ) : ( | |||||
| <Paper | |||||
| variant="outlined" | |||||
| sx={{ | |||||
| p: 3, | |||||
| borderTopLeftRadius: 0, | |||||
| borderTopRightRadius: 0, | |||||
| }} | |||||
| > | |||||
| <div className="text-slate-700">{t("No data available")}</div> | |||||
| </Paper> | |||||
| ) | |||||
| )} | |||||
| </Box> | |||||
| </> | |||||
| ); | |||||
| }; | |||||
| export default BomWeightingTabs; | |||||
| @@ -0,0 +1,2 @@ | |||||
| export { default } from "./BomWeightingTabs"; | |||||
| @@ -78,9 +78,16 @@ | |||||
| "User Group": "用戶群組", | "User Group": "用戶群組", | ||||
| "Items": "物料", | "Items": "物料", | ||||
| "BOM Weighting Score List": "物料清單權重得分", | "BOM Weighting Score List": "物料清單權重得分", | ||||
| "Material Weighting": "物料清單加權", | |||||
| "Material Score": "物料清單得分", | |||||
| "Coming soon": "即將推出", | |||||
| "Base Score": "基礎得分", | |||||
| "Column Name": "欄位名稱", | "Column Name": "欄位名稱", | ||||
| "Range": "範圍", | "Range": "範圍", | ||||
| "Weighting": "權重", | "Weighting": "權重", | ||||
| "Total weighting must equal 1": "權重總和必須等於 1", | |||||
| "Current total": "目前總和", | |||||
| "Weighting must be a number": "權重必須為數字", | |||||
| "Edit": "編輯", | "Edit": "編輯", | ||||
| "Edit BOM Weighting Score": "編輯物料清單", | "Edit BOM Weighting Score": "編輯物料清單", | ||||
| "Save": "儲存", | "Save": "儲存", | ||||
| @@ -0,0 +1,160 @@ | |||||
| package com.ffii.fpsms.modules.master.service | |||||
| import com.ffii.fpsms.modules.master.entity.Bom | |||||
| import com.ffii.fpsms.modules.master.entity.BomRepository | |||||
| import com.ffii.fpsms.modules.settings.entity.BomWeightingScoreRepository | |||||
| import org.springframework.stereotype.Service | |||||
| import org.springframework.transaction.annotation.Transactional | |||||
| import java.math.BigDecimal | |||||
| import java.math.RoundingMode | |||||
| @Service | |||||
| open class BomScoreRecalculateService( | |||||
| private val bomRepository: BomRepository, | |||||
| private val bomWeightingScoreRepository: BomWeightingScoreRepository, | |||||
| ) { | |||||
| /** | |||||
| * Recalculate baseScore for all non-deleted BOMs and persist the result. | |||||
| */ | |||||
| @Transactional | |||||
| open fun recalculateAllBaseScores(): Int { | |||||
| val boms: List<Bom> = bomRepository.findAllByDeletedIsFalse() | |||||
| if (boms.isEmpty()) return 0 | |||||
| boms.forEach { bom -> | |||||
| val newScore = calculateBaseScoreForBom(bom) | |||||
| bom.baseScore = newScore | |||||
| } | |||||
| bomRepository.saveAll(boms) | |||||
| return boms.size | |||||
| } | |||||
| /** | |||||
| * Same logic as BomService.calculateBaseScore (984–1023), | |||||
| * duplicated to avoid modifying BomService.kt. | |||||
| */ | |||||
| private fun calculateBaseScoreForBom(bom: Bom): BigDecimal { | |||||
| val scale = 2 | |||||
| val roundingMode = RoundingMode.HALF_UP | |||||
| var sum = BigDecimal.ZERO.setScale(scale, roundingMode) | |||||
| // Score columns: contribution = (extractedScore / range) * weighting * 100 | |||||
| val scoreColumns: List<Pair<String, BigDecimal>> = listOf( | |||||
| "isDark" to (bom.isDark?.toBigDecimal() ?: BigDecimal.ZERO), | |||||
| "isFloat" to (bom.isFloat?.toBigDecimal() ?: BigDecimal.ZERO), | |||||
| "isDense" to (bom.isDense?.toBigDecimal() ?: BigDecimal.ZERO), | |||||
| "allergicSubstances" to (bom.allergicSubstances?.toBigDecimal() ?: BigDecimal.ZERO), | |||||
| "timeSequence" to (bom.timeSequence?.toBigDecimal() ?: BigDecimal.ZERO), | |||||
| "complexity" to (bom.complexity?.toBigDecimal() ?: BigDecimal.ZERO), | |||||
| ) | |||||
| for ((code, extractedScore) in scoreColumns) { | |||||
| val row = bomWeightingScoreRepository.findByCodeAndDeletedFalse(code) ?: continue | |||||
| val range = (row.range ?: 1).toBigDecimal() | |||||
| val weighting = row.weighting ?: continue | |||||
| if (range.compareTo(BigDecimal.ZERO) == 0) continue | |||||
| val contribution = extractedScore | |||||
| .divide(range, scale, roundingMode) | |||||
| .multiply(weighting) | |||||
| .multiply(BigDecimal(100)) | |||||
| .setScale(scale, roundingMode) | |||||
| sum = sum.add(contribution) | |||||
| } | |||||
| // equipmentConflict: contribution = weighting * 100 only | |||||
| val equipmentConflictRow = | |||||
| bomWeightingScoreRepository.findByCodeAndDeletedFalse("equipmentConflict") | |||||
| if (equipmentConflictRow?.weighting != null) { | |||||
| val contribution = equipmentConflictRow.weighting!! | |||||
| .multiply(BigDecimal(100)) | |||||
| .setScale(scale, roundingMode) | |||||
| sum = sum.add(contribution) | |||||
| } | |||||
| return sum | |||||
| } | |||||
| } | |||||
| package com.ffii.fpsms.modules.master.service | |||||
| import com.ffii.fpsms.modules.master.entity.Bom | |||||
| import com.ffii.fpsms.modules.master.entity.BomRepository | |||||
| import com.ffii.fpsms.modules.settings.entity.BomWeightingScoreRepository | |||||
| import org.springframework.stereotype.Service | |||||
| import org.springframework.transaction.annotation.Transactional | |||||
| import java.math.BigDecimal | |||||
| import java.math.RoundingMode | |||||
| @Service | |||||
| open class BomScoreRecalculateService( | |||||
| private val bomRepository: BomRepository, | |||||
| private val bomWeightingScoreRepository: BomWeightingScoreRepository, | |||||
| ) { | |||||
| /** | |||||
| * Recalculate baseScore for all non-deleted BOMs and persist the result. | |||||
| */ | |||||
| @Transactional | |||||
| open fun recalculateAllBaseScores(): Int { | |||||
| val boms: List<Bom> = bomRepository.findAllByDeletedIsFalse() | |||||
| if (boms.isEmpty()) return 0 | |||||
| boms.forEach { bom -> | |||||
| val newScore = calculateBaseScoreForBom(bom) | |||||
| bom.baseScore = newScore | |||||
| } | |||||
| bomRepository.saveAll(boms) | |||||
| return boms.size | |||||
| } | |||||
| /** | |||||
| * Same logic as BomService.calculateBaseScore, but duplicated here | |||||
| * to avoid modifying the existing BomService file. | |||||
| */ | |||||
| private fun calculateBaseScoreForBom(bom: Bom): BigDecimal { | |||||
| val scale = 2 | |||||
| val roundingMode = RoundingMode.HALF_UP | |||||
| var sum = BigDecimal.ZERO.setScale(scale, roundingMode) | |||||
| // Score columns: contribution = (extractedScore / range) * weighting * 100 | |||||
| val scoreColumns: List<Pair<String, BigDecimal>> = listOf( | |||||
| "isDark" to (bom.isDark?.toBigDecimal() ?: BigDecimal.ZERO), | |||||
| "isFloat" to (bom.isFloat?.toBigDecimal() ?: BigDecimal.ZERO), | |||||
| "isDense" to (bom.isDense?.toBigDecimal() ?: BigDecimal.ZERO), | |||||
| "allergicSubstances" to (bom.allergicSubstances?.toBigDecimal() ?: BigDecimal.ZERO), | |||||
| "timeSequence" to (bom.timeSequence?.toBigDecimal() ?: BigDecimal.ZERO), | |||||
| "complexity" to (bom.complexity?.toBigDecimal() ?: BigDecimal.ZERO), | |||||
| ) | |||||
| for ((code, extractedScore) in scoreColumns) { | |||||
| val row = bomWeightingScoreRepository.findByCodeAndDeletedFalse(code) ?: continue | |||||
| val range = (row.range ?: 1).toBigDecimal() | |||||
| val weighting = row.weighting ?: continue | |||||
| if (range.compareTo(BigDecimal.ZERO) == 0.toBigDecimal()) continue | |||||
| val contribution = extractedScore | |||||
| .divide(range, scale, roundingMode) | |||||
| .multiply(weighting) | |||||
| .multiply(BigDecimal(100)) | |||||
| .setScale(scale, roundingMode) | |||||
| sum = sum.add(contribution) | |||||
| } | |||||
| // equipmentConflict: contribution = weighting * 100 only | |||||
| val equipmentConflictRow = | |||||
| bomWeightingScoreRepository.findByCodeAndDeletedFalse("equipmentConflict") | |||||
| if (equipmentConflictRow?.weighting != null) { | |||||
| val contribution = equipmentConflictRow.weighting!! | |||||
| .multiply(BigDecimal(100)) | |||||
| .setScale(scale, roundingMode) | |||||
| sum = sum.add(contribution) | |||||
| } | |||||
| return sum | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,54 @@ | |||||
| package com.ffii.fpsms.modules.master.web | |||||
| import com.ffii.fpsms.modules.master.service.BomScoreRecalculateService | |||||
| import org.springframework.web.bind.annotation.PostMapping | |||||
| import org.springframework.web.bind.annotation.RequestMapping | |||||
| import org.springframework.web.bind.annotation.RestController | |||||
| data class BomScoreRecalcResponse( | |||||
| val updatedCount: Int, | |||||
| ) | |||||
| @RestController | |||||
| @RequestMapping("/bom/scores") | |||||
| class BomScoreRecalcController( | |||||
| private val bomScoreRecalculateService: BomScoreRecalculateService, | |||||
| ) { | |||||
| /** | |||||
| * Recalculate and persist baseScore for all BOMs using the current weighting configuration. | |||||
| */ | |||||
| @PostMapping("/recalculate") | |||||
| fun recalculateAll(): BomScoreRecalcResponse { | |||||
| val count = bomScoreRecalculateService.recalculateAllBaseScores() | |||||
| return BomScoreRecalcResponse(updatedCount = count) | |||||
| } | |||||
| } | |||||
| package com.ffii.fpsms.modules.master.web | |||||
| import com.ffii.fpsms.modules.master.service.BomScoreRecalculateService | |||||
| import org.springframework.web.bind.annotation.PostMapping | |||||
| import org.springframework.web.bind.annotation.RequestMapping | |||||
| import org.springframework.web.bind.annotation.RestController | |||||
| data class BomScoreRecalcResponse( | |||||
| val updatedCount: Int, | |||||
| ) | |||||
| @RestController | |||||
| @RequestMapping("/bom/scores") | |||||
| class BomScoreRecalcController( | |||||
| private val bomScoreRecalculateService: BomScoreRecalculateService, | |||||
| ) { | |||||
| /** | |||||
| * Recalculate and persist baseScore for all BOMs using the current weighting configuration. | |||||
| */ | |||||
| @PostMapping("/recalculate") | |||||
| fun recalculateAll(): BomScoreRecalcResponse { | |||||
| val count = bomScoreRecalculateService.recalculateAllBaseScores() | |||||
| return BomScoreRecalcResponse(updatedCount = count) | |||||
| } | |||||
| } | |||||