| @@ -18,7 +18,7 @@ const Dashboard: React.FC<Props> = async ({ searchParams }) => { | |||||
| fetchEscalationLogsByUser() | fetchEscalationLogsByUser() | ||||
| return ( | return ( | ||||
| <I18nProvider namespaces={["dashboard", "common"]}> | |||||
| <I18nProvider namespaces={["dashboard", "common", "purchaseOrder"]}> | |||||
| <Suspense fallback={<DashboardPage.Loading />}> | <Suspense fallback={<DashboardPage.Loading />}> | ||||
| <DashboardPage searchParams={searchParams} /> | <DashboardPage searchParams={searchParams} /> | ||||
| </Suspense> | </Suspense> | ||||
| @@ -38,7 +38,7 @@ const productionProcess: React.FC = async () => { | |||||
| {t("Create Process")} | {t("Create Process")} | ||||
| </Button> */} | </Button> */} | ||||
| </Stack> | </Stack> | ||||
| <I18nProvider namespaces={["common", "production","purchaseOrder","jo"]}> | |||||
| <I18nProvider namespaces={["common", "production","purchaseOrder","jo","dashboard"]}> | |||||
| <ProductionProcessPage printerCombo={printerCombo} /> | <ProductionProcessPage printerCombo={printerCombo} /> | ||||
| </I18nProvider> | </I18nProvider> | ||||
| </> | </> | ||||
| @@ -0,0 +1,52 @@ | |||||
| "use client"; | |||||
| import { useState, useEffect } from "react"; | |||||
| import Tab from "@mui/material/Tab"; | |||||
| import Tabs from "@mui/material/Tabs"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| import { useRouter, useSearchParams } from "next/navigation"; | |||||
| type EquipmentTabsProps = { | |||||
| onTabChange?: (tabIndex: number) => void; | |||||
| }; | |||||
| const EquipmentTabs: React.FC<EquipmentTabsProps> = ({ onTabChange }) => { | |||||
| const router = useRouter(); | |||||
| const searchParams = useSearchParams(); | |||||
| const { t } = useTranslation("common"); | |||||
| const tabFromUrl = searchParams.get("tab"); | |||||
| const initialTabIndex = tabFromUrl ? parseInt(tabFromUrl, 10) : 0; | |||||
| const [tabIndex, setTabIndex] = useState(initialTabIndex); | |||||
| useEffect(() => { | |||||
| const tabFromUrl = searchParams.get("tab"); | |||||
| const newTabIndex = tabFromUrl ? parseInt(tabFromUrl, 10) : 0; | |||||
| if (newTabIndex !== tabIndex) { | |||||
| setTabIndex(newTabIndex); | |||||
| onTabChange?.(newTabIndex); | |||||
| } | |||||
| }, [searchParams, tabIndex, onTabChange]); | |||||
| const handleTabChange = (_e: React.SyntheticEvent, newValue: number) => { | |||||
| setTabIndex(newValue); | |||||
| onTabChange?.(newValue); | |||||
| const params = new URLSearchParams(searchParams.toString()); | |||||
| if (newValue === 0) { | |||||
| params.delete("tab"); | |||||
| } else { | |||||
| params.set("tab", newValue.toString()); | |||||
| } | |||||
| router.push(`/settings/equipment?${params.toString()}`, { scroll: false }); | |||||
| }; | |||||
| return ( | |||||
| <Tabs value={tabIndex} onChange={handleTabChange}> | |||||
| <Tab label={t("General Data")} /> | |||||
| <Tab label={t("Repair and Maintenance")} /> | |||||
| </Tabs> | |||||
| ); | |||||
| }; | |||||
| export default EquipmentTabs; | |||||
| @@ -0,0 +1,29 @@ | |||||
| import React from "react"; | |||||
| import { SearchParams } from "@/app/utils/fetchUtil"; | |||||
| import { I18nProvider, getServerI18n } from "@/i18n"; | |||||
| import { Typography } from "@mui/material"; | |||||
| import isString from "lodash/isString"; | |||||
| import { notFound } from "next/navigation"; | |||||
| import UpdateMaintenanceForm from "@/components/UpdateMaintenance/UpdateMaintenanceForm"; | |||||
| type Props = {} & SearchParams; | |||||
| const MaintenanceEditPage: React.FC<Props> = async ({ searchParams }) => { | |||||
| const type = "common"; | |||||
| const { t } = await getServerI18n(type); | |||||
| const id = isString(searchParams["id"]) | |||||
| ? parseInt(searchParams["id"]) | |||||
| : undefined; | |||||
| if (!id) { | |||||
| notFound(); | |||||
| } | |||||
| return ( | |||||
| <> | |||||
| <Typography variant="h4">{t("Update Equipment Maintenance and Repair")}</Typography> | |||||
| <I18nProvider namespaces={[type]}> | |||||
| <UpdateMaintenanceForm id={id} /> | |||||
| </I18nProvider> | |||||
| </> | |||||
| ); | |||||
| }; | |||||
| export default MaintenanceEditPage; | |||||
| @@ -0,0 +1,22 @@ | |||||
| import { SearchParams } from "@/app/utils/fetchUtil"; | |||||
| import { TypeEnum } from "@/app/utils/typeEnum"; | |||||
| import CreateEquipmentType from "@/components/CreateEquipment"; | |||||
| import { I18nProvider, getServerI18n } from "@/i18n"; | |||||
| import { Typography } from "@mui/material"; | |||||
| import isString from "lodash/isString"; | |||||
| type Props = {} & SearchParams; | |||||
| const materialSetting: React.FC<Props> = async ({ searchParams }) => { | |||||
| // const type = TypeEnum.PRODUCT; | |||||
| const { t } = await getServerI18n("common"); | |||||
| return ( | |||||
| <> | |||||
| {/* <Typography variant="h4">{t("Create Material")}</Typography> */} | |||||
| <I18nProvider namespaces={["common"]}> | |||||
| <CreateEquipmentType /> | |||||
| </I18nProvider> | |||||
| </> | |||||
| ); | |||||
| }; | |||||
| export default materialSetting; | |||||
| @@ -0,0 +1,29 @@ | |||||
| import { SearchParams } from "@/app/utils/fetchUtil"; | |||||
| import { TypeEnum } from "@/app/utils/typeEnum"; | |||||
| import CreateEquipmentType from "@/components/CreateEquipment"; | |||||
| import { I18nProvider, getServerI18n } from "@/i18n"; | |||||
| import { Typography } from "@mui/material"; | |||||
| import isString from "lodash/isString"; | |||||
| import { notFound } from "next/navigation"; | |||||
| type Props = {} & SearchParams; | |||||
| const productSetting: React.FC<Props> = async ({ searchParams }) => { | |||||
| const type = "common"; | |||||
| const { t } = await getServerI18n(type); | |||||
| const id = isString(searchParams["id"]) | |||||
| ? parseInt(searchParams["id"]) | |||||
| : undefined; | |||||
| if (!id) { | |||||
| notFound(); | |||||
| } | |||||
| return ( | |||||
| <> | |||||
| {/* <Typography variant="h4">{t("Create Material")}</Typography> */} | |||||
| <I18nProvider namespaces={[type]}> | |||||
| <CreateEquipmentType id={id} /> | |||||
| </I18nProvider> | |||||
| </> | |||||
| ); | |||||
| }; | |||||
| export default productSetting; | |||||
| @@ -0,0 +1,29 @@ | |||||
| import { Metadata } from "next"; | |||||
| import { I18nProvider } from "@/i18n"; | |||||
| import ImportBomWrapper from "@/components/ImportBom/ImportBomWrapper"; | |||||
| import Stack from "@mui/material/Stack"; | |||||
| import Typography from "@mui/material/Typography"; | |||||
| export const metadata: Metadata = { | |||||
| title: "Import BOM", | |||||
| }; | |||||
| export default async function ImportBomPage() { | |||||
| return ( | |||||
| <> | |||||
| <Stack | |||||
| direction="row" | |||||
| justifyContent="space-between" | |||||
| flexWrap="wrap" | |||||
| rowGap={2} | |||||
| > | |||||
| <Typography variant="h4" marginInlineEnd={2}> | |||||
| Import BOM | |||||
| </Typography> | |||||
| </Stack> | |||||
| <I18nProvider namespaces={["common"]}> | |||||
| <ImportBomWrapper /> | |||||
| </I18nProvider> | |||||
| </> | |||||
| ); | |||||
| } | |||||
| @@ -51,6 +51,19 @@ export default qcItemAll; | |||||
| @@ -0,0 +1,53 @@ | |||||
| "use client"; | |||||
| import axiosInstance from "@/app/(main)/axios/axiosInstance"; | |||||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||||
| import type { | |||||
| BomFormatCheckResponse, | |||||
| BomUploadResponse, | |||||
| ImportBomItemPayload, | |||||
| } from "./index"; | |||||
| export async function uploadBomFiles( | |||||
| files: File[] | |||||
| ): Promise<BomUploadResponse> { | |||||
| const formData = new FormData(); | |||||
| files.forEach((f) => formData.append("files", f, f.name)); | |||||
| const response = await axiosInstance.post<BomUploadResponse>( | |||||
| `${NEXT_PUBLIC_API_URL}/bom/import-bom/upload`, | |||||
| formData, | |||||
| { | |||||
| transformRequest: [ | |||||
| (data: unknown, headers?: Record<string, unknown>) => { | |||||
| if (data instanceof FormData && headers && "Content-Type" in headers) { | |||||
| delete headers["Content-Type"]; | |||||
| } | |||||
| return data; | |||||
| }, | |||||
| ], | |||||
| } | |||||
| ); | |||||
| return response.data; | |||||
| } | |||||
| export async function checkBomFormat( | |||||
| batchId: string | |||||
| ): Promise<BomFormatCheckResponse> { | |||||
| const response = await axiosInstance.post<BomFormatCheckResponse>( | |||||
| `${NEXT_PUBLIC_API_URL}/bom/import-bom/format-check`, | |||||
| { batchId } | |||||
| ); | |||||
| return response.data; | |||||
| } | |||||
| export async function importBom( | |||||
| batchId: string, | |||||
| items: ImportBomItemPayload[] | |||||
| ): Promise<Blob> { | |||||
| const response = await axiosInstance.post( | |||||
| `${NEXT_PUBLIC_API_URL}/bom/import-bom`, | |||||
| { batchId, items }, | |||||
| { responseType: "blob" } | |||||
| ); | |||||
| return response.data as Blob; | |||||
| } | |||||
| @@ -30,6 +30,8 @@ export interface EscalationResult { | |||||
| qcFailCount?: number; | qcFailCount?: number; | ||||
| qcTotalCount?: number; | qcTotalCount?: number; | ||||
| poCode?: string; | poCode?: string; | ||||
| jobOrderId?: number; | |||||
| jobOrderCode?: string; | |||||
| itemCode?: string; | itemCode?: string; | ||||
| dnDate?: number[]; | dnDate?: number[]; | ||||
| dnNo?: string; | dnNo?: string; | ||||
| @@ -674,12 +674,13 @@ export const fetchJobOrderLotsHierarchicalByPickOrderId = cache(async (pickOrder | |||||
| }, | }, | ||||
| ); | ); | ||||
| }); | }); | ||||
| export const fetchAllJoPickOrders = cache(async () => { | |||||
| export const fetchAllJoPickOrders = cache(async (isDrink?: boolean | null) => { | |||||
| const query = isDrink !== undefined && isDrink !== null | |||||
| ? `?isDrink=${isDrink}` | |||||
| : ""; | |||||
| return serverFetchJson<AllJoPickOrderResponse[]>( | return serverFetchJson<AllJoPickOrderResponse[]>( | ||||
| `${BASE_API_URL}/jo/AllJoPickOrder`, | |||||
| { | |||||
| method: "GET", | |||||
| } | |||||
| `${BASE_API_URL}/jo/AllJoPickOrder${query}`, | |||||
| { method: "GET" } | |||||
| ); | ); | ||||
| }); | }); | ||||
| export const fetchProductProcessLineDetail = cache(async (lineId: number) => { | export const fetchProductProcessLineDetail = cache(async (lineId: number) => { | ||||
| @@ -10,7 +10,11 @@ import { Column } from "@/components/SearchResults"; | |||||
| import SearchResults from "@/components/SearchResults/SearchResults"; | import SearchResults from "@/components/SearchResults/SearchResults"; | ||||
| import { arrayToDateString, arrayToDateTimeString } from "@/app/utils/formatUtil"; | import { arrayToDateString, arrayToDateTimeString } from "@/app/utils/formatUtil"; | ||||
| import { CardFilterContext } from "@/components/CollapsibleCard/CollapsibleCard"; | import { CardFilterContext } from "@/components/CollapsibleCard/CollapsibleCard"; | ||||
| import QcStockInModal from "@/components/Qc/QcStockInModal"; | |||||
| import { useSession } from "next-auth/react"; | |||||
| import { SessionWithTokens } from "@/config/authConfig"; | |||||
| import { StockInLineInput } from "@/app/api/stockIn"; | |||||
| import { PrinterCombo } from "@/app/api/settings/printer"; | |||||
| export type IQCItems = { | export type IQCItems = { | ||||
| id: number; | id: number; | ||||
| poId: number; | poId: number; | ||||
| @@ -25,20 +29,25 @@ export type IQCItems = { | |||||
| type Props = { | type Props = { | ||||
| type?: "dashboard" | "qc"; | type?: "dashboard" | "qc"; | ||||
| items: EscalationResult[]; | items: EscalationResult[]; | ||||
| printerCombo?: PrinterCombo[]; | |||||
| }; | }; | ||||
| const EscalationLogTable: React.FC<Props> = ({ | const EscalationLogTable: React.FC<Props> = ({ | ||||
| type = "dashboard", items | |||||
| type = "dashboard", items, printerCombo | |||||
| }) => { | }) => { | ||||
| const { t } = useTranslation("dashboard"); | |||||
| const { t } = useTranslation(["dashboard","purchaseOrder"]); | |||||
| const CARD_HEADER = t("stock in escalation list") | const CARD_HEADER = t("stock in escalation list") | ||||
| const pathname = usePathname(); | const pathname = usePathname(); | ||||
| const router = useRouter(); | const router = useRouter(); | ||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | |||||
| const sessionToken = session as SessionWithTokens | null; | |||||
| const [selectedId, setSelectedId] = useState<number | null>(null); | const [selectedId, setSelectedId] = useState<number | null>(null); | ||||
| const [escalationLogs, setEscalationLogs] = useState<EscalationResult[]>([]); | const [escalationLogs, setEscalationLogs] = useState<EscalationResult[]>([]); | ||||
| const [openModal, setOpenModal] = useState(false); | |||||
| const [modalInfo, setModalInfo] = useState<StockInLineInput>(); | |||||
| const [modalPrintSource, setModalPrintSource] = useState<"stockIn" | "productionProcess">("stockIn"); | |||||
| const useCardFilter = useContext(CardFilterContext); | const useCardFilter = useContext(CardFilterContext); | ||||
| const showCompleted = useMemo(() => { | const showCompleted = useMemo(() => { | ||||
| if (type === "dashboard") { | if (type === "dashboard") { | ||||
| @@ -54,7 +63,7 @@ const EscalationLogTable: React.FC<Props> = ({ | |||||
| setEscalationLogs(filteredEscLog); | setEscalationLogs(filteredEscLog); | ||||
| } | } | ||||
| }, [showCompleted, items]) | }, [showCompleted, items]) | ||||
| /* | |||||
| const navigateTo = useCallback( | const navigateTo = useCallback( | ||||
| (item: EscalationResult) => { | (item: EscalationResult) => { | ||||
| setSelectedId(item.id); | setSelectedId(item.id); | ||||
| @@ -63,13 +72,27 @@ const EscalationLogTable: React.FC<Props> = ({ | |||||
| }, | }, | ||||
| [router, pathname] | [router, pathname] | ||||
| ); | ); | ||||
| */ | |||||
| const onRowClick = useCallback((item: EscalationResult) => { | const onRowClick = useCallback((item: EscalationResult) => { | ||||
| if (type == "dashboard") { | |||||
| router.push(`/po/edit?id=${item.poId}&selectedIds=${item.poId}&polId=${item.polId}&stockInLineId=${item.stockInLineId}`); | |||||
| if (type !== "dashboard") return; | |||||
| if (!item.stockInLineId) { | |||||
| alert(t("Invalid Stock In Line Id")); | |||||
| return; | |||||
| } | } | ||||
| }, [router]) | |||||
| setModalInfo({ | |||||
| id: item.stockInLineId, | |||||
| }); | |||||
| setModalPrintSource(item.jobOrderId ? "productionProcess" : "stockIn"); | |||||
| setOpenModal(true); | |||||
| }, [type, t]); | |||||
| const closeNewModal = useCallback(() => { | |||||
| setOpenModal(false); | |||||
| setModalInfo(undefined); | |||||
| }, []); | |||||
| // const handleKeyDown = useCallback( | // const handleKeyDown = useCallback( | ||||
| // (e: React.KeyboardEvent, item: EscalationResult) => { | // (e: React.KeyboardEvent, item: EscalationResult) => { | ||||
| // if (e.key === 'Enter' || e.key === ' ') { | // if (e.key === 'Enter' || e.key === ' ') { | ||||
| @@ -119,10 +142,13 @@ const EscalationLogTable: React.FC<Props> = ({ | |||||
| }, | }, | ||||
| { | { | ||||
| name: "poCode", | name: "poCode", | ||||
| label: t("Po Code"), | |||||
| label: t("Po Code/Jo Code"), | |||||
| align: "left", | align: "left", | ||||
| headerAlign: "left", | headerAlign: "left", | ||||
| sx: { width: "15%", minWidth: 100 }, | sx: { width: "15%", minWidth: 100 }, | ||||
| renderCell: (params) => { | |||||
| return params.jobOrderCode ?? params.poCode ?? "-"; | |||||
| } | |||||
| }, | }, | ||||
| { | { | ||||
| name: "recordDate", | name: "recordDate", | ||||
| @@ -255,14 +281,28 @@ const EscalationLogTable: React.FC<Props> = ({ | |||||
| </Table> | </Table> | ||||
| </TableContainer> | </TableContainer> | ||||
| );*/} | );*/} | ||||
| return ( | |||||
| <SearchResults | |||||
| onRowClick={onRowClick} | |||||
| items={escalationLogs} | |||||
| columns={getColumnByType(type)} | |||||
| isAutoPaging={false} | |||||
| /> | |||||
| return ( | |||||
| <> | |||||
| <SearchResults | |||||
| onRowClick={onRowClick} | |||||
| items={escalationLogs} | |||||
| columns={getColumnByType(type)} | |||||
| isAutoPaging={false} | |||||
| /> | |||||
| <QcStockInModal | |||||
| session={sessionToken} | |||||
| open={openModal} | |||||
| onClose={closeNewModal} | |||||
| inputDetail={modalInfo} | |||||
| printerCombo={printerCombo || []} | |||||
| warehouse={[]} | |||||
| printSource="productionProcess" | |||||
| uiMode="default" | |||||
| /> | |||||
| </> | |||||
| ) | ) | ||||
| }; | }; | ||||
| export default EscalationLogTable; | export default EscalationLogTable; | ||||
| @@ -0,0 +1,41 @@ | |||||
| "use client"; | |||||
| import Card from "@mui/material/Card"; | |||||
| import CardContent from "@mui/material/CardContent"; | |||||
| import Skeleton from "@mui/material/Skeleton"; | |||||
| import Stack from "@mui/material/Stack"; | |||||
| import React from "react"; | |||||
| export const EquipmentTypeSearchLoading: React.FC = () => { | |||||
| return ( | |||||
| <> | |||||
| <Card> | |||||
| <CardContent> | |||||
| <Stack spacing={2}> | |||||
| <Skeleton variant="rounded" height={60} /> | |||||
| <Skeleton variant="rounded" height={60} /> | |||||
| <Skeleton variant="rounded" height={60} /> | |||||
| <Skeleton | |||||
| variant="rounded" | |||||
| height={50} | |||||
| width={100} | |||||
| sx={{ alignSelf: "flex-end" }} | |||||
| /> | |||||
| </Stack> | |||||
| </CardContent> | |||||
| </Card> | |||||
| <Card> | |||||
| <CardContent> | |||||
| <Stack spacing={2}> | |||||
| <Skeleton variant="rounded" height={40} /> | |||||
| <Skeleton variant="rounded" height={40} /> | |||||
| <Skeleton variant="rounded" height={40} /> | |||||
| <Skeleton variant="rounded" height={40} /> | |||||
| </Stack> | |||||
| </CardContent> | |||||
| </Card> | |||||
| </> | |||||
| ); | |||||
| }; | |||||
| export default EquipmentTypeSearchLoading; | |||||
| @@ -0,0 +1,492 @@ | |||||
| "use client"; | |||||
| import React, { | |||||
| ChangeEvent, | |||||
| Dispatch, | |||||
| MouseEvent, | |||||
| SetStateAction, | |||||
| useCallback, | |||||
| useMemo, | |||||
| useState, | |||||
| } from "react"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| import Paper from "@mui/material/Paper"; | |||||
| import Table from "@mui/material/Table"; | |||||
| import TableBody from "@mui/material/TableBody"; | |||||
| import TableCell, { TableCellProps } from "@mui/material/TableCell"; | |||||
| import TableContainer from "@mui/material/TableContainer"; | |||||
| import TableHead from "@mui/material/TableHead"; | |||||
| import TablePagination, { | |||||
| TablePaginationProps, | |||||
| } from "@mui/material/TablePagination"; | |||||
| import TableRow from "@mui/material/TableRow"; | |||||
| import IconButton, { IconButtonOwnProps } from "@mui/material/IconButton"; | |||||
| import { | |||||
| ButtonOwnProps, | |||||
| Checkbox, | |||||
| Icon, | |||||
| IconOwnProps, | |||||
| SxProps, | |||||
| Theme, | |||||
| } from "@mui/material"; | |||||
| import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline"; | |||||
| import { decimalFormatter, integerFormatter } from "@/app/utils/formatUtil"; | |||||
| import { filter, remove, uniq } from "lodash"; | |||||
| export interface ResultWithId { | |||||
| id: string | number; | |||||
| } | |||||
| type ColumnType = "icon" | "decimal" | "integer" | "checkbox"; | |||||
| interface BaseColumn<T extends ResultWithId> { | |||||
| name: keyof T; | |||||
| label: string; | |||||
| align?: TableCellProps["align"]; | |||||
| headerAlign?: TableCellProps["align"]; | |||||
| sx?: SxProps<Theme> | undefined; | |||||
| style?: Partial<HTMLElement["style"]> & { [propName: string]: string }; | |||||
| type?: ColumnType; | |||||
| renderCell?: (params: T) => React.ReactNode; | |||||
| renderHeader?: () => React.ReactNode; | |||||
| } | |||||
| interface IconColumn<T extends ResultWithId> extends BaseColumn<T> { | |||||
| name: keyof T; | |||||
| type: "icon"; | |||||
| icon?: React.ReactNode; | |||||
| icons?: { [columnValue in keyof T]: React.ReactNode }; | |||||
| color?: IconOwnProps["color"]; | |||||
| colors?: { [columnValue in keyof T]: IconOwnProps["color"] }; | |||||
| } | |||||
| interface DecimalColumn<T extends ResultWithId> extends BaseColumn<T> { | |||||
| type: "decimal"; | |||||
| } | |||||
| interface IntegerColumn<T extends ResultWithId> extends BaseColumn<T> { | |||||
| type: "integer"; | |||||
| } | |||||
| interface CheckboxColumn<T extends ResultWithId> extends BaseColumn<T> { | |||||
| type: "checkbox"; | |||||
| disabled?: (params: T) => boolean; | |||||
| // checkboxIds: readonly (string | number)[], | |||||
| // setCheckboxIds: (ids: readonly (string | number)[]) => void | |||||
| } | |||||
| interface ColumnWithAction<T extends ResultWithId> extends BaseColumn<T> { | |||||
| onClick: (item: T) => void; | |||||
| buttonIcon: React.ReactNode; | |||||
| buttonIcons: { [columnValue in keyof T]: React.ReactNode }; | |||||
| buttonColor?: IconButtonOwnProps["color"]; | |||||
| } | |||||
| export type Column<T extends ResultWithId> = | |||||
| | BaseColumn<T> | |||||
| | IconColumn<T> | |||||
| | DecimalColumn<T> | |||||
| | CheckboxColumn<T> | |||||
| | ColumnWithAction<T>; | |||||
| interface Props<T extends ResultWithId> { | |||||
| totalCount?: number; | |||||
| items: T[]; | |||||
| columns: Column<T>[]; | |||||
| noWrapper?: boolean; | |||||
| setPagingController?: Dispatch< | |||||
| SetStateAction<{ | |||||
| pageNum: number; | |||||
| pageSize: number; | |||||
| }> | |||||
| >; | |||||
| pagingController?: { pageNum: number; pageSize: number }; | |||||
| isAutoPaging?: boolean; | |||||
| checkboxIds?: (string | number)[]; | |||||
| setCheckboxIds?: Dispatch<SetStateAction<(string | number)[]>>; | |||||
| onRowClick?: (item: T) => void; | |||||
| renderExpandedRow?: (item: T) => React.ReactNode; | |||||
| hideHeader?: boolean; | |||||
| } | |||||
| function isActionColumn<T extends ResultWithId>( | |||||
| column: Column<T>, | |||||
| ): column is ColumnWithAction<T> { | |||||
| return Boolean((column as ColumnWithAction<T>).onClick); | |||||
| } | |||||
| function isIconColumn<T extends ResultWithId>( | |||||
| column: Column<T>, | |||||
| ): column is IconColumn<T> { | |||||
| return column.type === "icon"; | |||||
| } | |||||
| function isDecimalColumn<T extends ResultWithId>( | |||||
| column: Column<T>, | |||||
| ): column is DecimalColumn<T> { | |||||
| return column.type === "decimal"; | |||||
| } | |||||
| function isIntegerColumn<T extends ResultWithId>( | |||||
| column: Column<T>, | |||||
| ): column is IntegerColumn<T> { | |||||
| return column.type === "integer"; | |||||
| } | |||||
| function isCheckboxColumn<T extends ResultWithId>( | |||||
| column: Column<T>, | |||||
| ): column is CheckboxColumn<T> { | |||||
| return column.type === "checkbox"; | |||||
| } | |||||
| function convertObjectKeysToLowercase<T extends object>( | |||||
| obj: T, | |||||
| ): object | undefined { | |||||
| return obj | |||||
| ? Object.fromEntries( | |||||
| Object.entries(obj).map(([key, value]) => [key.toLowerCase(), value]), | |||||
| ) | |||||
| : undefined; | |||||
| } | |||||
| function handleIconColors<T extends ResultWithId>( | |||||
| column: IconColumn<T>, | |||||
| value: T[keyof T], | |||||
| ): IconOwnProps["color"] { | |||||
| const colors = convertObjectKeysToLowercase(column.colors ?? {}); | |||||
| const valueKey = String(value).toLowerCase() as keyof typeof colors; | |||||
| if (colors && valueKey in colors) { | |||||
| return colors[valueKey]; | |||||
| } | |||||
| return column.color ?? "primary"; | |||||
| } | |||||
| function handleIconIcons<T extends ResultWithId>( | |||||
| column: IconColumn<T>, | |||||
| value: T[keyof T], | |||||
| ): React.ReactNode { | |||||
| const icons = convertObjectKeysToLowercase(column.icons ?? {}); | |||||
| const valueKey = String(value).toLowerCase() as keyof typeof icons; | |||||
| if (icons && valueKey in icons) { | |||||
| return icons[valueKey]; | |||||
| } | |||||
| return column.icon ?? <CheckCircleOutlineIcon fontSize="small" />; | |||||
| } | |||||
| export const defaultPagingController: { pageNum: number; pageSize: number } = { | |||||
| pageNum: 1, | |||||
| pageSize: 10, | |||||
| }; | |||||
| export type defaultSetPagingController = Dispatch< | |||||
| SetStateAction<{ | |||||
| pageNum: number; | |||||
| pageSize: number; | |||||
| }> | |||||
| > | |||||
| function EquipmentSearchResults<T extends ResultWithId>({ | |||||
| items, | |||||
| columns, | |||||
| noWrapper, | |||||
| pagingController, | |||||
| setPagingController, | |||||
| isAutoPaging = true, | |||||
| totalCount, | |||||
| checkboxIds = [], | |||||
| setCheckboxIds = undefined, | |||||
| onRowClick = undefined, | |||||
| renderExpandedRow = undefined, | |||||
| hideHeader = false, | |||||
| }: Props<T>) { | |||||
| const { t } = useTranslation("common"); | |||||
| const [page, setPage] = React.useState(0); | |||||
| const [rowsPerPage, setRowsPerPage] = React.useState(10); | |||||
| const handleChangePage: TablePaginationProps["onPageChange"] = ( | |||||
| _event, | |||||
| newPage, | |||||
| ) => { | |||||
| console.log(_event); | |||||
| setPage(newPage); | |||||
| if (setPagingController) { | |||||
| setPagingController({ | |||||
| ...(pagingController ?? defaultPagingController), | |||||
| pageNum: newPage + 1, | |||||
| }); | |||||
| } | |||||
| }; | |||||
| const handleChangeRowsPerPage: TablePaginationProps["onRowsPerPageChange"] = ( | |||||
| event, | |||||
| ) => { | |||||
| console.log(event); | |||||
| const newSize = +event.target.value; | |||||
| setRowsPerPage(newSize); | |||||
| setPage(0); | |||||
| if (setPagingController) { | |||||
| setPagingController({ | |||||
| ...(pagingController ?? defaultPagingController), | |||||
| pageNum: 1, | |||||
| pageSize: newSize, | |||||
| }); | |||||
| } | |||||
| }; | |||||
| const currItems = useMemo(() => { | |||||
| return items.length > 10 ? items | |||||
| .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) | |||||
| .map((i) => i.id) | |||||
| : items.map((i) => i.id) | |||||
| }, [items, page, rowsPerPage]) | |||||
| const currItemsWithChecked = useMemo(() => { | |||||
| return filter(checkboxIds, function (c) { | |||||
| return currItems.includes(c); | |||||
| }) | |||||
| }, [checkboxIds, items, page, rowsPerPage]) | |||||
| const handleRowClick = useCallback( | |||||
| (event: MouseEvent<unknown>, item: T, columns: Column<T>[]) => { | |||||
| let disabled = false; | |||||
| columns.forEach((col) => { | |||||
| if (isCheckboxColumn(col) && col.disabled) { | |||||
| disabled = col.disabled(item); | |||||
| if (disabled) { | |||||
| return; | |||||
| } | |||||
| } | |||||
| }); | |||||
| if (disabled) { | |||||
| return; | |||||
| } | |||||
| const id = item.id; | |||||
| if (setCheckboxIds) { | |||||
| const selectedIndex = checkboxIds.indexOf(id); | |||||
| let newSelected: (string | number)[] = []; | |||||
| if (selectedIndex === -1) { | |||||
| newSelected = newSelected.concat(checkboxIds, id); | |||||
| } else if (selectedIndex === 0) { | |||||
| newSelected = newSelected.concat(checkboxIds.slice(1)); | |||||
| } else if (selectedIndex === checkboxIds.length - 1) { | |||||
| newSelected = newSelected.concat(checkboxIds.slice(0, -1)); | |||||
| } else if (selectedIndex > 0) { | |||||
| newSelected = newSelected.concat( | |||||
| checkboxIds.slice(0, selectedIndex), | |||||
| checkboxIds.slice(selectedIndex + 1), | |||||
| ); | |||||
| } | |||||
| setCheckboxIds(newSelected); | |||||
| } | |||||
| }, | |||||
| [checkboxIds, setCheckboxIds], | |||||
| ); | |||||
| const handleSelectAllClick = (event: React.ChangeEvent<HTMLInputElement>) => { | |||||
| if (setCheckboxIds) { | |||||
| const pageItemId = currItems | |||||
| if (event.target.checked) { | |||||
| setCheckboxIds((prev) => uniq([...prev, ...pageItemId])) | |||||
| } else { | |||||
| setCheckboxIds((prev) => filter(prev, function (p) { return !pageItemId.includes(p); })) | |||||
| } | |||||
| } | |||||
| } | |||||
| const table = ( | |||||
| <> | |||||
| <TableContainer sx={{ maxHeight: 440 }}> | |||||
| <Table stickyHeader={!hideHeader}> | |||||
| {!hideHeader && ( | |||||
| <TableHead> | |||||
| <TableRow> | |||||
| {columns.map((column, idx) => ( | |||||
| isCheckboxColumn(column) ? | |||||
| <TableCell | |||||
| align={column.headerAlign} | |||||
| sx={column.sx} | |||||
| key={`${column.name.toString()}${idx}`} | |||||
| > | |||||
| <Checkbox | |||||
| color="primary" | |||||
| indeterminate={currItemsWithChecked.length > 0 && currItemsWithChecked.length < currItems.length} | |||||
| checked={currItems.length > 0 && currItemsWithChecked.length >= currItems.length} | |||||
| onChange={handleSelectAllClick} | |||||
| /> | |||||
| </TableCell> | |||||
| : <TableCell | |||||
| align={column.headerAlign} | |||||
| sx={column.sx} | |||||
| key={`${column.name.toString()}${idx}`} | |||||
| > | |||||
| {column.renderHeader ? ( | |||||
| column.renderHeader() | |||||
| ) : ( | |||||
| column.label.split('\n').map((line, index) => ( | |||||
| <div key={index}>{line}</div> | |||||
| )) | |||||
| )} | |||||
| </TableCell> | |||||
| ))} | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| )} | |||||
| <TableBody> | |||||
| {isAutoPaging | |||||
| ? items | |||||
| .slice((pagingController?.pageNum ? pagingController?.pageNum - 1 :page) * (pagingController?.pageSize ?? rowsPerPage), | |||||
| (pagingController?.pageNum ? pagingController?.pageNum - 1 :page) * (pagingController?.pageSize ?? rowsPerPage) + (pagingController?.pageSize ?? rowsPerPage)) | |||||
| .map((item) => { | |||||
| return ( | |||||
| <React.Fragment key={item.id}> | |||||
| <TableRow | |||||
| hover | |||||
| tabIndex={-1} | |||||
| onClick={(event) => { | |||||
| setCheckboxIds | |||||
| ? handleRowClick(event, item, columns) | |||||
| : undefined | |||||
| if (onRowClick) { | |||||
| onRowClick(item) | |||||
| } | |||||
| } | |||||
| } | |||||
| role={setCheckboxIds ? "checkbox" : undefined} | |||||
| > | |||||
| {columns.map((column, idx) => { | |||||
| const columnName = column.name; | |||||
| return ( | |||||
| <TabelCells | |||||
| key={`${columnName.toString()}-${idx}`} | |||||
| column={column} | |||||
| columnName={columnName} | |||||
| idx={idx} | |||||
| item={item} | |||||
| checkboxIds={checkboxIds} | |||||
| /> | |||||
| ); | |||||
| })} | |||||
| </TableRow> | |||||
| {renderExpandedRow && renderExpandedRow(item)} | |||||
| </React.Fragment> | |||||
| ); | |||||
| }) | |||||
| : items.map((item) => { | |||||
| return ( | |||||
| <React.Fragment key={item.id}> | |||||
| <TableRow hover tabIndex={-1} | |||||
| onClick={(event) => { | |||||
| setCheckboxIds | |||||
| ? handleRowClick(event, item, columns) | |||||
| : undefined | |||||
| if (onRowClick) { | |||||
| onRowClick(item) | |||||
| } | |||||
| } | |||||
| } | |||||
| role={setCheckboxIds ? "checkbox" : undefined} | |||||
| > | |||||
| {columns.map((column, idx) => { | |||||
| const columnName = column.name; | |||||
| return ( | |||||
| <TabelCells | |||||
| key={`${columnName.toString()}-${idx}`} | |||||
| column={column} | |||||
| columnName={columnName} | |||||
| idx={idx} | |||||
| item={item} | |||||
| checkboxIds={checkboxIds} | |||||
| /> | |||||
| ); | |||||
| })} | |||||
| </TableRow> | |||||
| {renderExpandedRow && renderExpandedRow(item)} | |||||
| </React.Fragment> | |||||
| ); | |||||
| })} | |||||
| </TableBody> | |||||
| </Table> | |||||
| </TableContainer> | |||||
| <TablePagination | |||||
| rowsPerPageOptions={[10, 25, 100]} | |||||
| component="div" | |||||
| count={!totalCount || totalCount == 0 ? items.length : totalCount} | |||||
| rowsPerPage={pagingController?.pageSize ? pagingController?.pageSize : rowsPerPage} | |||||
| page={pagingController?.pageNum ? pagingController?.pageNum - 1 : page} | |||||
| onPageChange={handleChangePage} | |||||
| onRowsPerPageChange={handleChangeRowsPerPage} | |||||
| labelRowsPerPage={t("Rows per page")} | |||||
| labelDisplayedRows={({ from, to, count }) => | |||||
| `${from}-${to} of ${count !== -1 ? count : `more than ${to}`}` | |||||
| } | |||||
| /> | |||||
| </> | |||||
| ); | |||||
| return noWrapper ? table : <Paper sx={{ overflow: "hidden" }}>{table}</Paper>; | |||||
| } | |||||
| interface TableCellsProps<T extends ResultWithId> { | |||||
| column: Column<T>; | |||||
| columnName: keyof T; | |||||
| idx: number; | |||||
| item: T; | |||||
| checkboxIds: (string | number)[]; | |||||
| } | |||||
| function TabelCells<T extends ResultWithId>({ | |||||
| column, | |||||
| columnName, | |||||
| idx, | |||||
| item, | |||||
| checkboxIds = [], | |||||
| }: TableCellsProps<T>) { | |||||
| const isItemSelected = checkboxIds.includes(item.id); | |||||
| return ( | |||||
| <TableCell | |||||
| align={column.align} | |||||
| sx={column.sx} | |||||
| key={`${columnName.toString()}-${idx}`} | |||||
| > | |||||
| {isActionColumn(column) ? ( | |||||
| <IconButton | |||||
| color={column.buttonColor ?? "primary"} | |||||
| onClick={() => column.onClick(item)} | |||||
| > | |||||
| {column.buttonIcon} | |||||
| </IconButton> | |||||
| ) : isIconColumn(column) ? ( | |||||
| <Icon color={handleIconColors(column, item[columnName])}> | |||||
| {handleIconIcons(column, item[columnName])} | |||||
| </Icon> | |||||
| ) : isDecimalColumn(column) ? ( | |||||
| <>{decimalFormatter.format(Number(item[columnName]))}</> | |||||
| ) : isIntegerColumn(column) ? ( | |||||
| <>{integerFormatter.format(Number(item[columnName]))}</> | |||||
| ) : isCheckboxColumn(column) ? ( | |||||
| <Checkbox | |||||
| disabled={column.disabled ? column.disabled(item) : undefined} | |||||
| checked={isItemSelected} | |||||
| /> | |||||
| ) : column.renderCell ? ( | |||||
| column.renderCell(item) | |||||
| ) : ( | |||||
| <>{item[columnName] as string}</> | |||||
| )} | |||||
| </TableCell> | |||||
| ); | |||||
| } | |||||
| export default EquipmentSearchResults; | |||||
| @@ -0,0 +1,35 @@ | |||||
| "use client"; | |||||
| import { useState, useEffect } from "react"; | |||||
| import EquipmentSearch from "./EquipmentSearch"; | |||||
| import EquipmentSearchLoading from "./EquipmentSearchLoading"; | |||||
| import EquipmentTabs from "@/app/(main)/settings/equipment/EquipmentTabs"; | |||||
| import { useSearchParams } from "next/navigation"; | |||||
| interface SubComponents { | |||||
| Loading: typeof EquipmentSearchLoading; | |||||
| } | |||||
| const EquipmentSearchWrapper: React.FC & SubComponents = () => { | |||||
| const searchParams = useSearchParams(); | |||||
| const tabFromUrl = searchParams.get("tab"); | |||||
| const initialTabIndex = tabFromUrl ? parseInt(tabFromUrl, 10) : 0; | |||||
| const [tabIndex, setTabIndex] = useState(initialTabIndex); | |||||
| useEffect(() => { | |||||
| const tabFromUrl = searchParams.get("tab"); | |||||
| const newTabIndex = tabFromUrl ? parseInt(tabFromUrl, 10) : 0; | |||||
| setTabIndex(newTabIndex); | |||||
| }, [searchParams]); | |||||
| return ( | |||||
| <> | |||||
| <EquipmentTabs onTabChange={setTabIndex} /> | |||||
| <EquipmentSearch equipments={[]} tabIndex={tabIndex} /> | |||||
| </> | |||||
| ); | |||||
| }; | |||||
| EquipmentSearchWrapper.Loading = EquipmentSearchLoading; | |||||
| export default EquipmentSearchWrapper; | |||||
| @@ -0,0 +1,219 @@ | |||||
| "use client"; | |||||
| import React, { useMemo, useState } from "react"; | |||||
| import Box from "@mui/material/Box"; | |||||
| import Button from "@mui/material/Button"; | |||||
| import TextField from "@mui/material/TextField"; | |||||
| import InputAdornment from "@mui/material/InputAdornment"; | |||||
| import Checkbox from "@mui/material/Checkbox"; | |||||
| import Accordion from "@mui/material/Accordion"; | |||||
| import AccordionSummary from "@mui/material/AccordionSummary"; | |||||
| import AccordionDetails from "@mui/material/AccordionDetails"; | |||||
| import Typography from "@mui/material/Typography"; | |||||
| import Stack from "@mui/material/Stack"; | |||||
| import Paper from "@mui/material/Paper"; | |||||
| import CircularProgress from "@mui/material/CircularProgress"; | |||||
| import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; | |||||
| import SearchIcon from "@mui/icons-material/Search"; | |||||
| import type { BomFormatFileGroup } from "@/app/api/bom"; | |||||
| import { importBom } from "@/app/api/bom/client"; | |||||
| type CorrectItem = { fileName: string; isAlsoWip: boolean }; | |||||
| type Props = { | |||||
| batchId: string; | |||||
| correctFileNames: string[]; | |||||
| failList: BomFormatFileGroup[]; | |||||
| uploadedCount: number; | |||||
| onBack?: () => void; | |||||
| }; | |||||
| export default function ImportBomResultForm({ | |||||
| batchId, | |||||
| correctFileNames, | |||||
| failList, | |||||
| uploadedCount, | |||||
| onBack, | |||||
| }: Props) { | |||||
| const [search, setSearch] = useState(""); | |||||
| const [items, setItems] = useState<CorrectItem[]>(() => | |||||
| correctFileNames.map((fileName) => ({ fileName, isAlsoWip: false })) | |||||
| ); | |||||
| const [submitting, setSubmitting] = useState(false); | |||||
| const [successMsg, setSuccessMsg] = useState<string | null>(null); | |||||
| const filteredCorrect = useMemo(() => { | |||||
| if (!search.trim()) return items; | |||||
| const q = search.trim().toLowerCase(); | |||||
| return items.filter((i) => i.fileName.toLowerCase().includes(q)); | |||||
| }, [items, search]); | |||||
| const handleToggleWip = (fileName: string) => { | |||||
| setItems((prev) => | |||||
| prev.map((x) => | |||||
| x.fileName === fileName | |||||
| ? { ...x, isAlsoWip: !x.isAlsoWip } | |||||
| : x | |||||
| ) | |||||
| ); | |||||
| }; | |||||
| const handleConfirm = async () => { | |||||
| setSubmitting(true); | |||||
| setSuccessMsg(null); | |||||
| try { | |||||
| const blob = await importBom(batchId, items); | |||||
| const url = URL.createObjectURL(blob); | |||||
| const a = document.createElement("a"); | |||||
| a.href = url; | |||||
| a.download = `bom_excel_issue_log_${new Date().toISOString().slice(0, 10)}.xlsx`; | |||||
| a.click(); | |||||
| URL.revokeObjectURL(url); | |||||
| setSuccessMsg("匯入完成,已下載 issue log。"); | |||||
| } catch (err) { | |||||
| console.error(err); | |||||
| setSuccessMsg("匯入失敗,請查看主控台。"); | |||||
| } finally { | |||||
| setSubmitting(false); | |||||
| } | |||||
| }; | |||||
| const wipCount = items.filter((i) => i.isAlsoWip).length; | |||||
| const totalChecked = correctFileNames.length + failList.length; | |||||
| return ( | |||||
| <Stack spacing={2}> | |||||
| <Stack direction="row" alignItems="center" spacing={2} flexWrap="wrap"> | |||||
| {onBack && ( | |||||
| <Button variant="outlined" onClick={onBack}> | |||||
| 返回重選檔案 | |||||
| </Button> | |||||
| )} | |||||
| <Stack direction="column" spacing={0.5}> | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| 已上傳 {uploadedCount} 個檔案,檢查結果共 {totalChecked} 筆:正確 {correctFileNames.length} 個、失敗 {failList.length} 個 | |||||
| </Typography> | |||||
| {uploadedCount !== totalChecked && ( | |||||
| <Typography variant="caption" color="warning.main"> | |||||
| 上傳數與檢查筆數不符,可能因檔名重複;重新上傳後會為重複檔名自動加 _2、_3 等區分,全部都會列入檢查。 | |||||
| </Typography> | |||||
| )} | |||||
| </Stack> | |||||
| </Stack> | |||||
| <Box | |||||
| sx={{ | |||||
| display: "grid", | |||||
| gridTemplateColumns: "1fr 1fr", | |||||
| gap: 2, | |||||
| alignItems: "stretch", | |||||
| }} | |||||
| > | |||||
| <Paper variant="outlined" sx={{ p: 2 }}> | |||||
| <Typography variant="subtitle1" gutterBottom> | |||||
| 正確 BOM 列表(可匯入) | |||||
| </Typography> | |||||
| <TextField | |||||
| size="small" | |||||
| placeholder="搜尋檔名" | |||||
| value={search} | |||||
| onChange={(e) => setSearch(e.target.value)} | |||||
| InputProps={{ | |||||
| startAdornment: ( | |||||
| <InputAdornment position="start"> | |||||
| <SearchIcon /> | |||||
| </InputAdornment> | |||||
| ), | |||||
| }} | |||||
| sx={{ mb: 2, width: "100%" }} | |||||
| /> | |||||
| <Stack spacing={0.5}> | |||||
| {filteredCorrect.map((item) => ( | |||||
| <Stack | |||||
| key={item.fileName} | |||||
| direction="row" | |||||
| alignItems="center" | |||||
| spacing={1} | |||||
| > | |||||
| <Checkbox | |||||
| checked={item.isAlsoWip} | |||||
| onChange={() => | |||||
| handleToggleWip(item.fileName) | |||||
| } | |||||
| size="small" | |||||
| /> | |||||
| <Typography | |||||
| variant="body2" | |||||
| sx={{ flex: 1 }} | |||||
| noWrap | |||||
| > | |||||
| {item.fileName} | |||||
| </Typography> | |||||
| <Typography variant="caption" color="text.secondary"> | |||||
| {item.isAlsoWip ? "同時建 WIP" : ""} | |||||
| </Typography> | |||||
| </Stack> | |||||
| ))} | |||||
| </Stack> | |||||
| </Paper> | |||||
| <Paper variant="outlined" sx={{ p: 2 }}> | |||||
| <Typography variant="subtitle1" gutterBottom> | |||||
| 失敗 BOM 列表 | |||||
| </Typography> | |||||
| {failList.length === 0 ? ( | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| 無 | |||||
| </Typography> | |||||
| ) : ( | |||||
| failList.map((f) => ( | |||||
| <Accordion key={f.fileName} disableGutters> | |||||
| <AccordionSummary expandIcon={<ExpandMoreIcon />}> | |||||
| <Typography variant="body2"> | |||||
| {f.fileName} | |||||
| </Typography> | |||||
| </AccordionSummary> | |||||
| <AccordionDetails> | |||||
| <Stack component="ul" sx={{ pl: 2, m: 0 }}> | |||||
| {f.problems.map((p, i) => ( | |||||
| <Typography | |||||
| key={i} | |||||
| component="li" | |||||
| variant="body2" | |||||
| color="error" | |||||
| > | |||||
| {p} | |||||
| </Typography> | |||||
| ))} | |||||
| </Stack> | |||||
| </AccordionDetails> | |||||
| </Accordion> | |||||
| )) | |||||
| )} | |||||
| </Paper> | |||||
| </Box> | |||||
| <Stack direction="row" alignItems="center" spacing={2}> | |||||
| <Button | |||||
| variant="contained" | |||||
| onClick={handleConfirm} | |||||
| disabled={submitting || items.length === 0} | |||||
| > | |||||
| 確認匯入 | |||||
| </Button> | |||||
| {submitting && <CircularProgress size={24} />} | |||||
| {successMsg && ( | |||||
| <Typography variant="body2" color="primary"> | |||||
| {successMsg} | |||||
| </Typography> | |||||
| )} | |||||
| </Stack> | |||||
| {items.length > 0 && ( | |||||
| <Typography variant="caption" color="text.secondary"> | |||||
| 將匯入 {items.length} 個 BOM | |||||
| {wipCount > 0 ? `,其中 ${wipCount} 個同時建立 WIP` : ""} | |||||
| </Typography> | |||||
| )} | |||||
| </Stack> | |||||
| ); | |||||
| } | |||||
| @@ -0,0 +1,127 @@ | |||||
| "use client"; | |||||
| import React, { useState } from "react"; | |||||
| import Button from "@mui/material/Button"; | |||||
| import Stack from "@mui/material/Stack"; | |||||
| import Typography from "@mui/material/Typography"; | |||||
| import CircularProgress from "@mui/material/CircularProgress"; | |||||
| import Box from "@mui/material/Box"; | |||||
| import Card from "@mui/material/Card"; | |||||
| import CardContent from "@mui/material/CardContent"; | |||||
| import Alert from "@mui/material/Alert"; | |||||
| import { uploadBomFiles } from "@/app/api/bom/client"; | |||||
| import { checkBomFormat } from "@/app/api/bom/client"; | |||||
| import type { BomFormatCheckResponse } from "@/app/api/bom"; | |||||
| type Props = { | |||||
| onSuccess: ( | |||||
| batchId: string, | |||||
| results: BomFormatCheckResponse, | |||||
| uploadedCount: number | |||||
| ) => void; | |||||
| }; | |||||
| function getErrorMessage(err: unknown): string { | |||||
| if (err && typeof err === "object" && "response" in err) { | |||||
| const res = (err as { response?: { status?: number; data?: unknown } }) | |||||
| .response; | |||||
| if (res?.status === 500) | |||||
| return "伺服器錯誤 (500),請確認後端服務已啟動且 API 路徑正確。"; | |||||
| if (res?.data && typeof res.data === "string" && res.data.length < 200) | |||||
| return res.data; | |||||
| } | |||||
| if (err && typeof err === "object" && "message" in err) | |||||
| return String((err as { message: string }).message); | |||||
| return "上傳或檢查失敗,請稍後再試。"; | |||||
| } | |||||
| export default function ImportBomUpload({ onSuccess }: Props) { | |||||
| const [files, setFiles] = useState<File[]>([]); | |||||
| const [loading, setLoading] = useState(false); | |||||
| const [error, setError] = useState<string | null>(null); | |||||
| const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { | |||||
| const selected = e.target.files; | |||||
| if (!selected?.length) return; | |||||
| const list = Array.from(selected).filter((f) => | |||||
| f.name.toLowerCase().endsWith(".xlsx") | |||||
| ); | |||||
| setFiles(list); | |||||
| setError(null); | |||||
| }; | |||||
| const handleUploadAndCheck = async () => { | |||||
| if (files.length === 0) { | |||||
| setError("請至少選擇一個 .xlsx 檔案"); | |||||
| return; | |||||
| } | |||||
| setLoading(true); | |||||
| setError(null); | |||||
| try { | |||||
| const { batchId } = await uploadBomFiles(files); | |||||
| const results = await checkBomFormat(batchId); | |||||
| onSuccess(batchId, results, files.length); | |||||
| } catch (err: unknown) { | |||||
| setError(getErrorMessage(err)); | |||||
| } finally { | |||||
| setLoading(false); | |||||
| } | |||||
| }; | |||||
| return ( | |||||
| <Card variant="outlined" sx={{ maxWidth: 560 }}> | |||||
| <CardContent> | |||||
| <Stack spacing={2.5}> | |||||
| <Typography variant="h6" color="text.primary"> | |||||
| 選擇 BOM Excel 檔案 | |||||
| </Typography> | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| 可多選 .xlsx 檔案,或選擇資料夾一次加入多個檔案。 | |||||
| </Typography> | |||||
| <Stack direction="row" alignItems="center" spacing={2} flexWrap="wrap"> | |||||
| <Button variant="outlined" component="label"> | |||||
| 選擇檔案 | |||||
| <input | |||||
| type="file" | |||||
| hidden | |||||
| multiple | |||||
| accept=".xlsx" | |||||
| onChange={handleFileChange} | |||||
| /> | |||||
| </Button> | |||||
| <Button variant="outlined" component="label"> | |||||
| 選擇資料夾 | |||||
| <input | |||||
| type="file" | |||||
| hidden | |||||
| accept=".xlsx" | |||||
| onChange={handleFileChange} | |||||
| {...{ webkitdirectory: "" }} | |||||
| /> | |||||
| </Button> | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| {files.length > 0 | |||||
| ? `已選 ${files.length} 個檔案` | |||||
| : "未選擇"} | |||||
| </Typography> | |||||
| </Stack> | |||||
| <Box sx={{ display: "flex", alignItems: "center", gap: 2 }}> | |||||
| <Button | |||||
| variant="contained" | |||||
| onClick={handleUploadAndCheck} | |||||
| disabled={loading || files.length === 0} | |||||
| > | |||||
| {loading ? "上傳與檢查中…" : "上傳並檢查"} | |||||
| </Button> | |||||
| {loading && <CircularProgress size={24} />} | |||||
| </Box> | |||||
| {error && ( | |||||
| <Alert severity="error" onClose={() => setError(null)}> | |||||
| {error} | |||||
| </Alert> | |||||
| )} | |||||
| </Stack> | |||||
| </CardContent> | |||||
| </Card> | |||||
| ); | |||||
| } | |||||
| @@ -0,0 +1,44 @@ | |||||
| "use client"; | |||||
| import React, { useState } from "react"; | |||||
| import Stack from "@mui/material/Stack"; | |||||
| import ImportBomUpload from "./ImportBomUpload"; | |||||
| import ImportBomResultForm from "./ImportBomResultForm"; | |||||
| import type { BomFormatCheckResponse } from "@/app/api/bom"; | |||||
| export default function ImportBomWrapper() { | |||||
| const [batchId, setBatchId] = useState<string | null>(null); | |||||
| const [formatResults, setFormatResults] = useState<BomFormatCheckResponse | null>(null); | |||||
| const [uploadedCount, setUploadedCount] = useState<number>(0); | |||||
| const handleUploadSuccess = ( | |||||
| id: string, | |||||
| results: BomFormatCheckResponse, | |||||
| count: number | |||||
| ) => { | |||||
| setBatchId(id); | |||||
| setFormatResults(results); | |||||
| setUploadedCount(count); | |||||
| }; | |||||
| const handleBack = () => { | |||||
| setBatchId(null); | |||||
| setFormatResults(null); | |||||
| }; | |||||
| return ( | |||||
| <Stack spacing={3}> | |||||
| {formatResults === null ? ( | |||||
| <ImportBomUpload onSuccess={handleUploadSuccess} /> | |||||
| ) : batchId ? ( | |||||
| <ImportBomResultForm | |||||
| batchId={batchId} | |||||
| correctFileNames={formatResults.correctFileNames} | |||||
| failList={formatResults.failList} | |||||
| uploadedCount={uploadedCount} | |||||
| onBack={handleBack} | |||||
| /> | |||||
| ) : null} | |||||
| </Stack> | |||||
| ); | |||||
| } | |||||
| @@ -0,0 +1,4 @@ | |||||
| export { default as ImportBomWrapper } from "./ImportBomWrapper"; | |||||
| export { default as ImportBomUpload } from "./ImportBomUpload"; | |||||
| export { default as ImportBomResultForm } from "./ImportBomResultForm"; | |||||
| export { default } from "./ImportBomWrapper"; | |||||
| @@ -77,7 +77,11 @@ const JoCreateFormModal: React.FC<Props> = ({ | |||||
| onClose() | onClose() | ||||
| setMultiplier(1); | setMultiplier(1); | ||||
| }, [reset, onClose]) | }, [reset, onClose]) | ||||
| const duplicateLabels = useMemo(() => { | |||||
| const count = new Map<string, number>(); | |||||
| bomCombo.forEach((b) => count.set(b.label, (count.get(b.label) ?? 0) + 1)); | |||||
| return new Set(Array.from(count.entries()).filter(([, c]) => c > 1).map(([l]) => l)); | |||||
| }, [bomCombo]); | |||||
| const handleAutoCompleteChange = useCallback( | const handleAutoCompleteChange = useCallback( | ||||
| (event: SyntheticEvent<Element, Event>, value: BomCombo, onChange: (...event: any[]) => void) => { | (event: SyntheticEvent<Element, Event>, value: BomCombo, onChange: (...event: any[]) => void) => { | ||||
| console.log("BOM changed to:", value); | console.log("BOM changed to:", value); | ||||
| @@ -235,11 +239,20 @@ const JoCreateFormModal: React.FC<Props> = ({ | |||||
| }} | }} | ||||
| render={({ field, fieldState: { error } }) => ( | render={({ field, fieldState: { error } }) => ( | ||||
| <Autocomplete | <Autocomplete | ||||
| disableClearable | |||||
| options={bomCombo} | |||||
| onChange={(event, value) => { | |||||
| handleAutoCompleteChange(event, value, field.onChange) | |||||
| }} | |||||
| disableClearable | |||||
| options={bomCombo} | |||||
| getOptionLabel={(option) => { | |||||
| if (!option) return ""; | |||||
| if (duplicateLabels.has(option.label)) { | |||||
| const d = (option.description || "").trim().toUpperCase(); | |||||
| const suffix = d === "WIP" ? t("WIP") : d === "FG" ? t("FG") : option.description ? t(option.description) : ""; | |||||
| return suffix ? `${option.label} (${suffix})` : option.label; | |||||
| } | |||||
| return option.label; | |||||
| }} | |||||
| onChange={(event, value) => { | |||||
| handleAutoCompleteChange(event, value, field.onChange); | |||||
| }} | |||||
| onBlur={field.onBlur} | onBlur={field.onBlur} | ||||
| renderInput={(params) => ( | renderInput={(params) => ( | ||||
| <TextField | <TextField | ||||
| @@ -29,11 +29,14 @@ const JoPickOrderList: React.FC<Props> = ({ onSwitchToRecordTab }) =>{ | |||||
| const [page, setPage] = useState(0); | const [page, setPage] = useState(0); | ||||
| const [selectedPickOrderId, setSelectedPickOrderId] = useState<number | undefined>(undefined); | const [selectedPickOrderId, setSelectedPickOrderId] = useState<number | undefined>(undefined); | ||||
| const [selectedJobOrderId, setSelectedJobOrderId] = useState<number | undefined>(undefined); | const [selectedJobOrderId, setSelectedJobOrderId] = useState<number | undefined>(undefined); | ||||
| type PickOrderFilter = "all" | "drink" | "other"; | |||||
| const [filter, setFilter] = useState<PickOrderFilter>("all"); | |||||
| const fetchPickOrders = useCallback(async () => { | const fetchPickOrders = useCallback(async () => { | ||||
| setLoading(true); | setLoading(true); | ||||
| try { | try { | ||||
| const data = await fetchAllJoPickOrders(); | |||||
| const isDrinkParam = | |||||
| filter === "all" ? undefined : filter === "drink" ? true : false; | |||||
| const data = await fetchAllJoPickOrders(isDrinkParam); | |||||
| setPickOrders(Array.isArray(data) ? data : []); | setPickOrders(Array.isArray(data) ? data : []); | ||||
| setPage(0); | setPage(0); | ||||
| } catch (e) { | } catch (e) { | ||||
| @@ -42,11 +45,11 @@ const JoPickOrderList: React.FC<Props> = ({ onSwitchToRecordTab }) =>{ | |||||
| } finally { | } finally { | ||||
| setLoading(false); | setLoading(false); | ||||
| } | } | ||||
| }, []); | |||||
| }, [filter]); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| fetchPickOrders(); | |||||
| }, [fetchPickOrders]); | |||||
| fetchPickOrders( ); | |||||
| }, [fetchPickOrders, filter]); | |||||
| const handleBackToList = useCallback(() => { | const handleBackToList = useCallback(() => { | ||||
| setSelectedPickOrderId(undefined); | setSelectedPickOrderId(undefined); | ||||
| setSelectedJobOrderId(undefined); | setSelectedJobOrderId(undefined); | ||||
| @@ -87,7 +90,31 @@ const JoPickOrderList: React.FC<Props> = ({ onSwitchToRecordTab }) =>{ | |||||
| <CircularProgress /> | <CircularProgress /> | ||||
| </Box> | </Box> | ||||
| ) : ( | ) : ( | ||||
| <Box> | <Box> | ||||
| <Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap', mb: 2 }}> | |||||
| <Button | |||||
| variant={filter === 'all' ? 'contained' : 'outlined'} | |||||
| size="small" | |||||
| onClick={() => setFilter('all')} | |||||
| > | |||||
| {t("All")} | |||||
| </Button> | |||||
| <Button | |||||
| variant={filter === 'drink' ? 'contained' : 'outlined'} | |||||
| size="small" | |||||
| onClick={() => setFilter('drink')} | |||||
| > | |||||
| {t("Drink")} | |||||
| </Button> | |||||
| <Button | |||||
| variant={filter === 'other' ? 'contained' : 'outlined'} | |||||
| size="small" | |||||
| onClick={() => setFilter('other')} | |||||
| > | |||||
| {t("Other")} | |||||
| </Button> | |||||
| </Box> | |||||
| <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> | <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> | ||||
| {t("Total pick orders")}: {pickOrders.length} | {t("Total pick orders")}: {pickOrders.length} | ||||
| </Typography> | </Typography> | ||||
| @@ -106,6 +133,7 @@ const JoPickOrderList: React.FC<Props> = ({ onSwitchToRecordTab }) =>{ | |||||
| const finishedCount = pickOrder.finishedPickOLineCount ?? 0; | const finishedCount = pickOrder.finishedPickOLineCount ?? 0; | ||||
| return ( | return ( | ||||
| <Grid key={pickOrder.id} item xs={12} sm={6} md={4}> | <Grid key={pickOrder.id} item xs={12} sm={6} md={4}> | ||||
| <Card | <Card | ||||
| sx={{ | sx={{ | ||||
| @@ -43,7 +43,7 @@ interface ProductProcessListProps { | |||||
| const PER_PAGE = 6; | const PER_PAGE = 6; | ||||
| const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess, printerCombo ,onSelectMatchingStock}) => { | const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess, printerCombo ,onSelectMatchingStock}) => { | ||||
| const { t } = useTranslation( ["common", "production","purchaseOrder"]); | |||||
| const { t } = useTranslation( ["common", "production","purchaseOrder","dashboard"]); | |||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | const { data: session } = useSession() as { data: SessionWithTokens | null }; | ||||
| const sessionToken = session as SessionWithTokens | null; | const sessionToken = session as SessionWithTokens | null; | ||||
| const [loading, setLoading] = useState(false); | const [loading, setLoading] = useState(false); | ||||
| @@ -327,6 +327,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess | |||||
| printerCombo={printerCombo} | printerCombo={printerCombo} | ||||
| warehouse={[]} | warehouse={[]} | ||||
| printSource="productionProcess" | printSource="productionProcess" | ||||
| uiMode="default" | |||||
| /> | /> | ||||
| {processes.length > 0 && ( | {processes.length > 0 && ( | ||||
| <TablePagination | <TablePagination | ||||