Procházet zdrojové kódy

Supporting Function: Equipment Handle and QR Code Printing

master
B.E.N.S.O.N před 3 týdny
rodič
revize
40229f6d67
13 změnil soubory, kde provedl 1390 přidání a 200 odebrání
  1. +2
    -1
      src/app/(main)/settings/equipment/page.tsx
  2. +33
    -0
      src/app/api/settings/equipment/client.ts
  3. +33
    -0
      src/app/api/settings/equipmentDetail/client.ts
  4. +32
    -0
      src/app/api/settings/equipmentDetail/index.ts
  5. +1
    -0
      src/components/Breadcrumb/Breadcrumb.tsx
  6. +643
    -58
      src/components/EquipmentSearch/EquipmentSearch.tsx
  7. +2
    -0
      src/components/EquipmentSearch/EquipmentSearchLoading.tsx
  8. +81
    -65
      src/components/EquipmentSearch/EquipmentSearchResults.tsx
  9. +2
    -2
      src/components/SearchBox/SearchBox.tsx
  10. +520
    -62
      src/components/qrCodeHandles/qrCodeHandleEquipmentSearch.tsx
  11. +7
    -3
      src/components/qrCodeHandles/qrCodeHandleEquipmentSearchWrapper.tsx
  12. +26
    -2
      src/components/qrCodeHandles/qrCodeHandleTabs.tsx
  13. +8
    -7
      src/i18n/zh/common.json

+ 2
- 1
src/app/(main)/settings/equipment/page.tsx Zobrazit soubor

@@ -12,6 +12,7 @@ import { Suspense } from "react";
import { fetchAllEquipments } from "@/app/api/settings/equipment";
import { I18nProvider } from "@/i18n";
import EquipmentSearchWrapper from "@/components/EquipmentSearch/EquipmentSearchWrapper";
import EquipmentSearchLoading from "@/components/EquipmentSearch/EquipmentSearchLoading";

export const metadata: Metadata = {
title: "Equipment Type",
@@ -33,7 +34,7 @@ const productSetting: React.FC = async () => {
{t("Equipment")}
</Typography>
</Stack>
<Suspense fallback={<EquipmentSearchWrapper.Loading />}>
<Suspense fallback={<EquipmentSearchLoading />}>
<I18nProvider namespaces={["common", "project"]}>
<EquipmentSearchWrapper />
</I18nProvider>


+ 33
- 0
src/app/api/settings/equipment/client.ts Zobrazit soubor

@@ -0,0 +1,33 @@
"use client";

import { NEXT_PUBLIC_API_URL } from "@/config/api";
import { EquipmentResult } from "./index";

export const exportEquipmentQrCode = async (equipmentIds: number[]): Promise<{ blobValue: Uint8Array; filename: string }> => {

const token = localStorage.getItem("accessToken");
const response = await fetch(`${NEXT_PUBLIC_API_URL}/Equipment/export-qrcode`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token && { Authorization: `Bearer ${token}` }),
},
body: JSON.stringify({ equipmentIds }),
});

if (!response.ok) {
if (response.status === 401) {
throw new Error("Unauthorized: Please log in again");
}
throw new Error(`Failed to export QR code: ${response.status} ${response.statusText}`);
}

const filename = response.headers.get("Content-Disposition")?.split("filename=")[1]?.replace(/"/g, "") || "equipment_qrcode.pdf";
const blob = await response.blob();
const arrayBuffer = await blob.arrayBuffer();
const blobValue = new Uint8Array(arrayBuffer);

return { blobValue, filename };
};

+ 33
- 0
src/app/api/settings/equipmentDetail/client.ts Zobrazit soubor

@@ -0,0 +1,33 @@
"use client";

import { NEXT_PUBLIC_API_URL } from "@/config/api";
import { EquipmentDetailResult } from "./index";

export const exportEquipmentQrCode = async (equipmentDetailIds: number[]): Promise<{ blobValue: Uint8Array; filename: string }> => {

const token = localStorage.getItem("accessToken");
const response = await fetch(`${NEXT_PUBLIC_API_URL}/Equipment/export-qrcode`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token && { Authorization: `Bearer ${token}` }),
},
body: JSON.stringify({ equipmentDetailIds }),
});

if (!response.ok) {
if (response.status === 401) {
throw new Error("Unauthorized: Please log in again");
}
throw new Error(`Failed to export QR code: ${response.status} ${response.statusText}`);
}

const filename = response.headers.get("Content-Disposition")?.split("filename=")[1]?.replace(/"/g, "") || "equipment_qrcode.pdf";
const blob = await response.blob();
const arrayBuffer = await blob.arrayBuffer();
const blobValue = new Uint8Array(arrayBuffer);

return { blobValue, filename };
};

+ 32
- 0
src/app/api/settings/equipmentDetail/index.ts Zobrazit soubor

@@ -0,0 +1,32 @@
import { cache } from "react";
import "server-only";
import { serverFetchJson } from "../../../utils/fetchUtil";
import { BASE_API_URL } from "../../../../config/api";

export type EquipmentDetailResult = {
id: string | number;
code: string;
name: string;
description: string | undefined;
equipmentCode?: string;
equipmentTypeId?: string | number | undefined;
repairAndMaintenanceStatus?: boolean | number;
latestRepairAndMaintenanceDate?: string | Date;
lastRepairAndMaintenanceDate?: string | Date;
repairAndMaintenanceRemarks?: string;
};

export const fetchAllEquipmentDetails = cache(async () => {
return serverFetchJson<EquipmentDetailResult[]>(`${BASE_API_URL}/EquipmentDetail`, {
next: { tags: ["equipmentDetails"] },
});
});

export const fetchEquipmentDetail = cache(async (id: number) => {
return serverFetchJson<EquipmentDetailResult>(
`${BASE_API_URL}/EquipmentDetail/details/${id}`,
{
next: { tags: ["equipmentDetails"] },
},
);
});

+ 1
- 0
src/components/Breadcrumb/Breadcrumb.tsx Zobrazit soubor

