| @@ -177,6 +177,25 @@ export const releaseProdSchedule = cache(async (data: ReleaseProdScheduleReq) => | |||
| 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) => { | |||
| const response = serverFetchJson<SaveProdScheduleResponse>( | |||
| `${BASE_API_URL}/productionSchedule/detail/detailed/save`, | |||
| @@ -105,6 +105,8 @@ export interface DetailedProdScheduleLineResult { | |||
| stockQty: number; // Warehouse stock quantity | |||
| daysLeft: number; // Days remaining before stockout | |||
| needNoOfJobOrder: number; | |||
| prodQty: number; | |||
| outputQty: number; | |||
| } | |||
| export interface DetailedProdScheduleLineBomMaterialResult { | |||
| @@ -12,6 +12,7 @@ import { | |||
| SearchProdSchedule, | |||
| fetchDetailedProdSchedules, | |||
| fetchProdSchedules, | |||
| exportProdSchedule, | |||
| testDetailedSchedule, | |||
| } from "@/app/api/scheduling/actions"; | |||
| import { defaultPagingController } from "../SearchResults/SearchResults"; | |||
| @@ -21,6 +22,7 @@ import { orderBy, uniqBy, upperFirst } from "lodash"; | |||
| import { Button, Stack } from "@mui/material"; | |||
| import isToday from 'dayjs/plugin/isToday'; | |||
| import useUploadContext from "../UploadProvider/useUploadContext"; | |||
| import { FileDownload, CalendarMonth } from "@mui/icons-material"; | |||
| dayjs.extend(isToday); | |||
| // may need move to "index" or "actions" | |||
| @@ -298,21 +300,68 @@ const DSOverview: React.FC<Props> = ({ type, defaultInputs }) => { | |||
| } | |||
| }, [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 ( | |||
| <> | |||
| <Stack | |||
| direction="row" | |||
| justifyContent="flex-end" | |||
| flexWrap="wrap" | |||
| rowGap={2} | |||
| spacing={2} // This provides consistent space between buttons | |||
| sx={{ mb: 3 }} // Adds some margin below the button group | |||
| > | |||
| <Button | |||
| variant="contained" | |||
| variant="outlined" // Outlined variant makes it look distinct from the primary action | |||
| color="primary" | |||
| startIcon={<CalendarMonth />} | |||
| onClick={testDetailedScheduleClick} | |||
| // disabled={filteredSchedules.some(ele => arrayToDayjs(ele.scheduleAt).isToday())} | |||
| > | |||
| {t("Detailed Schedule")} | |||
| </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> | |||
| <SearchBox | |||
| criteria={searchCriteria} | |||
| @@ -194,8 +194,7 @@ const DetailedScheduleDetailView: React.FC<Props> = ({ | |||
| } | |||
| }, [scheduleId, setIsUploading, t, router]); | |||
| // -------------------------------------------------------------------- | |||
| const [tempValue, setTempValue] = useState<string | number | null>(null) | |||
| const onEditClick = useCallback((rowId: number) => { | |||
| const row = formProps.getValues("prodScheduleLines").find(ele => ele.id == rowId) | |||
| @@ -298,7 +297,7 @@ const DetailedScheduleDetailView: React.FC<Props> = ({ | |||
| onClick={onGlobalReleaseClick} | |||
| disabled={!scheduleId} // Disable if we don't have a schedule ID | |||
| > | |||
| {t("放單(自動生成工單)")} | |||
| {t("生成工單")} | |||
| </Button> | |||
| {/* ------------------------------------------- */} | |||
| @@ -32,6 +32,7 @@ const DetailedScheduleDetailWrapper: React.FC<Props> & SubComponents = async ({ | |||
| stockQty: line.stockQty || 0, | |||
| daysLeft: line.daysLeft || 0, | |||
| needNoOfJobOrder: line.needNoOfJobOrder || 0, | |||
| outputQty: line.outputQty || 0, | |||
| })).sort((a, b) => b.priority - a.priority); | |||
| } | |||
| @@ -108,9 +108,16 @@ const ViewByFGDetails: React.FC<Props> = ({ | |||
| style: { textAlign: "right" } as any, | |||
| 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", | |||
| label: t("生產量"), | |||
| label: t("生產批次"), | |||
| type: "read-only", | |||
| style: { textAlign: "right", fontWeight: "bold" } as any, | |||
| renderCell: (row) => <>{row.needNoOfJobOrder ?? 0}</>, | |||
| @@ -137,7 +144,7 @@ const ViewByFGDetails: React.FC<Props> = ({ | |||
| isEditable={true} | |||
| isEdit={isEdit} | |||
| hasCollapse={true} | |||
| // Note: onReleaseClick is NOT passed here to hide the row-level "Release" function | |||
| onReleaseClick={onReleaseClick} | |||
| onEditClick={onEditClick} | |||
| handleEditChange={handleEditChange} | |||
| onSaveClick={onSaveClick} | |||
| @@ -227,7 +227,7 @@ function ScheduleTable<T extends ResultWithId>({ | |||
| return ( | |||
| <> | |||
| <TableRow hover tabIndex={-1} key={row.id}> | |||
| {/*isDetailedType(type) && ( | |||
| {isDetailedType(type) && ( | |||
| <TableCell> | |||
| <IconButton | |||
| color="primary" | |||
| @@ -241,7 +241,7 @@ function ScheduleTable<T extends ResultWithId>({ | |||
| <PlayCircleOutlineIcon /> | |||
| </IconButton> | |||
| </TableCell> | |||
| )*/} | |||
| )} | |||
| {(isEditable || hasCollapse) && ( | |||
| <TableCell> | |||
| {editingRowId === row.id ? ( | |||