| @@ -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 { | ||||
| @@ -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} | ||||
| @@ -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 ? ( | ||||