Przeglądaj źródła

update bom import ,epqc,

reset-do-picking-order
CANCERYS\kw093 2 tygodni temu
rodzic
commit
dfbd808b3a
23 zmienionych plików z 2349 dodań i 36 usunięć
  1. +1
    -1
      src/app/(main)/dashboard/page.tsx
  2. +1
    -1
      src/app/(main)/productionProcess/page.tsx
  3. +52
    -0
      src/app/(main)/settings/importBom/EquipmentTabs.tsx
  4. +29
    -0
      src/app/(main)/settings/importBom/MaintenanceEdit/page.tsx
  5. +22
    -0
      src/app/(main)/settings/importBom/create/page.tsx
  6. +29
    -0
      src/app/(main)/settings/importBom/edit/page.tsx
  7. +29
    -0
      src/app/(main)/settings/importBom/page.tsx
  8. +13
    -0
      src/app/(main)/settings/qcItemAll/page.tsx
  9. +53
    -0
      src/app/api/bom/client.ts
  10. +2
    -0
      src/app/api/escalation/index.ts
  11. +6
    -5
      src/app/api/jo/actions.ts
  12. +57
    -17
      src/components/DashboardPage/escalation/EscalationLogTable.tsx
  13. +1039
    -0
      src/components/ImportBom/EquipmentSearch.tsx
  14. +41
    -0
      src/components/ImportBom/EquipmentSearchLoading.tsx
  15. +492
    -0
      src/components/ImportBom/EquipmentSearchResults.tsx
  16. +35
    -0
      src/components/ImportBom/EquipmentSearchWrapper.tsx
  17. +219
    -0
      src/components/ImportBom/ImportBomResultForm.tsx
  18. +127
    -0
      src/components/ImportBom/ImportBomUpload.tsx
  19. +44
    -0
      src/components/ImportBom/ImportBomWrapper.tsx
  20. +4
    -0
      src/components/ImportBom/index.ts
  21. +19
    -6
      src/components/JoSearch/JoCreateFormModal.tsx
  22. +33
    -5
      src/components/Jodetail/JoPickOrderList.tsx
  23. +2
    -1
      src/components/ProductionProcess/ProductionProcessList.tsx

+ 1
- 1
src/app/(main)/dashboard/page.tsx Wyświetl plik

@@ -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>


+ 1
- 1
src/app/(main)/productionProcess/page.tsx Wyświetl plik

@@ -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>
</>


+ 52
- 0
src/app/(main)/settings/importBom/EquipmentTabs.tsx Wyświetl plik

@@ -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;

+ 29
- 0
src/app/(main)/settings/importBom/MaintenanceEdit/page.tsx Wyświetl plik

@@ -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;

+ 22
- 0
src/app/(main)/settings/importBom/create/page.tsx Wyświetl plik

@@ -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;

+ 29
- 0
src/app/(main)/settings/importBom/edit/page.tsx Wyświetl plik

@@ -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;

+ 29
- 0
src/app/(main)/settings/importBom/page.tsx Wyświetl plik

@@ -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>
</>
);
}

+ 13
- 0
src/app/(main)/settings/qcItemAll/page.tsx Wyświetl plik

@@ -51,6 +51,19 @@ export default qcItemAll;





















+ 53
- 0
src/app/api/bom/client.ts Wyświetl plik

@@ -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;
}

+ 2
- 0
src/app/api/escalation/index.ts Wyświetl plik

@@ -30,6 +30,8 @@ export interface EscalationResult {
qcFailCount?: number;
qcTotalCount?: number;
poCode?: string;
jobOrderId?: number;
jobOrderCode?: string;
itemCode?: string;
dnDate?: number[];
dnNo?: string;


+ 6
- 5
src/app/api/jo/actions.ts Wyświetl plik

@@ -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) => {


+ 57
- 17
src/components/DashboardPage/escalation/EscalationLogTable.tsx Wyświetl plik

@@ -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;

+ 1039
- 0
src/components/ImportBom/EquipmentSearch.tsx
Plik diff jest za duży
Wyświetl plik


+ 41
- 0
src/components/ImportBom/EquipmentSearchLoading.tsx Wyświetl plik

@@ -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;

+ 492
- 0
src/components/ImportBom/EquipmentSearchResults.tsx Wyświetl plik

@@ -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;

+ 35
- 0
src/components/ImportBom/EquipmentSearchWrapper.tsx Wyświetl plik

@@ -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;

+ 219
- 0
src/components/ImportBom/ImportBomResultForm.tsx Wyświetl plik

@@ -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>
);
}

+ 127
- 0
src/components/ImportBom/ImportBomUpload.tsx Wyświetl plik

@@ -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>
);
}

+ 44
- 0
src/components/ImportBom/ImportBomWrapper.tsx Wyświetl plik

@@ -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>
);
}

+ 4
- 0
src/components/ImportBom/index.ts Wyświetl plik

@@ -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";

+ 19
- 6
src/components/JoSearch/JoCreateFormModal.tsx Wyświetl plik

@@ -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


+ 33
- 5
src/components/Jodetail/JoPickOrderList.tsx Wyświetl plik

@@ -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={{


+ 2
- 1
src/components/ProductionProcess/ProductionProcessList.tsx Wyświetl plik

@@ -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


Ładowanie…
Anuluj
Zapisz