@@ -17,6 +17,7 @@ const pathToLabelMap: { [path: string]: string } = {
"/settings/qrCodeHandle": "QR Code Handle",
"/settings/rss": "Demand Forecast Setting",
"/settings/equipment": "Equipment",
"/settings/equipment/MaintenanceEdit": "MaintenanceEdit",
"/settings/shop": "ShopAndTruck",
"/settings/shop/detail": "Shop Detail",
"/settings/shop/truckdetail": "Truck Lane Detail",


+ 643
- 58
src/components/EquipmentSearch/EquipmentSearch.tsx Zobrazit soubor

@@ -1,20 +1,35 @@
"use client";

import { useCallback, useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import SearchBox, { Criterion } from "../SearchBox";
import { EquipmentResult } from "@/app/api/settings/equipment";
import { useTranslation } from "react-i18next";
import EquipmentSearchResults, { Column } from "./EquipmentSearchResults";
import { EditNote } from "@mui/icons-material";
import { useRouter, useSearchParams } from "next/navigation";
import { GridDeleteIcon } from "@mui/x-data-grid";
import { TypeEnum } from "@/app/utils/typeEnum";
import axios from "axios";
import { useRouter } from "next/navigation";
import { BASE_API_URL, NEXT_PUBLIC_API_URL } from "@/config/api";
import axiosInstance from "@/app/(main)/axios/axiosInstance";
import { arrayToDateTimeString } from "@/app/utils/formatUtil";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
import CircularProgress from "@mui/material/CircularProgress";
import TableRow from "@mui/material/TableRow";
import TableCell from "@mui/material/TableCell";
import Collapse from "@mui/material/Collapse";
import Grid from "@mui/material/Grid";
import DeleteIcon from "@mui/icons-material/Delete";
import AddIcon from "@mui/icons-material/Add";
import Button from "@mui/material/Button";
import Dialog from "@mui/material/Dialog";
import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
import DialogActions from "@mui/material/DialogActions";
import TextField from "@mui/material/TextField";
import Autocomplete from "@mui/material/Autocomplete";

type Props = {
equipments: EquipmentResult[];
@@ -28,14 +43,37 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => {
useState<EquipmentResult[]>([]);
const { t } = useTranslation("common");
const router = useRouter();
const [filterObj, setFilterObj] = useState({});
const [pagingController, setPagingController] = useState({
pageNum: 1,
pageSize: 10,
const [filterObjByTab, setFilterObjByTab] = useState<Record<number, SearchQuery>>({
0: {},
1: {},
});
const [pagingControllerByTab, setPagingControllerByTab] = useState<Record<number, { pageNum: number; pageSize: number }>>({
0: { pageNum: 1, pageSize: 10 },
1: { pageNum: 1, pageSize: 10 },
});
const [totalCount, setTotalCount] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [isReady, setIsReady] = useState(false);

const filterObj = filterObjByTab[tabIndex] || {};
const pagingController = pagingControllerByTab[tabIndex] || { pageNum: 1, pageSize: 10 };
const [expandedRows, setExpandedRows] = useState<Set<string | number>>(new Set());
const [equipmentDetailsMap, setEquipmentDetailsMap] = useState<Map<string | number, EquipmentResult[]>>(new Map());
const [loadingDetailsMap, setLoadingDetailsMap] = useState<Map<string | number, boolean>>(new Map());
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [itemToDelete, setItemToDelete] = useState<{ id: string | number; equipmentId: string | number } | null>(null);
const [deleting, setDeleting] = useState(false);
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [equipmentList, setEquipmentList] = useState<EquipmentResult[]>([]);
const [selectedDescription, setSelectedDescription] = useState<string>("");
const [selectedName, setSelectedName] = useState<string>("");
const [selectedEquipmentCode, setSelectedEquipmentCode] = useState<string>("");
const [isExistingCombination, setIsExistingCombination] = useState(false);
const [loadingEquipments, setLoadingEquipments] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
const checkReady = () => {
@@ -90,20 +128,12 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => {
},
];
}
return [
{ label: t("Code"), paramName: "code", type: "text" },
{ label: t("Description"), paramName: "description", type: "text" },
{ label: "設備編號", paramName: "code", type: "text" },
];
}, [t, tabIndex]);

const onDetailClick = useCallback(
(equipment: EquipmentResult) => {
router.push(`/settings/equipment/edit?id=${equipment.id}`);
},
[router],
);

const onMaintenanceEditClick = useCallback(
(equipment: EquipmentResult) => {
router.push(`/settings/equipment/MaintenanceEdit?id=${equipment.id}`);
@@ -116,34 +146,226 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => {
[router],
);

const fetchEquipmentDetailsByEquipmentId = useCallback(async (equipmentId: string | number) => {
setLoadingDetailsMap(prev => new Map(prev).set(equipmentId, true));
try {
const response = await axiosInstance.get<{
records: EquipmentResult[];
total: number;
}>(`${NEXT_PUBLIC_API_URL}/EquipmentDetail/byEquipmentId/${equipmentId}`);
if (response.status === 200) {
setEquipmentDetailsMap(prev => new Map(prev).set(equipmentId, response.data.records || []));
}
} catch (error) {
console.error("Error fetching equipment details:", error);
setEquipmentDetailsMap(prev => new Map(prev).set(equipmentId, []));
} finally {
setLoadingDetailsMap(prev => new Map(prev).set(equipmentId, false));
}
}, []);

const handleDeleteClick = useCallback((detailId: string | number, equipmentId: string | number) => {
setItemToDelete({ id: detailId, equipmentId });
setDeleteDialogOpen(true);
}, []);

const handleDeleteConfirm = useCallback(async () => {
if (!itemToDelete) return;

setDeleting(true);
try {
const response = await axiosInstance.delete(
`${NEXT_PUBLIC_API_URL}/EquipmentDetail/delete/${itemToDelete.id}`
);

if (response.status === 200 || response.status === 204) {
setEquipmentDetailsMap(prev => {
const newMap = new Map(prev);
const currentDetails = newMap.get(itemToDelete.equipmentId) || [];
const updatedDetails = currentDetails.filter(detail => detail.id !== itemToDelete.id);
newMap.set(itemToDelete.equipmentId, updatedDetails);
return newMap;
});
}
} catch (error) {
console.error("Error deleting equipment detail:", error);
alert("刪除失敗,請稍後再試");
} finally {
setDeleting(false);
setDeleteDialogOpen(false);
setItemToDelete(null);
}
}, [itemToDelete]);

const handleDeleteCancel = useCallback(() => {
setDeleteDialogOpen(false);
setItemToDelete(null);
}, []);

const fetchEquipmentList = useCallback(async () => {
setLoadingEquipments(true);
try {
const response = await axiosInstance.get<{
records: EquipmentResult[];
total: number;
}>(`${NEXT_PUBLIC_API_URL}/Equipment/getRecordByPage`, {
params: {
pageNum: 1,
pageSize: 1000,
},
});
if (response.status === 200) {
setEquipmentList(response.data.records || []);
}
} catch (error) {
console.error("Error fetching equipment list:", error);
setEquipmentList([]);
} finally {
setLoadingEquipments(false);
}
}, []);

const handleAddClick = useCallback(() => {
setAddDialogOpen(true);
fetchEquipmentList();
}, [fetchEquipmentList]);

const handleAddDialogClose = useCallback(() => {
setAddDialogOpen(false);
setSelectedDescription("");
setSelectedName("");
setSelectedEquipmentCode("");
setIsExistingCombination(false);
}, []);

const availableDescriptions = useMemo(() => {
const descriptions = equipmentList
.map((eq) => eq.description)
.filter((desc): desc is string => Boolean(desc));
return Array.from(new Set(descriptions));
}, [equipmentList]);

const availableNames = useMemo(() => {
const names = equipmentList
.map((eq) => eq.name)
.filter((name): name is string => Boolean(name));
return Array.from(new Set(names));
}, [equipmentList]);

useEffect(() => {
const checkAndGenerateEquipmentCode = async () => {
if (!selectedDescription || !selectedName) {
setIsExistingCombination(false);
setSelectedEquipmentCode("");
return;
}

const equipmentCode = `${selectedDescription}-${selectedName}`;
const existingEquipment = equipmentList.find((eq) => eq.code === equipmentCode);
if (existingEquipment) {
setIsExistingCombination(true);
try {
const existingDetailsResponse = await axiosInstance.get<{
records: EquipmentResult[];
total: number;
}>(`${NEXT_PUBLIC_API_URL}/EquipmentDetail/byDescriptionIncludingDeleted/${encodeURIComponent(equipmentCode)}`);

let newEquipmentCode = "";
if (existingDetailsResponse.data.records && existingDetailsResponse.data.records.length > 0) {
const equipmentCodePatterns = existingDetailsResponse.data.records
.map((detail) => {
if (!detail.equipmentCode) return null;
const match = detail.equipmentCode.match(/^([A-Za-z]+)(\d+)$/);
if (match) {
const originalNumber = match[2];
return {
prefix: match[1],
number: parseInt(match[2], 10),
paddingLength: originalNumber.length
};
}
return null;
})
.filter((pattern): pattern is { prefix: string; number: number; paddingLength: number } => pattern !== null);

if (equipmentCodePatterns.length > 0) {
const prefix = equipmentCodePatterns[0].prefix;
const maxEquipmentCodeNumber = Math.max(...equipmentCodePatterns.map(p => p.number));
const maxPaddingLength = Math.max(...equipmentCodePatterns.map(p => p.paddingLength));
const nextNumber = maxEquipmentCodeNumber + 1;
newEquipmentCode = `${prefix}${String(nextNumber).padStart(maxPaddingLength, '0')}`;
} else {
newEquipmentCode = `LSS${String(1).padStart(2, '0')}`;
}
} else {
newEquipmentCode = `LSS${String(1).padStart(2, '0')}`;
}
setSelectedEquipmentCode(newEquipmentCode);
} catch (error) {
console.error("Error checking existing equipment details:", error);
setIsExistingCombination(false);
setSelectedEquipmentCode("");
}
} else {
setIsExistingCombination(false);
setSelectedEquipmentCode("");
}
};

checkAndGenerateEquipmentCode();
}, [selectedDescription, selectedName, equipmentList]);

const handleToggleExpand = useCallback(
(id: string | number, code: string) => {
setExpandedRows(prev => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
if (!equipmentDetailsMap.has(id)) {
fetchEquipmentDetailsByEquipmentId(id);
}
}
return newSet;
});
},
[equipmentDetailsMap, fetchEquipmentDetailsByEquipmentId]
);

const generalDataColumns = useMemo<Column<EquipmentResult>[]>(
() => [
{
name: "id",
label: t("Details"),
onClick: onDetailClick,
buttonIcon: <EditNote />,
},
{
name: "code",
label: t("Code"),
},
{
name: "description",
label: t("Description"),
},
{
name: "equipmentTypeId",
label: t("Equipment Type"),
},
{
name: "action",
label: t(""),
buttonIcon: <GridDeleteIcon />,
onClick: onDeleteClick,
label: "設備編號",
renderCell: (item) => (
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
handleToggleExpand(item.id, item.code);
}}
sx={{ padding: 0.5 }}
>
{expandedRows.has(item.id) ? (
<KeyboardArrowUpIcon fontSize="small" />
) : (
<KeyboardArrowDownIcon fontSize="small" />
)}
</IconButton>
<Typography>{item.code}</Typography>
</Box>
),
},
],
[onDetailClick, onDeleteClick, t],
[t, handleToggleExpand, expandedRows],
);

const repairMaintenanceColumns = useMemo<Column<EquipmentResult>[]>(
@@ -250,8 +472,6 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => {
const transformedFilter: any = { ...filterObj };
// For maintenance tab (tabIndex === 1), if equipmentCode is provided,
// also search by code (equipment name) with the same value
if (tabIndex === 1 && transformedFilter.equipmentCode) {
transformedFilter.code = transformedFilter.equipmentCode;
}
@@ -308,24 +528,253 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => {
}, [filterObj, pagingController.pageNum, pagingController.pageSize, tabIndex, isReady, refetchData]);

const onReset = useCallback(() => {
setFilterObj({});
setPagingController({
pageNum: 1,
pageSize: pagingController.pageSize,
});
}, [pagingController.pageSize]);
setFilterObjByTab(prev => ({
...prev,
[tabIndex]: {},
}));
setPagingControllerByTab(prev => ({
...prev,
[tabIndex]: {
pageNum: 1,
pageSize: prev[tabIndex]?.pageSize || 10,
},
}));
}, [tabIndex]);

