| @@ -1,7 +1,7 @@ | |||
| import { Metadata } from "next"; | |||
| import { getServerI18n, I18nProvider } from "@/i18n"; | |||
| import PageTitleBar from "@/components/PageTitleBar"; | |||
| import BomWeightingScoreTable from "@/components/BomWeightingScoreTable"; | |||
| import BomWeightingTabs from "@/components/BomWeightingTabs"; | |||
| import { fetchBomWeightingScores } from "@/app/api/settings/bomWeighting"; | |||
| export const metadata: Metadata = { | |||
| @@ -16,7 +16,7 @@ const BomWeightingScorePage: React.FC = async () => { | |||
| <> | |||
| <PageTitleBar title={t("BOM Weighting Score List")} className="mb-4" /> | |||
| <I18nProvider namespaces={["common"]}> | |||
| <BomWeightingScoreTable bomWeightingScores={bomWeightingScores} /> | |||
| <BomWeightingTabs bomWeightingScores={bomWeightingScores} /> | |||
| </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"; | |||
| 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 () => { | |||
| 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 { BomWeightingScoreResult } from "@/app/api/settings/bomWeighting"; | |||
| 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 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 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 { | |||
| bomWeightingScores: BomWeightingScoreResult[]; | |||
| @@ -24,98 +21,137 @@ interface Props { | |||
| const BomWeightingScoreTable: React.FC<Props> & { Loading?: React.FC } = ({ bomWeightingScores: initialBomWeightingScores }) => { | |||
| const { t } = useTranslation("common"); | |||
| 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 [formData, setFormData] = useState({ | |||
| name: "", | |||
| range: "", | |||
| weighting: "", | |||
| remarks: "", | |||
| }); | |||
| const [draftWeightingById, setDraftWeightingById] = useState<Record<number, string>>({}); | |||
| useEffect(() => { | |||
| setBomWeightingScores(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) => { | |||
| e?.preventDefault(); | |||
| e?.stopPropagation(); | |||
| if (!editingItem || isSaving) return; | |||
| if (!isEditMode || isSaving) return; | |||
| setIsSaving(true); | |||
| 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) { | |||
| 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."; | |||
| alert(errorMessage); | |||
| } finally { | |||
| setIsSaving(false); | |||
| } | |||
| }, [editingItem, formData, t, handleCloseDialog, isSaving]); | |||
| }, [isEditMode, isSaving, bomWeightingScores, draftWeightingById, extractWeightingNumber, cancelEditMode, t]); | |||
| 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", | |||
| headerName: t("Name"), | |||
| @@ -132,38 +168,60 @@ const BomWeightingScoreTable: React.FC<Props> & { Loading?: React.FC } = ({ bomW | |||
| field: "weighting", | |||
| headerName: t("Weighting"), | |||
| 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) => { | |||
| const value = params.value; | |||
| if (value == null || value === undefined) return ""; | |||
| 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 ( | |||
| <> | |||
| <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 | |||
| rows={bomWeightingScores} | |||
| columns={columns} | |||
| @@ -171,51 +229,12 @@ const BomWeightingScoreTable: React.FC<Props> & { Loading?: React.FC } = ({ bomW | |||
| autoHeight | |||
| disableRowSelectionOnClick | |||
| hideFooterPagination={true} | |||
| sx={{ | |||
| "& .MuiDataGrid-columnHeaderTitle": { fontSize: 15 }, | |||
| "& .MuiDataGrid-cell": { fontSize: 16 }, | |||
| }} | |||
| /> | |||
| </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": "用戶群組", | |||
| "Items": "物料", | |||
| "BOM Weighting Score List": "物料清單權重得分", | |||
| "Material Weighting": "物料清單加權", | |||
| "Material Score": "物料清單得分", | |||
| "Coming soon": "即將推出", | |||
| "Base Score": "基礎得分", | |||
| "Column Name": "欄位名稱", | |||
| "Range": "範圍", | |||
| "Weighting": "權重", | |||
| "Total weighting must equal 1": "權重總和必須等於 1", | |||
| "Current total": "目前總和", | |||
| "Weighting must be a number": "權重必須為數字", | |||
| "Edit": "編輯", | |||
| "Edit BOM Weighting Score": "編輯物料清單", | |||
| "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) | |||
| } | |||
| } | |||