FPSMS-frontend
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

654 lines
21 KiB

  1. import { useSession } from "next-auth/react";
  2. import Box from "@mui/material/Box";
  3. import Stack from "@mui/material/Stack";
  4. import Typography from "@mui/material/Typography";
  5. import React from "react";
  6. import List from "@mui/material/List";
  7. import ListItemButton from "@mui/material/ListItemButton";
  8. import ListItemText from "@mui/material/ListItemText";
  9. import ListItemIcon from "@mui/material/ListItemIcon";
  10. import Dashboard from "@mui/icons-material/Dashboard";
  11. import Storefront from "@mui/icons-material/Storefront";
  12. import LocalShipping from "@mui/icons-material/LocalShipping";
  13. import Assignment from "@mui/icons-material/Assignment";
  14. import Inventory from "@mui/icons-material/Inventory";
  15. import AssignmentTurnedIn from "@mui/icons-material/AssignmentTurnedIn";
  16. import ReportProblem from "@mui/icons-material/ReportProblem";
  17. import QrCodeIcon from "@mui/icons-material/QrCode";
  18. import ViewModule from "@mui/icons-material/ViewModule";
  19. import Description from "@mui/icons-material/Description";
  20. import CalendarMonth from "@mui/icons-material/CalendarMonth";
  21. import Factory from "@mui/icons-material/Factory";
  22. import PostAdd from "@mui/icons-material/PostAdd";
  23. import Kitchen from "@mui/icons-material/Kitchen";
  24. import Inventory2 from "@mui/icons-material/Inventory2";
  25. import Print from "@mui/icons-material/Print";
  26. import Assessment from "@mui/icons-material/Assessment";
  27. import ShowChart from "@mui/icons-material/ShowChart";
  28. import Settings from "@mui/icons-material/Settings";
  29. import Person from "@mui/icons-material/Person";
  30. import Group from "@mui/icons-material/Group";
  31. import Category from "@mui/icons-material/Category";
  32. import TrendingUp from "@mui/icons-material/TrendingUp";
  33. import Build from "@mui/icons-material/Build";
  34. import Warehouse from "@mui/icons-material/Warehouse";
  35. import VerifiedUser from "@mui/icons-material/VerifiedUser";
  36. import Label from "@mui/icons-material/Label";
  37. import Checklist from "@mui/icons-material/Checklist";
  38. import Science from "@mui/icons-material/Science";
  39. import UploadFile from "@mui/icons-material/UploadFile";
  40. import Sync from "@mui/icons-material/Sync";
  41. import { useTranslation } from "react-i18next";
  42. import { usePathname } from "next/navigation";
  43. import Link from "next/link";
  44. import { NAVIGATION_CONTENT_WIDTH } from "@/config/uiConfig";
  45. import Logo from "../Logo";
  46. import { AUTH } from "../../authorities";
  47. import PurchaseStockInNavAlerts from "./PurchaseStockInNavAlerts";
  48. import JobOrderFgStockInNavAlerts from "./JobOrderFgStockInNavAlerts";
  49. interface NavigationItem {
  50. icon: React.ReactNode;
  51. label: string;
  52. path: string;
  53. children?: NavigationItem[];
  54. isHidden?: boolean | undefined;
  55. requiredAbility?: string | string[];
  56. }
  57. const NavigationContent: React.FC = () => {
  58. const { data: session, status } = useSession();
  59. const abilities = session?.user?.abilities ?? [];
  60. // Helper: check if user has required permission
  61. const hasAbility = (required?: string | string[]): boolean => {
  62. if (!required) return true; // no requirement → always show
  63. if (Array.isArray(required)) {
  64. return required.some(ability => abilities.includes(ability));
  65. }
  66. return abilities.includes(required);
  67. };
  68. const navigationItems: NavigationItem[] = [
  69. {
  70. icon: <Dashboard />,
  71. label: "Dashboard",
  72. path: "/dashboard",
  73. },
  74. {
  75. icon: <Storefront />,
  76. label: "Store Management",
  77. path: "",
  78. requiredAbility: [AUTH.PURCHASE, AUTH.STOCK, AUTH.STOCK_TAKE, AUTH.STOCK_FG, AUTH.STOCK_IN_BIND, AUTH.ADMIN],
  79. children: [
  80. {
  81. icon: <LocalShipping />,
  82. label: "Purchase Order",
  83. requiredAbility: [AUTH.PURCHASE, AUTH.ADMIN],
  84. path: "/po",
  85. },
  86. {
  87. icon: <Assignment />,
  88. label: "Pick Order",
  89. requiredAbility: [AUTH.STOCK, AUTH.ADMIN],
  90. path: "/pickOrder",
  91. },
  92. {
  93. icon: <Inventory />,
  94. label: "View item In-out And inventory Ledger",
  95. requiredAbility: [AUTH.STOCK, AUTH.ADMIN],
  96. path: "/inventory",
  97. },
  98. {
  99. icon: <AssignmentTurnedIn />,
  100. label: "Stock Take Management",
  101. requiredAbility: [AUTH.STOCK, AUTH.STOCK_TAKE, AUTH.ADMIN],
  102. path: "/stocktakemanagement",
  103. },
  104. {
  105. icon: <ReportProblem />,
  106. label: "Stock Issue",
  107. requiredAbility: [AUTH.STOCK, AUTH.STOCK_TAKE, AUTH.ADMIN],
  108. path: "/stockIssue",
  109. },
  110. {
  111. icon: <QrCodeIcon />,
  112. label: "Put Away Scan",
  113. requiredAbility: [AUTH.STOCK, AUTH.STOCK_TAKE, AUTH.STOCK_IN_BIND, AUTH.ADMIN],
  114. path: "/putAway",
  115. },
  116. {
  117. icon: <ViewModule />,
  118. label: "Finished Good Order",
  119. requiredAbility: [AUTH.STOCK_FG, AUTH.ADMIN],
  120. path: "/finishedGood",
  121. },
  122. {
  123. icon: <ViewModule />,
  124. label: "Finished Good Management",
  125. requiredAbility: [AUTH.ADMIN],
  126. path: "/finishedGood/management",
  127. },
  128. {
  129. icon: <Description />,
  130. label: "Stock Record",
  131. requiredAbility: [AUTH.STOCK, AUTH.STOCK_TAKE, AUTH.STOCK_IN_BIND, AUTH.STOCK_FG, AUTH.ADMIN],
  132. path: "/stockRecord",
  133. },
  134. /*
  135. {
  136. icon: <Description />,
  137. label: "Do Workbench",
  138. requiredAbility: [AUTH.STOCK, AUTH.STOCK_TAKE, AUTH.STOCK_IN_BIND, AUTH.STOCK_FG, AUTH.ADMIN],
  139. path: "/doworkbench",
  140. },
  141. */
  142. ],
  143. },
  144. {
  145. icon: <LocalShipping />,
  146. label: "Delivery Order",
  147. path: "/do",
  148. requiredAbility: [AUTH.STOCK_FG, AUTH.ADMIN],
  149. },
  150. {
  151. icon: <CalendarMonth />,
  152. label: "Scheduling",
  153. path: "/ps",
  154. requiredAbility: [AUTH.FORECAST, AUTH.ADMIN],
  155. isHidden: false,
  156. },
  157. {
  158. icon: <Factory />,
  159. label: "Management Job Order",
  160. path: "",
  161. requiredAbility: [AUTH.JOB_CREATE, AUTH.JOB_PICK, AUTH.JOB_PROD, AUTH.ADMIN],
  162. children: [
  163. {
  164. icon: <PostAdd />,
  165. label: "Search Job Order/ Create Job Order",
  166. requiredAbility: [AUTH.JOB_CREATE, AUTH.ADMIN],
  167. path: "/jo",
  168. },
  169. {
  170. icon: <Inventory />,
  171. label: "Job Order Pickexcution",
  172. requiredAbility: [AUTH.JOB_PICK, AUTH.JOB_MAT, AUTH.ADMIN],
  173. path: "/jodetail",
  174. },
  175. {
  176. icon: <Kitchen />,
  177. label: "Job Order Production Process",
  178. requiredAbility: [AUTH.JOB_PROD, AUTH.ADMIN],
  179. path: "/productionProcess",
  180. },
  181. {
  182. icon: <Inventory2 />,
  183. label: "Bag Usage",
  184. requiredAbility: [AUTH.JOB_PROD, AUTH.ADMIN],
  185. path: "/bag",
  186. },
  187. ],
  188. },
  189. {
  190. icon: <Print />,
  191. label: "打袋機",
  192. path: "/bagPrint",
  193. requiredAbility: [AUTH.JOB_PROD, AUTH.ADMIN],
  194. isHidden: false,
  195. },
  196. {
  197. icon: <Print />,
  198. label: "檸檬機(激光機)",
  199. path: "/laserPrint",
  200. requiredAbility: [AUTH.JOB_PROD, AUTH.ADMIN],
  201. isHidden: false,
  202. },
  203. {
  204. icon: <Assessment />,
  205. label: "報告管理",
  206. path: "/report",
  207. requiredAbility: [AUTH.TESTING, AUTH.ADMIN],
  208. isHidden: false,
  209. },
  210. {
  211. icon: <Sync />,
  212. label: "M18 Sync",
  213. path: "/m18Syn",
  214. requiredAbility: [AUTH.ADMIN],
  215. isHidden: false,
  216. },
  217. {
  218. icon: <ShowChart />,
  219. label: "圖表報告",
  220. path: "",
  221. requiredAbility: [AUTH.TESTING, AUTH.ADMIN],
  222. isHidden: false,
  223. children: [
  224. {
  225. icon: <Storefront />,
  226. label: "採購",
  227. path: "/chart/purchase",
  228. requiredAbility: [AUTH.TESTING, AUTH.ADMIN],
  229. },
  230. {
  231. icon: <Assignment />,
  232. label: "工單",
  233. path: "/chart/joborder",
  234. requiredAbility: [AUTH.TESTING, AUTH.ADMIN],
  235. },
  236. {
  237. icon: <ViewModule />,
  238. label: "工單即時看板",
  239. path: "/chart/joborder/board",
  240. requiredAbility: [AUTH.TESTING, AUTH.ADMIN],
  241. },
  242. {
  243. icon: <LocalShipping />,
  244. label: "發貨與配送",
  245. path: "/chart/delivery",
  246. requiredAbility: [AUTH.TESTING, AUTH.ADMIN],
  247. },
  248. {
  249. icon: <Warehouse />,
  250. label: "庫存與倉儲",
  251. path: "/chart/warehouse",
  252. requiredAbility: [AUTH.TESTING, AUTH.ADMIN],
  253. },
  254. {
  255. icon: <TrendingUp />,
  256. label: "預測與計劃",
  257. path: "/chart/forecast",
  258. requiredAbility: [AUTH.TESTING, AUTH.ADMIN],
  259. },
  260. ],
  261. },
  262. {
  263. icon: <Settings />,
  264. label: "Settings",
  265. path: "",
  266. requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN],
  267. children: [
  268. {
  269. icon: <Person />,
  270. label: "User",
  271. path: "/settings/user",
  272. requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN],
  273. },
  274. //{
  275. // icon: <Group />,
  276. // label: "User Group",
  277. // path: "/settings/user",
  278. // requiredAbility: [AUTH.VIEW_GROUP, AUTH.ADMIN],
  279. //},
  280. {
  281. icon: <Category />,
  282. label: "Items",
  283. path: "/settings/items",
  284. },
  285. {
  286. icon: <Build />,
  287. label: "Equipment",
  288. path: "/settings/equipment",
  289. },
  290. {
  291. icon: <Warehouse />,
  292. label: "Warehouse",
  293. path: "/settings/warehouse",
  294. },
  295. {
  296. icon: <Print />,
  297. label: "Printer",
  298. path: "/settings/printer",
  299. },
  300. {
  301. icon: <Assessment />,
  302. label: "Price Inquiry",
  303. path: "/settings/itemPrice",
  304. },
  305. {
  306. icon: <VerifiedUser />,
  307. label: "QC Check Item",
  308. path: "/settings/qcItem",
  309. },
  310. {
  311. icon: <Label />,
  312. label: "QC Category",
  313. path: "/settings/qcCategory",
  314. },
  315. {
  316. icon: <Checklist />,
  317. label: "QC Item All",
  318. path: "/settings/qcItemAll",
  319. },
  320. {
  321. icon: <Storefront />,
  322. label: "ShopAndTruck",
  323. path: "/settings/shop",
  324. },
  325. {
  326. icon: <TrendingUp />,
  327. label: "Demand Forecast Setting",
  328. path: "/settings/rss",
  329. },
  330. //{
  331. // icon: <Person />,
  332. // label: "Customer",
  333. // path: "/settings/user",
  334. //},
  335. {
  336. icon: <ViewModule />,
  337. label: "BOM Weighting Score List",
  338. path: "/settings/bomWeighting",
  339. },
  340. {
  341. icon: <QrCodeIcon />,
  342. label: "QR Code Handle",
  343. path: "/settings/qrCodeHandle",
  344. },
  345. {
  346. icon: <Science />,
  347. label: "Import Testing",
  348. path: "/settings/m18ImportTesting",
  349. },
  350. {
  351. icon: <UploadFile />,
  352. label: "Import Excel",
  353. path: "/settings/importExcel",
  354. },
  355. {
  356. icon: <UploadFile />,
  357. label: "Import BOM",
  358. path: "/settings/importBom",
  359. },
  360. ],
  361. },
  362. ];
  363. const { t } = useTranslation("common");
  364. const pathname = usePathname();
  365. const abilitySet = new Set(abilities.map((a) => String(a).trim()));
  366. /** 採購入庫側欄紅點:TESTING / ADMIN / STOCK */
  367. const canSeePoAlerts =
  368. abilitySet.has(AUTH.TESTING) || abilitySet.has(AUTH.ADMIN) || abilitySet.has(AUTH.STOCK);
  369. /** 工單 QC/上架紅點:仍僅 TESTING */
  370. const canSeeJoFgAlerts = abilitySet.has(AUTH.TESTING);
  371. const [openItems, setOpenItems] = React.useState<string[]>([]);
  372. /** Keep parent sections expanded on deep links (e.g. /po/edit from nav red spot) so alerts stay visible. */
  373. React.useEffect(() => {
  374. const ensureOpen: string[] = [];
  375. if (pathname.startsWith("/chart")) {
  376. ensureOpen.push("圖表報告");
  377. }
  378. if (pathname === "/po" || pathname.startsWith("/po/")) {
  379. ensureOpen.push("Store Management");
  380. }
  381. if (pathname === "/productionProcess" || pathname.startsWith("/productionProcess/")) {
  382. ensureOpen.push("Management Job Order");
  383. }
  384. if (ensureOpen.length === 0) return;
  385. setOpenItems((prev) => {
  386. const set = new Set(prev);
  387. let changed = false;
  388. for (const label of ensureOpen) {
  389. if (!set.has(label)) {
  390. set.add(label);
  391. changed = true;
  392. }
  393. }
  394. return changed ? Array.from(set) : prev;
  395. });
  396. }, [pathname]);
  397. const toggleItem = (label: string) => {
  398. setOpenItems((prevOpenItems) =>
  399. prevOpenItems.includes(label)
  400. ? prevOpenItems.filter((item) => item !== label)
  401. : [...prevOpenItems, label],
  402. );
  403. };
  404. const selectedLeafPath = React.useMemo(() => {
  405. const leafPaths: string[] = [];
  406. const walk = (items: NavigationItem[]) => {
  407. for (const it of items) {
  408. if (it.isHidden) continue;
  409. if (!hasAbility(it.requiredAbility)) continue;
  410. if (it.path) leafPaths.push(it.path);
  411. if (it.children?.length) walk(it.children);
  412. }
  413. };
  414. walk(navigationItems);
  415. // Pick the most specific (longest) match to avoid double-highlighting
  416. const matches = leafPaths.filter((p) => pathname === p || pathname.startsWith(p + "/"));
  417. matches.sort((a, b) => b.length - a.length);
  418. return matches[0] ?? "";
  419. }, [hasAbility, navigationItems, pathname]);
  420. const renderNavigationItem = (item: NavigationItem) => {
  421. if (!hasAbility(item.requiredAbility)) {
  422. return null;
  423. }
  424. const isOpen = openItems.includes(item.label);
  425. const hasVisibleChildren = item.children?.some(child => hasAbility(child.requiredAbility));
  426. const isLeaf = Boolean(item.path);
  427. const isSelected = isLeaf && item.path
  428. ? item.path === selectedLeafPath
  429. : hasVisibleChildren && item.children?.some((c) => c.path && c.path === selectedLeafPath);
  430. const content = (
  431. <ListItemButton
  432. selected={isSelected}
  433. onClick={isLeaf ? undefined : () => toggleItem(item.label)}
  434. sx={{
  435. mx: 1,
  436. "&.Mui-selected .MuiListItemIcon-root": { color: "primary.main" },
  437. }}
  438. >
  439. <ListItemIcon sx={{ minWidth: 40 }}>{item.icon}</ListItemIcon>
  440. <ListItemText
  441. primary={t(item.label)}
  442. primaryTypographyProps={{ fontWeight: isSelected ? 600 : 500 }}
  443. />
  444. </ListItemButton>
  445. );
  446. return (
  447. <Box key={`${item.label}-${item.path}`}>
  448. {isLeaf ? (
  449. <Link href={item.path!} style={{ textDecoration: "none", color: "inherit" }}>
  450. {content}
  451. </Link>
  452. ) : (
  453. content
  454. )}
  455. {item.children && isOpen && hasVisibleChildren && (
  456. <List sx={{ pl: 2, py: 0 }}>
  457. {item.children.map(
  458. (child) => !child.isHidden && hasAbility(child.requiredAbility) && (
  459. child.path === "/po" ? (
  460. <Box
  461. key={`${child.label}-${child.path}`}
  462. sx={{
  463. display: "flex",
  464. alignItems: "stretch",
  465. mx: 1,
  466. borderRadius: 1,
  467. overflow: "hidden",
  468. "&:hover": { bgcolor: "action.hover" },
  469. }}
  470. >
  471. <Box
  472. component={Link}
  473. href={child.path}
  474. sx={{
  475. flex: 1,
  476. minWidth: 0,
  477. textDecoration: "none",
  478. color: "inherit",
  479. display: "flex",
  480. }}
  481. >
  482. <ListItemButton
  483. selected={child.path === selectedLeafPath}
  484. sx={{
  485. flex: 1,
  486. py: 1,
  487. pr: 0.5,
  488. "&.Mui-selected .MuiListItemIcon-root": { color: "primary.main" },
  489. }}
  490. >
  491. <ListItemIcon sx={{ minWidth: 40 }}>{child.icon}</ListItemIcon>
  492. <ListItemText
  493. primary={t(child.label)}
  494. primaryTypographyProps={{
  495. fontWeight:
  496. child.path === selectedLeafPath ? 600 : 500,
  497. fontSize: "0.875rem",
  498. }}
  499. />
  500. </ListItemButton>
  501. </Box>
  502. <PurchaseStockInNavAlerts enabled={canSeePoAlerts} />
  503. </Box>
  504. ) : child.path === "/productionProcess" ? (
  505. <Box
  506. key={`${child.label}-${child.path}`}
  507. sx={{
  508. display: "flex",
  509. alignItems: "stretch",
  510. mx: 1,
  511. borderRadius: 1,
  512. overflow: "hidden",
  513. "&:hover": { bgcolor: "action.hover" },
  514. }}
  515. >
  516. <Box
  517. component={Link}
  518. href={child.path}
  519. sx={{
  520. flex: 1,
  521. minWidth: 0,
  522. textDecoration: "none",
  523. color: "inherit",
  524. display: "flex",
  525. }}
  526. >
  527. <ListItemButton
  528. selected={child.path === selectedLeafPath}
  529. sx={{
  530. flex: 1,
  531. py: 1,
  532. pr: 0.5,
  533. "&.Mui-selected .MuiListItemIcon-root": { color: "primary.main" },
  534. }}
  535. >
  536. <ListItemIcon sx={{ minWidth: 40 }}>{child.icon}</ListItemIcon>
  537. <ListItemText
  538. primary={t(child.label)}
  539. primaryTypographyProps={{
  540. fontWeight:
  541. child.path === selectedLeafPath ? 600 : 500,
  542. fontSize: "0.875rem",
  543. }}
  544. />
  545. </ListItemButton>
  546. </Box>
  547. <JobOrderFgStockInNavAlerts enabled={canSeeJoFgAlerts} />
  548. </Box>
  549. ) : (
  550. <Box
  551. key={`${child.label}-${child.path}`}
  552. component={Link}
  553. href={child.path}
  554. sx={{ textDecoration: "none", color: "inherit" }}
  555. >
  556. <ListItemButton
  557. selected={child.path === selectedLeafPath}
  558. sx={{
  559. mx: 1,
  560. py: 1,
  561. "&.Mui-selected .MuiListItemIcon-root": { color: "primary.main" },
  562. }}
  563. >
  564. <ListItemIcon sx={{ minWidth: 40 }}>{child.icon}</ListItemIcon>
  565. <ListItemText
  566. primary={t(child.label)}
  567. primaryTypographyProps={{
  568. fontWeight:
  569. child.path === selectedLeafPath
  570. ? 600
  571. : 500,
  572. fontSize: "0.875rem",
  573. }}
  574. />
  575. </ListItemButton>
  576. </Box>
  577. )
  578. ),
  579. )}
  580. </List>
  581. )}
  582. </Box>
  583. );
  584. };
  585. if (status === "loading") {
  586. return <Box sx={{ width: NAVIGATION_CONTENT_WIDTH, p: 3 }}>Loading...</Box>;
  587. }
  588. return (
  589. <Box sx={{ width: NAVIGATION_CONTENT_WIDTH, height: "100%", display: "flex", flexDirection: "column" }}>
  590. <Box
  591. className="bg-gradient-to-br from-blue-500/15 via-slate-100 to-slate-50 dark:from-blue-500/20 dark:via-slate-800 dark:to-slate-900"
  592. sx={{
  593. mx: 1,
  594. mt: 1,
  595. mb: 1,
  596. px: 1.5,
  597. py: 2,
  598. flexShrink: 0,
  599. display: "flex",
  600. alignItems: "center",
  601. justifyContent: "flex-start",
  602. minHeight: 56,
  603. }}
  604. >
  605. <Stack direction="column" alignItems="flex-start" spacing={0.5}>
  606. <Logo height={42} />
  607. {process.env.NODE_ENV === "production" && (
  608. <Typography
  609. variant="body2"
  610. sx={{
  611. fontWeight: 700,
  612. color: "warning.dark",
  613. bgcolor: "warning.light",
  614. px: 1,
  615. py: 0.5,
  616. borderRadius: 1,
  617. border: "1px solid",
  618. borderColor: "warning.main",
  619. }}
  620. >
  621. 正式服務器 Production Server
  622. </Typography>
  623. )}
  624. </Stack>
  625. </Box>
  626. <Box sx={{ borderTop: 1, borderColor: "divider" }} />
  627. <List component="nav" sx={{ flex: 1, overflow: "auto", py: 1, px: 0 }}>
  628. {navigationItems
  629. .filter(item => !item.isHidden)
  630. .map(renderNavigationItem)
  631. .filter(Boolean)}
  632. </List>
  633. </Box>
  634. );
  635. };
  636. export default NavigationContent;