const handleSaveEquipmentDetail = useCallback(async () => {
if (!selectedName || !selectedDescription) {
return;
}

if (!isExistingCombination && !selectedEquipmentCode) {
alert("請輸入設備編號");
return;
}
setSaving(true);
try {
const equipmentCode = `${selectedDescription}-${selectedName}`;
let equipment = equipmentList.find((eq) => eq.code === equipmentCode);
let equipmentId: string | number;
if (!equipment) {
const equipmentResponse = await axiosInstance.post<EquipmentResult>(
`${NEXT_PUBLIC_API_URL}/Equipment/save`,
{
code: equipmentCode,
name: selectedName,
description: selectedDescription,
id: null,
}
);
equipment = equipmentResponse.data;
equipmentId = equipment.id;
} else {
equipmentId = equipment.id;
}
const existingDetailsResponse = await axiosInstance.get<{
records: EquipmentResult[];
total: number;
}>(`${NEXT_PUBLIC_API_URL}/EquipmentDetail/byDescriptionIncludingDeleted/${encodeURIComponent(equipmentCode)}`);

let newName = "1號";
let newEquipmentCode = "";
if (existingDetailsResponse.data.records && existingDetailsResponse.data.records.length > 0) {
const numbers = existingDetailsResponse.data.records
.map((detail) => {
const match = detail.name?.match(/(\d+)號/);
return match ? parseInt(match[1], 10) : 0;
})
.filter((num) => num > 0);
if (numbers.length > 0) {
const maxNumber = Math.max(...numbers);
newName = `${maxNumber + 1}號`;
}

if (isExistingCombination) {
const equipmentCodePatterns = existingDetailsResponse.data.records
.map((detail) => {
if (!detail.equipmentCode) return null;
const match = detail.equipmentCode.match(/^([A-Za-z]+)(\d+)$/);
if (match) {
const originalNumber = match[2];
return {
prefix: match[1],
number: parseInt(match[2], 10),
paddingLength: originalNumber.length
};
}
return null;
})
.filter((pattern): pattern is { prefix: string; number: number; paddingLength: number } => pattern !== null);

if (equipmentCodePatterns.length > 0) {
const prefix = equipmentCodePatterns[0].prefix;
const maxEquipmentCodeNumber = Math.max(...equipmentCodePatterns.map(p => p.number));
const maxPaddingLength = Math.max(...equipmentCodePatterns.map(p => p.paddingLength));
const nextNumber = maxEquipmentCodeNumber + 1;
newEquipmentCode = `${prefix}${String(nextNumber).padStart(maxPaddingLength, '0')}`;
} else {
newEquipmentCode = `LSS${String(1).padStart(2, '0')}`;
}
} else {
newEquipmentCode = selectedEquipmentCode;
}
} else {
if (isExistingCombination) {
newEquipmentCode = `LSS${String(1).padStart(2, '0')}`;
} else {
newEquipmentCode = selectedEquipmentCode;
}
}

const detailCode = `${equipmentCode}-${newName}`;
await axiosInstance.post<EquipmentResult>(
`${NEXT_PUBLIC_API_URL}/EquipmentDetail/save`,
{
code: detailCode,
name: newName,
description: equipmentCode,
equipmentCode: newEquipmentCode,
id: null,
equipmentTypeId: equipmentId,
repairAndMaintenanceStatus: false,
}
);
handleAddDialogClose();
if (tabIndex === 0) {
await refetchData(filterObj);
if (equipmentDetailsMap.has(equipmentId)) {
await fetchEquipmentDetailsByEquipmentId(equipmentId);
}
}
alert("新增成功");
} catch (error: any) {
console.error("Error saving equipment detail:", error);
const errorMessage = error.response?.data?.message || error.message || "保存失敗,請稍後再試";
alert(errorMessage);
} finally {
setSaving(false);
}
}, [selectedName, selectedDescription, selectedEquipmentCode, isExistingCombination, equipmentList, refetchData, filterObj, handleAddDialogClose, tabIndex, equipmentDetailsMap, fetchEquipmentDetailsByEquipmentId]);

