| @@ -0,0 +1,44 @@ | |||||
| import { Suspense } from "react"; | |||||
| import { Metadata } from "next"; | |||||
| import Typography from "@mui/material/Typography"; | |||||
| import { getServerI18n } from "@/i18n"; | |||||
| import QrCodeHandleSearchWrapper from "@/components/qrCodeHandles/qrCodeHandleSearchWrapper"; | |||||
| import QrCodeHandleEquipmentSearchWrapper from "@/components/qrCodeHandles/qrCodeHandleEquipmentSearchWrapper"; | |||||
| import QrCodeHandleTabs from "@/components/qrCodeHandles/qrCodeHandleTabs"; | |||||
| import { I18nProvider } from "@/i18n"; | |||||
| import Box from "@mui/material/Box"; | |||||
| export const metadata: Metadata = { title: "QR Code Handle" }; | |||||
| const QrCodeHandlePage: React.FC = async () => { | |||||
| const { t } = await getServerI18n("common"); | |||||
| return ( | |||||
| <Box sx={{ width: "100%" }}> | |||||
| <Typography variant="h5" sx={{ mb: 3 }}> | |||||
| {t("QR Code Handle")} | |||||
| </Typography> | |||||
| <I18nProvider namespaces={["common", "user"]}> | |||||
| <QrCodeHandleTabs | |||||
| userTabContent={ | |||||
| <Suspense fallback={<QrCodeHandleSearchWrapper.Loading />}> | |||||
| <I18nProvider namespaces={["user", "common", "dashboard"]}> | |||||
| <QrCodeHandleSearchWrapper /> | |||||
| </I18nProvider> | |||||
| </Suspense> | |||||
| } | |||||
| equipmentTabContent={ | |||||
| <Suspense fallback={<QrCodeHandleEquipmentSearchWrapper.Loading />}> | |||||
| <I18nProvider namespaces={["common", "project", "dashboard"]}> | |||||
| <QrCodeHandleEquipmentSearchWrapper /> | |||||
| </I18nProvider> | |||||
| </Suspense> | |||||
| } | |||||
| /> | |||||
| </I18nProvider> | |||||
| </Box> | |||||
| ); | |||||
| }; | |||||
| export default QrCodeHandlePage; | |||||
| @@ -34,7 +34,7 @@ const User: React.FC = async () => { | |||||
| {t("Create User")} | {t("Create User")} | ||||
| </Button> | </Button> | ||||
| </Stack> | </Stack> | ||||
| <I18nProvider namespaces={["user", "common"]}> | |||||
| <I18nProvider namespaces={["user", "common", "dashboard"]}> | |||||
| <Suspense fallback={<UserSearch.Loading />}> | <Suspense fallback={<UserSearch.Loading />}> | ||||
| <UserSearch /> | <UserSearch /> | ||||
| </Suspense> | </Suspense> | ||||
| @@ -177,6 +177,25 @@ export const releaseProdSchedule = cache(async (data: ReleaseProdScheduleReq) => | |||||
| return response; | return response; | ||||
| }) | }) | ||||
| export const exportProdSchedule = async (token: string | null) => { | |||||
| if (!token) throw new Error("No access token found"); | |||||
| const response = await fetch(`${BASE_API_URL}/productionSchedule/export-prod-schedule`, { | |||||
| method: "POST", | |||||
| headers: { | |||||
| "Accept": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", | |||||
| "Authorization": `Bearer ${token}` | |||||
| } | |||||
| }); | |||||
| if (!response.ok) throw new Error(`Backend error: ${response.status}`); | |||||
| const arrayBuffer = await response.arrayBuffer(); | |||||
| // Convert to Base64 so Next.js can send it safely over the wire | |||||
| return Buffer.from(arrayBuffer).toString('base64'); | |||||
| }; | |||||
| export const saveProdScheduleLine = cache(async (data: ReleaseProdScheduleInputs) => { | export const saveProdScheduleLine = cache(async (data: ReleaseProdScheduleInputs) => { | ||||
| const response = serverFetchJson<SaveProdScheduleResponse>( | const response = serverFetchJson<SaveProdScheduleResponse>( | ||||
| `${BASE_API_URL}/productionSchedule/detail/detailed/save`, | `${BASE_API_URL}/productionSchedule/detail/detailed/save`, | ||||
| @@ -105,6 +105,8 @@ export interface DetailedProdScheduleLineResult { | |||||
| stockQty: number; // Warehouse stock quantity | stockQty: number; // Warehouse stock quantity | ||||
| daysLeft: number; // Days remaining before stockout | daysLeft: number; // Days remaining before stockout | ||||
| needNoOfJobOrder: number; | needNoOfJobOrder: number; | ||||
| prodQty: number; | |||||
| outputQty: number; | |||||
| } | } | ||||
| export interface DetailedProdScheduleLineBomMaterialResult { | export interface DetailedProdScheduleLineBomMaterialResult { | ||||
| @@ -9,6 +9,7 @@ export interface PrinterCombo { | |||||
| label?: string; | label?: string; | ||||
| code?: string; | code?: string; | ||||
| name?: string; | name?: string; | ||||
| type?: string; | |||||
| description?: string; | description?: string; | ||||
| ip?: string; | ip?: string; | ||||
| port?: number; | port?: number; | ||||
| @@ -14,9 +14,11 @@ import { cache } from "react"; | |||||
| export interface UserInputs { | export interface UserInputs { | ||||
| username: string; | username: string; | ||||
| // name: string; | // name: string; | ||||
| staffNo?: string; | |||||
| addAuthIds?: number[]; | addAuthIds?: number[]; | ||||
| removeAuthIds?: number[]; | removeAuthIds?: number[]; | ||||
| password?: string; | password?: string; | ||||
| confirmPassword?: string; | |||||
| } | } | ||||
| export interface PasswordInputs { | export interface PasswordInputs { | ||||
| @@ -0,0 +1,32 @@ | |||||
| "use client"; | |||||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||||
| export const exportUserQrCode = async (userIds: number[]): Promise<{ blobValue: Uint8Array; filename: string }> => { | |||||
| const token = localStorage.getItem("accessToken"); | |||||
| const response = await fetch(`${NEXT_PUBLIC_API_URL}/user/export-qrcode`, { | |||||
| method: "POST", | |||||
| headers: { | |||||
| "Content-Type": "application/json", | |||||
| ...(token && { Authorization: `Bearer ${token}` }), | |||||
| }, | |||||
| body: JSON.stringify({ userIds }), | |||||
| }); | |||||
| 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, "") || "user_qrcode.pdf"; | |||||
| const blob = await response.blob(); | |||||
| const arrayBuffer = await blob.arrayBuffer(); | |||||
| const blobValue = new Uint8Array(arrayBuffer); | |||||
| return { blobValue, filename }; | |||||
| }; | |||||
| @@ -7,6 +7,7 @@ export interface UserResult { | |||||
| action: any; | action: any; | ||||
| id: number; | id: number; | ||||
| username: string; | username: string; | ||||
| staffNo: number; | |||||
| // name: string; | // name: string; | ||||
| } | } | ||||
| @@ -71,3 +72,26 @@ export const fetchEscalationCombo = cache(async () => { | |||||
| next: { tags: ["escalationCombo"]} | next: { tags: ["escalationCombo"]} | ||||
| }) | }) | ||||
| }) | }) | ||||
| export const exportUserQrCode = async (userIds: number[]): Promise<{ blobValue: Uint8Array; filename: string }> => { | |||||
| const response = await fetch(`${BASE_API_URL}/user/export-qrcode`, { | |||||
| method: "POST", | |||||
| headers: { | |||||
| "Content-Type": "application/json", | |||||
| }, | |||||
| body: JSON.stringify({ userIds }), | |||||
| }); | |||||
| if (!response.ok) { | |||||
| throw new Error("Failed to export QR code"); | |||||
| } | |||||
| const filename = response.headers.get("Content-Disposition")?.split("filename=")[1]?.replace(/"/g, "") || "user_qrcode.pdf"; | |||||
| const blob = await response.blob(); | |||||
| const arrayBuffer = await blob.arrayBuffer(); | |||||
| const blobValue = new Uint8Array(arrayBuffer); | |||||
| return { blobValue, filename }; | |||||
| }; | |||||
| @@ -14,6 +14,7 @@ const pathToLabelMap: { [path: string]: string } = { | |||||
| "/tasks": "Task Template", | "/tasks": "Task Template", | ||||
| "/tasks/create": "Create Task Template", | "/tasks/create": "Create Task Template", | ||||
| "/settings/qcItem": "Qc Item", | "/settings/qcItem": "Qc Item", | ||||
| "/settings/qrCodeHandle": "QR Code Handle", | |||||
| "/settings/rss": "Demand Forecast Setting", | "/settings/rss": "Demand Forecast Setting", | ||||
| "/scheduling/rough": "Demand Forecast", | "/scheduling/rough": "Demand Forecast", | ||||
| "/scheduling/rough/edit": "FG & Material Demand Forecast Detail", | "/scheduling/rough/edit": "FG & Material Demand Forecast Detail", | ||||
| @@ -12,6 +12,7 @@ import { | |||||
| SearchProdSchedule, | SearchProdSchedule, | ||||
| fetchDetailedProdSchedules, | fetchDetailedProdSchedules, | ||||
| fetchProdSchedules, | fetchProdSchedules, | ||||
| exportProdSchedule, | |||||
| testDetailedSchedule, | testDetailedSchedule, | ||||
| } from "@/app/api/scheduling/actions"; | } from "@/app/api/scheduling/actions"; | ||||
| import { defaultPagingController } from "../SearchResults/SearchResults"; | import { defaultPagingController } from "../SearchResults/SearchResults"; | ||||
| @@ -21,6 +22,7 @@ import { orderBy, uniqBy, upperFirst } from "lodash"; | |||||
| import { Button, Stack } from "@mui/material"; | import { Button, Stack } from "@mui/material"; | ||||
| import isToday from 'dayjs/plugin/isToday'; | import isToday from 'dayjs/plugin/isToday'; | ||||
| import useUploadContext from "../UploadProvider/useUploadContext"; | import useUploadContext from "../UploadProvider/useUploadContext"; | ||||
| import { FileDownload, CalendarMonth } from "@mui/icons-material"; | |||||
| dayjs.extend(isToday); | dayjs.extend(isToday); | ||||
| // may need move to "index" or "actions" | // may need move to "index" or "actions" | ||||
| @@ -298,21 +300,68 @@ const DSOverview: React.FC<Props> = ({ type, defaultInputs }) => { | |||||
| } | } | ||||
| }, [inputs]) | }, [inputs]) | ||||
| const exportProdScheduleClick = async () => { | |||||
| try { | |||||
| const token = localStorage.getItem("accessToken"); | |||||
| // 1. Get Base64 string from server | |||||
| const base64String = await exportProdSchedule(token); | |||||
| // 2. Convert Base64 back to Blob | |||||
| const byteCharacters = atob(base64String); | |||||
| const byteNumbers = new Array(byteCharacters.length); | |||||
| for (let i = 0; i < byteCharacters.length; i++) { | |||||
| byteNumbers[i] = byteCharacters.charCodeAt(i); | |||||
| } | |||||
| const byteArray = new Uint8Array(byteNumbers); | |||||
| const blob = new Blob([byteArray], { | |||||
| type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" | |||||
| }); | |||||
| // 3. Trigger download (same as before) | |||||
| const url = window.URL.createObjectURL(blob); | |||||
| const link = document.createElement("a"); | |||||
| link.href = url; | |||||
| link.download = "production_schedule.xlsx"; | |||||
| link.click(); | |||||
| window.URL.revokeObjectURL(url); | |||||
| } catch (error) { | |||||
| console.error(error); | |||||
| alert("Export failed. Check the console for details."); | |||||
| } | |||||
| }; | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| <Stack | <Stack | ||||
| direction="row" | direction="row" | ||||
| justifyContent="flex-end" | justifyContent="flex-end" | ||||
| flexWrap="wrap" | flexWrap="wrap" | ||||
| rowGap={2} | |||||
| spacing={2} // This provides consistent space between buttons | |||||
| sx={{ mb: 3 }} // Adds some margin below the button group | |||||
| > | > | ||||
| <Button | <Button | ||||
| variant="contained" | |||||
| variant="outlined" // Outlined variant makes it look distinct from the primary action | |||||
| color="primary" | |||||
| startIcon={<CalendarMonth />} | |||||
| onClick={testDetailedScheduleClick} | onClick={testDetailedScheduleClick} | ||||
| // disabled={filteredSchedules.some(ele => arrayToDayjs(ele.scheduleAt).isToday())} | |||||
| > | > | ||||
| {t("Detailed Schedule")} | {t("Detailed Schedule")} | ||||
| </Button> | </Button> | ||||
| <Button | |||||
| variant="contained" // Solid button for the "Export" action | |||||
| color="success" // Green color often signifies a successful action/download | |||||
| startIcon={<FileDownload />} | |||||
| onClick={exportProdScheduleClick} | |||||
| sx={{ | |||||
| boxShadow: 2, | |||||
| '&:hover': { backgroundColor: 'success.dark', boxShadow: 4 } | |||||
| }} | |||||
| > | |||||
| {t("Export Schedule")} | |||||
| </Button> | |||||
| </Stack> | </Stack> | ||||
| <SearchBox | <SearchBox | ||||
| criteria={searchCriteria} | criteria={searchCriteria} | ||||
| @@ -194,8 +194,7 @@ const DetailedScheduleDetailView: React.FC<Props> = ({ | |||||
| } | } | ||||
| }, [scheduleId, setIsUploading, t, router]); | }, [scheduleId, setIsUploading, t, router]); | ||||
| // -------------------------------------------------------------------- | // -------------------------------------------------------------------- | ||||
| const [tempValue, setTempValue] = useState<string | number | null>(null) | const [tempValue, setTempValue] = useState<string | number | null>(null) | ||||
| const onEditClick = useCallback((rowId: number) => { | const onEditClick = useCallback((rowId: number) => { | ||||
| const row = formProps.getValues("prodScheduleLines").find(ele => ele.id == rowId) | const row = formProps.getValues("prodScheduleLines").find(ele => ele.id == rowId) | ||||
| @@ -298,7 +297,7 @@ const DetailedScheduleDetailView: React.FC<Props> = ({ | |||||
| onClick={onGlobalReleaseClick} | onClick={onGlobalReleaseClick} | ||||
| disabled={!scheduleId} // Disable if we don't have a schedule ID | disabled={!scheduleId} // Disable if we don't have a schedule ID | ||||
| > | > | ||||
| {t("放單(自動生成工單)")} | |||||
| {t("生成工單")} | |||||
| </Button> | </Button> | ||||
| {/* ------------------------------------------- */} | {/* ------------------------------------------- */} | ||||
| @@ -32,6 +32,7 @@ const DetailedScheduleDetailWrapper: React.FC<Props> & SubComponents = async ({ | |||||
| stockQty: line.stockQty || 0, | stockQty: line.stockQty || 0, | ||||
| daysLeft: line.daysLeft || 0, | daysLeft: line.daysLeft || 0, | ||||
| needNoOfJobOrder: line.needNoOfJobOrder || 0, | needNoOfJobOrder: line.needNoOfJobOrder || 0, | ||||
| outputQty: line.outputQty || 0, | |||||
| })).sort((a, b) => b.priority - a.priority); | })).sort((a, b) => b.priority - a.priority); | ||||
| } | } | ||||
| @@ -108,9 +108,16 @@ const ViewByFGDetails: React.FC<Props> = ({ | |||||
| style: { textAlign: "right" } as any, | style: { textAlign: "right" } as any, | ||||
| renderCell: (row) => <>{row.daysLeft ?? 0}</>, | renderCell: (row) => <>{row.daysLeft ?? 0}</>, | ||||
| }, | }, | ||||
| { | |||||
| field: "outputQty", | |||||
| label: t("每批次生產數"), | |||||
| type: "read-only", | |||||
| style: { textAlign: "right", fontWeight: "bold" } as any, | |||||
| renderCell: (row) => <>{row.outputQty ?? 0}</>, | |||||
| }, | |||||
| { | { | ||||
| field: "needNoOfJobOrder", | field: "needNoOfJobOrder", | ||||
| label: t("生產量"), | |||||
| label: t("生產批次"), | |||||
| type: "read-only", | type: "read-only", | ||||
| style: { textAlign: "right", fontWeight: "bold" } as any, | style: { textAlign: "right", fontWeight: "bold" } as any, | ||||
| renderCell: (row) => <>{row.needNoOfJobOrder ?? 0}</>, | renderCell: (row) => <>{row.needNoOfJobOrder ?? 0}</>, | ||||
| @@ -137,7 +144,7 @@ const ViewByFGDetails: React.FC<Props> = ({ | |||||
| isEditable={true} | isEditable={true} | ||||
| isEdit={isEdit} | isEdit={isEdit} | ||||
| hasCollapse={true} | hasCollapse={true} | ||||
| // Note: onReleaseClick is NOT passed here to hide the row-level "Release" function | |||||
| onReleaseClick={onReleaseClick} | |||||
| onEditClick={onEditClick} | onEditClick={onEditClick} | ||||
| handleEditChange={handleEditChange} | handleEditChange={handleEditChange} | ||||
| onSaveClick={onSaveClick} | onSaveClick={onSaveClick} | ||||
| @@ -47,7 +47,7 @@ interface Props { | |||||
| auths: auth[]; | auths: auth[]; | ||||
| } | } | ||||
| const EditUser: React.FC<Props> = async ({ user, rules, auths }) => { | |||||
| const EditUser: React.FC<Props> = ({ user, rules, auths }) => { | |||||
| console.log(user); | console.log(user); | ||||
| const { t } = useTranslation("user"); | const { t } = useTranslation("user"); | ||||
| const formProps = useForm<UserInputs>(); | const formProps = useForm<UserInputs>(); | ||||
| @@ -73,28 +73,31 @@ const EditUser: React.FC<Props> = async ({ user, rules, auths }) => { | |||||
| const errors = formProps.formState.errors; | const errors = formProps.formState.errors; | ||||
| const resetForm = React.useCallback(() => { | |||||
| const resetForm = React.useCallback((e?: React.MouseEvent<HTMLButtonElement>) => { | |||||
| e?.preventDefault(); | |||||
| e?.stopPropagation(); | |||||
| console.log("triggerred"); | console.log("triggerred"); | ||||
| console.log(addAuthIds); | console.log(addAuthIds); | ||||
| try { | try { | ||||
| formProps.reset({ | formProps.reset({ | ||||
| username: user.username, | username: user.username, | ||||
| // name: user.name, | |||||
| // email: user.email, | |||||
| staffNo: user.staffNo?.toString() ??"", | |||||
| addAuthIds: addAuthIds, | addAuthIds: addAuthIds, | ||||
| removeAuthIds: [], | removeAuthIds: [], | ||||
| password: "", | password: "", | ||||
| confirmPassword: "", | |||||
| }); | }); | ||||
| formProps.clearErrors(); | |||||
| console.log(formProps.formState.defaultValues); | console.log(formProps.formState.defaultValues); | ||||
| } catch (error) { | } catch (error) { | ||||
| console.log(error); | console.log(error); | ||||
| setServerError(t("An error has occurred. Please try again later.")); | setServerError(t("An error has occurred. Please try again later.")); | ||||
| } | } | ||||
| }, [auths, user]); | |||||
| }, [formProps, auths, user, addAuthIds, t]); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| resetForm(); | resetForm(); | ||||
| }, []); | |||||
| }, [user.id]); | |||||
| const hasErrorsInTab = ( | const hasErrorsInTab = ( | ||||
| tabIndex: number, | tabIndex: number, | ||||
| @@ -146,6 +149,7 @@ const EditUser: React.FC<Props> = async ({ user, rules, auths }) => { | |||||
| } | } | ||||
| const userData = { | const userData = { | ||||
| username: data.username, | username: data.username, | ||||
| staffNo: data.staffNo, | |||||
| // name: user.name, | // name: user.name, | ||||
| locked: false, | locked: false, | ||||
| addAuthIds: data.addAuthIds || [], | addAuthIds: data.addAuthIds || [], | ||||
| @@ -218,17 +222,25 @@ const EditUser: React.FC<Props> = async ({ user, rules, auths }) => { | |||||
| {tabIndex == 0 && <UserDetail />} | {tabIndex == 0 && <UserDetail />} | ||||
| {tabIndex === 1 && <AuthAllocation auths={auths!} />} | {tabIndex === 1 && <AuthAllocation auths={auths!} />} | ||||
| <Stack direction="row" justifyContent="flex-end" gap={1}> | <Stack direction="row" justifyContent="flex-end" gap={1}> | ||||
| <Button | |||||
| variant="text" | |||||
| startIcon={<RestartAlt />} | |||||
| onClick={resetForm} | |||||
| > | |||||
| {t("Reset")} | |||||
| </Button> | |||||
| <Button | |||||
| variant="text" | |||||
| startIcon={<RestartAlt />} | |||||
| onClick={(e) => { | |||||
| e.preventDefault(); | |||||
| e.stopPropagation(); | |||||
| resetForm(e); | |||||
| }} | |||||
| type="button" | |||||
| > | |||||
| {t("Reset")} | |||||
| </Button> | |||||
| <Button | <Button | ||||
| variant="outlined" | variant="outlined" | ||||
| startIcon={<Close />} | startIcon={<Close />} | ||||
| onClick={handleCancel} | onClick={handleCancel} | ||||
| type="button" | |||||
| > | > | ||||
| {t("Cancel")} | {t("Cancel")} | ||||
| </Button> | </Button> | ||||
| @@ -20,8 +20,14 @@ const UserDetail: React.FC = () => { | |||||
| register, | register, | ||||
| formState: { errors }, | formState: { errors }, | ||||
| control, | control, | ||||
| watch, | |||||
| } = useFormContext<UserInputs>(); | } = useFormContext<UserInputs>(); | ||||
| const password = watch("password"); | |||||
| const confirmPassword = watch("confirmPassword"); | |||||
| const username = watch("username"); | |||||
| const staffNo = watch("staffNo"); | |||||
| return ( | return ( | ||||
| <Card> | <Card> | ||||
| <CardContent component={Stack} spacing={4}> | <CardContent component={Stack} spacing={4}> | ||||
| @@ -33,35 +39,55 @@ const UserDetail: React.FC = () => { | |||||
| <TextField | <TextField | ||||
| label={t("username")} | label={t("username")} | ||||
| fullWidth | fullWidth | ||||
| variant="filled" | |||||
| InputLabelProps={{ | |||||
| shrink: !!username, | |||||
| sx: { fontSize: "0.9375rem" }, | |||||
| }} | |||||
| InputProps={{ | |||||
| sx: { paddingTop: "8px" }, | |||||
| }} | |||||
| {...register("username", { | {...register("username", { | ||||
| required: "username required!", | required: "username required!", | ||||
| })} | })} | ||||
| error={Boolean(errors.username)} | error={Boolean(errors.username)} | ||||
| /> | /> | ||||
| </Grid> | </Grid> | ||||
| <Grid item xs={6}> | |||||
| <TextField | |||||
| label={t("staffNo")} | |||||
| fullWidth | |||||
| variant="filled" | |||||
| InputLabelProps={{ | |||||
| shrink: !!staffNo, | |||||
| sx: { fontSize: "0.9375rem" }, | |||||
| }} | |||||
| InputProps={{ | |||||
| sx: { paddingTop: "8px" }, | |||||
| }} | |||||
| {...register("staffNo")} | |||||
| error={Boolean(errors.staffNo)} | |||||
| helperText={ | |||||
| Boolean(errors.staffNo) && errors.staffNo?.message | |||||
| ? t(errors.staffNo.message) | |||||
| : "" | |||||
| } | |||||
| /> | |||||
| </Grid> | |||||
| <Grid item xs={6}> | <Grid item xs={6}> | ||||
| <TextField | <TextField | ||||
| label={t("password")} | label={t("password")} | ||||
| fullWidth | fullWidth | ||||
| type="password" | |||||
| variant="filled" | |||||
| InputLabelProps={{ | |||||
| shrink: !!password, | |||||
| sx: { fontSize: "0.9375rem" }, | |||||
| }} | |||||
| InputProps={{ | |||||
| sx: { paddingTop: "8px" }, | |||||
| }} | |||||
| {...register("password")} | {...register("password")} | ||||
| // helperText={ | |||||
| // Boolean(errors.password) && | |||||
| // (errors.password?.message | |||||
| // ? t(errors.password.message) | |||||
| // : | |||||
| // (<> | |||||
| // - 8-20 characters | |||||
| // <br/> | |||||
| // - Uppercase letters | |||||
| // <br/> | |||||
| // - Lowercase letters | |||||
| // <br/> | |||||
| // - Numbers | |||||
| // <br/> | |||||
| // - Symbols | |||||
| // </>) | |||||
| // ) | |||||
| // } | |||||
| helperText={ | helperText={ | ||||
| Boolean(errors.password) && | Boolean(errors.password) && | ||||
| (errors.password?.message | (errors.password?.message | ||||
| @@ -71,6 +97,36 @@ const UserDetail: React.FC = () => { | |||||
| error={Boolean(errors.password)} | error={Boolean(errors.password)} | ||||
| /> | /> | ||||
| </Grid> | </Grid> | ||||
| <Grid item xs={6}> | |||||
| <TextField | |||||
| label={t("Confirm Password")} | |||||
| fullWidth | |||||
| type="password" | |||||
| variant="filled" | |||||
| InputLabelProps={{ | |||||
| shrink: !!confirmPassword, | |||||
| sx: { fontSize: "0.9375rem" }, | |||||
| }} | |||||
| InputProps={{ | |||||
| sx: { paddingTop: "8px" }, | |||||
| }} | |||||
| {...register("confirmPassword", { | |||||
| validate: (value) => { | |||||
| if (password && value !== password) { | |||||
| return "Passwords do not match"; | |||||
| } | |||||
| return true; | |||||
| }, | |||||
| })} | |||||
| error={Boolean(errors.confirmPassword)} | |||||
| helperText={ | |||||
| Boolean(errors.confirmPassword) && | |||||
| (errors.confirmPassword?.message | |||||
| ? t(errors.confirmPassword.message) | |||||
| : "") | |||||
| } | |||||
| /> | |||||
| </Grid> | |||||
| {/* <Grid item xs={6}> | {/* <Grid item xs={6}> | ||||
| <TextField | <TextField | ||||
| label={t("name")} | label={t("name")} | ||||
| @@ -89,16 +145,3 @@ const UserDetail: React.FC = () => { | |||||
| export default UserDetail; | export default UserDetail; | ||||
| { | |||||
| /* <> | |||||
| - 8-20 characters | |||||
| <br/> | |||||
| - Uppercase letters | |||||
| <br/> | |||||
| - Lowercase letters | |||||
| <br/> | |||||
| - Numbers | |||||
| <br/> | |||||
| - Symbols | |||||
| </> */ | |||||
| } | |||||
| @@ -17,6 +17,7 @@ import Assignment from "@mui/icons-material/Assignment"; | |||||
| import Settings from "@mui/icons-material/Settings"; | import Settings from "@mui/icons-material/Settings"; | ||||
| import Analytics from "@mui/icons-material/Analytics"; | import Analytics from "@mui/icons-material/Analytics"; | ||||
| import Payments from "@mui/icons-material/Payments"; | import Payments from "@mui/icons-material/Payments"; | ||||
| import QrCodeIcon from "@mui/icons-material/QrCode"; | |||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import Typography from "@mui/material/Typography"; | import Typography from "@mui/material/Typography"; | ||||
| import { usePathname } from "next/navigation"; | import { usePathname } from "next/navigation"; | ||||
| @@ -268,11 +269,11 @@ const NavigationContent: React.FC = () => { | |||||
| label: "Demand Forecast Setting", | label: "Demand Forecast Setting", | ||||
| path: "/settings/rss", | path: "/settings/rss", | ||||
| }, | }, | ||||
| { | |||||
| icon: <RequestQuote />, | |||||
| label: "Equipment Type", | |||||
| path: "/settings/equipmentType", | |||||
| }, | |||||
| //{ | |||||
| // icon: <RequestQuote />, | |||||
| // label: "Equipment Type", | |||||
| // path: "/settings/equipmentType", | |||||
| //}, | |||||
| { | { | ||||
| icon: <RequestQuote />, | icon: <RequestQuote />, | ||||
| label: "Equipment", | label: "Equipment", | ||||
| @@ -308,6 +309,11 @@ const NavigationContent: React.FC = () => { | |||||
| label: "QC Check Template", | label: "QC Check Template", | ||||
| path: "/settings/user", | path: "/settings/user", | ||||
| }, | }, | ||||
| { | |||||
| icon: <QrCodeIcon />, | |||||
| label: "QR Code Handle", | |||||
| path: "/settings/qrCodeHandle", | |||||
| }, | |||||
| // { | // { | ||||
| // icon: <RequestQuote />, | // icon: <RequestQuote />, | ||||
| // label: "Mail", | // label: "Mail", | ||||
| @@ -227,7 +227,7 @@ function ScheduleTable<T extends ResultWithId>({ | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| <TableRow hover tabIndex={-1} key={row.id}> | <TableRow hover tabIndex={-1} key={row.id}> | ||||
| {/*isDetailedType(type) && ( | |||||
| {isDetailedType(type) && ( | |||||
| <TableCell> | <TableCell> | ||||
| <IconButton | <IconButton | ||||
| color="primary" | color="primary" | ||||
| @@ -241,7 +241,7 @@ function ScheduleTable<T extends ResultWithId>({ | |||||
| <PlayCircleOutlineIcon /> | <PlayCircleOutlineIcon /> | ||||
| </IconButton> | </IconButton> | ||||
| </TableCell> | </TableCell> | ||||
| )*/} | |||||
| )} | |||||
| {(isEditable || hasCollapse) && ( | {(isEditable || hasCollapse) && ( | ||||
| <TableCell> | <TableCell> | ||||
| {editingRowId === row.id ? ( | {editingRowId === row.id ? ( | ||||
| @@ -8,8 +8,11 @@ import EditNote from "@mui/icons-material/EditNote"; | |||||
| import DeleteIcon from "@mui/icons-material/Delete"; | import DeleteIcon from "@mui/icons-material/Delete"; | ||||
| import { useRouter } from "next/navigation"; | import { useRouter } from "next/navigation"; | ||||
| import { deleteDialog, successDialog } from "../Swal/CustomAlerts"; | import { deleteDialog, successDialog } from "../Swal/CustomAlerts"; | ||||
| import useUploadContext from "../UploadProvider/useUploadContext"; | |||||
| import { downloadFile } from "@/app/utils/commonUtil"; | |||||
| import { UserResult } from "@/app/api/user"; | import { UserResult } from "@/app/api/user"; | ||||
| import { deleteUser } from "@/app/api/user/actions"; | import { deleteUser } from "@/app/api/user/actions"; | ||||
| import QrCodeIcon from "@mui/icons-material/QrCode"; | |||||
| import UserSearchLoading from "./UserSearchLoading"; | import UserSearchLoading from "./UserSearchLoading"; | ||||
| interface Props { | interface Props { | ||||
| @@ -22,7 +25,12 @@ type SearchParamNames = keyof SearchQuery; | |||||
| const UserSearch: React.FC<Props> = ({ users }) => { | const UserSearch: React.FC<Props> = ({ users }) => { | ||||
| const { t } = useTranslation("user"); | const { t } = useTranslation("user"); | ||||
| const [filteredUser, setFilteredUser] = useState(users); | const [filteredUser, setFilteredUser] = useState(users); | ||||
| const [pagingController, setPagingController] = useState({ | |||||
| pageNum: 1, | |||||
| pageSize: 10, | |||||
| }); | |||||
| const router = useRouter(); | const router = useRouter(); | ||||
| const { setIsUploading } = useUploadContext(); | |||||
| const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | ||||
| () => [ | () => [ | ||||
| @@ -31,6 +39,11 @@ const UserSearch: React.FC<Props> = ({ users }) => { | |||||
| paramName: "username", | paramName: "username", | ||||
| type: "text", | type: "text", | ||||
| }, | }, | ||||
| { | |||||
| label: t("staffNo"), | |||||
| paramName: "staffNo", | |||||
| type: "text", | |||||
| }, | |||||
| ], | ], | ||||
| [t], | [t], | ||||
| ); | ); | ||||
| @@ -43,6 +56,20 @@ const UserSearch: React.FC<Props> = ({ users }) => { | |||||
| [router, t], | [router, t], | ||||
| ); | ); | ||||
| {/* | |||||
| const printQrcode = useCallback(async (lotLineId: number) => { | |||||
| setIsUploading(true); | |||||
| // const postData = { stockInLineIds: [42,43,44] }; | |||||
| const postData: LotLineToQrcode = { | |||||
| inventoryLotLineId: lotLineId | |||||
| } | |||||
| const response = await fetchQrCodeByLotLineId(postData); | |||||
| if (response) { | |||||
| downloadFile(new Uint8Array(response.blobValue), response.filename!); | |||||
| } | |||||
| setIsUploading(false); | |||||
| }, [setIsUploading]); | |||||
| */} | |||||
| const onDeleteClick = useCallback((users: UserResult) => { | const onDeleteClick = useCallback((users: UserResult) => { | ||||
| deleteDialog(async () => { | deleteDialog(async () => { | ||||
| await deleteUser(users.id); | await deleteUser(users.id); | ||||
| @@ -50,6 +77,9 @@ const UserSearch: React.FC<Props> = ({ users }) => { | |||||
| }, t); | }, t); | ||||
| }, []); | }, []); | ||||
| const columns = useMemo<Column<UserResult>[]>( | const columns = useMemo<Column<UserResult>[]>( | ||||
| () => [ | () => [ | ||||
| { | { | ||||
| @@ -59,6 +89,7 @@ const UserSearch: React.FC<Props> = ({ users }) => { | |||||
| buttonIcon: <EditNote />, | buttonIcon: <EditNote />, | ||||
| }, | }, | ||||
| { name: "username", label: t("Username") }, | { name: "username", label: t("Username") }, | ||||
| { name: "staffNo", label: t("staffNo") }, | |||||
| { | { | ||||
| name: "action", | name: "action", | ||||
| label: t("Delete"), | label: t("Delete"), | ||||
| @@ -75,20 +106,23 @@ const UserSearch: React.FC<Props> = ({ users }) => { | |||||
| <SearchBox | <SearchBox | ||||
| criteria={searchCriteria} | criteria={searchCriteria} | ||||
| onSearch={(query) => { | onSearch={(query) => { | ||||
| // setFilteredUser( | |||||
| // users.filter( | |||||
| // (t) => | |||||
| // t.name.toLowerCase().includes(query.name.toLowerCase()) && | |||||
| // t.code.toLowerCase().includes(query.code.toLowerCase()) && | |||||
| // t.description.toLowerCase().includes(query.description.toLowerCase()) | |||||
| // ) | |||||
| // ) | |||||
| setFilteredUser( | |||||
| users.filter((user) => { | |||||
| const usernameMatch = !query.username || | |||||
| user.username.toLowerCase().includes(query.username.toLowerCase()); | |||||
| const staffNoMatch = !query.staffNo || | |||||
| String(user.staffNo).includes(String(query.staffNo)); | |||||
| return usernameMatch && staffNoMatch; | |||||
| }) | |||||
| ); | |||||
| setPagingController({ pageNum: 1, pageSize: pagingController.pageSize }); | |||||
| }} | }} | ||||
| /> | /> | ||||
| <SearchResults<UserResult> | <SearchResults<UserResult> | ||||
| items={filteredUser} | items={filteredUser} | ||||
| columns={columns} | columns={columns} | ||||
| pagingController={{ pageNum: 1, pageSize: 10 }} | |||||
| pagingController={pagingController} | |||||
| setPagingController={setPagingController} | |||||
| /> | /> | ||||
| </> | </> | ||||
| ); | ); | ||||
| @@ -1 +1,2 @@ | |||||
| export { default } from "./UserSearchWrapper"; | export { default } from "./UserSearchWrapper"; | ||||
| @@ -0,0 +1,3 @@ | |||||
| export { default } from "./qrCodeHandleSearchWrapper"; | |||||
| export { default as QrCodeHandleSearch } from "./qrCodeHandleSearch"; | |||||
| export { default as QrCodeHandleEquipmentSearch } from "./qrCodeHandleEquipmentSearch"; | |||||
| @@ -0,0 +1,156 @@ | |||||
| "use client"; | |||||
| import { useCallback, useEffect, useMemo, useState } from "react"; | |||||
| import SearchBox, { Criterion } from "../SearchBox"; | |||||
| import { EquipmentResult } from "@/app/api/settings/equipment"; | |||||
| 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 axiosInstance from "@/app/(main)/axios/axiosInstance"; | |||||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||||
| type Props = { | |||||
| equipments: EquipmentResult[]; | |||||
| }; | |||||
| type SearchQuery = Partial<Omit<EquipmentResult, "id">>; | |||||
| type SearchParamNames = keyof SearchQuery; | |||||
| const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipments }) => { | |||||
| const [filteredEquipments, setFilteredEquipments] = | |||||
| useState<EquipmentResult[]>([]); | |||||
| const { t } = useTranslation("common"); | |||||
| const router = useRouter(); | |||||
| const [filterObj, setFilterObj] = useState({}); | |||||
| const [pagingController, setPagingController] = useState({ | |||||
| pageNum: 1, | |||||
| pageSize: 10, | |||||
| }); | |||||
| 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 columns = useMemo<Column<EquipmentResult>[]>( | |||||
| () => [ | |||||
| { | |||||
| 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"), | |||||
| }, | |||||
| { | |||||
| name: "action", | |||||
| label: t(""), | |||||
| buttonIcon: <GridDeleteIcon />, | |||||
| onClick: onDeleteClick, | |||||
| }, | |||||
| ], | |||||
| [filteredEquipments], | |||||
| ); | |||||
| interface ApiResponse<T> { | |||||
| records: T[]; | |||||
| total: number; | |||||
| } | |||||
| const refetchData = useCallback( | |||||
| async (filterObj: SearchQuery, pageNum: number, pageSize: number) => { | |||||
| const authHeader = axiosInstance.defaults.headers["Authorization"]; | |||||
| if (!authHeader) { | |||||
| setTimeout(() => { | |||||
| refetchData(filterObj, pageNum, pageSize); | |||||
| }, 10); | |||||
| return; | |||||
| } | |||||
| const params = { | |||||
| pageNum: pageNum, | |||||
| pageSize: pageSize, | |||||
| ...filterObj, | |||||
| }; | |||||
| try { | |||||
| const response = await axiosInstance.get<ApiResponse<EquipmentResult>>( | |||||
| `${NEXT_PUBLIC_API_URL}/Equipment/getRecordByPage`, | |||||
| { params }, | |||||
| ); | |||||
| console.log(response); | |||||
| if (response.status == 200) { | |||||
| setFilteredEquipments(response.data.records); | |||||
| setTotalCount(response.data.total); | |||||
| return response; | |||||
| } else { | |||||
| throw "400"; | |||||
| } | |||||
| } catch (error) { | |||||
| console.error("Error fetching equipment types:", error); | |||||
| throw error; | |||||
| } | |||||
| }, | |||||
| [], | |||||
| ); | |||||
| useEffect(() => { | |||||
| refetchData(filterObj, pagingController.pageNum, pagingController.pageSize); | |||||
| }, [filterObj, pagingController.pageNum, pagingController.pageSize]); | |||||
| const onReset = useCallback(() => { | |||||
| setFilterObj({}); | |||||
| setPagingController({ pageNum: 1, pageSize: 10 }); | |||||
| }, []); | |||||
| return ( | |||||
| <> | |||||
| <SearchBox | |||||
| criteria={searchCriteria} | |||||
| onSearch={(query) => { | |||||
| setFilterObj({ | |||||
| ...query, | |||||
| }); | |||||
| }} | |||||
| onReset={onReset} | |||||
| /> | |||||
| <SearchResults<EquipmentResult> | |||||
| items={filteredEquipments} | |||||
| columns={columns} | |||||
| setPagingController={setPagingController} | |||||
| pagingController={pagingController} | |||||
| totalCount={totalCount} | |||||
| isAutoPaging={false} | |||||
| /> | |||||
| </> | |||||
| ); | |||||
| }; | |||||
| export default QrCodeHandleEquipmentSearch; | |||||
| @@ -0,0 +1,17 @@ | |||||
| import React from "react"; | |||||
| import QrCodeHandleEquipmentSearch from "./qrCodeHandleEquipmentSearch"; | |||||
| import EquipmentSearchLoading from "../EquipmentSearch/EquipmentSearchLoading"; | |||||
| import { fetchAllEquipments } from "@/app/api/settings/equipment"; | |||||
| interface SubComponents { | |||||
| Loading: typeof EquipmentSearchLoading; | |||||
| } | |||||
| const QrCodeHandleEquipmentSearchWrapper: React.FC & SubComponents = async () => { | |||||
| const equipments = await fetchAllEquipments(); | |||||
| return <QrCodeHandleEquipmentSearch equipments={equipments} />; | |||||
| }; | |||||
| QrCodeHandleEquipmentSearchWrapper.Loading = EquipmentSearchLoading; | |||||
| export default QrCodeHandleEquipmentSearchWrapper; | |||||
| @@ -0,0 +1,507 @@ | |||||
| "use client"; | |||||
| import SearchBox, { Criterion } from "../SearchBox"; | |||||
| import { useCallback, useMemo, useState, useEffect } from "react"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| import SearchResults, { Column } from "../SearchResults"; | |||||
| import DeleteIcon from "@mui/icons-material/Delete"; | |||||
| import { useRouter } from "next/navigation"; | |||||
| import { deleteDialog, successDialog } from "../Swal/CustomAlerts"; | |||||
| import useUploadContext from "../UploadProvider/useUploadContext"; | |||||
| import { downloadFile } from "@/app/utils/commonUtil"; | |||||
| import { UserResult } from "@/app/api/user"; | |||||
| import { deleteUser } from "@/app/api/user/actions"; | |||||
| import QrCodeIcon from "@mui/icons-material/QrCode"; | |||||
| import { exportUserQrCode } from "@/app/api/user/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"; | |||||
| interface Props { | |||||
| users: UserResult[]; | |||||
| printerCombo: PrinterCombo[]; | |||||
| } | |||||
| type SearchQuery = Partial<Omit<UserResult, "id">>; | |||||
| type SearchParamNames = keyof SearchQuery; | |||||
| const QrCodeHandleSearch: React.FC<Props> = ({ users, printerCombo }) => { | |||||
| const { t } = useTranslation("user"); | |||||
| const [filteredUser, setFilteredUser] = useState(users); | |||||
| const router = useRouter(); | |||||
| const { setIsUploading } = useUploadContext(); | |||||
| const [pagingController, setPagingController] = useState({ | |||||
| pageNum: 1, | |||||
| pageSize: 10, | |||||
| }); | |||||
| const [checkboxIds, setCheckboxIds] = useState<number[]>([]); | |||||
| const [selectAll, setSelectAll] = useState(false); | |||||
| const [printQty, setPrintQty] = useState(1); | |||||
| const [previewOpen, setPreviewOpen] = useState(false); | |||||
| const [previewUrl, setPreviewUrl] = useState<string | null>(null); | |||||
| const [selectedUsersModalOpen, setSelectedUsersModalOpen] = 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 | |||||
| ); | |||||
| useEffect(() => { | |||||
| if (!selectedPrinter || !filteredPrinters.find(p => p.id === selectedPrinter.id)) { | |||||
| setSelectedPrinter(filteredPrinters.length > 0 ? filteredPrinters[0] : undefined); | |||||
| } | |||||
| }, [filteredPrinters, selectedPrinter]); | |||||
| const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | |||||
| () => [ | |||||
| { | |||||
| label: t("Username"), | |||||
| paramName: "username", | |||||
| type: "text", | |||||
| }, | |||||
| { | |||||
| label: t("staffNo"), | |||||
| paramName: "staffNo", | |||||
| type: "text", | |||||
| }, | |||||
| ], | |||||
| [t], | |||||
| ); | |||||
| const onDeleteClick = useCallback((user: UserResult) => { | |||||
| deleteDialog(async () => { | |||||
| await deleteUser(user.id); | |||||
| successDialog(t("Delete Success"), t); | |||||
| }, t); | |||||
| }, [t]); | |||||
| const handleSelectUser = useCallback((userId: number, checked: boolean) => { | |||||
| if (checked) { | |||||
| setCheckboxIds(prev => [...prev, userId]); | |||||
| } else { | |||||
| setCheckboxIds(prev => prev.filter(id => id !== userId)); | |||||
| setSelectAll(false); | |||||
| } | |||||
| }, []); | |||||
| const handleSelectAll = useCallback((checked: boolean) => { | |||||
| if (checked) { | |||||
| setCheckboxIds(filteredUser.map(user => user.id)); | |||||
| setSelectAll(true); | |||||
| } else { | |||||
| setCheckboxIds([]); | |||||
| setSelectAll(false); | |||||
| } | |||||
| }, [filteredUser]); | |||||
| const showPdfPreview = useCallback(async (userIds: number[]) => { | |||||
| if (userIds.length === 0) { | |||||
| return; | |||||
| } | |||||
| try { | |||||
| setIsUploading(true); | |||||
| const response = await exportUserQrCode(userIds); | |||||
| 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 (userIds: number[]) => { | |||||
| if (userIds.length === 0) { | |||||
| return; | |||||
| } | |||||
| try { | |||||
| setIsUploading(true); | |||||
| const response = await exportUserQrCode(userIds); | |||||
| downloadFile(response.blobValue, response.filename); | |||||
| setSelectedUsersModalOpen(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 response = await exportUserQrCode(checkboxIds); | |||||
| 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); | |||||
| setSelectedUsersModalOpen(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; | |||||
| } | |||||
| setSelectedUsersModalOpen(true); | |||||
| }, [checkboxIds]); | |||||
| const selectedUsers = useMemo(() => { | |||||
| return users.filter(user => checkboxIds.includes(user.id)); | |||||
| }, [users, checkboxIds]); | |||||
| const handleCloseSelectedUsersModal = useCallback(() => { | |||||
| setSelectedUsersModalOpen(false); | |||||
| }, []); | |||||
| const columns = useMemo<Column<UserResult>[]>( | |||||
| () => [ | |||||
| { | |||||
| name: "id", | |||||
| label: "", | |||||
| renderCell: (params) => ( | |||||
| <Checkbox | |||||
| checked={checkboxIds.includes(params.id)} | |||||
| onChange={(e) => handleSelectUser(params.id, e.target.checked)} | |||||
| onClick={(e) => e.stopPropagation()} | |||||
| /> | |||||
| ), | |||||
| }, | |||||
| { | |||||
| name: "username", | |||||
| label: t("Username"), | |||||
| align: "left", | |||||
| headerAlign: "left", | |||||
| }, | |||||
| { | |||||
| name: "staffNo", | |||||
| label: t("staffNo"), | |||||
| align: "left", | |||||
| headerAlign: "left", | |||||
| }, | |||||
| { | |||||
| name: "staffNo", | |||||
| label: t("qrcode"), | |||||
| align: "center", | |||||
| headerAlign: "center", | |||||
| onClick: async (user: UserResult) => { | |||||
| await showPdfPreview([user.id]); | |||||
| }, | |||||
| buttonIcon: <QrCodeIcon />, | |||||
| }, | |||||
| ], | |||||
| [t, checkboxIds, handleSelectUser, showPdfPreview], | |||||
| ); | |||||
| const onReset = useCallback(() => { | |||||
| setFilteredUser(users); | |||||
| }, [users]); | |||||
| return ( | |||||
| <> | |||||
| <SearchBox | |||||
| criteria={searchCriteria} | |||||
| onSearch={(query) => { | |||||
| setFilteredUser( | |||||
| users.filter((user) => { | |||||
| const usernameMatch = !query.username || | |||||
| user.username?.toLowerCase().includes(query.username?.toLowerCase() || ""); | |||||
| const staffNoMatch = !query.staffNo || | |||||
| user.staffNo?.toString().includes(query.staffNo?.toString() || ""); | |||||
| return usernameMatch && staffNoMatch; | |||||
| }) | |||||
| ); | |||||
| setPagingController({ pageNum: 1, pageSize: 10 }); | |||||
| }} | |||||
| onReset={onReset} | |||||
| /> | |||||
| <SearchResults<UserResult> | |||||
| items={filteredUser} | |||||
| columns={columns} | |||||
| pagingController={pagingController} | |||||
| setPagingController={setPagingController} | |||||
| totalCount={filteredUser.length} | |||||
| isAutoPaging={true} | |||||
| /> | |||||
| <Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 2 }}> | |||||
| <Button | |||||
| variant="outlined" | |||||
| onClick={() => handleSelectAll(!selectAll)} | |||||
| startIcon={<Checkbox checked={selectAll} />} | |||||
| > | |||||
| 選擇全部用戶 ({checkboxIds.length} / {filteredUser.length}) | |||||
| </Button> | |||||
| <Button | |||||
| variant="contained" | |||||
| onClick={handleViewSelectedQrCodes} | |||||
| disabled={checkboxIds.length === 0} | |||||
| color="primary" | |||||
| > | |||||
| 查看已選擇用戶二維碼 ({checkboxIds.length}) | |||||
| </Button> | |||||
| </Box> | |||||
| <Modal | |||||
| open={selectedUsersModalOpen} | |||||
| onClose={handleCloseSelectedUsersModal} | |||||
| 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"> | |||||
| 已選擇用戶 ({selectedUsers.length}) | |||||
| </Typography> | |||||
| <IconButton onClick={handleCloseSelectedUsersModal}> | |||||
| <CloseIcon /> | |||||
| </IconButton> | |||||
| </Box> | |||||
| <Box | |||||
| sx={{ | |||||
| flex: 1, | |||||
| overflow: 'auto', | |||||
| p: 2, | |||||
| }} | |||||
| > | |||||
| <TableContainer component={Paper} variant="outlined"> | |||||
| <Table> | |||||
| <TableHead> | |||||
| <TableRow> | |||||
| <TableCell> | |||||
| <strong>{t("Username")}</strong> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <strong>{t("staffNo")}</strong> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| {selectedUsers.length === 0 ? ( | |||||
| <TableRow> | |||||
| <TableCell colSpan={2} align="center"> | |||||
| 沒有選擇的用戶 | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ) : ( | |||||
| selectedUsers.map((user) => ( | |||||
| <TableRow key={user.id}> | |||||
| <TableCell>{user.username || '-'}</TableCell> | |||||
| <TableCell>{user.staffNo || '-'}</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" | |||||
| > | |||||
| 下載QR碼 | |||||
| </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 QrCodeHandleSearch; | |||||
| @@ -0,0 +1,40 @@ | |||||
| 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"; | |||||
| // Can make this nicer | |||||
| export const qrCodeHandleSearchLoading: 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 qrCodeHandleSearchLoading; | |||||
| @@ -0,0 +1,21 @@ | |||||
| import React from "react"; | |||||
| import QrCodeHandleSearch from "./qrCodeHandleSearch"; | |||||
| import QrCodeHandleSearchLoading from "./qrCodeHandleSearchLoading"; | |||||
| import { fetchUser } from "@/app/api/user"; | |||||
| import { fetchPrinterCombo } from "@/app/api/settings/printer"; | |||||
| interface SubComponents { | |||||
| Loading: typeof QrCodeHandleSearchLoading; | |||||
| } | |||||
| const QrCodeHandleSearchWrapper: React.FC & SubComponents = async () => { | |||||
| const [users, printerCombo] = await Promise.all([ | |||||
| fetchUser(), | |||||
| fetchPrinterCombo(), | |||||
| ]); | |||||
| return <QrCodeHandleSearch users={users} printerCombo={printerCombo} />; | |||||
| }; | |||||
| QrCodeHandleSearchWrapper.Loading = QrCodeHandleSearchLoading; | |||||
| export default QrCodeHandleSearchWrapper; | |||||
| @@ -0,0 +1,66 @@ | |||||
| "use client"; | |||||
| import { useState, ReactNode } from "react"; | |||||
| import { Box, Tabs, Tab } from "@mui/material"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| interface TabPanelProps { | |||||
| children?: ReactNode; | |||||
| index: number; | |||||
| value: number; | |||||
| } | |||||
| function TabPanel(props: TabPanelProps) { | |||||
| const { children, value, index, ...other } = props; | |||||
| return ( | |||||
| <div | |||||
| role="tabpanel" | |||||
| hidden={value !== index} | |||||
| id={`qr-code-handle-tabpanel-${index}`} | |||||
| aria-labelledby={`qr-code-handle-tab-${index}`} | |||||
| {...other} | |||||
| > | |||||
| {value === index && <Box sx={{ py: 3 }}>{children}</Box>} | |||||
| </div> | |||||
| ); | |||||
| } | |||||
| interface QrCodeHandleTabsProps { | |||||
| userTabContent: ReactNode; | |||||
| equipmentTabContent: ReactNode; | |||||
| } | |||||
| const QrCodeHandleTabs: React.FC<QrCodeHandleTabsProps> = ({ | |||||
| userTabContent, | |||||
| equipmentTabContent, | |||||
| }) => { | |||||
| const { t } = useTranslation("common"); | |||||
| const { t: tUser } = useTranslation("user"); | |||||
| const [currentTab, setCurrentTab] = useState(0); | |||||
| const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { | |||||
| setCurrentTab(newValue); | |||||
| }; | |||||
| return ( | |||||
| <Box sx={{ width: "100%" }}> | |||||
| <Box sx={{ borderBottom: 1, borderColor: "divider" }}> | |||||
| <Tabs value={currentTab} onChange={handleTabChange}> | |||||
| <Tab label={tUser("User")} /> | |||||
| <Tab label={t("Equipment")} /> | |||||
| </Tabs> | |||||
| </Box> | |||||
| <TabPanel value={currentTab} index={0}> | |||||
| {userTabContent} | |||||
| </TabPanel> | |||||
| <TabPanel value={currentTab} index={1}> | |||||
| {equipmentTabContent} | |||||
| </TabPanel> | |||||
| </Box> | |||||
| ); | |||||
| }; | |||||
| export default QrCodeHandleTabs; | |||||
| @@ -101,6 +101,23 @@ | |||||
| "QC Check Template": "QC檢查模板", | "QC Check Template": "QC檢查模板", | ||||
| "Mail": "郵件", | "Mail": "郵件", | ||||
| "Import Testing": "匯入測試", | "Import Testing": "匯入測試", | ||||
| "FG":"成品", | |||||
| "Qty":"數量", | |||||
| "FG & Material Demand Forecast Detail":"成品及材料需求預測詳情", | |||||
| "View item In-out And inventory Ledger":"查看物料出入庫及庫存日誌", | |||||
| "Delivery Order":"送貨訂單", | |||||
| "Detail Scheduling":"詳細排程", | |||||
| "Customer":"客戶", | |||||
| "qcItem":"品檢項目", | |||||
| "Item":"物料", | |||||
| "Production Date":"生產日期", | |||||
| "QC Check Item":"QC品檢項目", | |||||
| "QC Category":"QC品檢模板", | |||||
| "qcCategory":"品檢模板", | |||||
| "QC Check Template":"QC檢查模板", | |||||
| "QR Code Handle":"二維碼列印及下載", | |||||
| "Mail":"郵件", | |||||
| "Import Testing":"匯入測試", | |||||
| "Overview": "總覽", | "Overview": "總覽", | ||||
| "Projects": "專案", | "Projects": "專案", | ||||
| "Create Project": "新增專案", | "Create Project": "新增專案", | ||||
| @@ -112,12 +129,24 @@ | |||||
| "scheduling": "排程", | "scheduling": "排程", | ||||
| "settings": "設定", | "settings": "設定", | ||||
| "items": "物料", | "items": "物料", | ||||
| "edit":"編輯", | |||||
| "Edit Equipment Type":"設備類型詳情", | |||||
| "Edit Equipment":"設備詳情", | |||||
| "equipmentType":"設備種類", | |||||
| "Description":"描述", | |||||
| "edit": "編輯", | "edit": "編輯", | ||||
| "Edit Equipment Type": "設備類型詳情", | "Edit Equipment Type": "設備類型詳情", | ||||
| "Edit Equipment": "設備詳情", | "Edit Equipment": "設備詳情", | ||||
| "equipmentType": "設備類型", | "equipmentType": "設備類型", | ||||
| "Description": "描述", | "Description": "描述", | ||||
| "Details": "詳情", | "Details": "詳情", | ||||
| "Equipment Type Details":"設備類型詳情", | |||||
| "Equipment Type":"設備類型", | |||||
| "Save":"儲存", | |||||
| "Cancel":"取消", | |||||
| "Equipment Details":"設備詳情", | |||||
| "Exclude Date":"排除日期", | |||||
| "Finished Goods Name":"成品名稱", | |||||
| "Equipment Type Details": "設備類型詳情", | "Equipment Type Details": "設備類型詳情", | ||||
| "Save": "儲存", | "Save": "儲存", | ||||
| "Cancel": "取消", | "Cancel": "取消", | ||||
| @@ -461,6 +461,7 @@ | |||||
| "QC Category":"QC品檢模板", | "QC Category":"QC品檢模板", | ||||
| "qcCategory":"品檢模板", | "qcCategory":"品檢模板", | ||||
| "QC Check Template":"QC檢查模板", | "QC Check Template":"QC檢查模板", | ||||
| "QR Code Handle":"二維碼列印及下載", | |||||
| "Mail":"郵件", | "Mail":"郵件", | ||||
| "Import Testing":"匯入測試", | "Import Testing":"匯入測試", | ||||
| "Overview": "總覽", | "Overview": "總覽", | ||||
| @@ -1,5 +1,6 @@ | |||||
| { | { | ||||
| "Create User": "新增用戶", | "Create User": "新增用戶", | ||||
| "Edit User": "編輯用戶資料", | |||||
| "User Detail": "用戶詳細資料", | "User Detail": "用戶詳細資料", | ||||
| "User Authority": "用戶權限", | "User Authority": "用戶權限", | ||||
| "Authority Pool": "權限池", | "Authority Pool": "權限池", | ||||
| @@ -24,5 +25,8 @@ | |||||
| "Search by Authority or description or position.": "搜尋權限、描述或職位。", | "Search by Authority or description or position.": "搜尋權限、描述或職位。", | ||||
| "Remove": "移除", | "Remove": "移除", | ||||
| "User": "用戶", | "User": "用戶", | ||||
| "user": "用戶" | |||||
| "user": "用戶", | |||||
| "qrcode": "二維碼", | |||||
| "staffNo": "員工編號", | |||||
| "Rows per page": "每頁行數" | |||||
| } | } | ||||
| @@ -0,0 +1,3 @@ | |||||
| @@ -0,0 +1,3 @@ | |||||