| @@ -1,10 +1,24 @@ | |||||
| import { I18nProvider } from "@/i18n"; | import { I18nProvider } from "@/i18n"; | ||||
| import { authOptions } from "@/config/authConfig"; | |||||
| import { AUTH, hasAbility } from "@/authorities"; | |||||
| import { getServerSession } from "next-auth"; | |||||
| import { redirect } from "next/navigation"; | |||||
| export default function M18SyncLayout({ | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 67 | v1.0.0 | 2026-08-13 */ | |||||
| export default async function M18SyncLayout({ | |||||
| children, | children, | ||||
| }: { | }: { | ||||
| children: React.ReactNode; | children: React.ReactNode; | ||||
| }) { | }) { | ||||
| const session = await getServerSession(authOptions); | |||||
| const abilities = session?.user?.abilities ?? []; | |||||
| const canAccess = | |||||
| hasAbility(abilities, AUTH.M18_SYNC) || hasAbility(abilities, AUTH.ADMIN); | |||||
| if (!canAccess) { | |||||
| redirect("/dashboard"); | |||||
| } | |||||
| return ( | return ( | ||||
| <I18nProvider namespaces={["m18Sync", "navigation", "common"]}> | <I18nProvider namespaces={["m18Sync", "navigation", "common"]}> | ||||
| {children} | {children} | ||||
| @@ -26,6 +26,7 @@ function TabPanel(props: TabPanelProps) { | |||||
| ); | ); | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 67 | v1.0.0 | 2026-08-13 */ | |||||
| export default function M18SynPage() { | export default function M18SynPage() { | ||||
| const [tabValue, setTabValue] = useState(0); | const [tabValue, setTabValue] = useState(0); | ||||
| @@ -270,7 +271,7 @@ export default function M18SynPage() { | |||||
| M18 Sync (by code) | M18 Sync (by code) | ||||
| </Typography> | </Typography> | ||||
| <Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}> | <Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}> | ||||
| ADMIN only. Sync Purchase Order, Delivery Order, or product/material from M18 using document or item code. | |||||
| Requires 行政 (ADMIN) or M18同步 (M18_SYNC). Sync Purchase Order, Delivery Order, or product/material from M18 using document or item code. | |||||
| </Typography> | </Typography> | ||||
| <Tabs value={tabValue} onChange={(_, v) => setTabValue(v)} aria-label="M18 sync by code" centered variant="fullWidth"> | <Tabs value={tabValue} onChange={(_, v) => setTabValue(v)} aria-label="M18 sync by code" centered variant="fullWidth"> | ||||
| @@ -25,6 +25,8 @@ export const AUTH = { | |||||
| */ | */ | ||||
| PRODUCT_PROCESS: "PRODUCT_PROCESS", | PRODUCT_PROCESS: "PRODUCT_PROCESS", | ||||
| REPORT_MGMT: "REPORT_MGMT", | REPORT_MGMT: "REPORT_MGMT", | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 67 | v1.0.0 | 2026-08-13 — Manual M18 sync page (/m18Syn): ADMIN or this ability */ | |||||
| M18_SYNC: "M18_SYNC", | |||||
| } as const; | } as const; | ||||
| /** | /** | ||||
| @@ -61,6 +61,7 @@ interface NavigationItem { | |||||
| requiredAbility?: string | string[]; | requiredAbility?: string | string[]; | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 67 | v1.0.0 | 2026-08-13 */ | |||||
| const NavigationContent: React.FC = () => { | const NavigationContent: React.FC = () => { | ||||
| const { data: session, status } = useSession(); | const { data: session, status } = useSession(); | ||||
| const abilities = session?.user?.abilities ?? []; | const abilities = session?.user?.abilities ?? []; | ||||
| @@ -241,7 +242,7 @@ const NavigationContent: React.FC = () => { | |||||
| icon: <Sync />, | icon: <Sync />, | ||||
| labelKey: "nav.m18Sync", | labelKey: "nav.m18Sync", | ||||
| path: "/m18Syn", | path: "/m18Syn", | ||||
| requiredAbility: [AUTH.ADMIN], | |||||
| requiredAbility: [AUTH.M18_SYNC, AUTH.ADMIN], | |||||
| isHidden: false, | isHidden: false, | ||||
| }, | }, | ||||
| { | { | ||||
| @@ -228,6 +228,16 @@ const isCheckedStatus = (status: string | undefined): boolean => | |||||
| const isRejectedStatus = (status: string | undefined): boolean => | const isRejectedStatus = (status: string | undefined): boolean => | ||||
| String(status || "").toLowerCase() === "rejected"; | String(status || "").toLowerCase() === "rejected"; | ||||
| const isPendingSolStatus = (status: string | undefined): boolean => { | |||||
| const s = String(status || "").toLowerCase(); | |||||
| return ( | |||||
| s === "pending" || | |||||
| s === "partially_completed" || | |||||
| s === "partially_complete" || | |||||
| s === "" | |||||
| ); | |||||
| }; | |||||
| function safeDisplayTargetDate(targetDate: string | number[]): string { | function safeDisplayTargetDate(targetDate: string | number[]): string { | ||||
| try { | try { | ||||
| if (Array.isArray(targetDate) && targetDate.length >= 3) { | if (Array.isArray(targetDate) && targetDate.length >= 3) { | ||||
| @@ -346,7 +356,7 @@ function mapHierarchicalToPickOrders(data: unknown): PickOrderTopRow[] { | |||||
| }); | }); | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 28 | v1.0.2 | 2026-08-03 */ | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 28 | v1.0.3 | 2026-08-13 */ | |||||
| const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | ||||
| const { t } = useTranslation("pickOrder"); | const { t } = useTranslation("pickOrder"); | ||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | const { data: session } = useSession() as { data: SessionWithTokens | null }; | ||||
| @@ -1221,8 +1231,35 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| releaseProcessedQr(latest); | releaseProcessedQr(latest); | ||||
| return; | return; | ||||
| } | } | ||||
| const expectedPool = activeForUom.length > 0 ? activeForUom : allForUom; | |||||
| let expectedRow = pickExpectedRowForSubstitution(expectedPool) || allForUom[0]; | |||||
| // Align DO/JO: when no active suggested lot, bind to pending noLot / unavailable | |||||
| // rows instead of a completed row that still has lotNo. | |||||
| let expectedRow: LotRow | undefined; | |||||
| if (activeForUom.length > 0) { | |||||
| expectedRow = pickExpectedRowForSubstitution(activeForUom) || allForUom[0]; | |||||
| } else { | |||||
| const switchable = allForUom.find( | |||||
| (r) => | |||||
| r.stockOutLineId > 0 && | |||||
| isPendingSolStatus(r.status) && | |||||
| !isCompletedStatus(r.status) && | |||||
| !isCheckedStatus(r.status) && | |||||
| (isNoLotWorkbenchRow(r) || | |||||
| isRejectedStatus(r.status) || | |||||
| isInventoryLotLineUnavailable(r) || | |||||
| isLotAvailabilityExpired(r)), | |||||
| ); | |||||
| expectedRow = | |||||
| switchable || | |||||
| allForUom.find( | |||||
| (r) => | |||||
| r.stockOutLineId > 0 && | |||||
| isPendingSolStatus(r.status) && | |||||
| !isCompletedStatus(r.status) && | |||||
| !isCheckedStatus(r.status), | |||||
| ) || | |||||
| pickExpectedRowForSubstitution(allForUom) || | |||||
| allForUom[0]; | |||||
| } | |||||
| if (!expectedRow) { | if (!expectedRow) { | ||||
| setError(t("Scanned item is not found in current line")); | setError(t("Scanned item is not found in current line")); | ||||
| startTransition(() => { | startTransition(() => { | ||||