const renderExpandedRow = useCallback((item: EquipmentResult): React.ReactNode => {
if (tabIndex !== 0) {
return null;
}
const details = equipmentDetailsMap.get(item.id) || [];
const isLoading = loadingDetailsMap.get(item.id) || false;

return (
<TableRow key={`expanded-${item.id}`}>
<TableCell colSpan={columns.length} sx={{ py: 0, border: 0 }}>
<Collapse in={expandedRows.has(item.id)} timeout="auto" unmountOnExit>
<Box sx={{ margin: 2 }}>
{isLoading ? (
<Box sx={{ display: "flex", alignItems: "center", gap: 2, p: 2 }}>
<CircularProgress size={20} />
<Typography>載入中...</Typography>
</Box>
) : details.length === 0 ? (
<Typography sx={{ p: 2 }}>無相關設備詳細資料</Typography>
) : (
<Box>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: "bold" }}>
設備詳細資料 (設備編號: {item.code})
</Typography>
<Grid container spacing={2}>
{details.map((detail) => (
<Grid item xs={6} key={detail.id}>
<Box
sx={{
p: 2,
border: "1px solid",
borderColor: "divider",
borderRadius: 1,
height: "100%",
position: "relative",
}}
>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", mb: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
編號: {detail.code || "-"}
</Typography>
<IconButton
size="small"
color="error"
onClick={() => handleDeleteClick(detail.id, item.id)}
sx={{ ml: 1 }}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
{detail.name && (
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
名稱: {detail.name}
</Typography>
)}
{detail.description && (
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
描述: {detail.description}
</Typography>
)}
{detail.equipmentCode && (
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
設備編號: {detail.equipmentCode}
</Typography>
)}
</Box>
</Grid>
))}
</Grid>
</Box>
)}
</Box>
</Collapse>
</TableCell>
</TableRow>
);
}, [columns.length, equipmentDetailsMap, loadingDetailsMap, expandedRows, tabIndex, handleDeleteClick]);

