| @@ -18,7 +18,7 @@ const Dashboard: React.FC<Props> = async ({ searchParams }) => { | |||
| fetchEscalationLogsByUser() | |||
| return ( | |||
| <I18nProvider namespaces={["dashboard", "common"]}> | |||
| <I18nProvider namespaces={["dashboard", "common", "purchaseOrder"]}> | |||
| <Suspense fallback={<DashboardPage.Loading />}> | |||
| <DashboardPage searchParams={searchParams} /> | |||
| </Suspense> | |||
| @@ -38,7 +38,7 @@ const productionProcess: React.FC = async () => { | |||
| {t("Create Process")} | |||
| </Button> */} | |||
| </Stack> | |||
| <I18nProvider namespaces={["common", "production","purchaseOrder","jo"]}> | |||
| <I18nProvider namespaces={["common", "production","purchaseOrder","jo","dashboard"]}> | |||
| <ProductionProcessPage printerCombo={printerCombo} /> | |||
| </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; | |||
| qcTotalCount?: number; | |||
| poCode?: string; | |||
| jobOrderId?: number; | |||
| jobOrderCode?: string; | |||
| itemCode?: string; | |||
| dnDate?: number[]; | |||
| 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[]>( | |||
| `${BASE_API_URL}/jo/AllJoPickOrder`, | |||
| { | |||
| method: "GET", | |||
| } | |||
| `${BASE_API_URL}/jo/AllJoPickOrder${query}`, | |||
| { method: "GET" } | |||
| ); | |||
| }); | |||
| export const fetchProductProcessLineDetail = cache(async (lineId: number) => { | |||
| @@ -10,7 +10,11 @@ import { Column } from "@/components/SearchResults"; | |||
| import SearchResults from "@/components/SearchResults/SearchResults"; | |||
| import { arrayToDateString, arrayToDateTimeString } from "@/app/utils/formatUtil"; | |||
| 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 = { | |||
| id: number; | |||
| poId: number; | |||
| @@ -25,20 +29,25 @@ export type IQCItems = { | |||
| type Props = { | |||
| type?: "dashboard" | "qc"; | |||
| items: EscalationResult[]; | |||
| printerCombo?: PrinterCombo[]; | |||
| }; | |||
| 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 pathname = usePathname(); | |||
| 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 [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 showCompleted = useMemo(() => { | |||
| if (type === "dashboard") { | |||
| @@ -54,7 +63,7 @@ const EscalationLogTable: React.FC<Props> = ({ | |||
| setEscalationLogs(filteredEscLog); | |||
| } | |||
| }, [showCompleted, items]) | |||
| /* | |||
| const navigateTo = useCallback( | |||
| (item: EscalationResult) => { | |||
| setSelectedId(item.id); | |||
| @@ -63,13 +72,27 @@ const EscalationLogTable: React.FC<Props> = ({ | |||
| }, | |||
| [router, pathname] | |||
| ); | |||
| */ | |||
| 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( | |||
| // (e: React.KeyboardEvent, item: EscalationResult) => { | |||
| // if (e.key === 'Enter' || e.key === ' ') { | |||
| @@ -119,10 +142,13 @@ const EscalationLogTable: React.FC<Props> = ({ | |||
| }, | |||
| { | |||
| name: "poCode", | |||
| label: t("Po Code"), | |||
| label: t("Po Code/Jo Code"), | |||
| align: "left", | |||
| headerAlign: "left", | |||
| sx: { width: "15%", minWidth: 100 }, | |||
| renderCell: (params) => { | |||
| return params.jobOrderCode ?? params.poCode ?? "-"; | |||
| } | |||
| }, | |||
| { | |||
| name: "recordDate", | |||
| @@ -255,14 +281,28 @@ const EscalationLogTable: React.FC<Props> = ({ | |||
| </Table> | |||
| </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; | |||
| @@ -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() | |||
| setMultiplier(1); | |||
| }, [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( | |||
| (event: SyntheticEvent<Element, Event>, value: BomCombo, onChange: (...event: any[]) => void) => { | |||
| console.log("BOM changed to:", value); | |||
| @@ -235,11 +239,20 @@ const JoCreateFormModal: React.FC<Props> = ({ | |||
| }} | |||
| render={({ field, fieldState: { error } }) => ( | |||
| <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} | |||
| renderInput={(params) => ( | |||
| <TextField | |||
| @@ -29,11 +29,14 @@ const JoPickOrderList: React.FC<Props> = ({ onSwitchToRecordTab }) =>{ | |||
| const [page, setPage] = useState(0); | |||
| const [selectedPickOrderId, setSelectedPickOrderId] = 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 () => { | |||
| setLoading(true); | |||
| try { | |||
| const data = await fetchAllJoPickOrders(); | |||
| const isDrinkParam = | |||
| filter === "all" ? undefined : filter === "drink" ? true : false; | |||
| const data = await fetchAllJoPickOrders(isDrinkParam); | |||
| setPickOrders(Array.isArray(data) ? data : []); | |||
| setPage(0); | |||
| } catch (e) { | |||
| @@ -42,11 +45,11 @@ const JoPickOrderList: React.FC<Props> = ({ onSwitchToRecordTab }) =>{ | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| }, []); | |||
| }, [filter]); | |||
| useEffect(() => { | |||
| fetchPickOrders(); | |||
| }, [fetchPickOrders]); | |||
| fetchPickOrders( ); | |||
| }, [fetchPickOrders, filter]); | |||
| const handleBackToList = useCallback(() => { | |||
| setSelectedPickOrderId(undefined); | |||
| setSelectedJobOrderId(undefined); | |||
| @@ -87,7 +90,31 @@ const JoPickOrderList: React.FC<Props> = ({ onSwitchToRecordTab }) =>{ | |||
| <CircularProgress /> | |||
| </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 }}> | |||
| {t("Total pick orders")}: {pickOrders.length} | |||
| </Typography> | |||
| @@ -106,6 +133,7 @@ const JoPickOrderList: React.FC<Props> = ({ onSwitchToRecordTab }) =>{ | |||
| const finishedCount = pickOrder.finishedPickOLineCount ?? 0; | |||
| return ( | |||
| <Grid key={pickOrder.id} item xs={12} sm={6} md={4}> | |||
| <Card | |||
| sx={{ | |||
| @@ -43,7 +43,7 @@ interface ProductProcessListProps { | |||
| const PER_PAGE = 6; | |||
| 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 sessionToken = session as SessionWithTokens | null; | |||
| const [loading, setLoading] = useState(false); | |||
| @@ -327,6 +327,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess | |||
| printerCombo={printerCombo} | |||
| warehouse={[]} | |||
| printSource="productionProcess" | |||
| uiMode="default" | |||
| /> | |||
| {processes.length > 0 && ( | |||
| <TablePagination | |||