return (
<>
<SearchBox
criteria={searchCriteria}
onSearch={(query) => {
setFilterObj({
...query,
setFilterObjByTab(prev => {
const newState = { ...prev };
newState[tabIndex] = query as unknown as SearchQuery;
return newState;
});
}}
onReset={onReset}
/>
{tabIndex === 0 && (
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", mb: 2 }}>
<Typography variant="h6" component="h2">
設備編號
</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={handleAddClick}
color="primary"
>
新增
</Button>
</Box>
)}
<Box sx={{
"& .MuiTableContainer-root": {
overflowY: "auto",
@@ -337,14 +786,150 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => {
<EquipmentSearchResults<EquipmentResult>
items={filteredEquipments}
columns={columns}
setPagingController={setPagingController}
setPagingController={(newController) => {
setPagingControllerByTab(prev => {
const newState = { ...prev };
newState[tabIndex] = typeof newController === 'function'
? newController(prev[tabIndex] || { pageNum: 1, pageSize: 10 })
: newController;
return newState;
});
}}
pagingController={pagingController}
totalCount={totalCount}
isAutoPaging={false}
/>
</Box>
</>
);
};
renderExpandedRow={renderExpandedRow}
hideHeader={tabIndex === 0}
/>
</Box>
{/* Delete Confirmation Dialog */}
{deleteDialogOpen && (
<Dialog
open={deleteDialogOpen}
onClose={handleDeleteCancel}
aria-labelledby="delete-dialog-title"
aria-describedby="delete-dialog-description"
>
<DialogTitle id="delete-dialog-title">
確認刪除
</DialogTitle>
<DialogContent>
<DialogContentText id="delete-dialog-description">
您確定要刪除此設備詳細資料嗎?此操作無法復原。
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleDeleteCancel} disabled={deleting}>
取消
</Button>
<Button onClick={handleDeleteConfirm} color="error" disabled={deleting} autoFocus>
{deleting ? "刪除中..." : "刪除"}
</Button>
</DialogActions>
</Dialog>
)}

export default EquipmentSearch;
{/* Add Equipment Detail Dialog */}
<Dialog
open={addDialogOpen}
onClose={handleAddDialogClose}
aria-labelledby="add-dialog-title"
maxWidth="sm"
fullWidth
>
<DialogTitle id="add-dialog-title">
新增設備詳細資料
</DialogTitle>
<DialogContent>
<Box sx={{ pt: 2 }}>
<Autocomplete
freeSolo
options={availableDescriptions}
value={selectedDescription || null}
onChange={(event, newValue) => {
setSelectedDescription(newValue || '');
}}
onInputChange={(event, newInputValue) => {
setSelectedDescription(newInputValue);
}}
loading={loadingEquipments}
disabled={loadingEquipments || saving}
renderInput={(params) => (
<TextField
{...params}
label="種類"
placeholder="選擇或輸入種類"
/>
)}
sx={{ mb: 2 }}
/>
<Autocomplete
freeSolo
options={availableNames}
value={selectedName || null}
onChange={(event, newValue) => {
setSelectedName(newValue || '');
}}
onInputChange={(event, newInputValue) => {
setSelectedName(newInputValue);
}}
loading={loadingEquipments}
disabled={loadingEquipments || saving}
componentsProps={{
popper: {
placement: 'bottom-start',
modifiers: [
{
name: 'flip',
enabled: false,
},
{
name: 'preventOverflow',
enabled: true,
},
],
},
}}
renderInput={(params) => (
<TextField
{...params}
label="名稱"
placeholder="選擇或輸入名稱"
/>
)}
/>
<TextField
fullWidth
label="設備編號"
value={selectedEquipmentCode}
onChange={(e) => {
if (!isExistingCombination) {
setSelectedEquipmentCode(e.target.value);
}
}}
disabled={isExistingCombination || loadingEquipments || saving}
placeholder={isExistingCombination ? "自動生成" : "輸入設備編號"}
sx={{ mt: 2 }}
required={!isExistingCombination}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={handleAddDialogClose} disabled={saving}>
取消
</Button>
<Button
onClick={handleSaveEquipmentDetail}
variant="contained"
disabled={!selectedName || !selectedDescription || (!isExistingCombination && !selectedEquipmentCode) || loadingEquipments || saving}
>
{saving ? "保存中..." : "新增"}
</Button>
</DialogActions>
</Dialog>
</>
);
};
export default EquipmentSearch;

+ 2
- 0
src/components/EquipmentSearch/EquipmentSearchLoading.tsx Zobrazit soubor

@@ -1,3 +1,5 @@
"use client";

import Card from "@mui/material/Card";
import CardContent from "@mui/material/CardContent";
import Skeleton from "@mui/material/Skeleton";


+ 81
- 65
src/components/EquipmentSearch/EquipmentSearchResults.tsx Zobrazit soubor

@@ -48,6 +48,7 @@ interface BaseColumn<T extends ResultWithId> {
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> {
@@ -104,6 +105,8 @@ interface Props<T extends ResultWithId> {
checkboxIds?: (string | number)[];
setCheckboxIds?: Dispatch<SetStateAction<(string | number)[]>>;
onRowClick?: (item: T) => void;
renderExpandedRow?: (item: T) => React.ReactNode;
hideHeader?: boolean;
}

function isActionColumn<T extends ResultWithId>(
@@ -197,6 +200,8 @@ function EquipmentSearchResults<T extends ResultWithId>({
checkboxIds = [],
setCheckboxIds = undefined,
onRowClick = undefined,
renderExpandedRow = undefined,
hideHeader = false,
}: Props<T>) {
const { t } = useTranslation("common");
const [page, setPage] = React.useState(0);
@@ -303,35 +308,41 @@ function EquipmentSearchResults<T extends ResultWithId>({
const table = (
<>
<TableContainer sx={{ maxHeight: 440 }}>
<Table stickyHeader>
<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.label.split('\n').map((line, index) => (
<div key={index}>{line}</div> // Render each line in a div
))}
</TableCell>
))}
</TableRow>
</TableHead>
<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> // Render each line in a div
))
)}
</TableCell>
))}
</TableRow>
</TableHead>
)}
<TableBody>
{isAutoPaging
? items
@@ -339,10 +350,45 @@ function EquipmentSearchResults<T extends ResultWithId>({
(pagingController?.pageNum ? pagingController?.pageNum - 1 :page) * (pagingController?.pageSize ?? rowsPerPage) + (pagingController?.pageSize ?? rowsPerPage))
.map((item) => {
return (
<TableRow
hover
tabIndex={-1}
key={item.id}
<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)
@@ -370,38 +416,8 @@ function EquipmentSearchResults<T extends ResultWithId>({
);
})}
</TableRow>
);
})
: items.map((item) => {
return (
<TableRow hover tabIndex={-1} key={item.id}
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>


+ 2
- 2
src/components/SearchBox/SearchBox.tsx Zobrazit soubor

@@ -306,7 +306,7 @@ function SearchBox<T extends string>({
<Select
label={t(c.label)}
onChange={makeSelectChangeHandler(c.paramName)}
value={inputs[c.paramName]}
value={inputs[c.paramName] ?? "All"}
>
<MenuItem value={"All"}>{t("All")}</MenuItem>
{c.options.map((option) => (
@@ -323,7 +323,7 @@ function SearchBox<T extends string>({
<Select
label={t(c.label)}
onChange={makeSelectChangeHandler(c.paramName)}
value={inputs[c.paramName]}
value={inputs[c.paramName] ?? "All"}
>
<MenuItem value={"All"}>{t("All")}</MenuItem>
{c.options.map((option) => (


+ 520
- 62
src/components/qrCodeHandles/qrCodeHandleEquipmentSearch.tsx Zobrazit soubor

@@ -1,84 +1,102 @@
"use client";

import { useCallback, useEffect, useMemo, useState } from "react";
import SearchBox, { Criterion } from "../SearchBox";
import { EquipmentResult } from "@/app/api/settings/equipment";
import { useCallback, useMemo, useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import SearchResults, { Column } from "../SearchResults";
import { EditNote } from "@mui/icons-material";
import { useRouter } from "next/navigation";
import { GridDeleteIcon } from "@mui/x-data-grid";
import { successDialog } from "../Swal/CustomAlerts";
import useUploadContext from "../UploadProvider/useUploadContext";
import { downloadFile } from "@/app/utils/commonUtil";
import { EquipmentDetailResult } from "@/app/api/settings/equipmentDetail";
import { exportEquipmentQrCode } from "@/app/api/settings/equipmentDetail/client";
import {
Checkbox,
Box,
Button,
TextField,
Stack,
Autocomplete,
Modal,
Card,
IconButton,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Typography
} from "@mui/material";
import DownloadIcon from "@mui/icons-material/Download";
import PrintIcon from "@mui/icons-material/Print";
import CloseIcon from "@mui/icons-material/Close";
import { PrinterCombo } from "@/app/api/settings/printer";
import axiosInstance from "@/app/(main)/axios/axiosInstance";
import { NEXT_PUBLIC_API_URL } from "@/config/api";

type Props = {
equipments: EquipmentResult[];
};
interface Props {
equipmentDetails: EquipmentDetailResult[];
printerCombo: PrinterCombo[];
}

type SearchQuery = Partial<Omit<EquipmentResult, "id">>;
type SearchQuery = Partial<Omit<EquipmentDetailResult, "id">>;
type SearchParamNames = keyof SearchQuery;

const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipments }) => {
const [filteredEquipments, setFilteredEquipments] =
useState<EquipmentResult[]>([]);
const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipmentDetails, printerCombo }) => {
const { t } = useTranslation("common");
const [filteredEquipmentDetails, setFilteredEquipmentDetails] = useState<EquipmentDetailResult[]>([]);
const router = useRouter();
const [filterObj, setFilterObj] = useState({});
const { setIsUploading } = useUploadContext();
const [pagingController, setPagingController] = useState({
pageNum: 1,
pageSize: 10,
});
const [filterObj, setFilterObj] = useState({});
const [totalCount, setTotalCount] = useState(0);
const searchCriteria: Criterion<SearchParamNames>[] = useMemo(() => {
const searchCriteria: Criterion<SearchParamNames>[] = [
{ label: t("Code"), paramName: "code", type: "text" },
{ label: t("Description"), paramName: "description", type: "text" },
];
return searchCriteria;
}, [t, equipments]);

const onDetailClick = useCallback(
(equipment: EquipmentResult) => {
router.push(`/settings/equipment/edit?id=${equipment.id}`);
},
[router],
);

const onDeleteClick = useCallback(
(equipment: EquipmentResult) => {},
[router],
const [checkboxIds, setCheckboxIds] = useState<(string | number)[]>([]);
const [selectedEquipmentDetailsMap, setSelectedEquipmentDetailsMap] = useState<Map<string | number, EquipmentDetailResult>>(new Map());
const [selectAll, setSelectAll] = useState(false);
const [printQty, setPrintQty] = useState(1);
const [isSearching, setIsSearching] = useState(false);

const [previewOpen, setPreviewOpen] = useState(false);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);

const [selectedEquipmentDetailsModalOpen, setSelectedEquipmentDetailsModalOpen] = useState(false);

const filteredPrinters = useMemo(() => {
return printerCombo.filter((printer) => {
return printer.type === "A4";
});
}, [printerCombo]);

const [selectedPrinter, setSelectedPrinter] = useState<PrinterCombo | undefined>(
filteredPrinters.length > 0 ? filteredPrinters[0] : undefined
);

const columns = useMemo<Column<EquipmentResult>[]>(
useEffect(() => {
if (!selectedPrinter || !filteredPrinters.find(p => p.id === selectedPrinter.id)) {
setSelectedPrinter(filteredPrinters.length > 0 ? filteredPrinters[0] : undefined);
}
}, [filteredPrinters, selectedPrinter]);

const searchCriteria: Criterion<SearchParamNames>[] = useMemo(
() => [
{
name: "id",
label: t("Details"),
onClick: onDetailClick,
buttonIcon: <EditNote />,
},
{
name: "code",
label: t("Code"),
},
{
name: "equipmentTypeId",
label: t("Equipment Type"),
sx: {minWidth: 180},
},
{
name: "description",
label: t("Description"),
label: "設備名稱",
paramName: "code",
type: "text",
},
{
name: "action",
label: t(""),
buttonIcon: <GridDeleteIcon />,
onClick: onDeleteClick,
label: "設備編號",
paramName: "equipmentCode",
type: "text",
},
],
[filteredEquipments],
[],
);

interface ApiResponse<T> {
@@ -101,20 +119,19 @@ const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipments }) => {
...filterObj,
};
try {
const response = await axiosInstance.get<ApiResponse<EquipmentResult>>(
`${NEXT_PUBLIC_API_URL}/Equipment/getRecordByPage`,
const response = await axiosInstance.get<ApiResponse<EquipmentDetailResult>>(
`${NEXT_PUBLIC_API_URL}/EquipmentDetail/getRecordByPage`,
{ params },
);
console.log(response);
if (response.status == 200) {
setFilteredEquipments(response.data.records);
setFilteredEquipmentDetails(response.data.records);
setTotalCount(response.data.total);
return response;
} else {
throw "400";
}
} catch (error) {
console.error("Error fetching equipment types:", error);
console.error("Error fetching equipment details:", error);
throw error;
}
},
@@ -125,6 +142,228 @@ const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipments }) => {
refetchData(filterObj, pagingController.pageNum, pagingController.pageSize);
}, [filterObj, pagingController.pageNum, pagingController.pageSize]);

useEffect(() => {
if (filteredEquipmentDetails.length > 0) {
const allCurrentPageSelected = filteredEquipmentDetails.every(ed => checkboxIds.includes(ed.id));
setSelectAll(allCurrentPageSelected);
} else {
setSelectAll(false);
}
}, [filteredEquipmentDetails, checkboxIds]);

const handleSelectEquipmentDetail = useCallback((equipmentDetailId: string | number, checked: boolean) => {
if (checked) {
const equipmentDetail = filteredEquipmentDetails.find(ed => ed.id === equipmentDetailId);
if (equipmentDetail) {
setCheckboxIds(prev => [...prev, equipmentDetailId]);
setSelectedEquipmentDetailsMap(prev => {
const newMap = new Map(prev);
newMap.set(equipmentDetailId, equipmentDetail);
return newMap;
});
}
} else {
setCheckboxIds(prev => prev.filter(id => id !== equipmentDetailId));
setSelectedEquipmentDetailsMap(prev => {
const newMap = new Map(prev);
newMap.delete(equipmentDetailId);
return newMap;
});
setSelectAll(false);
}
}, [filteredEquipmentDetails]);

const fetchAllMatchingEquipmentDetails = useCallback(async (): Promise<EquipmentDetailResult[]> => {
const authHeader = axiosInstance.defaults.headers["Authorization"];
if (!authHeader) {
return [];
}
if (totalCount === 0) {
return [];
}
const params = {
pageNum: 1,
pageSize: totalCount > 0 ? totalCount : 10000,
...filterObj,
};
try {
const response = await axiosInstance.get<ApiResponse<EquipmentDetailResult>>(
`${NEXT_PUBLIC_API_URL}/EquipmentDetail/getRecordByPage`,
{ params },
);
if (response.status == 200) {
return response.data.records;
}
return [];
} catch (error) {
console.error("Error fetching all equipment details:", error);
return [];
}
}, [filterObj, totalCount]);

const handleSelectAll = useCallback(async (checked: boolean) => {
if (checked) {
try {
const allEquipmentDetails = await fetchAllMatchingEquipmentDetails();
const allIds = allEquipmentDetails.map(equipmentDetail => equipmentDetail.id);
setCheckboxIds(allIds);
setSelectedEquipmentDetailsMap(prev => {
const newMap = new Map(prev);
allEquipmentDetails.forEach(equipmentDetail => {
newMap.set(equipmentDetail.id, equipmentDetail);
});
return newMap;
});
setSelectAll(true);
} catch (error) {
console.error("Error selecting all equipment:", error);
}
} else {
setCheckboxIds([]);
setSelectedEquipmentDetailsMap(new Map());
setSelectAll(false);
}
}, [fetchAllMatchingEquipmentDetails]);

const showPdfPreview = useCallback(async (equipmentDetailIds: (string | number)[]) => {
if (equipmentDetailIds.length === 0) {
return;
}
try {
setIsUploading(true);
const numericIds = equipmentDetailIds.map(id => typeof id === 'string' ? parseInt(id) : id);
const response = await exportEquipmentQrCode(numericIds);
const blob = new Blob([new Uint8Array(response.blobValue)], { type: "application/pdf" });
const url = URL.createObjectURL(blob);
setPreviewUrl(`${url}#toolbar=0`);
setPreviewOpen(true);
} catch (error) {
console.error("Error exporting QR code:", error);
} finally {
setIsUploading(false);
}
}, [setIsUploading]);

const handleClosePreview = useCallback(() => {
setPreviewOpen(false);
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
setPreviewUrl(null);
}
}, [previewUrl]);

const handleDownloadQrCode = useCallback(async (equipmentDetailIds: (string | number)[]) => {
if (equipmentDetailIds.length === 0) {
return;
}
try {
setIsUploading(true);
const numericIds = equipmentDetailIds.map(id => typeof id === 'string' ? parseInt(id) : id);
const response = await exportEquipmentQrCode(numericIds);
downloadFile(response.blobValue, response.filename);
setSelectedEquipmentDetailsModalOpen(false);
successDialog("二維碼已下載", t);
} catch (error) {
console.error("Error exporting QR code:", error);
} finally {
setIsUploading(false);
}
}, [setIsUploading, t]);

const handlePrint = useCallback(async () => {
if (checkboxIds.length === 0) {
return;
}
try {
setIsUploading(true);
const numericIds = checkboxIds.map(id => typeof id === 'string' ? parseInt(id) : id);
const response = await exportEquipmentQrCode(numericIds);
const blob = new Blob([new Uint8Array(response.blobValue)], { type: "application/pdf" });
const url = URL.createObjectURL(blob);
const printWindow = window.open(url, '_blank');
if (printWindow) {
printWindow.onload = () => {
for (let i = 0; i < printQty; i++) {
setTimeout(() => {
printWindow.print();
}, i * 500);
}
};
}
setTimeout(() => {
URL.revokeObjectURL(url);
}, 1000);
setSelectedEquipmentDetailsModalOpen(false);
successDialog("二維碼已列印", t);
} catch (error) {
console.error("Error printing QR code:", error);
} finally {
setIsUploading(false);
}
}, [checkboxIds, printQty, setIsUploading, t]);

const handleViewSelectedQrCodes = useCallback(() => {
if (checkboxIds.length === 0) {
return;
}
setSelectedEquipmentDetailsModalOpen(true);
}, [checkboxIds]);

const selectedEquipmentDetails = useMemo(() => {
return Array.from(selectedEquipmentDetailsMap.values());
}, [selectedEquipmentDetailsMap]);

const handleCloseSelectedEquipmentDetailsModal = useCallback(() => {
setSelectedEquipmentDetailsModalOpen(false);
}, []);

const columns = useMemo<Column<EquipmentDetailResult>[]>(
() => [
{
name: "id",
label: "",
sx: { width: "50px", minWidth: "50px" },
renderCell: (params) => (
<Checkbox
checked={checkboxIds.includes(params.id)}
onChange={(e) => handleSelectEquipmentDetail(params.id, e.target.checked)}
onClick={(e) => e.stopPropagation()}
/>
),
},
{
name: "code",
label: "設備名稱",
align: "left",
headerAlign: "left",
sx: { width: "150px", minWidth: "150px" },
},
{
name: "description",
label: "設備描述",
align: "left",
headerAlign: "left",
sx: { width: "200px", minWidth: "200px" },
},
{
name: "equipmentCode",
label: "設備編號",
align: "left",
headerAlign: "left",
sx: { width: "150px", minWidth: "150px" },
},
],
[t, checkboxIds, handleSelectEquipmentDetail],
);

const onReset = useCallback(() => {
setFilterObj({});
setPagingController({ pageNum: 1, pageSize: 10 });
@@ -138,19 +377,238 @@ const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipments }) => {
setFilterObj({
...query,
});
setPagingController({ pageNum: 1, pageSize: 10 });
}}
onReset={onReset}
/>
<SearchResults<EquipmentResult>
items={filteredEquipments}
<SearchResults<EquipmentDetailResult>
items={filteredEquipmentDetails}
columns={columns}
setPagingController={setPagingController}
pagingController={pagingController}
setPagingController={setPagingController}
totalCount={totalCount}
isAutoPaging={false}
/>
<Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 2 }}>
<Button
variant="outlined"
onClick={() => handleSelectAll(!selectAll)}
startIcon={<Checkbox checked={selectAll} />}
disabled={isSearching || totalCount === 0}
>
選擇全部設備 ({checkboxIds.length} / {totalCount})
</Button>
<Button
variant="contained"
onClick={handleViewSelectedQrCodes}
disabled={checkboxIds.length === 0}
color="primary"
>
查看已選擇設備二維碼 ({checkboxIds.length})
</Button>
</Box>

<Modal
open={selectedEquipmentDetailsModalOpen}
onClose={handleCloseSelectedEquipmentDetailsModal}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Card
sx={{
position: 'relative',
width: '90%',
maxWidth: '800px',
maxHeight: '90vh',
display: 'flex',
flexDirection: 'column',
outline: 'none',
}}
>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
p: 2,
borderBottom: 1,
borderColor: 'divider',
}}
>
<Typography variant="h6" component="h2">
已選擇設備 ({selectedEquipmentDetails.length})
</Typography>
<IconButton onClick={handleCloseSelectedEquipmentDetailsModal}>
<CloseIcon />
</IconButton>
</Box>

<Box
sx={{
flex: 1,
overflow: 'auto',
p: 2,
}}
>
<TableContainer component={Paper} variant="outlined">
<Table>
<TableHead>
<TableRow>
<TableCell>
<strong>設備名稱</strong>
</TableCell>
<TableCell>
<strong>設備描述</strong>
</TableCell>
<TableCell>
<strong>設備編號</strong>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{selectedEquipmentDetails.length === 0 ? (
<TableRow>
<TableCell colSpan={3} align="center">
沒有選擇的設備
</TableCell>
</TableRow>
) : (
selectedEquipmentDetails.map((equipmentDetail) => (
<TableRow key={equipmentDetail.id}>
<TableCell>{equipmentDetail.code || '-'}</TableCell>
<TableCell>{equipmentDetail.description || '-'}</TableCell>
<TableCell>{equipmentDetail.equipmentCode || '-'}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</Box>

<Box
sx={{
p: 2,
borderTop: 1,
borderColor: 'divider',
bgcolor: 'background.paper',
}}
>
<Stack direction="row" justifyContent="flex-end" alignItems="center" gap={2}>
<Autocomplete<PrinterCombo>
options={filteredPrinters}
value={selectedPrinter ?? null}
onChange={(event, value) => {
setSelectedPrinter(value ?? undefined);
}}
getOptionLabel={(option) => option.name || option.label || option.code || String(option.id)}
renderInput={(params) => (
<TextField
{...params}
variant="outlined"
label="列印機"
sx={{ width: 300 }}
/>
)}
/>
<TextField
variant="outlined"
label="列印數量"
type="number"
value={printQty}
onChange={(e) => {
const value = parseInt(e.target.value) || 1;
setPrintQty(Math.max(1, value));
}}
inputProps={{ min: 1 }}
sx={{ width: 120 }}
/>
<Button
variant="contained"
startIcon={<PrintIcon />}
onClick={handlePrint}
disabled={checkboxIds.length === 0 || filteredPrinters.length === 0}
color="primary"
>
列印
</Button>
<Button
variant="contained"
startIcon={<DownloadIcon />}
onClick={() => handleDownloadQrCode(checkboxIds)}
disabled={checkboxIds.length === 0}
color="primary"
>
下載二維碼
</Button>
</Stack>
</Box>
</Card>
</Modal>

<Modal
open={previewOpen}
onClose={handleClosePreview}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Card
sx={{
position: 'relative',
width: '90%',
maxWidth: '900px',
maxHeight: '90vh',
display: 'flex',
flexDirection: 'column',
outline: 'none',
}}
>
<Box
sx={{
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
p: 2,
borderBottom: 1,
borderColor: 'divider',
}}
>
<IconButton
onClick={handleClosePreview}
>
<CloseIcon />
</IconButton>
</Box>

<Box
sx={{
flex: 1,
overflow: 'auto',
p: 2,
}}
>
{previewUrl && (
<iframe
src={previewUrl}
width="100%"
height="600px"
style={{
border: 'none',
}}
title="PDF Preview"
/>
)}
</Box>
</Card>
</Modal>
</>
);
};

export default QrCodeHandleEquipmentSearch;
export default QrCodeHandleEquipmentSearch;

+ 7
- 3
src/components/qrCodeHandles/qrCodeHandleEquipmentSearchWrapper.tsx Zobrazit soubor

@@ -1,15 +1,19 @@
import React from "react";
import QrCodeHandleEquipmentSearch from "./qrCodeHandleEquipmentSearch";
import EquipmentSearchLoading from "../EquipmentSearch/EquipmentSearchLoading";
import { fetchAllEquipments } from "@/app/api/settings/equipment";
import { fetchAllEquipmentDetails } from "@/app/api/settings/equipmentDetail";
import { fetchPrinterCombo } from "@/app/api/settings/printer";

interface SubComponents {
Loading: typeof EquipmentSearchLoading;
}

const QrCodeHandleEquipmentSearchWrapper: React.FC & SubComponents = async () => {
const equipments = await fetchAllEquipments();
return <QrCodeHandleEquipmentSearch equipments={equipments} />;
const [equipmentDetails, printerCombo] = await Promise.all([
fetchAllEquipmentDetails(),
fetchPrinterCombo(),
]);
return <QrCodeHandleEquipmentSearch equipmentDetails={equipmentDetails} printerCombo={printerCombo} />;
};

QrCodeHandleEquipmentSearchWrapper.Loading = EquipmentSearchLoading;


+ 26
- 2
src/components/qrCodeHandles/qrCodeHandleTabs.tsx Zobrazit soubor

@@ -1,8 +1,9 @@
"use client";

import { useState, ReactNode } from "react";
import { useState, ReactNode, useEffect } from "react";
import { Box, Tabs, Tab } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useSearchParams, useRouter } from "next/navigation";

interface TabPanelProps {
children?: ReactNode;
@@ -37,10 +38,33 @@ const QrCodeHandleTabs: React.FC<QrCodeHandleTabsProps> = ({
}) => {
const { t } = useTranslation("common");
const { t: tUser } = useTranslation("user");
const [currentTab, setCurrentTab] = useState(0);
const searchParams = useSearchParams();
const router = useRouter();
const getInitialTab = () => {
const tab = searchParams.get("tab");
if (tab === "equipment") return 1;
if (tab === "user") return 0;
return 0;
};

const [currentTab, setCurrentTab] = useState(getInitialTab);

useEffect(() => {
const tab = searchParams.get("tab");
if (tab === "equipment") {
setCurrentTab(1);
} else if (tab === "user") {
setCurrentTab(0);
}
}, [searchParams]);

const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
setCurrentTab(newValue);
const tabName = newValue === 1 ? "equipment" : "user";
const params = new URLSearchParams(searchParams.toString());
params.set("tab", tabName);
router.push(`?${params.toString()}`, { scroll: false });
};

return (


+ 8
- 7
src/i18n/zh/common.json Zobrazit soubor

@@ -374,17 +374,17 @@
"Filter by Status": "按狀態篩選",
"All": "全部",
"General Data": "基本資料",
"Repair and Maintenance": "維和保養",
"Repair and Maintenance Status": "維和保養狀態",
"Latest Repair and Maintenance Date": "最新維和保養日期",
"Last Repair and Maintenance Date": "上次維和保養日期",
"Repair and Maintenance Remarks": "維和保養備註",
"Repair and Maintenance": "維和保養",
"Repair and Maintenance Status": "維和保養狀態",
"Latest Repair and Maintenance Date": "最新維和保養日期",
"Last Repair and Maintenance Date": "上次維和保養日期",
"Repair and Maintenance Remarks": "維和保養備註",
"Rows per page": "每頁行數",
"Equipment Name": "設備名稱",
"Equipment Code": "設備編號",
"Yes": "是",
"No": "否",
"Update Equipment Maintenance and Repair": "更新設備的維和保養",
"Update Equipment Maintenance and Repair": "更新設備的維和保養",
"Equipment Information": "設備資訊",
"Loading": "載入中...",
"Equipment not found": "找不到設備",
@@ -401,5 +401,6 @@
"Search or select remark": "搜尋或選擇備註",
"Edit shop details": "編輯店鋪詳情",
"Add Shop to Truck Lane": "新增店鋪至卡車路線",
"Truck lane code already exists. Please use a different code.": "卡車路線編號已存在,請使用其他編號。"
"Truck lane code already exists. Please use a different code.": "卡車路線編號已存在,請使用其他編號。",
"MaintenanceEdit": "編輯維護和保養"
}

Načítá se…
Zrušit
Uložit