Сравнить коммиты

...

251 коммитов

Автор SHA1 Сообщение Дата
  PC-20260115JRSN\Administrator c6e499a557 reset function of po picking 4 дней назад
  CANCERYS\kw093 88d1b60fc7 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 4 дней назад
  CANCERYS\kw093 44d51c8390 update 4 дней назад
  PC-20260115JRSN\Administrator 67ee15b312 no message 4 дней назад
  CANCERYS\kw093 76ad78f126 update 4 дней назад
  CANCERYS\kw093 ad127b39ac update 4 дней назад
  CANCERYS\kw093 fc8b94c562 update 5 дней назад
  kelvin.yau de65686192 UPDATE OPEN INVENTORY FOR ITEMS WITH NO INVENTORY 5 дней назад
  CANCERYS\kw093 b7ccfe3574 update 5 дней назад
  B.E.N.S.O.N 56e5c937af Good Pick Issue Fixing 5 дней назад
  CANCERYS\kw093 25cfed96d6 update qR scan 5 дней назад
  B.E.N.S.O.N c5d79de697 Login Page Update 5 дней назад
  Tommy\2Fi-Staff d536dbb8d3 update variance report config 6 дней назад
  CANCERYS\kw093 1c737822c5 update 6 дней назад
  CANCERYS\kw093 d1423bdd29 update 6 дней назад
  PC-20260115JRSN\Administrator 7801b32fcd no message 6 дней назад
  Tommy\2Fi-Staff 84088c143d Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/jason/FPSMS-frontend into MergeProblem1 6 дней назад
  Tommy\2Fi-Staff bde63fdd4d fix putaway 6 дней назад
  CANCERYS\kw093 bbfc821d44 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 6 дней назад
  CANCERYS\kw093 77ad12967b jo edit 6 дней назад
  PC-20260115JRSN\Administrator 457e4f101f no message 6 дней назад
  CANCERYS\kw093 5e83e2c8e6 update 6 дней назад
  PC-20260115JRSN\Administrator c59949643e no message 6 дней назад
  PC-20260115JRSN\Administrator 49e11a72ee no message 6 дней назад
  kelvin.yau 3598941032 build bug fix 6 дней назад
  kelvin.yau 953cb0783e no message 6 дней назад
  kelvin.yau 5e4c8c46e7 no message 6 дней назад
  kelvin.yau 060de0d2f6 no message 6 дней назад
  kelvin.yau 84baa17e9f no message 6 дней назад
  kelvin.yau dc221be8b8 no message 6 дней назад
  kelvin.yau d91928082f fix frontend build error 6 дней назад
  TASTEOFASIA\MTMS f2a2337e1a no message 6 дней назад
  CANCERYS\kw093 7564ee01eb update stock take drop down 6 дней назад
  CANCERYS\kw093 92a0a894cc Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 6 дней назад
  CANCERYS\kw093 842aa9ffec update 6 дней назад
  kelvin.yau 5a0b3a43d0 update default store location for FA and WIP 1 неделю назад
  kelvin.yau f60c702e74 no message 1 неделю назад
  kelvin.yau 062a268bc8 bug fix 1 неделю назад
  kelvin.yau f82bb5e056 Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/derek/FPSMS-frontend into MergeProblem1 1 неделю назад
  kelvin.yau 3675c90342 price inqury 1 неделю назад
  PC-20260115JRSN\Administrator da9f8b277e adding some charts to test 1 неделю назад
  B.E.N.S.O.N e1bda42014 Dashboard UpDATE 1 неделю назад
  CANCERYS\kw093 9b5d1306d9 stocktakeALL 1 неделю назад
  CANCERYS\kw093 37f9eeed01 update stock take search 1 неделю назад
  PC-20260115JRSN\Administrator 190d78c6df adding PS settings 1 неделю назад
  CANCERYS\kw093 9b4db0dde5 update 1 неделю назад
  CANCERYS\kw093 e4f0273a0e product process list and warehouse 1 неделю назад
  kelvin.yau 4fa7bc2b8e translation issue 1 неделю назад
  CANCERYS\kw093 4b264a82a8 update bom ui 1 неделю назад
  CANCERYS\kw093 15592a176a Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 1 неделю назад
  CANCERYS\kw093 a494673402 update 1 неделю назад
  kelvin.yau 086cc40c0a Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/derek/FPSMS-frontend into MergeProblem1 1 неделю назад
  kelvin.yau 806c8c1242 A4 printer routing and register, see backend 1 неделю назад
  CANCERYS\kw093 081c76581c update stock in line lotNo and joborder show lotNo 1 неделю назад
  Tommy\2Fi-Staff 86bf59e675 make putaway smaller 1 неделю назад
  B.E.N.S.O.N 2b7ff5d2ea Warehouse Supporting Function Update 1 неделю назад
  B.E.N.S.O.N 5dbbe07614 Warehouse Supporting Function Update 1 неделю назад
  TASTEOFASIA\MTMS d31012af63 update the new server ip and setting in env-prod 1 неделю назад
  PC-20260115JRSN\Administrator edd947c227 try fixing the pages 1 неделю назад
  PC-20260115JRSN\Administrator 6d802eddf4 try fixing the page problem 1 неделю назад
  PC-20260115JRSN\Administrator 10fca7bc19 try to fix the page problem 1 неделю назад
  PC-20260115JRSN\Administrator 9e6cb8345e try to fix the redirect problem in server 1 неделю назад
  CANCERYS\kw093 2548b7a007 update stock in line 1 неделю назад
  [email protected] 9d376e4857 trying to build on server 1 неделю назад
  [email protected] 4f04ddde6e fixing the code the make project failed to build 1 неделю назад
  CANCERYS\kw093 d7a34cf064 updaate import bom 1 неделю назад
  CANCERYS\kw093 1e346fa9b8 Merge remote-tracking branch 'origin/MergeProblem1' into MergeProblem1 1 неделю назад
  CANCERYS\kw093 bf2b7f1101 update 1 неделю назад
  [email protected] d5fb8294ef adding bag printing page, copy from Bag1.py 1 неделю назад
  Tommy\2Fi-Staff f9499d9a37 no message 1 неделю назад
  B.E.N.S.O.N d5f19a7057 Bom Supporting Function 1 неделю назад
  CANCERYS\kw093 4f0df8f5f8 update 1 неделю назад
  CANCERYS\kw093 6dc9687949 Merge remote-tracking branch 'origin/MergeProblem1' into MergeProblem1 2 недель назад
  CANCERYS\kw093 dfbd808b3a update bom import ,epqc, 2 недель назад
  CANCERYS\kw093 dff5000125 update 2 недель назад
  CANCERYS\kw093 9ad4009bc3 update 2 недель назад
  B.E.N.S.O.N 51e4f705c3 Bom Supporting Function 2 недель назад
  kelvin.yau 88513e744b No longer refresh after QC 2 недель назад
  B.E.N.S.O.N aa4f0fff29 Update 2 недель назад
  [email protected] 435d041f5c no message 2 недель назад
  [email protected] 0a24dc116f no message 2 недель назад
  CANCERYS\kw093 42cb203514 update truck X 2 недель назад
  [email protected] 9d00348946 For my testing use, use the cam instead of barcode scanner for putaway 2 недель назад
  CANCERYS\kw093 c60f80fe1d update 2 недель назад
  CANCERYS\kw093 fb271f9209 update 2 недель назад
  CANCERYS\kw093 48a0fbb924 update 2 недель назад
  kelvin.yau 703ac2ba72 dashboard fix (FG + equipment) 2 недель назад
  kelvin.yau 86b2c12321 dashboard fix 2 недель назад
  kelvin.yau 19b4ed534c dashboards formatting (keep same) 2 недель назад
  Tommy\2Fi-Staff ad53e1a701 no message 2 недель назад
  Tommy\2Fi-Staff e59d79797a update 2 недель назад
  Tommy\2Fi-Staff d74f5d184b update 2 недель назад
  Tommy\2Fi-Staff 656a222976 upDATE 2 недель назад
  Tommy\2Fi-Staff f3c480b983 qcstockin ui update 2 недель назад
  kelvin.yau c5c6d61af2 Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/derek/FPSMS-frontend into MergeProblem1 2 недель назад
  kelvin.yau f4a3c12d99 title updates 2 недель назад
  CANCERYS\kw093 7cd54de584 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 2 недель назад
  CANCERYS\kw093 5e5fa63ce8 update 2 недель назад
  Tommy\2Fi-Staff 8daf185e60 trucklane dashboard 2 недель назад
  CANCERYS\kw093 66b8912ff0 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 2 недель назад
  CANCERYS\kw093 75f3e6a819 update 2 недель назад
  B.E.N.S.O.N 329830e09b New Goods Receipt Status Dashboard 2 недель назад
  B.E.N.S.O.N 35ee724b0f Merge remote-tracking branch 'origin/MergeProblem1' into MergeProblem1 2 недель назад
  B.E.N.S.O.N dc4767c312 New Goods Receipt Status Dashboard 2 недель назад
  Tommy\2Fi-Staff f650492e27 reportconfig update 2 недель назад
  Tommy\2Fi-Staff 0cf603a7e1 add handler filter 2 недель назад
  CANCERYS\kw093 2b3752d64f update 2 недель назад
  CANCERYS\kw093 09e8bdff0d update pucahseorder speed 2 недель назад
  Tommy\2Fi-Staff 89b0effbf4 trucklane dashboard update 2 недель назад
  CANCERYS\kw093 131893efa0 stock take input fix 2 недель назад
  B.E.N.S.O.N c81aed0950 User QR-Code Update 2 недель назад
  B.E.N.S.O.N 526058cbb9 update 2 недель назад
  CANCERYS\kw093 33cf1752b4 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 2 недель назад
  CANCERYS\kw093 1ee123ddb5 auto stock in "%FA%" and stock record page fix 2 недель назад
  B.E.N.S.O.N 9ead9d244e Bom Supporting Function 2 недель назад
  B.E.N.S.O.N 5c243b376b Merge remote-tracking branch 'origin/MergeProblem1' into MergeProblem1 2 недель назад
  B.E.N.S.O.N 66061a5837 update 2 недель назад
  CANCERYS\kw093 59b4a88735 update bag 2 недель назад
  [email protected] 2da62e9bc7 no message 3 недель назад
  CANCERYS\kw093 bcadb14423 fix stock reocrd 3 недель назад
  CANCERYS\kw093 a4a4075087 update confirm 3 недель назад
  CANCERYS\kw093 2040ef798e Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 3 недель назад
  CANCERYS\kw093 a40305f880 update stock take 3 недель назад
  [email protected] 2de29a9a8c make access right with STOCK can do stock take 3 недель назад
  kelvin.yau ae3fa7993c translation issue 3 недель назад
  kelvin.yau 26abb13a6c Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/derek/FPSMS-frontend into MergeProblem1 3 недель назад
  kelvin.yau 72b92cc6e7 stock adj + stocktrf 3 недель назад
  [email protected] b0e5aaa72a no message 3 недель назад
  CANCERYS\kw093 7e831edcf3 update jo,po,i18n 3 недель назад
  CANCERYS\kw093 42ee4a6d92 update 3 недель назад
  [email protected] 3236f144cd fix the /ps overlap problem 3 недель назад
  [email protected] f17ed17f87 no message 3 недель назад
  CANCERYS\kw093 7c93b9f880 Merge remote-tracking branch 'origin/MergeProblem1' into MergeProblem1 3 недель назад
  CANCERYS\kw093 0d0a05ed55 update zh 3 недель назад
  Tommy\2Fi-Staff a9833d424a translation & alignment 3 недель назад
  CANCERYS\kw093 eaa9477faa update jobmatch 3 недель назад
  [email protected] febf75eb38 it says it can control the popup keyboard size in tablet 3 недель назад
  B.E.N.S.O.N eac95c343c update 3 недель назад
  [email protected] 765491197f no message 3 недель назад
  [email protected] f0ddd56381 changed the look and feel slightly 3 недель назад
  PC-20260115JRSN\Administrator 3579a83ff7 no message 3 недель назад
  PC-20260115JRSN\Administrator b0356b7a8a Fix the files that make project failed to compile 3 недель назад
  B.E.N.S.O.N 0eb0936e45 Update 1 месяц назад
  CANCERYS\kw093 1544f3f653 Merge remote-tracking branch 'origin/MergeProblem1' into MergeProblem1 1 месяц назад
  CANCERYS\kw093 eb9714a79b update 1 месяц назад
  B.E.N.S.O.N d726d933b5 Report Page Update 1 месяц назад
  Tommy\2Fi-Staff 1059b8770a update 1 месяц назад
  CANCERYS\kw093 e8ef71601f update 1 месяц назад
  CANCERYS\kw093 ca8b3ea050 update 1 месяц назад
  CANCERYS\kw093 e5feedc2a7 update 1 месяц назад
  CANCERYS\kw093 263d12e248 update job pick dashboard 1 месяц назад
  CANCERYS\kw093 d56cd6e69f update 1 месяц назад
  Tommy\2Fi-Staff b320307a51 update 1 месяц назад
  Tommy\2Fi-Staff 3303de63d7 update search sorting 1 месяц назад
  Tommy\2Fi-Staff 4446c8503f Update StockBalanceReport & StockInTracabilityReport 1 месяц назад
  B.E.N.S.O.N 8b3f8fc6e9 Report Update 1 месяц назад
  Tommy\2Fi-Staff 6479034e62 TruckScheduleDashboard & StockInTraceability report update 1 месяц назад
  B.E.N.S.O.N 6d9ec7b372 Report Update 1 месяц назад
  CANCERYS\kw093 cb4c0aa11f Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 1 месяц назад
  CANCERYS\kw093 f408aba874 update 1 месяц назад
  kelvin.yau 754ef92046 translation 1 месяц назад
  CANCERYS\kw093 316d2fcdb1 update 1 месяц назад
  B.E.N.S.O.N e7c273ba0e Stock Item Consumption Trend Report 1 месяц назад
  CANCERYS\kw093 a71f0cc9a9 Merge remote-tracking branch 'origin/MergeProblem1' into MergeProblem1 1 месяц назад
  CANCERYS\kw093 c06ee2e543 update 1 месяц назад
  kelvin.yau 28fe834ab0 enson update 1 месяц назад
  B.E.N.S.O.N 8987046f00 Dashboard: Goods Receipt Status Update 1 месяц назад
  B.E.N.S.O.N 329ccc22bd FG/SemiFG Production Analysis Report Update 1 месяц назад
  CANCERYS\kw093 bdde9644f0 Merge remote-tracking branch 'origin/MergeProblem1' into MergeProblem1 1 месяц назад
  CANCERYS\kw093 5e6a440aae update dashBoard 1 месяц назад
  kelvin.yau 626a13ee60 Stock TRF UI update 1 месяц назад
  B.E.N.S.O.N 1600995bc1 FG/SemiFG Production Analysis Report Update 1 месяц назад
  CANCERYS\kw093 4f4a5baf75 update 1 месяц назад
  B.E.N.S.O.N a3c07650f8 FG/SemiFG Production Analysis Report 1 месяц назад
  CANCERYS\kw093 757ccc5cbd update select unit 1 месяц назад
  CANCERYS\kw093 a0675af6e0 upate select unit 1 месяц назад
  CANCERYS\kw093 b006a1115c update 1 месяц назад
  CANCERYS\kw093 e3f2b06561 update pick record user and putaway default warehouse 1 месяц назад
  CANCERYS\kw093 3501863943 update 1 месяц назад
  CANCERYS\kw093 8cbbdf5714 update 1 месяц назад
  [email protected] bdf7d52cd9 no message 1 месяц назад
  [email protected] fc398b038b no message 1 месяц назад
  [email protected] f747984479 make some chinese looks better 1 месяц назад
  CANCERYS\kw093 30823cee8e update scan lot 1 месяц назад
  CANCERYS\kw093 26302151c3 update qc putaway 1 месяц назад
  Tommy\2Fi-Staff 53cc1692ad fix fg goods status dasboard bug 1 месяц назад
  CANCERYS\kw093 878eaedfb6 update new stokc issue handle 1 месяц назад
  [email protected] b541872d24 no message 1 месяц назад
  CANCERYS\kw093 4fc7e87375 update some jo qr 1 месяц назад
  CANCERYS\kw093 549481e71a benson want remove / 1 месяц назад
  CANCERYS\kw093 4b1ed59261 dashboard 1 месяц назад
  CANCERYS\kw093 468e907db9 update 1 месяц назад
  CANCERYS\kw093 55d9e24f83 update qr code scan 1 месяц назад
  CANCERYS\kw093 c45802fb76 test 1 месяц назад
  CANCERYS\kw093 667cc5f184 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 1 месяц назад
  CANCERYS\kw093 0aedd3b83d update 1 месяц назад
  CANCERYS\kw093 29bdcf6c1a update do pick confirm 1 месяц назад
  CANCERYS\kw093 9e9c8d073c update 1 месяц назад
  CANCERYS\kw093 f807fcee82 update 1 месяц назад
  CANCERYS\kw093 5473ff820d update bar 1 месяц назад
  B.E.N.S.O.N 927485e8d3 Dashboard Page Update 1 месяц назад
  B.E.N.S.O.N feb162ae60 Dashboard: Goods Receipt Status Update 1 месяц назад
  B.E.N.S.O.N b58947b1e5 Dashboard: Goods Receipt Status 1 месяц назад
  CANCERYS\kw093 bb5f3d2584 update do issue form 1 месяц назад
  CANCERYS\kw093 d04e2eeadc update 1 месяц назад
  CANCERYS\kw093 8576172e8e fix scan lot and scan not match lt and new issue handle 1 месяц назад
  CANCERYS\kw093 be2fdb6a3b update 1 месяц назад
  CANCERYS\kw093 3fa46072fd Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 1 месяц назад
  CANCERYS\kw093 7cd450ef1b update printer select 1 месяц назад
  PC-20260115JRSN\Administrator 3930cd7f39 fixing the merged i18 master syn request 1 месяц назад
  CANCERYS\kw093 c02a6956c4 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 2 месяцев назад
  CANCERYS\kw093 a32e2b30bc printer 2 месяцев назад
  Tommy\2Fi-Staff e317d18821 Stock In Traceability Report 2 месяцев назад
  B.E.N.S.O.N 09d269f2b7 Update: Printer Handle 2 месяцев назад
  B.E.N.S.O.N 321927854e Supporting function: Printer Handle 2 месяцев назад
  CANCERYS\kw093 3c014abbff update approve can 0 2 месяцев назад
  CANCERYS\kw093 f903dae3c1 update skip button 2 месяцев назад
  CANCERYS\kw093 483577ed0d update do search 2 месяцев назад
  B.E.N.S.O.N d09ee3a962 Update 2 месяцев назад
  B.E.N.S.O.N e62830e1e2 Merge remote-tracking branch 'origin/MergeProblem1' into MergeProblem1 2 месяцев назад
  B.E.N.S.O.N 4702c93a93 path 2 месяцев назад
  kelvin.yau 88d1354944 fix 2 месяцев назад
  kelvin.yau de2f012c24 stock transfer ui 2 месяцев назад
  Tommy\2Fi-Staff cc68dfbb65 update item 2 месяцев назад
  [email protected] 363306c98e fixing the ps export path 2 месяцев назад
  CANCERYS\kw093 bc5d88699c update page control 2 месяцев назад
  CANCERYS\kw093 b24ae5dfea stockissue 2 месяцев назад
  CANCERYS\kw093 d7e139dd2c i18n 2 месяцев назад
  [email protected] 7ce84920e2 fixing the GET type 2 месяцев назад
  [email protected] 30eb8517d1 refining the data syn 2 месяцев назад
  Tommy\2Fi-Staff 4cb751740c update shop and truck lazy load 2 месяцев назад
  Tommy\2Fi-Staff 289e59d2b5 update missing item, update FG pick status dashboard 2 месяцев назад
  [email protected] c48d070a77 refining the m18 import testing params 2 месяцев назад
  CANCERYS\kw093 a0febe7794 update qcitem combine page 2 месяцев назад
  CANCERYS\kw093 d240e23bab Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 2 месяцев назад
  CANCERYS\kw093 8f9e94530e update path 2 месяцев назад
  PC-20260115JRSN\Administrator 063faba2e7 adding printer testing for HANS 2 месяцев назад
  B.E.N.S.O.N d92242ea2c Dashboard: Goods Receipt Status UI 2 месяцев назад
  Tommy\2Fi-Staff d50aebb674 Dashboard ui 2 месяцев назад
  B.E.N.S.O.N 1d921e105d Dashboard: Goods Receipt Status UI 2 месяцев назад
  Tommy\2Fi-Staff 0008e1471f Missing Item supporting function &report 2 месяцев назад
  CANCERYS\kw093 770d569f9b productprocess 2 месяцев назад
  CANCERYS\kw093 6aefd923c5 updatestock issue 2 месяцев назад
  CANCERYS\kw093 a661b1dfc2 update putasway show 2 месяцев назад
  CANCERYS\kw093 1dbe9c67c1 upate i18n 2 месяцев назад
  CANCERYS\kw093 8b12ae623b Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 2 месяцев назад
  CANCERYS\kw093 1f07b8ea5a update stockissue api 2 месяцев назад
  kelvin.yau 2ffa66c4a3 updated inventorylotline table 2 месяцев назад
  kelvin.yau 9f635df2eb Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/derek/FPSMS-frontend into MergeProblem1 2 месяцев назад
  kelvin.yau e76073f36e test 2 месяцев назад
  [email protected] 44d6b8f823 no message 2 месяцев назад
100 измененных файлов: 7098 добавлений и 570 удалений
  1. +91
    -0
      .cursor/rules.md
  2. +3
    -3
      .env.production
  3. +149
    -1
      package-lock.json
  4. +3
    -2
      package.json
  5. +23
    -0
      src/app/(main)/bagPrint/page.tsx
  6. +51
    -0
      src/app/(main)/chart/_components/ChartCard.tsx
  7. +31
    -0
      src/app/(main)/chart/_components/DateRangeSelect.tsx
  8. +12
    -0
      src/app/(main)/chart/_components/constants.ts
  9. +46
    -0
      src/app/(main)/chart/_components/exportChartToXlsx.ts
  10. +393
    -0
      src/app/(main)/chart/delivery/page.tsx
  11. +177
    -0
      src/app/(main)/chart/forecast/page.tsx
  12. +367
    -0
      src/app/(main)/chart/joborder/page.tsx
  13. +24
    -0
      src/app/(main)/chart/layout.tsx
  14. +5
    -0
      src/app/(main)/chart/page.tsx
  15. +74
    -0
      src/app/(main)/chart/purchase/page.tsx
  16. +362
    -0
      src/app/(main)/chart/warehouse/page.tsx
  17. +1
    -1
      src/app/(main)/dashboard/page.tsx
  18. +20
    -22
      src/app/(main)/do/edit/page.tsx
  19. +2
    -8
      src/app/(main)/do/page.tsx
  20. +34
    -35
      src/app/(main)/jo/edit/page.tsx
  21. +18
    -27
      src/app/(main)/jo/page.tsx
  22. +31
    -30
      src/app/(main)/jodetail/edit/page.tsx
  23. +21
    -30
      src/app/(main)/jodetail/page.tsx
  24. +1
    -1
      src/app/(main)/layout.tsx
  25. +1
    -1
      src/app/(main)/productionProcess/page.tsx
  26. +935
    -218
      src/app/(main)/ps/page.tsx
  27. +37
    -0
      src/app/(main)/putAwayCam/page.tsx
  28. +59
    -0
      src/app/(main)/report/GRN_REPORT_BACKEND_SPEC.md
  29. +181
    -0
      src/app/(main)/report/SemiFGProductionAnalysisReport.tsx
  30. +99
    -0
      src/app/(main)/report/grnReportApi.ts
  31. +349
    -37
      src/app/(main)/report/page.tsx
  32. +102
    -0
      src/app/(main)/report/semiFGProductionAnalysisApi.ts
  33. +25
    -0
      src/app/(main)/settings/bomWeighting/page.tsx
  34. +52
    -0
      src/app/(main)/settings/importBom/EquipmentTabs.tsx
  35. +29
    -0
      src/app/(main)/settings/importBom/MaintenanceEdit/page.tsx
  36. +22
    -0
      src/app/(main)/settings/importBom/create/page.tsx
  37. +29
    -0
      src/app/(main)/settings/importBom/edit/page.tsx
  38. +29
    -0
      src/app/(main)/settings/importBom/page.tsx
  39. +27
    -0
      src/app/(main)/settings/itemPrice/page.tsx
  40. +22
    -0
      src/app/(main)/settings/printer/create/page.tsx
  41. +38
    -0
      src/app/(main)/settings/printer/edit/page.tsx
  42. +47
    -0
      src/app/(main)/settings/printer/page.tsx
  43. +19
    -0
      src/app/(main)/settings/qcItem copy/create/not-found.tsx
  44. +26
    -0
      src/app/(main)/settings/qcItem copy/create/page.tsx
  45. +19
    -0
      src/app/(main)/settings/qcItem copy/edit/not-found.tsx
  46. +53
    -0
      src/app/(main)/settings/qcItem copy/edit/page.tsx
  47. +48
    -0
      src/app/(main)/settings/qcItem copy/page.tsx
  48. +72
    -0
      src/app/(main)/settings/qcItemAll/page.tsx
  49. +10
    -10
      src/app/(main)/settings/warehouse/page.tsx
  50. +3
    -3
      src/app/(main)/stockIssue/page.tsx
  51. +1
    -1
      src/app/(main)/stocktakemanagement/page.tsx
  52. +206
    -36
      src/app/(main)/testing/page.tsx
  53. +24
    -1
      src/app/api/bag/action.ts
  54. +82
    -0
      src/app/api/bagPrint/actions.ts
  55. +106
    -0
      src/app/api/bom/client.ts
  56. +86
    -10
      src/app/api/bom/index.ts
  57. +16
    -0
      src/app/api/bom/recalculateClient.ts
  58. +443
    -0
      src/app/api/chart/client.ts
  59. +24
    -0
      src/app/api/dashboard/actions.ts
  60. +17
    -0
      src/app/api/dashboard/client.ts
  61. +146
    -13
      src/app/api/do/actions.tsx
  62. +2
    -2
      src/app/api/do/client.ts
  63. +2
    -0
      src/app/api/escalation/index.ts
  64. +30
    -0
      src/app/api/inventory/actions.ts
  65. +2
    -0
      src/app/api/inventory/index.ts
  66. +160
    -23
      src/app/api/jo/actions.ts
  67. +2
    -0
      src/app/api/jo/index.ts
  68. +22
    -3
      src/app/api/pdf/actions.ts
  69. +116
    -7
      src/app/api/pickOrder/actions.ts
  70. +16
    -1
      src/app/api/po/actions.ts
  71. +1
    -1
      src/app/api/po/index.ts
  72. +3
    -3
      src/app/api/qc/index.ts
  73. +30
    -0
      src/app/api/settings/bomWeighting/actions.ts
  74. +30
    -0
      src/app/api/settings/bomWeighting/client.ts
  75. +23
    -0
      src/app/api/settings/bomWeighting/index.ts
  76. +25
    -0
      src/app/api/settings/bomWeighting/page.tsx
  77. +1
    -0
      src/app/api/settings/item/actions.ts
  78. +5
    -0
      src/app/api/settings/item/index.ts
  79. +53
    -2
      src/app/api/settings/m18ImportTesting/actions.ts
  80. +61
    -0
      src/app/api/settings/printer/actions.ts
  81. +28
    -2
      src/app/api/settings/printer/index.ts
  82. +28
    -0
      src/app/api/settings/qcCategory/client.ts
  83. +9
    -0
      src/app/api/settings/qcCategory/index.ts
  84. +283
    -0
      src/app/api/settings/qcItemAll/actions.ts
  85. +107
    -0
      src/app/api/settings/qcItemAll/index.ts
  86. +48
    -0
      src/app/api/stockAdjustment/actions.ts
  87. +8
    -1
      src/app/api/stockIn/actions.ts
  88. +8
    -0
      src/app/api/stockIn/index.ts
  89. +229
    -0
      src/app/api/stockIssue/actions.ts
  90. +70
    -4
      src/app/api/stockTake/actions.ts
  91. +2
    -1
      src/app/api/user/actions.ts
  92. +67
    -5
      src/app/api/warehouse/actions.ts
  93. +22
    -0
      src/app/api/warehouse/client.ts
  94. +8
    -1
      src/app/api/warehouse/index.ts
  95. +85
    -3
      src/app/global.css
  96. +9
    -1
      src/app/layout.tsx
  97. +12
    -12
      src/app/login/page.tsx
  98. +31
    -0
      src/app/utils/clientAuthFetch.ts
  99. +28
    -8
      src/app/utils/fetchUtil.ts
  100. +9
    -0
      src/app/utils/formatUtil.ts

+ 91
- 0
.cursor/rules.md Просмотреть файл

@@ -0,0 +1,91 @@
# Project Guidelines - Always Follow These Rules

## UI Standard (apply to all pages)

All pages under `(main)` must share the same look and feel. Use this as the single source of truth for new and existing pages.

### Stack & layout

- **Styling:** Tailwind CSS for layout and utilities. MUI components are used with the project theme (primary blue, neutral borders) so they match the standard.
- **Page wrapper:** Do **not** add a full-page wrapper with its own background or padding. The main layout (`src/app/(main)/layout.tsx`) already provides:
- Background: `bg-slate-50` (light), `dark:bg-slate-900` (dark)
- Padding: `p-4 sm:p-4 md:p-6 lg:p-8`
- **Responsive:** Mobile-first; use breakpoints `sm`, `md`, `lg` (e.g. `flex-col sm:flex-row`, `p-4 md:p-6 lg:p-8`).
- **Spacing:** Multiples of 4px only: `p-4`, `m-8`, `gap-2`, `gap-4`, `mb-4`, etc.

### Theme & colors

- **Default:** Light mode. Dark mode supported via `dark` class on `html`; use `dark:` Tailwind variants where needed.
- **Primary:** `#3b82f6` (blue) — main actions, links, focus rings. In MUI this is `palette.primary.main`.
- **Accent:** `#10b981` (emerald) — success, export, confirm actions.
- **Design tokens** are in `src/app/global.css` (`:root` / `.dark`): `--primary`, `--accent`, `--background`, `--foreground`, `--card`, `--border`, `--muted`. Use these in custom CSS or Tailwind when you need to stay in sync.

### Page structure (every page)

1. **Page title bar (consistent across all pages):**
- Use the shared **PageTitleBar** component from `@/components/PageTitleBar` so every menu destination has the same title style.
- It renders a bar with: left primary accent (4px), white/card background, padding, and title typography (`text-xl` / `sm:text-2xl`, bold, slate-900 / dark slate-100).
- **Usage:** `<PageTitleBar title={t("Page Title")} className="mb-4" />` or with actions: `<PageTitleBar title={t("Page Title")} actions={<Button>...</Button>} className="mb-4" />`.
- Do **not** put a bare `<h1>` or `<Typography variant="h4">` as the main page heading; use PageTitleBar for consistency.

2. **Content:** Fragments or divs with `space-y-4` (or `Stack spacing={2}` in MUI) between sections. No extra full-width background wrapper.

### Search criteria

- **When using the shared SearchBox component:** It already uses the standard card style. Ensure the parent page does not wrap it in another card.
- **When building a custom search/query bar:** Use the shared class so it matches SearchBox:
- Wrapper: `className="app-search-criteria ..."` (plus layout classes like `flex flex-wrap items-center gap-2 p-4`).
- Label for “Search criteria” style: `className="app-search-criteria-label"` if you need a small uppercase label.
- **Search button:** Primary action = blue (MUI `variant="contained"` `color="primary"`, or Tailwind `bg-blue-500 text-white`). Reset = outline with neutral border (e.g. MUI `variant="outlined"` with slate border, or Tailwind `border border-slate-300`).

### Forms & inputs

- **Standard look (enforced by MUI theme):** White background, border `#e2e8f0` (neutral 200), focus ring primary blue. Use MUI `TextField` / `FormControl` / date pickers as-is; the theme in `src/theme/devias-material-kit` already matches this.
- **Tailwind-only forms (e.g. /ps):** Use the same tokens: `border border-slate-300`, `bg-white`, `focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20`, `text-slate-900`, `placeholder-slate-400`.

### Buttons

- **Primary action:** Blue filled — MUI `variant="contained"` `color="primary"` or Tailwind `bg-blue-500 text-white hover:bg-blue-600`.
- **Secondary / cancel:** Outline, neutral — MUI `variant="outlined"` with border `#e2e8f0` / `#334155` text, or Tailwind `border border-slate-300 text-slate-700 hover:bg-slate-100`.
- **Accent (e.g. export, success):** Green — MUI `color="success"` or Tailwind `bg-emerald-500` / `text-emerald-600` for outline.
- **Spacing:** Use `gap-2` or `gap-4` between buttons; keep padding multiples of 4 (e.g. `px-4 py-2`).

### Tables & grids

- **Container:** Wrap tables/grids in a card-style container so they match across pages:
- MUI: `<Paper variant="outlined" sx={{ overflow: "hidden" }}>` (theme already uses 8px radius, neutral border).
- Tailwind: `rounded-lg border border-slate-200 bg-white shadow-sm`.
- **Data grid (MUI X DataGrid):** Use `StyledDataGrid` from `@/components/StyledDataGrid`. It applies header bg neutral[50], header text neutral[700], cell padding and borders to match the standard.
- **Table (MUI Table):** Use `SearchResults` when you have a paginated list; it uses `Paper variant="outlined"` and theme table styles (header bg, borders).
- **Header row:** Background `bg-slate-50` / `neutral[50]`, text `text-slate-700` / `neutral[700]`, font-weight 600, padding `px-4 py-3` or theme default.
- **Body rows:** Border `border-slate-200` / theme divider, hover `hover:bg-slate-50` / `action.hover`.

### Cards & surfaces

- **Standard card:** 8px radius, 1px border (`var(--border)` or `neutral[200]`), white background (`var(--card)`), light shadow (`0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)`). MUI `Card` and `Paper` are themed to match.
- **Search-criteria card:** Use class `app-search-criteria` (left 4px primary border, same radius and shadow as above).

### Menu bar & sidebar

- **App bar (top):** White background, 1px bottom border (`palette.divider`), no heavy shadow (`elevation={0}`). Toolbar with consistent min-height and horizontal padding. Profile and title use `text.secondary` and font-weight 600.
- **Sidebar (navigation drawer):** Same as cards: white background, 1px right border, light shadow. Logo area with padding and bottom border; nav list with 4px/8px margins, 8px border-radius on items. **Selected item:** primary light background tint, primary text/icon, font-weight 600. **Hover:** neutral hover background. Use `ListItemButton` with `mx: 1`, `minWidth: 40` on icons. Child items slightly smaller font (0.875rem).
- **Profile dropdown:** Menu with 8px radius, 1px border (outlined Paper). Dense list, padding on header and items. Sign out as `MenuItem`.
- **Selection logic:** Nav item is selected when `pathname === item.path` or `pathname.startsWith(item.path + "/")`. Parent with children expands on click; leaf items navigate via Link.
- **Icons:** Use one icon per menu item that matches the action or section (e.g. Dashboard, LocalShipping for delivery, CalendarMonth for scheduling, Settings for settings). Prefer distinct MUI icons so items are easy to scan; avoid reusing the same icon for many items.

### Reference implementations

- **/ps** — Tailwind-only: query bar (`app-search-criteria`), buttons, table container, modals. Good reference for Tailwind patterns.
- **/do** — SearchBox + StyledDataGrid inside Paper; page title on layout. Good reference for MUI + layout.
- **/jo** — SearchBox + SearchResults (Paper-wrapped table); page title on layout. Same layout and search pattern as /do.

When adding a **new page**, reuse the same structure: rely on the main layout for background/padding, use one optional standard `<h1>`, then SearchBox (or `app-search-criteria` for custom bars), then Paper-wrapped grid/table or other content, with buttons and forms following the rules above.

### Checklist for new pages

- [ ] No extra full-page wrapper (background/padding come from main layout).
- [ ] Page title: use `<PageTitleBar title={...} />` (optional `actions`). Add `className="mb-4"` for spacing below.
- [ ] Search/filter: use `SearchBox` or a div with `className="app-search-criteria"` for the bar.
- [ ] Tables/grids: wrap in `Paper variant="outlined"` (MUI) or `rounded-lg border border-slate-200 bg-white shadow-sm` (Tailwind); use `StyledDataGrid` or `SearchResults` where applicable.
- [ ] Buttons: primary = blue contained, secondary = outlined neutral, accent = green for success/export.
- [ ] Spacing: multiples of 4px (`p-4`, `gap-2`, `mb-4`); responsive with `sm`/`md`/`lg`.

+ 3
- 3
.env.production Просмотреть файл

@@ -1,4 +1,4 @@
API_URL=http://10.100.0.81:8090/api
API_URL=http://10.10.0.81:8090/api
NEXTAUTH_SECRET=secret
NEXTAUTH_URL=http://10.100.0.81:3000
NEXT_PUBLIC_API_URL=http://10.100.0.81:8090/api
NEXTAUTH_URL=http://10.10.0.81:3000
NEXT_PUBLIC_API_URL=http://10.10.0.81:8090/api

+ 149
- 1
package-lock.json Просмотреть файл

@@ -18,6 +18,7 @@
"@mui/material-nextjs": "^5.15.0",
"@mui/x-data-grid": "^6.18.7",
"@mui/x-date-pickers": "^6.18.7",
"@tanstack/react-table": "^8.21.3",
"@tiptap/core": "^2.14.0",
"@tiptap/extension-color": "^2.14.0",
"@tiptap/extension-document": "^2.14.0",
@@ -44,6 +45,7 @@
"i18next": "^23.7.11",
"i18next-resources-to-backend": "^1.2.0",
"lodash": "^4.17.21",
"lucide-react": "^0.536.0",
"mui-color-input": "^7.0.0",
"next": "14.0.4",
"next-auth": "^4.24.5",
@@ -61,7 +63,8 @@
"react-toastify": "^11.0.5",
"reactstrap": "^9.2.2",
"styled-components": "^6.1.8",
"sweetalert2": "^11.10.3"
"sweetalert2": "^11.10.3",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/lodash": "^4.14.202",
@@ -3005,6 +3008,39 @@
"tslib": "^2.4.0"
}
},
"node_modules/@tanstack/react-table": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
"license": "MIT",
"dependencies": {
"@tanstack/table-core": "8.21.3"
},
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/@tanstack/table-core": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tiptap/core": {
"version": "2.22.3",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.22.3.tgz",
@@ -3921,6 +3957,15 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -4582,6 +4627,19 @@
}
]
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chalk": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
@@ -4659,6 +4717,15 @@
"node": ">=6"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
@@ -4750,6 +4817,18 @@
"node": ">=10"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
@@ -6102,6 +6181,15 @@
"node": ">= 6"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fraction.js": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
@@ -7438,6 +7526,15 @@
"node": ">=10"
}
},
"node_modules/lucide-react": {
"version": "0.536.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.536.0.tgz",
"integrity": "sha512-2PgvNa9v+qz4Jt/ni8vPLt4jwoFybXHuubQT8fv4iCW5TjDxkbZjNZZHa485ad73NSEn/jdsEtU57eE1g+ma8A==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/magic-string": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz",
@@ -9520,6 +9617,18 @@
"integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==",
"deprecated": "Please use @jridgewell/sourcemap-codec instead"
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
@@ -10684,6 +10793,24 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/workbox-background-sync": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-6.6.0.tgz",
@@ -11069,6 +11196,27 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",


+ 3
- 2
package.json Просмотреть файл

@@ -5,7 +5,7 @@
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "set NODE_OPTIONS=--inspect&& next start",
"start": "set NODE_OPTIONS=--inspect --max-old-space-size=6144&& next start",
"lint": "next lint",
"type-check": "tsc --noEmit"
},
@@ -65,7 +65,8 @@
"react-toastify": "^11.0.5",
"reactstrap": "^9.2.2",
"styled-components": "^6.1.8",
"sweetalert2": "^11.10.3"
"sweetalert2": "^11.10.3",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/lodash": "^4.14.202",


+ 23
- 0
src/app/(main)/bagPrint/page.tsx Просмотреть файл

@@ -0,0 +1,23 @@
import BagPrintSearch from "@/components/BagPrint/BagPrintSearch";
import { Stack, Typography } from "@mui/material";
import { Metadata } from "next";
import React from "react";

export const metadata: Metadata = {
title: "打袋機",
};

const BagPrintPage: React.FC = () => {
return (
<>
<Stack direction="row" justifyContent="space-between" flexWrap="wrap" rowGap={2}>
<Typography variant="h4" marginInlineEnd={2}>
打袋機
</Typography>
</Stack>
<BagPrintSearch />
</>
);
};

export default BagPrintPage;

+ 51
- 0
src/app/(main)/chart/_components/ChartCard.tsx Просмотреть файл

@@ -0,0 +1,51 @@
"use client";

import { Card, CardContent, Typography, Stack, Button } from "@mui/material";
import FileDownload from "@mui/icons-material/FileDownload";
import { exportChartToXlsx } from "./exportChartToXlsx";

export default function ChartCard({
title,
filters,
children,
exportFilename,
exportData,
}: {
title: string;
filters?: React.ReactNode;
children: React.ReactNode;
/** If provided with exportData, shows "匯出 Excel" button. */
exportFilename?: string;
exportData?: Record<string, unknown>[];
}) {
const handleExport = () => {
if (exportFilename && exportData) {
exportChartToXlsx(exportData, exportFilename);
}
};

return (
<Card sx={{ mb: 3 }}>
<CardContent>
<Stack direction="row" flexWrap="wrap" alignItems="center" gap={2} sx={{ mb: 2 }}>
<Typography variant="h6" component="span">
{title}
</Typography>
{filters}
{exportFilename && exportData && (
<Button
size="small"
variant="outlined"
startIcon={<FileDownload />}
onClick={handleExport}
sx={{ ml: "auto" }}
>
匯出 Excel
</Button>
)}
</Stack>
{children}
</CardContent>
</Card>
);
}

+ 31
- 0
src/app/(main)/chart/_components/DateRangeSelect.tsx Просмотреть файл

@@ -0,0 +1,31 @@
"use client";

import { FormControl, InputLabel, Select, MenuItem } from "@mui/material";
import { RANGE_DAYS } from "./constants";

export default function DateRangeSelect({
value,
onChange,
label = "日期範圍",
}: {
value: number;
onChange: (v: number) => void;
label?: string;
}) {
return (
<FormControl size="small" sx={{ minWidth: 130 }}>
<InputLabel>{label}</InputLabel>
<Select
value={value}
label={label}
onChange={(e) => onChange(Number(e.target.value))}
>
{RANGE_DAYS.map((d) => (
<MenuItem key={d} value={d}>
最近 {d} 天
</MenuItem>
))}
</Select>
</FormControl>
);
}

+ 12
- 0
src/app/(main)/chart/_components/constants.ts Просмотреть файл

@@ -0,0 +1,12 @@
import dayjs from "dayjs";

export const RANGE_DAYS = [7, 30, 90] as const;
export const TOP_ITEMS_LIMIT_OPTIONS = [10, 20, 50, 100] as const;
export const ITEM_CODE_DEBOUNCE_MS = 400;
export const DEFAULT_RANGE_DAYS = 30;

export function toDateRange(rangeDays: number) {
const end = dayjs().format("YYYY-MM-DD");
const start = dayjs().subtract(rangeDays, "day").format("YYYY-MM-DD");
return { startDate: start, endDate: end };
}

+ 46
- 0
src/app/(main)/chart/_components/exportChartToXlsx.ts Просмотреть файл

@@ -0,0 +1,46 @@
import * as XLSX from "xlsx";

/**
* Export an array of row objects to a .xlsx file and trigger download.
* @param rows Array of objects (keys become column headers)
* @param filename Download filename (without .xlsx)
* @param sheetName Optional sheet name (default "Sheet1")
*/
export function exportChartToXlsx(
rows: Record<string, unknown>[],
filename: string,
sheetName = "Sheet1"
): void {
if (rows.length === 0) {
const ws = XLSX.utils.aoa_to_sheet([[]]);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, sheetName);
XLSX.writeFile(wb, `${filename}.xlsx`);
return;
}
const ws = XLSX.utils.json_to_sheet(rows);

// Auto-set column widths based on header length (simple heuristic).
const header = Object.keys(rows[0] ?? {});
if (header.length > 0) {
ws["!cols"] = header.map((h) => ({
// Basic width: header length + padding, minimum 12
wch: Math.max(12, h.length + 4),
}));

// Make header row look like a header (bold).
header.forEach((_, colIdx) => {
const cellRef = XLSX.utils.encode_cell({ r: 0, c: colIdx });
const cell = ws[cellRef];
if (cell) {
cell.s = {
font: { bold: true },
};
}
});
}

const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, sheetName);
XLSX.writeFile(wb, `${filename}.xlsx`);
}

+ 393
- 0
src/app/(main)/chart/delivery/page.tsx Просмотреть файл

@@ -0,0 +1,393 @@
"use client";

import React, { useCallback, useMemo, useState } from "react";
import {
Box,
Typography,
Skeleton,
Alert,
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
Autocomplete,
Chip,
} from "@mui/material";
import dynamic from "next/dynamic";
import LocalShipping from "@mui/icons-material/LocalShipping";
import {
fetchDeliveryOrderByDate,
fetchTopDeliveryItems,
fetchTopDeliveryItemsItemOptions,
fetchStaffDeliveryPerformance,
fetchStaffDeliveryPerformanceHandlers,
type StaffOption,
type TopDeliveryItemOption,
} from "@/app/api/chart/client";
import ChartCard from "../_components/ChartCard";
import DateRangeSelect from "../_components/DateRangeSelect";
import { toDateRange, DEFAULT_RANGE_DAYS, TOP_ITEMS_LIMIT_OPTIONS } from "../_components/constants";

const ApexCharts = dynamic(() => import("react-apexcharts"), { ssr: false });

const PAGE_TITLE = "發貨與配送";

type Criteria = {
delivery: { rangeDays: number };
topItems: { rangeDays: number; limit: number };
staffPerf: { rangeDays: number };
};

const defaultCriteria: Criteria = {
delivery: { rangeDays: DEFAULT_RANGE_DAYS },
topItems: { rangeDays: DEFAULT_RANGE_DAYS, limit: 10 },
staffPerf: { rangeDays: DEFAULT_RANGE_DAYS },
};

export default function DeliveryChartPage() {
const [criteria, setCriteria] = useState<Criteria>(defaultCriteria);
const [topItemsSelected, setTopItemsSelected] = useState<TopDeliveryItemOption[]>([]);
const [topItemOptions, setTopItemOptions] = useState<TopDeliveryItemOption[]>([]);
const [staffSelected, setStaffSelected] = useState<StaffOption[]>([]);
const [staffOptions, setStaffOptions] = useState<StaffOption[]>([]);
const [error, setError] = useState<string | null>(null);
const [chartData, setChartData] = useState<{
delivery: { date: string; orderCount: number; totalQty: number }[];
topItems: { itemCode: string; itemName: string; totalQty: number }[];
staffPerf: { date: string; staffName: string; orderCount: number; totalMinutes: number }[];
}>({ delivery: [], topItems: [], staffPerf: [] });
const [loadingCharts, setLoadingCharts] = useState<Record<string, boolean>>({});

const updateCriteria = useCallback(
<K extends keyof Criteria>(key: K, updater: (prev: Criteria[K]) => Criteria[K]) => {
setCriteria((prev) => ({ ...prev, [key]: updater(prev[key]) }));
},
[]
);
const setChartLoading = useCallback((key: string, value: boolean) => {
setLoadingCharts((prev) => (prev[key] === value ? prev : { ...prev, [key]: value }));
}, []);

React.useEffect(() => {
const { startDate: s, endDate: e } = toDateRange(criteria.delivery.rangeDays);
setChartLoading("delivery", true);
fetchDeliveryOrderByDate(s, e)
.then((data) =>
setChartData((prev) => ({
...prev,
delivery: data as { date: string; orderCount: number; totalQty: number }[],
}))
)
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setChartLoading("delivery", false));
}, [criteria.delivery, setChartLoading]);

React.useEffect(() => {
const { startDate: s, endDate: e } = toDateRange(criteria.topItems.rangeDays);
setChartLoading("topItems", true);
fetchTopDeliveryItems(
s,
e,
criteria.topItems.limit,
topItemsSelected.length > 0 ? topItemsSelected.map((o) => o.itemCode) : undefined
)
.then((data) =>
setChartData((prev) => ({
...prev,
topItems: data as { itemCode: string; itemName: string; totalQty: number }[],
}))
)
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setChartLoading("topItems", false));
}, [criteria.topItems, topItemsSelected, setChartLoading]);

React.useEffect(() => {
const { startDate: s, endDate: e } = toDateRange(criteria.staffPerf.rangeDays);
const staffNos = staffSelected.length > 0 ? staffSelected.map((o) => o.staffNo) : undefined;
setChartLoading("staffPerf", true);
fetchStaffDeliveryPerformance(s, e, staffNos)
.then((data) =>
setChartData((prev) => ({
...prev,
staffPerf: data as {
date: string;
staffName: string;
orderCount: number;
totalMinutes: number;
}[],
}))
)
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setChartLoading("staffPerf", false));
}, [criteria.staffPerf, staffSelected, setChartLoading]);

React.useEffect(() => {
fetchStaffDeliveryPerformanceHandlers()
.then(setStaffOptions)
.catch(() => setStaffOptions([]));
}, []);
React.useEffect(() => {
const { startDate: s, endDate: e } = toDateRange(criteria.topItems.rangeDays);
fetchTopDeliveryItemsItemOptions(s, e).then(setTopItemOptions).catch(() => setTopItemOptions([]));
}, [criteria.topItems.rangeDays]);

const staffPerfByStaff = useMemo(() => {
const map = new Map<string, { orderCount: number; totalMinutes: number }>();
for (const r of chartData.staffPerf) {
const name = r.staffName || "Unknown";
const cur = map.get(name) ?? { orderCount: 0, totalMinutes: 0 };
map.set(name, {
orderCount: cur.orderCount + r.orderCount,
totalMinutes: cur.totalMinutes + r.totalMinutes,
});
}
return Array.from(map.entries()).map(([staffName, v]) => ({
staffName,
orderCount: v.orderCount,
totalMinutes: v.totalMinutes,
avgMinutesPerOrder: v.orderCount > 0 ? Math.round(v.totalMinutes / v.orderCount) : 0,
}));
}, [chartData.staffPerf]);

return (
<Box sx={{ maxWidth: 1200, mx: "auto" }}>
<Typography variant="h5" sx={{ mb: 2, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}>
<LocalShipping /> {PAGE_TITLE}
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}

<ChartCard
title="按日期發貨單數量"
exportFilename="發貨單數量_按日期"
exportData={chartData.delivery.map((d) => ({ 日期: d.date, 單數: d.orderCount }))}
filters={
<DateRangeSelect
value={criteria.delivery.rangeDays}
onChange={(v) => updateCriteria("delivery", (c) => ({ ...c, rangeDays: v }))}
/>
}
>
{loadingCharts.delivery ? (
<Skeleton variant="rectangular" height={320} />
) : (
<ApexCharts
options={{
chart: { type: "bar" },
xaxis: { categories: chartData.delivery.map((d) => d.date) },
yaxis: { title: { text: "單數" } },
plotOptions: { bar: { horizontal: false, columnWidth: "60%" } },
dataLabels: { enabled: false },
}}
series={[{ name: "單數", data: chartData.delivery.map((d) => d.orderCount) }]}
type="bar"
width="100%"
height={320}
/>
)}
</ChartCard>

<ChartCard
title="發貨數量排行(按物料)"
exportFilename="發貨數量排行_按物料"
exportData={chartData.topItems.map((i) => ({ 物料編碼: i.itemCode, 物料名稱: i.itemName, 數量: i.totalQty }))}
filters={
<>
<DateRangeSelect
value={criteria.topItems.rangeDays}
onChange={(v) => updateCriteria("topItems", (c) => ({ ...c, rangeDays: v }))}
/>
<FormControl size="small" sx={{ minWidth: 100 }}>
<InputLabel>顯示</InputLabel>
<Select
value={criteria.topItems.limit}
label="顯示"
onChange={(e) => updateCriteria("topItems", (c) => ({ ...c, limit: Number(e.target.value) }))}
>
{TOP_ITEMS_LIMIT_OPTIONS.map((n) => (
<MenuItem key={n} value={n}>
{n} 條
</MenuItem>
))}
</Select>
</FormControl>
<Autocomplete
multiple
size="small"
options={topItemOptions}
value={topItemsSelected}
onChange={(_, v) => setTopItemsSelected(v)}
getOptionLabel={(opt) => [opt.itemCode, opt.itemName].filter(Boolean).join(" - ") || opt.itemCode}
isOptionEqualToValue={(a, b) => a.itemCode === b.itemCode}
renderInput={(params) => (
<TextField {...params} label="物料" placeholder="不選則全部" />
)}
renderTags={(value, getTagProps) =>
value.map((option, index) => {
const { key: _key, ...tagProps } = getTagProps({ index });
return (
<Chip
key={option.itemCode}
label={[option.itemCode, option.itemName].filter(Boolean).join(" - ")}
size="small"
{...tagProps}
/>
);
})
}
sx={{ minWidth: 280 }}
/>
</>
}
>
{loadingCharts.topItems ? (
<Skeleton variant="rectangular" height={320} />
) : (
<ApexCharts
options={{
chart: { type: "bar" },
xaxis: {
categories: chartData.topItems.map((i) => `${i.itemCode} ${i.itemName}`.trim()),
},
plotOptions: { bar: { horizontal: true, barHeight: "70%" } },
dataLabels: { enabled: true },
}}
series={[{ name: "數量", data: chartData.topItems.map((i) => i.totalQty) }]}
type="bar"
width="100%"
height={Math.max(320, chartData.topItems.length * 36)}
/>
)}
</ChartCard>

<ChartCard
title="員工發貨績效(每日揀貨數量與耗時)"
exportFilename="員工發貨績效"
exportData={chartData.staffPerf.map((r) => ({ 日期: r.date, 員工: r.staffName, 揀單數: r.orderCount, 總分鐘: r.totalMinutes }))}
filters={
<>
<DateRangeSelect
value={criteria.staffPerf.rangeDays}
onChange={(v) => updateCriteria("staffPerf", (c) => ({ ...c, rangeDays: v }))}
/>
<Autocomplete
multiple
size="small"
options={staffOptions}
value={staffSelected}
onChange={(_, v) => setStaffSelected(v)}
getOptionLabel={(opt) => [opt.staffNo, opt.name].filter(Boolean).join(" - ") || opt.staffNo}
isOptionEqualToValue={(a, b) => a.staffNo === b.staffNo}
renderInput={(params) => (
<TextField {...params} label="員工" placeholder="不選則全部" />
)}
renderTags={(value, getTagProps) =>
value.map((option, index) => {
const { key: _key, ...tagProps } = getTagProps({ index });
return (
<Chip
key={option.staffNo}
label={[option.staffNo, option.name].filter(Boolean).join(" - ")}
size="small"
{...tagProps}
/>
);
})
}
sx={{ minWidth: 260 }}
/>
</>
}
>
{loadingCharts.staffPerf ? (
<Skeleton variant="rectangular" height={320} />
) : chartData.staffPerf.length === 0 ? (
<Typography color="text.secondary" sx={{ py: 3 }}>
此日期範圍內尚無完成之發貨單,或無揀貨人資料。請更換日期範圍或確認發貨單(DO)已由員工完成並有紀錄揀貨時間。
</Typography>
) : (
<>
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
週期內每人揀單數及總耗時(首揀至完成)
</Typography>
<Box
component="table"
sx={{
width: "100%",
borderCollapse: "collapse",
"& th, & td": {
border: "1px solid",
borderColor: "divider",
px: 1.5,
py: 1,
textAlign: "left",
},
"& th": { bgcolor: "action.hover", fontWeight: 600 },
}}
>
<thead>
<tr>
<th>員工</th>
<th>揀單數</th>
<th>總分鐘</th>
<th>平均分鐘/單</th>
</tr>
</thead>
<tbody>
{staffPerfByStaff.length === 0 ? (
<tr>
<td colSpan={4}>無數據</td>
</tr>
) : (
staffPerfByStaff.map((row) => (
<tr key={row.staffName}>
<td>{row.staffName}</td>
<td>{row.orderCount}</td>
<td>{row.totalMinutes}</td>
<td>{row.avgMinutesPerOrder}</td>
</tr>
))
)}
</tbody>
</Box>
</Box>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
每日按員工單數
</Typography>
<ApexCharts
options={{
chart: { type: "bar", stacked: true },
xaxis: {
categories: Array.from(new Set(chartData.staffPerf.map((r) => r.date))).sort(),
},
yaxis: { title: { text: "單數" } },
plotOptions: { bar: { columnWidth: "60%" } },
dataLabels: { enabled: false },
legend: { position: "top" },
}}
series={(() => {
const staffNames = Array.from(new Set(chartData.staffPerf.map((r) => r.staffName))).filter(Boolean).sort();
const dates = Array.from(new Set(chartData.staffPerf.map((r) => r.date))).sort();
return staffNames.map((name) => ({
name: name || "Unknown",
data: dates.map((d) => {
const row = chartData.staffPerf.find((r) => r.date === d && r.staffName === name);
return row ? row.orderCount : 0;
}),
}));
})()}
type="bar"
width="100%"
height={320}
/>
</>
)}
</ChartCard>
</Box>
);
}

+ 177
- 0
src/app/(main)/chart/forecast/page.tsx Просмотреть файл

@@ -0,0 +1,177 @@
"use client";

import React, { useCallback, useState } from "react";
import { Box, Typography, Skeleton, Alert } from "@mui/material";
import dynamic from "next/dynamic";
import TrendingUp from "@mui/icons-material/TrendingUp";
import {
fetchProductionScheduleByDate,
fetchPlannedOutputByDateAndItem,
} from "@/app/api/chart/client";
import ChartCard from "../_components/ChartCard";
import DateRangeSelect from "../_components/DateRangeSelect";
import { toDateRange, DEFAULT_RANGE_DAYS } from "../_components/constants";

const ApexCharts = dynamic(() => import("react-apexcharts"), { ssr: false });

const PAGE_TITLE = "預測與計劃";

type Criteria = {
prodSchedule: { rangeDays: number };
plannedOutputByDate: { rangeDays: number };
};

const defaultCriteria: Criteria = {
prodSchedule: { rangeDays: DEFAULT_RANGE_DAYS },
plannedOutputByDate: { rangeDays: DEFAULT_RANGE_DAYS },
};

export default function ForecastChartPage() {
const [criteria, setCriteria] = useState<Criteria>(defaultCriteria);
const [error, setError] = useState<string | null>(null);
const [chartData, setChartData] = useState<{
prodSchedule: { date: string; scheduledItemCount: number; totalEstProdCount: number }[];
plannedOutputByDate: { date: string; itemCode: string; itemName: string; qty: number }[];
}>({ prodSchedule: [], plannedOutputByDate: [] });
const [loadingCharts, setLoadingCharts] = useState<Record<string, boolean>>({});

const updateCriteria = useCallback(
<K extends keyof Criteria>(key: K, updater: (prev: Criteria[K]) => Criteria[K]) => {
setCriteria((prev) => ({ ...prev, [key]: updater(prev[key]) }));
},
[]
);
const setChartLoading = useCallback((key: string, value: boolean) => {
setLoadingCharts((prev) => (prev[key] === value ? prev : { ...prev, [key]: value }));
}, []);

React.useEffect(() => {
const { startDate: s, endDate: e } = toDateRange(criteria.prodSchedule.rangeDays);
setChartLoading("prodSchedule", true);
fetchProductionScheduleByDate(s, e)
.then((data) =>
setChartData((prev) => ({
...prev,
prodSchedule: data as {
date: string;
scheduledItemCount: number;
totalEstProdCount: number;
}[],
}))
)
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setChartLoading("prodSchedule", false));
}, [criteria.prodSchedule, setChartLoading]);

React.useEffect(() => {
const { startDate: s, endDate: e } = toDateRange(criteria.plannedOutputByDate.rangeDays);
setChartLoading("plannedOutputByDate", true);
fetchPlannedOutputByDateAndItem(s, e)
.then((data) =>
setChartData((prev) => ({
...prev,
plannedOutputByDate: data as { date: string; itemCode: string; itemName: string; qty: number }[],
}))
)
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setChartLoading("plannedOutputByDate", false));
}, [criteria.plannedOutputByDate, setChartLoading]);

return (
<Box sx={{ maxWidth: 1200, mx: "auto" }}>
<Typography variant="h5" sx={{ mb: 2, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}>
<TrendingUp /> {PAGE_TITLE}
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}

<ChartCard
title="按日期生產排程(預估產量)"
exportFilename="生產排程_按日期"
exportData={chartData.prodSchedule.map((d) => ({ 日期: d.date, 已排物料: d.scheduledItemCount, 預估產量: d.totalEstProdCount }))}
filters={
<DateRangeSelect
value={criteria.prodSchedule.rangeDays}
onChange={(v) => updateCriteria("prodSchedule", (c) => ({ ...c, rangeDays: v }))}
/>
}
>
{loadingCharts.prodSchedule ? (
<Skeleton variant="rectangular" height={320} />
) : (
<ApexCharts
options={{
chart: { type: "bar" },
xaxis: { categories: chartData.prodSchedule.map((d) => d.date) },
yaxis: { title: { text: "數量" } },
plotOptions: { bar: { columnWidth: "60%" } },
dataLabels: { enabled: false },
}}
series={[
{ name: "已排物料", data: chartData.prodSchedule.map((d) => d.scheduledItemCount) },
{ name: "預估產量", data: chartData.prodSchedule.map((d) => d.totalEstProdCount) },
]}
type="bar"
width="100%"
height={320}
/>
)}
</ChartCard>

<ChartCard
title="按物料計劃日產量(預測)"
exportFilename="按物料計劃日產量_預測"
exportData={chartData.plannedOutputByDate.map((r) => ({ 日期: r.date, 物料編碼: r.itemCode, 物料名稱: r.itemName, 數量: r.qty }))}
filters={
<DateRangeSelect
value={criteria.plannedOutputByDate.rangeDays}
onChange={(v) => updateCriteria("plannedOutputByDate", (c) => ({ ...c, rangeDays: v }))}
/>
}
>
{loadingCharts.plannedOutputByDate ? (
<Skeleton variant="rectangular" height={320} />
) : (() => {
const rows = chartData.plannedOutputByDate;
const dates = Array.from(new Set(rows.map((r) => r.date))).sort();
const items = Array.from(
new Map(rows.map((r) => [r.itemCode, { itemCode: r.itemCode, itemName: r.itemName || "" }])).values()
).sort((a, b) => a.itemCode.localeCompare(b.itemCode));
const series = items.map(({ itemCode, itemName }) => ({
name: [itemCode, itemName].filter(Boolean).join(" ") || itemCode,
data: dates.map((d) => {
const r = rows.find((x) => x.date === d && x.itemCode === itemCode);
return r ? r.qty : 0;
}),
}));
if (dates.length === 0 || series.length === 0) {
return (
<Typography color="text.secondary" sx={{ py: 3 }}>
此日期範圍內尚無排程資料。
</Typography>
);
}
return (
<ApexCharts
options={{
chart: { type: "bar" },
xaxis: { categories: dates },
yaxis: { title: { text: "數量" } },
plotOptions: { bar: { columnWidth: "60%" } },
dataLabels: { enabled: false },
legend: { position: "top", horizontalAlign: "left" },
}}
series={series}
type="bar"
width="100%"
height={Math.max(320, dates.length * 24)}
/>
);
})()}
</ChartCard>
</Box>
);
}

+ 367
- 0
src/app/(main)/chart/joborder/page.tsx Просмотреть файл

@@ -0,0 +1,367 @@
"use client";

import React, { useCallback, useState } from "react";
import { Box, Typography, Skeleton, Alert, TextField } from "@mui/material";
import dynamic from "next/dynamic";
import dayjs from "dayjs";
import Assignment from "@mui/icons-material/Assignment";
import {
fetchJobOrderByStatus,
fetchJobOrderCountByDate,
fetchJobOrderCreatedCompletedByDate,
fetchJobMaterialPendingPickedByDate,
fetchJobProcessPendingCompletedByDate,
fetchJobEquipmentWorkingWorkedByDate,
} from "@/app/api/chart/client";
import ChartCard from "../_components/ChartCard";
import DateRangeSelect from "../_components/DateRangeSelect";
import { toDateRange, DEFAULT_RANGE_DAYS } from "../_components/constants";

const ApexCharts = dynamic(() => import("react-apexcharts"), { ssr: false });

const PAGE_TITLE = "工單";

type Criteria = {
joCountByDate: { rangeDays: number };
joCreatedCompleted: { rangeDays: number };
joDetail: { rangeDays: number };
};

const defaultCriteria: Criteria = {
joCountByDate: { rangeDays: DEFAULT_RANGE_DAYS },
joCreatedCompleted: { rangeDays: DEFAULT_RANGE_DAYS },
joDetail: { rangeDays: DEFAULT_RANGE_DAYS },
};

export default function JobOrderChartPage() {
const [joTargetDate, setJoTargetDate] = useState<string>(() => dayjs().format("YYYY-MM-DD"));
const [criteria, setCriteria] = useState<Criteria>(defaultCriteria);
const [error, setError] = useState<string | null>(null);
const [chartData, setChartData] = useState<{
joStatus: { status: string; count: number }[];
joCountByDate: { date: string; orderCount: number }[];
joCreatedCompleted: { date: string; createdCount: number; completedCount: number }[];
joMaterial: { date: string; pendingCount: number; pickedCount: number }[];
joProcess: { date: string; pendingCount: number; completedCount: number }[];
joEquipment: { date: string; workingCount: number; workedCount: number }[];
}>({
joStatus: [],
joCountByDate: [],
joCreatedCompleted: [],
joMaterial: [],
joProcess: [],
joEquipment: [],
});
const [loadingCharts, setLoadingCharts] = useState<Record<string, boolean>>({});

const updateCriteria = useCallback(
<K extends keyof Criteria>(key: K, updater: (prev: Criteria[K]) => Criteria[K]) => {
setCriteria((prev) => ({ ...prev, [key]: updater(prev[key]) }));
},
[]
);
const setChartLoading = useCallback((key: string, value: boolean) => {
setLoadingCharts((prev) => (prev[key] === value ? prev : { ...prev, [key]: value }));
}, []);

React.useEffect(() => {
setChartLoading("joStatus", true);
fetchJobOrderByStatus(joTargetDate)
.then((data) =>
setChartData((prev) => ({
...prev,
joStatus: data as { status: string; count: number }[],
}))
)
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setChartLoading("joStatus", false));
}, [joTargetDate, setChartLoading]);

React.useEffect(() => {
const { startDate: s, endDate: e } = toDateRange(criteria.joCountByDate.rangeDays);
setChartLoading("joCountByDate", true);
fetchJobOrderCountByDate(s, e)
.then((data) =>
setChartData((prev) => ({
...prev,
joCountByDate: data as { date: string; orderCount: number }[],
}))
)
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setChartLoading("joCountByDate", false));
}, [criteria.joCountByDate, setChartLoading]);

React.useEffect(() => {
const { startDate: s, endDate: e } = toDateRange(criteria.joCreatedCompleted.rangeDays);
setChartLoading("joCreatedCompleted", true);
fetchJobOrderCreatedCompletedByDate(s, e)
.then((data) =>
setChartData((prev) => ({
...prev,
joCreatedCompleted: data as {
date: string;
createdCount: number;
completedCount: number;
}[],
}))
)
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setChartLoading("joCreatedCompleted", false));
}, [criteria.joCreatedCompleted, setChartLoading]);

React.useEffect(() => {
const { startDate: s, endDate: e } = toDateRange(criteria.joDetail.rangeDays);
setChartLoading("joMaterial", true);
fetchJobMaterialPendingPickedByDate(s, e)
.then((data) =>
setChartData((prev) => ({
...prev,
joMaterial: data as { date: string; pendingCount: number; pickedCount: number }[],
}))
)
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setChartLoading("joMaterial", false));
}, [criteria.joDetail, setChartLoading]);

React.useEffect(() => {
const { startDate: s, endDate: e } = toDateRange(criteria.joDetail.rangeDays);
setChartLoading("joProcess", true);
fetchJobProcessPendingCompletedByDate(s, e)
.then((data) =>
setChartData((prev) => ({
...prev,
joProcess: data as { date: string; pendingCount: number; completedCount: number }[],
}))
)
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setChartLoading("joProcess", false));
}, [criteria.joDetail, setChartLoading]);

React.useEffect(() => {
const { startDate: s, endDate: e } = toDateRange(criteria.joDetail.rangeDays);
setChartLoading("joEquipment", true);
fetchJobEquipmentWorkingWorkedByDate(s, e)
.then((data) =>
setChartData((prev) => ({
...prev,
joEquipment: data as { date: string; workingCount: number; workedCount: number }[],
}))
)
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setChartLoading("joEquipment", false));
}, [criteria.joDetail, setChartLoading]);

return (
<Box sx={{ maxWidth: 1200, mx: "auto" }}>
<Typography variant="h5" sx={{ mb: 2, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}>
<Assignment /> {PAGE_TITLE}
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}

<ChartCard
title="工單按狀態"
exportFilename="工單_按狀態"
exportData={chartData.joStatus.map((p) => ({ 狀態: p.status, 數量: p.count }))}
filters={
<TextField
size="small"
label="日期(計劃開始)"
type="date"
value={joTargetDate}
onChange={(e) => setJoTargetDate(e.target.value)}
InputLabelProps={{ shrink: true }}
sx={{ minWidth: 180 }}
/>
}
>
{loadingCharts.joStatus ? (
<Skeleton variant="rectangular" height={320} />
) : (
<ApexCharts
options={{
chart: { type: "donut" },
labels: chartData.joStatus.map((p) => p.status),
legend: { position: "bottom" },
}}
series={chartData.joStatus.map((p) => p.count)}
type="donut"
width="100%"
height={320}
/>
)}
</ChartCard>

<ChartCard
title="按日期工單數量(計劃開始日)"
exportFilename="工單數量_按日期"
exportData={chartData.joCountByDate.map((d) => ({ 日期: d.date, 工單數: d.orderCount }))}
filters={
<DateRangeSelect
value={criteria.joCountByDate.rangeDays}
onChange={(v) => updateCriteria("joCountByDate", (c) => ({ ...c, rangeDays: v }))}
/>
}
>
{loadingCharts.joCountByDate ? (
<Skeleton variant="rectangular" height={320} />
) : (
<ApexCharts
options={{
chart: { type: "bar" },
xaxis: { categories: chartData.joCountByDate.map((d) => d.date) },
yaxis: { title: { text: "單數" } },
plotOptions: { bar: { columnWidth: "60%" } },
dataLabels: { enabled: false },
}}
series={[{ name: "工單數", data: chartData.joCountByDate.map((d) => d.orderCount) }]}
type="bar"
width="100%"
height={320}
/>
)}
</ChartCard>

<ChartCard
title="工單創建與完成按日期"
exportFilename="工單創建與完成_按日期"
exportData={chartData.joCreatedCompleted.map((d) => ({ 日期: d.date, 創建: d.createdCount, 完成: d.completedCount }))}
filters={
<DateRangeSelect
value={criteria.joCreatedCompleted.rangeDays}
onChange={(v) => updateCriteria("joCreatedCompleted", (c) => ({ ...c, rangeDays: v }))}
/>
}
>
{loadingCharts.joCreatedCompleted ? (
<Skeleton variant="rectangular" height={320} />
) : (
<ApexCharts
options={{
chart: { type: "line" },
xaxis: { categories: chartData.joCreatedCompleted.map((d) => d.date) },
yaxis: { title: { text: "數量" } },
stroke: { curve: "smooth" },
dataLabels: { enabled: false },
}}
series={[
{ name: "創建", data: chartData.joCreatedCompleted.map((d) => d.createdCount) },
{ name: "完成", data: chartData.joCreatedCompleted.map((d) => d.completedCount) },
]}
type="line"
width="100%"
height={320}
/>
)}
</ChartCard>

<Typography variant="h6" sx={{ mt: 3, mb: 1, fontWeight: 600 }}>
工單物料/工序/設備
</Typography>
<ChartCard
title="物料待領/已揀(按工單計劃日)"
exportFilename="工單物料_待領已揀_按日期"
exportData={chartData.joMaterial.map((d) => ({ 日期: d.date, 待領: d.pendingCount, 已揀: d.pickedCount }))}
filters={
<DateRangeSelect
value={criteria.joDetail.rangeDays}
onChange={(v) => updateCriteria("joDetail", (c) => ({ ...c, rangeDays: v }))}
/>
}
>
{loadingCharts.joMaterial ? (
<Skeleton variant="rectangular" height={320} />
) : (
<ApexCharts
options={{
chart: { type: "bar" },
xaxis: { categories: chartData.joMaterial.map((d) => d.date) },
yaxis: { title: { text: "筆數" } },
plotOptions: { bar: { columnWidth: "60%" } },
dataLabels: { enabled: false },
legend: { position: "top" },
}}
series={[
{ name: "待領", data: chartData.joMaterial.map((d) => d.pendingCount) },
{ name: "已揀", data: chartData.joMaterial.map((d) => d.pickedCount) },
]}
type="bar"
width="100%"
height={320}
/>
)}
</ChartCard>

<ChartCard
title="工序待完成/已完成(按工單計劃日)"
exportFilename="工單工序_待完成已完成_按日期"
exportData={chartData.joProcess.map((d) => ({ 日期: d.date, 待完成: d.pendingCount, 已完成: d.completedCount }))}
filters={
<DateRangeSelect
value={criteria.joDetail.rangeDays}
onChange={(v) => updateCriteria("joDetail", (c) => ({ ...c, rangeDays: v }))}
/>
}
>
{loadingCharts.joProcess ? (
<Skeleton variant="rectangular" height={320} />
) : (
<ApexCharts
options={{
chart: { type: "bar" },
xaxis: { categories: chartData.joProcess.map((d) => d.date) },
yaxis: { title: { text: "筆數" } },
plotOptions: { bar: { columnWidth: "60%" } },
dataLabels: { enabled: false },
legend: { position: "top" },
}}
series={[
{ name: "待完成", data: chartData.joProcess.map((d) => d.pendingCount) },
{ name: "已完成", data: chartData.joProcess.map((d) => d.completedCount) },
]}
type="bar"
width="100%"
height={320}
/>
)}
</ChartCard>

<ChartCard
title="設備使用中/已使用(按工單)"
exportFilename="工單設備_使用中已使用_按日期"
exportData={chartData.joEquipment.map((d) => ({ 日期: d.date, 使用中: d.workingCount, 已使用: d.workedCount }))}
filters={
<DateRangeSelect
value={criteria.joDetail.rangeDays}
onChange={(v) => updateCriteria("joDetail", (c) => ({ ...c, rangeDays: v }))}
/>
}
>
{loadingCharts.joEquipment ? (
<Skeleton variant="rectangular" height={320} />
) : (
<ApexCharts
options={{
chart: { type: "bar" },
xaxis: { categories: chartData.joEquipment.map((d) => d.date) },
yaxis: { title: { text: "筆數" } },
plotOptions: { bar: { columnWidth: "60%" } },
dataLabels: { enabled: false },
legend: { position: "top" },
}}
series={[
{ name: "使用中", data: chartData.joEquipment.map((d) => d.workingCount) },
{ name: "已使用", data: chartData.joEquipment.map((d) => d.workedCount) },
]}
type="bar"
width="100%"
height={320}
/>
)}
</ChartCard>
</Box>
);
}

+ 24
- 0
src/app/(main)/chart/layout.tsx Просмотреть файл

@@ -0,0 +1,24 @@
import { Metadata } from "next";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { authOptions } from "@/config/authConfig";
import { AUTH } from "@/authorities";

export const metadata: Metadata = {
title: "圖表報告",
};

export default async function ChartLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getServerSession(authOptions);
const abilities = session?.user?.abilities ?? [];
const canViewCharts =
abilities.includes(AUTH.TESTING) || abilities.includes(AUTH.ADMIN);
if (!canViewCharts) {
redirect("/dashboard");
}
return <>{children}</>;
}

+ 5
- 0
src/app/(main)/chart/page.tsx Просмотреть файл

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";

export default function ChartIndexPage() {
redirect("/chart/warehouse");
}

+ 74
- 0
src/app/(main)/chart/purchase/page.tsx Просмотреть файл

@@ -0,0 +1,74 @@
"use client";

import React, { useState } from "react";
import { Box, Typography, Skeleton, Alert, TextField } from "@mui/material";
import dynamic from "next/dynamic";
import ShoppingCart from "@mui/icons-material/ShoppingCart";
import { fetchPurchaseOrderByStatus } from "@/app/api/chart/client";
import ChartCard from "../_components/ChartCard";
import dayjs from "dayjs";

const ApexCharts = dynamic(() => import("react-apexcharts"), { ssr: false });

const PAGE_TITLE = "採購";

export default function PurchaseChartPage() {
const [poTargetDate, setPoTargetDate] = useState<string>(() => dayjs().format("YYYY-MM-DD"));
const [error, setError] = useState<string | null>(null);
const [chartData, setChartData] = useState<{ status: string; count: number }[]>([]);
const [loading, setLoading] = useState(true);

React.useEffect(() => {
setLoading(true);
fetchPurchaseOrderByStatus(poTargetDate)
.then((data) => setChartData(data as { status: string; count: number }[]))
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setLoading(false));
}, [poTargetDate]);

return (
<Box sx={{ maxWidth: 1200, mx: "auto" }}>
<Typography variant="h5" sx={{ mb: 2, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}>
<ShoppingCart /> {PAGE_TITLE}
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}

<ChartCard
title="按狀態採購單"
exportFilename="採購單_按狀態"
exportData={chartData.map((p) => ({ 狀態: p.status, 數量: p.count }))}
filters={
<TextField
size="small"
label="日期"
type="date"
value={poTargetDate}
onChange={(e) => setPoTargetDate(e.target.value)}
InputLabelProps={{ shrink: true }}
sx={{ minWidth: 160 }}
/>
}
>
{loading ? (
<Skeleton variant="rectangular" height={320} />
) : (
<ApexCharts
options={{
chart: { type: "donut" },
labels: chartData.map((p) => p.status),
legend: { position: "bottom" },
}}
series={chartData.map((p) => p.count)}
type="donut"
width="100%"
height={320}
/>
)}
</ChartCard>
</Box>
);
}

+ 362
- 0
src/app/(main)/chart/warehouse/page.tsx Просмотреть файл

@@ -0,0 +1,362 @@
"use client";

import React, { useCallback, useState } from "react";
import { Box, Typography, Skeleton, Alert, TextField, Button, Chip, Stack } from "@mui/material";
import dynamic from "next/dynamic";
import dayjs from "dayjs";
import WarehouseIcon from "@mui/icons-material/Warehouse";
import {
fetchStockTransactionsByDate,
fetchStockInOutByDate,
fetchStockBalanceTrend,
fetchConsumptionTrendByMonth,
} from "@/app/api/chart/client";
import ChartCard from "../_components/ChartCard";
import DateRangeSelect from "../_components/DateRangeSelect";
import { toDateRange, DEFAULT_RANGE_DAYS, ITEM_CODE_DEBOUNCE_MS } from "../_components/constants";

const ApexCharts = dynamic(() => import("react-apexcharts"), { ssr: false });

const PAGE_TITLE = "庫存與倉儲";

type Criteria = {
stockTxn: { rangeDays: number };
stockInOut: { rangeDays: number };
balance: { rangeDays: number };
consumption: { rangeDays: number };
};

const defaultCriteria: Criteria = {
stockTxn: { rangeDays: DEFAULT_RANGE_DAYS },
stockInOut: { rangeDays: DEFAULT_RANGE_DAYS },
balance: { rangeDays: DEFAULT_RANGE_DAYS },
consumption: { rangeDays: DEFAULT_RANGE_DAYS },
};

export default function WarehouseChartPage() {
const [criteria, setCriteria] = useState<Criteria>(defaultCriteria);
const [itemCodeBalance, setItemCodeBalance] = useState("");
const [debouncedItemCodeBalance, setDebouncedItemCodeBalance] = useState("");
const [consumptionItemCodes, setConsumptionItemCodes] = useState<string[]>([]);
const [consumptionItemCodeInput, setConsumptionItemCodeInput] = useState("");
const [error, setError] = useState<string | null>(null);
const [chartData, setChartData] = useState<{
stockTxn: { date: string; inQty: number; outQty: number; totalQty: number }[];
stockInOut: { date: string; inQty: number; outQty: number }[];
balance: { date: string; balance: number }[];
consumption: { month: string; outQty: number }[];
consumptionByItems?: { months: string[]; series: { name: string; data: number[] }[] };
}>({ stockTxn: [], stockInOut: [], balance: [], consumption: [] });
const [loadingCharts, setLoadingCharts] = useState<Record<string, boolean>>({});

const updateCriteria = useCallback(
<K extends keyof Criteria>(key: K, updater: (prev: Criteria[K]) => Criteria[K]) => {
setCriteria((prev) => ({ ...prev, [key]: updater(prev[key]) }));
},
[]
);
const setChartLoading = useCallback((key: string, value: boolean) => {
setLoadingCharts((prev) => (prev[key] === value ? prev : { ...prev, [key]: value }));
}, []);

React.useEffect(() => {
const t = setTimeout(() => setDebouncedItemCodeBalance(itemCodeBalance), ITEM_CODE_DEBOUNCE_MS);
return () => clearTimeout(t);
}, [itemCodeBalance]);
const addConsumptionItem = useCallback(() => {
const code = consumptionItemCodeInput.trim();
if (!code || consumptionItemCodes.includes(code)) return;
setConsumptionItemCodes((prev) => [...prev, code].sort());
setConsumptionItemCodeInput("");
}, [consumptionItemCodeInput, consumptionItemCodes]);

React.useEffect(() => {
const { startDate: s, endDate: e } = toDateRange(criteria.stockTxn.rangeDays);
setChartLoading("stockTxn", true);
fetchStockTransactionsByDate(s, e)
.then((data) =>
setChartData((prev) => ({
...prev,
stockTxn: data as { date: string; inQty: number; outQty: number; totalQty: number }[],
}))
)
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setChartLoading("stockTxn", false));
}, [criteria.stockTxn, setChartLoading]);

React.useEffect(() => {
const { startDate: s, endDate: e } = toDateRange(criteria.stockInOut.rangeDays);
setChartLoading("stockInOut", true);
fetchStockInOutByDate(s, e)
.then((data) =>
setChartData((prev) => ({
...prev,
stockInOut: data as { date: string; inQty: number; outQty: number }[],
}))
)
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setChartLoading("stockInOut", false));
}, [criteria.stockInOut, setChartLoading]);

React.useEffect(() => {
const { startDate: s, endDate: e } = toDateRange(criteria.balance.rangeDays);
const item = debouncedItemCodeBalance.trim() || undefined;
setChartLoading("balance", true);
fetchStockBalanceTrend(s, e, item)
.then((data) =>
setChartData((prev) => ({
...prev,
balance: data as { date: string; balance: number }[],
}))
)
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setChartLoading("balance", false));
}, [criteria.balance, debouncedItemCodeBalance, setChartLoading]);

React.useEffect(() => {
const { startDate: s, endDate: e } = toDateRange(criteria.consumption.rangeDays);
setChartLoading("consumption", true);
if (consumptionItemCodes.length === 0) {
fetchConsumptionTrendByMonth(dayjs().year(), s, e, undefined)
.then((data) =>
setChartData((prev) => ({
...prev,
consumption: data as { month: string; outQty: number }[],
consumptionByItems: undefined,
}))
)
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setChartLoading("consumption", false));
return;
}
Promise.all(
consumptionItemCodes.map((code) =>
fetchConsumptionTrendByMonth(dayjs().year(), s, e, code)
)
)
.then((results) => {
const byItem = results.map((rows, i) => ({
itemCode: consumptionItemCodes[i],
rows: rows as { month: string; outQty: number }[],
}));
const allMonths = Array.from(
new Set(byItem.flatMap((x) => x.rows.map((r) => r.month)))
).sort();
const series = byItem.map(({ itemCode, rows }) => ({
name: itemCode,
data: allMonths.map((m) => {
const r = rows.find((x) => x.month === m);
return r ? r.outQty : 0;
}),
}));
setChartData((prev) => ({
...prev,
consumption: [],
consumptionByItems: { months: allMonths, series },
}));
})
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setChartLoading("consumption", false));
}, [criteria.consumption, consumptionItemCodes, setChartLoading]);

return (
<Box sx={{ maxWidth: 1200, mx: "auto" }}>
<Typography variant="h5" sx={{ mb: 2, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}>
<WarehouseIcon /> {PAGE_TITLE}
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}

<ChartCard
title="按日期庫存流水(入/出/合計)"
exportFilename="庫存流水_按日期"
exportData={chartData.stockTxn.map((s) => ({ 日期: s.date, 入庫: s.inQty, 出庫: s.outQty, 合計: s.totalQty }))}
filters={
<DateRangeSelect
value={criteria.stockTxn.rangeDays}
onChange={(v) => updateCriteria("stockTxn", (c) => ({ ...c, rangeDays: v }))}
/>
}
>
{loadingCharts.stockTxn ? (
<Skeleton variant="rectangular" height={320} />
) : (
<ApexCharts
options={{
chart: { type: "line" },
xaxis: { categories: chartData.stockTxn.map((s) => s.date) },
yaxis: { title: { text: "數量" } },
stroke: { curve: "smooth" },
dataLabels: { enabled: false },
}}
series={[
{ name: "入庫", data: chartData.stockTxn.map((s) => s.inQty) },
{ name: "出庫", data: chartData.stockTxn.map((s) => s.outQty) },
{ name: "合計", data: chartData.stockTxn.map((s) => s.totalQty) },
]}
type="line"
width="100%"
height={320}
/>
)}
</ChartCard>

<ChartCard
title="按日期入庫與出庫"
exportFilename="入庫與出庫_按日期"
exportData={chartData.stockInOut.map((s) => ({ 日期: s.date, 入庫: s.inQty, 出庫: s.outQty }))}
filters={
<DateRangeSelect
value={criteria.stockInOut.rangeDays}
onChange={(v) => updateCriteria("stockInOut", (c) => ({ ...c, rangeDays: v }))}
/>
}
>
{loadingCharts.stockInOut ? (
<Skeleton variant="rectangular" height={320} />
) : (
<ApexCharts
options={{
chart: { type: "area", stacked: false },
xaxis: { categories: chartData.stockInOut.map((s) => s.date) },
yaxis: { title: { text: "數量" } },
stroke: { curve: "smooth" },
dataLabels: { enabled: false },
}}
series={[
{ name: "入庫", data: chartData.stockInOut.map((s) => s.inQty) },
{ name: "出庫", data: chartData.stockInOut.map((s) => s.outQty) },
]}
type="area"
width="100%"
height={320}
/>
)}
</ChartCard>

<ChartCard
title="庫存餘額趨勢"
exportFilename="庫存餘額趨勢"
exportData={chartData.balance.map((b) => ({ 日期: b.date, 餘額: b.balance }))}
filters={
<>
<DateRangeSelect
value={criteria.balance.rangeDays}
onChange={(v) => updateCriteria("balance", (c) => ({ ...c, rangeDays: v }))}
/>
<TextField
size="small"
label="物料編碼"
placeholder="可選"
value={itemCodeBalance}
onChange={(e) => setItemCodeBalance(e.target.value)}
sx={{ minWidth: 180 }}
/>
</>
}
>
{loadingCharts.balance ? (
<Skeleton variant="rectangular" height={320} />
) : (
<ApexCharts
options={{
chart: { type: "line" },
xaxis: { categories: chartData.balance.map((b) => b.date) },
yaxis: { title: { text: "餘額" } },
stroke: { curve: "smooth" },
dataLabels: { enabled: false },
}}
series={[{ name: "餘額", data: chartData.balance.map((b) => b.balance) }]}
type="line"
width="100%"
height={320}
/>
)}
</ChartCard>

<ChartCard
title="按月考勤消耗趨勢(出庫量)"
exportFilename="按月考勤消耗趨勢_出庫量"
exportData={
chartData.consumptionByItems
? chartData.consumptionByItems.series.flatMap((s) =>
s.data.map((qty, i) => ({
月份: chartData.consumptionByItems!.months[i],
物料編碼: s.name,
出庫量: qty,
}))
)
: chartData.consumption.map((c) => ({ 月份: c.month, 出庫量: c.outQty }))
}
filters={
<>
<DateRangeSelect
value={criteria.consumption.rangeDays}
onChange={(v) => updateCriteria("consumption", (c) => ({ ...c, rangeDays: v }))}
/>
<Stack direction="row" alignItems="center" flexWrap="wrap" gap={1}>
<TextField
size="small"
label="物料編碼"
placeholder={consumptionItemCodes.length === 0 ? "不選則全部合計" : "新增物料以分項顯示"}
value={consumptionItemCodeInput}
onChange={(e) => setConsumptionItemCodeInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), addConsumptionItem())}
sx={{ minWidth: 180 }}
/>
<Button size="small" variant="outlined" onClick={addConsumptionItem}>
新增
</Button>
{consumptionItemCodes.map((code) => (
<Chip
key={code}
label={code}
size="small"
onDelete={() =>
setConsumptionItemCodes((prev) => prev.filter((c) => c !== code))
}
/>
))}
</Stack>
</>
}
>
{loadingCharts.consumption ? (
<Skeleton variant="rectangular" height={320} />
) : chartData.consumptionByItems ? (
<ApexCharts
options={{
chart: { type: "bar", stacked: false },
xaxis: { categories: chartData.consumptionByItems.months },
yaxis: { title: { text: "出庫量" } },
plotOptions: { bar: { columnWidth: "60%" } },
dataLabels: { enabled: false },
legend: { position: "top" },
}}
series={chartData.consumptionByItems.series}
type="bar"
width="100%"
height={320}
/>
) : (
<ApexCharts
options={{
chart: { type: "bar" },
xaxis: { categories: chartData.consumption.map((c) => c.month) },
yaxis: { title: { text: "出庫量" } },
plotOptions: { bar: { columnWidth: "60%" } },
dataLabels: { enabled: false },
}}
series={[{ name: "出庫量", data: chartData.consumption.map((c) => c.outQty) }]}
type="bar"
width="100%"
height={320}
/>
)}
</ChartCard>
</Box>
);
}

+ 1
- 1
src/app/(main)/dashboard/page.tsx Просмотреть файл

@@ -18,7 +18,7 @@ const Dashboard: React.FC<Props> = async ({ searchParams }) => {
fetchEscalationLogsByUser()

return (
<I18nProvider namespaces={["dashboard", "common"]}>
<I18nProvider namespaces={["dashboard", "common", "purchaseOrder"]}>
<Suspense fallback={<DashboardPage.Loading />}>
<DashboardPage searchParams={searchParams} />
</Suspense>


+ 20
- 22
src/app/(main)/do/edit/page.tsx Просмотреть файл

@@ -1,38 +1,36 @@
import { SearchParams } from "@/app/utils/fetchUtil";
import DoDetail from "@/components/DoDetail/DodetailWrapper";
import DoDetail from "@/components/DoDetail/DoDetailWrapper";
import PageTitleBar from "@/components/PageTitleBar";
import { I18nProvider, getServerI18n } from "@/i18n";
import { Typography } from "@mui/material";
import { isArray } from "lodash";
import { Metadata } from "next";
import { notFound } from "next/navigation";
import { Suspense } from "react";

export const metadata: Metadata = {
title: "Edit Delivery Order Detail"
}
title: "Edit Delivery Order Detail",
};

type Props = SearchParams;

const DoEdit: React.FC<Props> = async ({ searchParams }) => {
const { t } = await getServerI18n("do");
const id = searchParams["id"];
const { t } = await getServerI18n("do");
const id = searchParams["id"];

if (!id || isArray(id) || !isFinite(parseInt(id))) {
notFound();
}
if (!id || isArray(id) || !isFinite(parseInt(id))) {
notFound();
}

return (
<>
<Typography variant="h4" marginInlineEnd={2}>
{t("Edit Delivery Order Detail")}
</Typography>
<I18nProvider namespaces={["do", "common"]}>
<Suspense fallback={<DoDetail.Loading />}>
<DoDetail id={parseInt(id)} />
</Suspense>
</I18nProvider>
</>
);
}
return (
<>
<PageTitleBar title={t("Edit Delivery Order Detail")} className="mb-4" />
<I18nProvider namespaces={["do", "common"]}>
<Suspense fallback={<DoDetail.Loading />}>
<DoDetail id={parseInt(id)} />
</Suspense>
</I18nProvider>
</>
);
};

export default DoEdit;

+ 2
- 8
src/app/(main)/do/page.tsx Просмотреть файл

@@ -2,7 +2,7 @@
// import { getServerI18n } from "@/i18n"
import DoSearch from "../../../components/DoSearch";
import { getServerI18n } from "../../../i18n";
import { Stack, Typography } from "@mui/material";
import PageTitleBar from "@/components/PageTitleBar";
import { I18nProvider } from "@/i18n";
import { Metadata } from "next";
import { Suspense } from "react";
@@ -16,13 +16,7 @@ const DeliveryOrder: React.FC = async () => {

return (
<>
<Stack
direction="row"
justifyContent={"space-between"}
flexWrap={"wrap"}
rowGap={2}
></Stack>

<PageTitleBar title={t("Delivery Order")} className="mb-4" />
<I18nProvider namespaces={["do", "common"]}>
<Suspense fallback={<DoSearch.Loading />}>
<DoSearch />


+ 34
- 35
src/app/(main)/jo/edit/page.tsx Просмотреть файл

@@ -1,52 +1,51 @@
import { fetchJoDetail } from "@/app/api/jo";
import { SearchParams, ServerFetchError } from "@/app/utils/fetchUtil";
import JoSave from "@/components/JoSave";
import PageTitleBar from "@/components/PageTitleBar";
import { I18nProvider, getServerI18n } from "@/i18n";
import { Typography } from "@mui/material";
import { isArray } from "lodash";
import { Metadata } from "next";
import { notFound } from "next/navigation";
import { Suspense } from "react";

export const metadata: Metadata = {
title: "Edit Job Order Detail"
}
title: "Edit Job Order Detail",
};

type Props = SearchParams;

const JoEdit: React.FC<Props> = async ({ searchParams }) => {
const { t } = await getServerI18n("jo");
const id = searchParams["id"];
if (!id || isArray(id) || !isFinite(parseInt(id))) {
notFound();
}
try {
await fetchJoDetail(parseInt(id))
} catch (e) {
if (e instanceof ServerFetchError && (e.response?.status === 404 || e.response?.status === 400)) {
console.log("Job Order not found:", e);
} else {
console.error("Error fetching Job Order detail:", e);
}
notFound();
const { t } = await getServerI18n("jo");
const id = searchParams["id"];
if (!id || isArray(id) || !isFinite(parseInt(id))) {
notFound();
}
try {
await fetchJoDetail(parseInt(id));
} catch (e) {
if (
e instanceof ServerFetchError &&
(e.response?.status === 404 || e.response?.status === 400)
) {
console.log("Job Order not found:", e);
} else {
console.error("Error fetching Job Order detail:", e);
}


return (
<>
<Typography variant="h4" marginInlineEnd={2}>
{t("Edit Job Order Detail")}
</Typography>
<I18nProvider namespaces={["jo", "common"]}>
<Suspense fallback={<JoSave.Loading />}>
<JoSave id={parseInt(id)} />
</Suspense>
</I18nProvider>
</>
);
}
notFound();
}

return (
<>
<PageTitleBar title={t("Edit Job Order Detail")} className="mb-4" />
<I18nProvider namespaces={["jo", "common"]}>
<Suspense fallback={<JoSave.Loading />}>
<JoSave id={parseInt(id)} />
</Suspense>
</I18nProvider>
</>
);
};

export default JoEdit;

+ 18
- 27
src/app/(main)/jo/page.tsx Просмотреть файл

@@ -1,38 +1,29 @@
import { preloadBomCombo } from "@/app/api/bom";
import JoSearch from "@/components/JoSearch";
import PageTitleBar from "@/components/PageTitleBar";
import { I18nProvider, getServerI18n } from "@/i18n";
import { Stack, Typography } from "@mui/material";
import { Metadata } from "next";
import React, { Suspense } from "react";

export const metadata: Metadata = {
title: "Job Order"
}
title: "Job Order",
};

const jo: React.FC = async () => {
const { t } = await getServerI18n("jo");
const Jo: React.FC = async () => {
const { t } = await getServerI18n("jo");

preloadBomCombo()
preloadBomCombo();

return (
<>
<Stack
direction="row"
justifyContent="space-between"
flexWrap="wrap"
rowGap={2}
>
<Typography variant="h4" marginInlineEnd={2}>
{t("Search Job Order/ Create Job Order")}
</Typography>
</Stack>
<I18nProvider namespaces={["jo", "common", "purchaseOrder", "dashboard","common"]}> {/* TODO: Improve */}
<Suspense fallback={<JoSearch.Loading />}>
<JoSearch />
</Suspense>
</I18nProvider>
</>
)
}
return (
<>
<PageTitleBar title={t("Search Job Order/ Create Job Order")} className="mb-4" />
<I18nProvider namespaces={["jo", "common", "purchaseOrder", "dashboard"]}>
<Suspense fallback={<JoSearch.Loading />}>
<JoSearch />
</Suspense>
</I18nProvider>
</>
);
};

export default jo;
export default Jo;

+ 31
- 30
src/app/(main)/jodetail/edit/page.tsx Просмотреть файл

@@ -1,8 +1,8 @@
import { fetchJoDetail } from "@/app/api/jo";
import { SearchParams, ServerFetchError } from "@/app/utils/fetchUtil";
import JoSave from "@/components/JoSave/JoSave";
import PageTitleBar from "@/components/PageTitleBar";
import { I18nProvider, getServerI18n } from "@/i18n";
import { Typography } from "@mui/material";
import { isArray } from "lodash";
import { Metadata } from "next";
import { notFound } from "next/navigation";
@@ -10,40 +10,41 @@ import { Suspense } from "react";
import GeneralLoading from "@/components/General/GeneralLoading";

export const metadata: Metadata = {
title: "Edit Job Order Detail"
}
title: "Edit Job Order Detail",
};

type Props = SearchParams;

const JoEdit: React.FC<Props> = async ({ searchParams }) => {
const { t } = await getServerI18n("jo");
const id = searchParams["id"];
const JodetailEdit: React.FC<Props> = async ({ searchParams }) => {
const { t } = await getServerI18n("jo");
const id = searchParams["id"];

if (!id || isArray(id) || !isFinite(parseInt(id))) {
notFound();
}
if (!id || isArray(id) || !isFinite(parseInt(id))) {
notFound();
}

try {
await fetchJoDetail(parseInt(id))
} catch (e) {
if (e instanceof ServerFetchError && (e.response?.status === 404 || e.response?.status === 400)) {
console.log(e)
notFound();
}
try {
await fetchJoDetail(parseInt(id));
} catch (e) {
if (
e instanceof ServerFetchError &&
(e.response?.status === 404 || e.response?.status === 400)
) {
console.log(e);
notFound();
}
}

return (
<>
<Typography variant="h4" marginInlineEnd={2}>
{t("Edit Job Order Detail")}
</Typography>
<I18nProvider namespaces={["jo", "common"]}>
<Suspense fallback={<GeneralLoading />}>
<JoSave id={parseInt(id)} defaultValues={undefined} />
</Suspense>
</I18nProvider>
</>
);
}
return (
<>
<PageTitleBar title={t("Edit Job Order Detail")} className="mb-4" />
<I18nProvider namespaces={["jo", "common"]}>
<Suspense fallback={<GeneralLoading />}>
<JoSave id={parseInt(id)} defaultValues={undefined} />
</Suspense>
</I18nProvider>
</>
);
};

export default JoEdit;
export default JodetailEdit;

+ 21
- 30
src/app/(main)/jodetail/page.tsx Просмотреть файл

@@ -1,39 +1,30 @@
import { preloadBomCombo } from "@/app/api/bom";
import JodetailSearch from "@/components/Jodetail/JodetailSearch";
import JodetailSearchWrapper from "@/components/Jodetail/FinishedGoodSearchWrapper";
import GeneralLoading from "@/components/General/GeneralLoading";
import PageTitleBar from "@/components/PageTitleBar";
import { I18nProvider, getServerI18n } from "@/i18n";
import { Stack, Typography } from "@mui/material";
import { Metadata } from "next";
import React, { Suspense } from "react";
import GeneralLoading from "@/components/General/GeneralLoading";
import JodetailSearchWrapper from "@/components/Jodetail/FinishedGoodSearchWrapper";

export const metadata: Metadata = {
title: "Job Order Pickexcution"
}
title: "Job Order Pick Execution",
};

const jo: React.FC = async () => {
const { t } = await getServerI18n("jo");
const Jodetail: React.FC = async () => {
const { t } = await getServerI18n("jo");

preloadBomCombo()
preloadBomCombo();

return (
<>
<Stack
direction="row"
justifyContent="space-between"
flexWrap="wrap"
rowGap={2}
>
<Typography variant="h4" marginInlineEnd={2}>
{t("Job Order Pickexcution")}
</Typography>
</Stack>
<I18nProvider namespaces={["jo", "common", "pickOrder"]}>
<Suspense fallback={<GeneralLoading />}>
<JodetailSearchWrapper />
</Suspense>
</I18nProvider>
</>
)
}
return (
<>
<PageTitleBar title={t("Job Order Pick Execution")} className="mb-4" />
<I18nProvider namespaces={["jo", "common", "pickOrder"]}>
<Suspense fallback={<GeneralLoading />}>
<JodetailSearchWrapper />
</Suspense>
</I18nProvider>
</>
);
};

export default jo;
export default Jodetail;

+ 1
- 1
src/app/(main)/layout.tsx Просмотреть файл

@@ -49,8 +49,8 @@ export default async function MainLayout({
component="main"
sx={{
marginInlineStart: { xs: 0, xl: NAVIGATION_CONTENT_WIDTH },
padding: { xs: "1rem", sm: "1.5rem", lg: "3rem" },
}}
className="min-h-screen bg-slate-50 p-4 sm:p-4 md:p-6 lg:p-8 dark:bg-slate-900"
>
<Stack spacing={2}>
<I18nProvider namespaces={["common"]}>


+ 1
- 1
src/app/(main)/productionProcess/page.tsx Просмотреть файл

@@ -38,7 +38,7 @@ const productionProcess: React.FC = async () => {
{t("Create Process")}
</Button> */}
</Stack>
<I18nProvider namespaces={["common", "production","purchaseOrder","jo"]}>
<I18nProvider namespaces={["common", "production","purchaseOrder","jo","dashboard"]}>
<ProductionProcessPage printerCombo={printerCombo} />
</I18nProvider>
</>


+ 935
- 218
src/app/(main)/ps/page.tsx
Разница между файлами не показана из-за своего большого размера
Просмотреть файл


+ 37
- 0
src/app/(main)/putAwayCam/page.tsx Просмотреть файл

@@ -0,0 +1,37 @@
import PutAwayCamScanWrapper from "@/components/PutAwayScan/PutAwayCamScanWrapper";
import { I18nProvider, getServerI18n } from "@/i18n";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { Metadata } from "next";
import { Suspense } from "react";

export const metadata: Metadata = {
title: "Put Away Camera",
};

const PutAwayCamPage: React.FC = async () => {
const { t } = await getServerI18n("putAway");

return (
<>
<Stack
direction="row"
justifyContent="space-between"
flexWrap="wrap"
rowGap={2}
>
<Typography variant="h4" marginInlineEnd={2}>
{t("Put Away")}
</Typography>
</Stack>
<I18nProvider namespaces={["putAway", "purchaseOrder", "common"]}>
<Suspense fallback={<PutAwayCamScanWrapper.Loading />}>
<PutAwayCamScanWrapper />
</Suspense>
</I18nProvider>
</>
);
};

export default PutAwayCamPage;


+ 59
- 0
src/app/(main)/report/GRN_REPORT_BACKEND_SPEC.md Просмотреть файл

@@ -0,0 +1,59 @@
# GRN Report – Backend API Spec

The frontend **GRN/入倉明細報告** report calls the following endpoint. The backend must implement it to return JSON (not PDF).

## Endpoint

- **Method:** `GET`
- **Path:** `/report/grn-report`
- **Query parameters (all optional):**
- `receiptDateStart` – date (e.g. `yyyy-MM-dd`), filter receipt date from
- `receiptDateEnd` – date (e.g. `yyyy-MM-dd`), filter receipt date to
- `itemCode` – string, filter by item code (partial match if desired)

## Response

- **Content-Type:** `application/json`
- **Body:** Either an array of row objects, or an object with a `rows` array:

```json
{
"rows": [
{
"poCode": "PO-2025-001",
"deliveryNoteNo": "DN-12345",
"receiptDate": "2025-03-15",
"itemCode": "MAT-001",
"itemName": "Raw Material A",
"acceptedQty": 100,
"receivedQty": 100,
"demandQty": 120,
"uom": "KG",
"purchaseUomDesc": "Kilogram",
"stockUomDesc": "KG",
"productLotNo": "LOT-001",
"expiryDate": "2026-03-01",
"supplier": "Supplier Name",
"status": "completed"
}
]
}
```

Or a direct array:

```json
[
{ "poCode": "PO-2025-001", "deliveryNoteNo": "DN-12345", ... }
]
```

## Suggested backend implementation

- Use data that “generates the GRN” (Goods Received Note): e.g. **stock-in lines** (or equivalent) linked to **PO** and **delivery note**.
- Filter by:
- `receiptDate` (or equivalent) between `receiptDateStart` and `receiptDateEnd` when provided.
- `itemCode` when provided.
- Return one row per GRN line with at least: **PO/delivery note no.**, **itemCode**, **itemName**, **qty** (e.g. `acceptedQty`), **uom**, and optionally receipt date, lot, expiry, supplier, status.

Frontend builds the Excel from this JSON and downloads it with columns: PO No., Delivery Note No., Receipt Date, Item Code, Item Name, Qty, Demand Qty, UOM, Product Lot No., Expiry Date, Supplier, Status.

+ 181
- 0
src/app/(main)/report/SemiFGProductionAnalysisReport.tsx Просмотреть файл

@@ -0,0 +1,181 @@
"use client";

import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Chip,
Typography,
} from '@mui/material';
import PrintIcon from '@mui/icons-material/Print';
import {
fetchSemiFGItemCodes,
fetchSemiFGItemCodesWithCategory,
generateSemiFGProductionAnalysisReport,
ItemCodeWithCategory,
} from './semiFGProductionAnalysisApi';

interface SemiFGProductionAnalysisReportProps {
criteria: Record<string, string>;
requiredFieldLabels: string[];
loading: boolean;
setLoading: (loading: boolean) => void;
reportTitle?: string;
}

export default function SemiFGProductionAnalysisReport({
criteria,
requiredFieldLabels,
loading,
setLoading,
reportTitle = '成品/半成品生產分析報告',
}: SemiFGProductionAnalysisReportProps) {
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [selectedItemCodesInfo, setSelectedItemCodesInfo] = useState<ItemCodeWithCategory[]>([]);
const [itemCodesWithCategory, setItemCodesWithCategory] = useState<Record<string, ItemCodeWithCategory>>({});

// Fetch item codes with category when stockCategory changes
useEffect(() => {
const stockCategory = criteria.stockCategory || '';
if (stockCategory) {
fetchSemiFGItemCodesWithCategory(stockCategory)
.then((items) => {
const categoryMap: Record<string, ItemCodeWithCategory> = {};
items.forEach((item) => {
categoryMap[item.code] = item;
});
setItemCodesWithCategory((prev) => ({ ...prev, ...categoryMap }));
})
.catch((error) => {
console.error('Failed to fetch item codes with category:', error);
});
}
}, [criteria.stockCategory]);

const handlePrintClick = async () => {
// Validate required fields
if (requiredFieldLabels.length > 0) {
alert(`缺少必填條件:\n- ${requiredFieldLabels.join('\n- ')}`);
return;
}

// If no itemCode is selected, print directly without confirmation
if (!criteria.itemCode) {
await executePrint();
return;
}

// If itemCode is selected, show confirmation dialog
const selectedCodes = criteria.itemCode.split(',').filter((code) => code.trim());
const itemCodesInfo: ItemCodeWithCategory[] = selectedCodes.map((code) => {
const codeTrimmed = code.trim();
const categoryInfo = itemCodesWithCategory[codeTrimmed];
return {
code: codeTrimmed,
category: categoryInfo?.category || 'Unknown',
name: categoryInfo?.name || '',
};
});
setSelectedItemCodesInfo(itemCodesInfo);
setShowConfirmDialog(true);
};

const executePrint = async () => {
setLoading(true);
try {
await generateSemiFGProductionAnalysisReport(criteria, reportTitle);
setShowConfirmDialog(false);
} catch (error) {
console.error('Failed to generate report:', error);
alert('An error occurred while generating the report. Please try again.');
} finally {
setLoading(false);
}
};

return (
<>
<Button
variant="contained"
size="large"
startIcon={<PrintIcon />}
onClick={handlePrintClick}
disabled={loading}
sx={{ px: 4 }}
>
{loading ? '生成報告...' : '列印報告'}
</Button>

{/* Confirmation Dialog for 成品/半成品生產分析報告 */}
<Dialog
open={showConfirmDialog}
onClose={() => setShowConfirmDialog(false)}
maxWidth="md"
fullWidth
>
<DialogTitle>
<Typography variant="h6" fontWeight="bold">
已選擇的物料編號以及列印成品/半成品生產分析報告
</Typography>
</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
請確認以下已選擇的物料編號及其類別:
</Typography>
<TableContainer component={Paper} variant="outlined">
<Table>
<TableHead>
<TableRow>
<TableCell>
<strong>物料編號及名稱</strong>
</TableCell>
<TableCell>
<strong>類別</strong>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{selectedItemCodesInfo.map((item, index) => {
const displayName = item.name ? `${item.code} ${item.name}` : item.code;
return (
<TableRow key={index}>
<TableCell>{displayName}</TableCell>
<TableCell>
<Chip
label={item.category || 'Unknown'}
color={item.category === 'FG' ? 'primary' : item.category === 'WIP' ? 'secondary' : 'default'}
size="small"
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
</DialogContent>
<DialogActions sx={{ p: 2 }}>
<Button onClick={() => setShowConfirmDialog(false)}>取消</Button>
<Button
variant="contained"
onClick={executePrint}
disabled={loading}
startIcon={<PrintIcon />}
>
{loading ? '生成報告...' : '確認列印報告'}
</Button>
</DialogActions>
</Dialog>
</>
);
}

+ 99
- 0
src/app/(main)/report/grnReportApi.ts Просмотреть файл

@@ -0,0 +1,99 @@
"use client";

import { NEXT_PUBLIC_API_URL } from "@/config/api";
import { clientAuthFetch } from "@/app/utils/clientAuthFetch";
import { exportChartToXlsx } from "@/app/(main)/chart/_components/exportChartToXlsx";

export interface GrnReportRow {
poCode?: string;
deliveryNoteNo?: string;
receiptDate?: string;
itemCode?: string;
itemName?: string;
acceptedQty?: number;
receivedQty?: number;
demandQty?: number;
uom?: string;
purchaseUomDesc?: string;
stockUomDesc?: string;
productLotNo?: string;
expiryDate?: string;
supplierCode?: string;
supplier?: string;
status?: string;
grnId?: number | string;
[key: string]: unknown;
}

export interface GrnReportResponse {
rows: GrnReportRow[];
}

/**
* Fetch GRN (Goods Received Note) report data by date range.
* Backend: GET /report/grn-report?receiptDateStart=&receiptDateEnd=&itemCode=
*/
export async function fetchGrnReportData(
criteria: Record<string, string>
): Promise<GrnReportRow[]> {
const queryParams = new URLSearchParams(criteria).toString();
const url = `${NEXT_PUBLIC_API_URL}/report/grn-report?${queryParams}`;

const response = await clientAuthFetch(url, {
method: "GET",
headers: { Accept: "application/json" },
});

if (response.status === 401 || response.status === 403)
throw new Error("Unauthorized");
if (!response.ok)
throw new Error(`HTTP error! status: ${response.status}`);

const data = (await response.json()) as GrnReportResponse | GrnReportRow[];
const rows = Array.isArray(data) ? data : (data as GrnReportResponse).rows ?? [];
return rows;
}

/** Excel column headers (bilingual) for GRN report */
function toExcelRow(r: GrnReportRow): Record<string, string | number | undefined> {
return {
"PO No. / 訂單編號": r.poCode ?? "",
"Supplier Code / 供應商編號": r.supplierCode ?? "",
"Delivery Note No. / 送貨單編號": r.deliveryNoteNo ?? "",
"Receipt Date / 收貨日期": r.receiptDate ?? "",
"Item Code / 物料編號": r.itemCode ?? "",
"Item Name / 物料名稱": r.itemName ?? "",
"Qty / 數量": r.acceptedQty ?? r.receivedQty ?? "",
"Demand Qty / 訂單數量": r.demandQty ?? "",
"UOM / 單位": r.uom ?? r.purchaseUomDesc ?? r.stockUomDesc ?? "",
"Product Lot No. / 批次": r.productLotNo ?? "",
"Expiry Date / 到期日": r.expiryDate ?? "",
"Supplier / 供應商": r.supplier ?? "",
"Status / 狀態": r.status ?? "",
"GRN Id / M18 單號": r.grnId ?? "",
};
}

/**
* Generate and download GRN report as Excel.
*/
export async function generateGrnReportExcel(
criteria: Record<string, string>,
reportTitle: string = "PO 入倉記錄"
): Promise<void> {
const rows = await fetchGrnReportData(criteria);
const excelRows = rows.map(toExcelRow);
const start = criteria.receiptDateStart;
const end = criteria.receiptDateEnd;
let datePart: string;
if (start && end && start === end) {
datePart = start;
} else if (start || end) {
datePart = `${start || ""}_to_${end || ""}`;
} else {
datePart = new Date().toISOString().slice(0, 10);
}
const safeDatePart = datePart.replace(/[^\d\-_/]/g, "");
const filename = `${reportTitle}_${safeDatePart}`;
exportChartToXlsx(excelRows, filename, "GRN");
}

+ 349
- 37
src/app/(main)/report/page.tsx Просмотреть файл

@@ -1,6 +1,6 @@
"use client";

import React, { useState, useMemo } from 'react';
import React, { useState, useMemo, useEffect } from 'react';
import {
Box,
Card,
@@ -10,17 +10,33 @@ import {
TextField,
Button,
Grid,
Divider
Divider,
Chip,
Autocomplete
} from '@mui/material';
import PrintIcon from '@mui/icons-material/Print';
import { REPORTS, ReportDefinition } from '@/config/reportConfig';
import { getSession } from "next-auth/react";
import { NEXT_PUBLIC_API_URL } from '@/config/api';
import { clientAuthFetch } from '@/app/utils/clientAuthFetch';
import SemiFGProductionAnalysisReport from './SemiFGProductionAnalysisReport';
import {
fetchSemiFGItemCodes,
fetchSemiFGItemCodesWithCategory
} from './semiFGProductionAnalysisApi';
import { generateGrnReportExcel } from './grnReportApi';

interface ItemCodeWithName {
code: string;
name: string;
}

export default function ReportPage() {
const [selectedReportId, setSelectedReportId] = useState<string>('');
const [criteria, setCriteria] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false);

const [dynamicOptions, setDynamicOptions] = useState<Record<string, { label: string; value: string }[]>>({});
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
// Find the configuration for the currently selected report
const currentReport = useMemo(() =>
REPORTS.find((r) => r.id === selectedReportId),
@@ -31,10 +47,90 @@ export default function ReportPage() {
setCriteria({}); // Clear criteria when switching reports
};

const handleFieldChange = (name: string, value: string) => {
setCriteria((prev) => ({ ...prev, [name]: value }));
const handleFieldChange = (name: string, value: string | string[]) => {
const stringValue = Array.isArray(value) ? value.join(',') : value;
setCriteria((prev) => ({ ...prev, [name]: stringValue }));
// If this is stockCategory and there's a field that depends on it, fetch dynamic options
if (name === 'stockCategory' && currentReport) {
const itemCodeField = currentReport.fields.find(f => f.name === 'itemCode' && f.dynamicOptions);
if (itemCodeField && itemCodeField.dynamicOptionsEndpoint) {
fetchDynamicOptions(itemCodeField, stringValue);
}
}
};

const fetchDynamicOptions = async (field: any, paramValue: string) => {
if (!field.dynamicOptionsEndpoint) return;
try {
// Use API service for SemiFG Production Analysis Report (rep-005)
if (currentReport?.id === 'rep-005' && field.name === 'itemCode') {
const itemCodesWithName = await fetchSemiFGItemCodes(paramValue);
const itemsWithCategory = await fetchSemiFGItemCodesWithCategory(paramValue);
const categoryMap: Record<string, { code: string; category: string; name?: string }> = {};
itemsWithCategory.forEach(item => {
categoryMap[item.code] = item;
});
const options = itemCodesWithName.map(item => {
const code = item.code;
const name = item.name || '';
const category = categoryMap[code]?.category || '';
let label = name ? `${code} ${name}` : code;
if (category) {
label = `${label} (${category})`;
}
return { label, value: code };
});
setDynamicOptions((prev) => ({ ...prev, [field.name]: options }));
return;
}
// Handle other reports with dynamic options
let url = field.dynamicOptionsEndpoint;
if (paramValue && paramValue !== 'All' && !paramValue.includes('All')) {
url = `${field.dynamicOptionsEndpoint}?${field.dynamicOptionsParam}=${paramValue}`;
}

const response = await clientAuthFetch(url, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});

if (response.status === 401 || response.status === 403) return;
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);

const data = await response.json();
const options = Array.isArray(data)
? data.map((item: any) => ({ label: item.label || item.name || item.code || String(item), value: item.value || item.code || String(item) }))
: [];
setDynamicOptions((prev) => ({ ...prev, [field.name]: options }));
} catch (error) {
console.error("Failed to fetch dynamic options:", error);
setDynamicOptions((prev) => ({ ...prev, [field.name]: [] }));
}
};

// Load initial options when report is selected
useEffect(() => {
if (currentReport) {
currentReport.fields.forEach(field => {
if (field.dynamicOptions && field.dynamicOptionsEndpoint) {
// Load all options initially
fetchDynamicOptions(field, '');
}
});
}
// Clear dynamic options when report changes
setDynamicOptions({});
}, [selectedReportId]);

const handlePrint = async () => {
if (!currentReport) return;

@@ -44,25 +140,57 @@ export default function ReportPage() {
.map(field => field.label);

if (missingFields.length > 0) {
alert(`Please enter the following mandatory fields:\n- ${missingFields.join('\n- ')}`);
alert(`缺少必填條件:\n- ${missingFields.join('\n- ')}`);
return;
}

// For rep-005, the print logic is handled by SemiFGProductionAnalysisReport component
if (currentReport.id === 'rep-005') return;

// For Excel reports (e.g. GRN), fetch JSON and download as .xlsx
if (currentReport.responseType === 'excel') {
await executeExcelReport();
return;
}

await executePrint();
};

const executeExcelReport = async () => {
if (!currentReport) return;
setLoading(true);
try {
if (currentReport.id === 'rep-014') {
await generateGrnReportExcel(criteria, currentReport.title);
}
setShowConfirmDialog(false);
} catch (error) {
console.error("Failed to generate Excel report:", error);
alert("An error occurred while generating the report. Please try again.");
} finally {
setLoading(false);
}
};

const executePrint = async () => {
if (!currentReport) return;

setLoading(true);
try {
const token = localStorage.getItem("accessToken");
const queryParams = new URLSearchParams(criteria).toString();
const url = `${currentReport.apiEndpoint}?${queryParams}`;
const response = await fetch(url, {
const response = await clientAuthFetch(url, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Accept': 'application/pdf',
},
headers: { 'Accept': 'application/pdf' },
});

if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
if (response.status === 401 || response.status === 403) return;
if (!response.ok) {
const errorText = await response.text();
console.error("Response error:", errorText);
throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}`);
}

const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);
@@ -80,6 +208,8 @@ export default function ReportPage() {
link.click();
link.remove();
window.URL.revokeObjectURL(downloadUrl);
setShowConfirmDialog(false);
} catch (error) {
console.error("Failed to generate report:", error);
alert("An error occurred while generating the report. Please try again.");
@@ -91,21 +221,21 @@ export default function ReportPage() {
return (
<Box sx={{ p: 4, maxWidth: 1000, margin: '0 auto' }}>
<Typography variant="h4" gutterBottom fontWeight="bold">
Report Management
報告管理
</Typography>
<Card sx={{ mb: 4, boxShadow: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
Select Report Type
選擇報告
</Typography>
<TextField
select
fullWidth
label="Report List"
label="報告列表"
value={selectedReportId}
onChange={handleReportChange}
helperText="Please select which report you want to generate"
helperText="選擇報告"
>
{REPORTS.map((report) => (
<MenuItem key={report.id} value={report.id}>
@@ -120,44 +250,226 @@ export default function ReportPage() {
<Card sx={{ boxShadow: 3, animation: 'fadeIn 0.5s' }}>
<CardContent>
<Typography variant="h6" color="primary" gutterBottom>
Search Criteria: {currentReport.title}
搜尋條件: {currentReport.title}
</Typography>
<Divider sx={{ mb: 3 }} />
<Grid container spacing={3}>
{currentReport.fields.map((field) => (
<Grid item xs={12} sm={6} key={field.name}>
{currentReport.fields.map((field) => {
const options = field.dynamicOptions
? (dynamicOptions[field.name] || [])
: (field.options || []);
const currentValue = criteria[field.name] || '';
const valueForSelect = field.multiple
? (currentValue ? currentValue.split(',').map(v => v.trim()).filter(v => v) : [])
: currentValue;

// Use larger grid size for 成品/半成品生產分析報告
const gridSize = currentReport.id === 'rep-005' ? { xs: 12, sm: 12, md: 6 } : { xs: 12, sm: 6 };

// Use Autocomplete for fields that allow input
if (field.type === 'select' && field.allowInput) {
const autocompleteValue = field.multiple
? (Array.isArray(valueForSelect) ? valueForSelect : [])
: (valueForSelect || null);
return (
<Grid item {...gridSize} key={field.name}>
<Autocomplete
multiple={field.multiple || false}
freeSolo
options={options.map(opt => opt.value)}
value={autocompleteValue}
onChange={(event, newValue, reason) => {
if (field.multiple) {
// Handle multiple selection - newValue is an array
let values: string[] = [];
if (Array.isArray(newValue)) {
values = newValue
.map(v => typeof v === 'string' ? v.trim() : String(v).trim())
.filter(v => v !== '');
}
handleFieldChange(field.name, values);
} else {
// Handle single selection - newValue can be string or null
const value = typeof newValue === 'string' ? newValue.trim() : (newValue || '');
handleFieldChange(field.name, value);
}
}}
onKeyDown={(event) => {
// Allow Enter key to add custom value in multiple mode
if (field.multiple && event.key === 'Enter') {
const target = event.target as HTMLInputElement;
if (target && target.value && target.value.trim()) {
const currentValues = Array.isArray(autocompleteValue) ? autocompleteValue : [];
const newValue = target.value.trim();
if (!currentValues.includes(newValue)) {
handleFieldChange(field.name, [...currentValues, newValue]);
// Clear the input
setTimeout(() => {
if (target) target.value = '';
}, 0);
}
}
}
}}
renderInput={(params) => (
<TextField
{...params}
fullWidth
label={field.label}
placeholder={field.placeholder || "選擇或輸入物料編號"}
sx={currentReport.id === 'rep-005' ? {
'& .MuiOutlinedInput-root': {
minHeight: '64px',
fontSize: '1rem'
},
'& .MuiInputLabel-root': {
fontSize: '1rem'
}
} : {}}
/>
)}
renderTags={(value, getTagProps) =>
value.map((option, index) => {
// Find the label for the option if it exists in options
const optionObj = options.find(opt => opt.value === option);
const displayLabel = optionObj ? optionObj.label : String(option);
return (
<Chip
variant="outlined"
label={displayLabel}
{...getTagProps({ index })}
key={`${option}-${index}`}
/>
);
})
}
getOptionLabel={(option) => {
// Find the label for the option if it exists in options
const optionObj = options.find(opt => opt.value === option);
return optionObj ? optionObj.label : String(option);
}}
/>
</Grid>
);
}

// Regular TextField for other fields
return (
<Grid item {...gridSize} key={field.name}>
<TextField
fullWidth
label={field.label}
type={field.type}
placeholder={field.placeholder}
InputLabelProps={field.type === 'date' ? { shrink: true } : {}}
onChange={(e) => handleFieldChange(field.name, e.target.value)}
value={criteria[field.name] || ''}
sx={currentReport.id === 'rep-005' ? {
'& .MuiOutlinedInput-root': {
minHeight: '64px',
fontSize: '1rem'
},
'& .MuiInputLabel-root': {
fontSize: '1rem'
}
} : {}}
onChange={(e) => {
if (field.multiple) {
const value = typeof e.target.value === 'string'
? e.target.value.split(',')
: e.target.value;
// Special handling for stockCategory
if (field.name === 'stockCategory' && Array.isArray(value)) {
const currentValues = (criteria[field.name] || '').split(',').map(v => v.trim()).filter(v => v);
const newValues = value.map(v => String(v).trim()).filter(v => v);
const wasOnlyAll = currentValues.length === 1 && currentValues[0] === 'All';
const hasAll = newValues.includes('All');
const hasOthers = newValues.some(v => v !== 'All');
if (hasAll && hasOthers) {
// User selected "All" along with other options
// If previously only "All" was selected, user is trying to switch - remove "All" and keep others
if (wasOnlyAll) {
const filteredValue = newValues.filter(v => v !== 'All');
handleFieldChange(field.name, filteredValue);
} else {
// User added "All" to existing selections - keep only "All"
handleFieldChange(field.name, ['All']);
}
} else if (hasAll && !hasOthers) {
// Only "All" is selected
handleFieldChange(field.name, ['All']);
} else if (!hasAll && hasOthers) {
// Other options selected without "All"
handleFieldChange(field.name, newValues);
} else {
// Empty selection
handleFieldChange(field.name, []);
}
} else {
handleFieldChange(field.name, value);
}
} else {
handleFieldChange(field.name, e.target.value);
}
}}
value={valueForSelect}
select={field.type === 'select'}
SelectProps={field.multiple ? {
multiple: true,
renderValue: (selected: any) => {
if (Array.isArray(selected)) {
return selected.join(', ');
}
return selected;
}
} : {}}
>
{field.type === 'select' && field.options?.map((opt) => (
{field.type === 'select' && options.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>
{opt.label}
</MenuItem>
))}
</TextField>
</Grid>
))}
);
})}
</Grid>

<Box sx={{ mt: 4, display: 'flex', justifyContent: 'flex-end' }}>
<Button
variant="contained"
size="large"
startIcon={<PrintIcon />}
onClick={handlePrint}
disabled={loading}
sx={{ px: 4 }}
>
{loading ? "Generating..." : "Print Report"}
</Button>
{currentReport.id === 'rep-005' ? (
<SemiFGProductionAnalysisReport
criteria={criteria}
requiredFieldLabels={currentReport.fields.filter(f => f.required && !criteria[f.name]).map(f => f.label)}
loading={loading}
setLoading={setLoading}
reportTitle={currentReport.title}
/>
) : currentReport.responseType === 'excel' ? (
<Button
variant="contained"
size="large"
startIcon={<PrintIcon />}
onClick={handlePrint}
disabled={loading}
sx={{ px: 4 }}
>
{loading ? "生成報告..." : "匯出 Excel"}
</Button>
) : (
<Button
variant="contained"
size="large"
startIcon={<PrintIcon />}
onClick={handlePrint}
disabled={loading}
sx={{ px: 4 }}
>
{loading ? "生成報告..." : "列印報告"}
</Button>
)}
</Box>
</CardContent>
</Card>


+ 102
- 0
src/app/(main)/report/semiFGProductionAnalysisApi.ts Просмотреть файл

@@ -0,0 +1,102 @@
"use client";

import { NEXT_PUBLIC_API_URL } from '@/config/api';
import { clientAuthFetch } from '@/app/utils/clientAuthFetch';

export interface ItemCodeWithName {
code: string;
name: string;
}

export interface ItemCodeWithCategory {
code: string;
category: string;
name?: string;
}

/**
* Fetch item codes for SemiFG Production Analysis Report
* @param stockCategory - Comma-separated stock categories (e.g., "FG,WIP") or empty string for all
* @returns Array of item codes with names
*/
export const fetchSemiFGItemCodes = async (
stockCategory: string = ''
): Promise<ItemCodeWithName[]> => {
let url = `${NEXT_PUBLIC_API_URL}/report/semi-fg-item-codes`;
if (stockCategory && stockCategory !== 'All' && !stockCategory.includes('All')) {
url = `${url}?stockCategory=${stockCategory}`;
}

const response = await clientAuthFetch(url, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});

if (response.status === 401 || response.status === 403) throw new Error("Unauthorized");
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);

return await response.json();
};

/**
* Fetch item codes with category information for SemiFG Production Analysis Report
* @param stockCategory - Comma-separated stock categories (e.g., "FG,WIP") or empty string for all
* @returns Array of item codes with category and name
*/
export const fetchSemiFGItemCodesWithCategory = async (
stockCategory: string = ''
): Promise<ItemCodeWithCategory[]> => {
let url = `${NEXT_PUBLIC_API_URL}/report/semi-fg-item-codes-with-category`;
if (stockCategory && stockCategory !== 'All' && !stockCategory.includes('All')) {
url = `${url}?stockCategory=${stockCategory}`;
}

const response = await clientAuthFetch(url, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});

if (response.status === 401 || response.status === 403) throw new Error("Unauthorized");
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);

return await response.json();
};

/**
* Generate and download the SemiFG Production Analysis Report PDF
* @param criteria - Report criteria parameters
* @param reportTitle - Title of the report for filename
* @returns Promise that resolves when download is complete
*/
export const generateSemiFGProductionAnalysisReport = async (
criteria: Record<string, string>,
reportTitle: string = '成品/半成品生產分析報告'
): Promise<void> => {
const queryParams = new URLSearchParams(criteria).toString();
const url = `${NEXT_PUBLIC_API_URL}/report/print-semi-fg-production-analysis?${queryParams}`;

const response = await clientAuthFetch(url, {
method: 'GET',
headers: { 'Accept': 'application/pdf' },
});

if (response.status === 401 || response.status === 403) throw new Error("Unauthorized");
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);

const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = downloadUrl;
const contentDisposition = response.headers.get('Content-Disposition');
let fileName = `${reportTitle}.pdf`;
if (contentDisposition?.includes('filename=')) {
fileName = contentDisposition.split('filename=')[1].split(';')[0].replace(/"/g, '');
}
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(downloadUrl);
};

+ 25
- 0
src/app/(main)/settings/bomWeighting/page.tsx Просмотреть файл

@@ -0,0 +1,25 @@
import { Metadata } from "next";
import { getServerI18n, I18nProvider } from "@/i18n";
import PageTitleBar from "@/components/PageTitleBar";
import BomWeightingTabs from "@/components/BomWeightingTabs";
import { fetchBomWeightingScores } from "@/app/api/settings/bomWeighting";

export const metadata: Metadata = {
title: "BOM Weighting Score",
};

const BomWeightingScorePage: React.FC = async () => {
const { t } = await getServerI18n("common");
const bomWeightingScores = await fetchBomWeightingScores();

return (
<>
<PageTitleBar title={t("BOM Weighting Score List")} className="mb-4" />
<I18nProvider namespaces={["common"]}>
<BomWeightingTabs bomWeightingScores={bomWeightingScores} />
</I18nProvider>
</>
);
};

export default BomWeightingScorePage;

+ 52
- 0
src/app/(main)/settings/importBom/EquipmentTabs.tsx Просмотреть файл

@@ -0,0 +1,52 @@
"use client";

import { useState, useEffect } from "react";
import Tab from "@mui/material/Tab";
import Tabs from "@mui/material/Tabs";
import { useTranslation } from "react-i18next";
import { useRouter, useSearchParams } from "next/navigation";

type EquipmentTabsProps = {
onTabChange?: (tabIndex: number) => void;
};

const EquipmentTabs: React.FC<EquipmentTabsProps> = ({ onTabChange }) => {
const router = useRouter();
const searchParams = useSearchParams();
const { t } = useTranslation("common");
const tabFromUrl = searchParams.get("tab");
const initialTabIndex = tabFromUrl ? parseInt(tabFromUrl, 10) : 0;
const [tabIndex, setTabIndex] = useState(initialTabIndex);

useEffect(() => {
const tabFromUrl = searchParams.get("tab");
const newTabIndex = tabFromUrl ? parseInt(tabFromUrl, 10) : 0;
if (newTabIndex !== tabIndex) {
setTabIndex(newTabIndex);
onTabChange?.(newTabIndex);
}
}, [searchParams, tabIndex, onTabChange]);

const handleTabChange = (_e: React.SyntheticEvent, newValue: number) => {
setTabIndex(newValue);
onTabChange?.(newValue);
const params = new URLSearchParams(searchParams.toString());
if (newValue === 0) {
params.delete("tab");
} else {
params.set("tab", newValue.toString());
}
router.push(`/settings/equipment?${params.toString()}`, { scroll: false });
};

return (
<Tabs value={tabIndex} onChange={handleTabChange}>
<Tab label={t("General Data")} />
<Tab label={t("Repair and Maintenance")} />
</Tabs>
);
};

export default EquipmentTabs;

+ 29
- 0
src/app/(main)/settings/importBom/MaintenanceEdit/page.tsx Просмотреть файл

@@ -0,0 +1,29 @@
import React from "react";
import { SearchParams } from "@/app/utils/fetchUtil";
import { I18nProvider, getServerI18n } from "@/i18n";
import { Typography } from "@mui/material";
import isString from "lodash/isString";
import { notFound } from "next/navigation";
import UpdateMaintenanceForm from "@/components/UpdateMaintenance/UpdateMaintenanceForm";

type Props = {} & SearchParams;

const MaintenanceEditPage: React.FC<Props> = async ({ searchParams }) => {
const type = "common";
const { t } = await getServerI18n(type);
const id = isString(searchParams["id"])
? parseInt(searchParams["id"])
: undefined;
if (!id) {
notFound();
}
return (
<>
<Typography variant="h4">{t("Update Equipment Maintenance and Repair")}</Typography>
<I18nProvider namespaces={[type]}>
<UpdateMaintenanceForm id={id} />
</I18nProvider>
</>
);
};
export default MaintenanceEditPage;

+ 22
- 0
src/app/(main)/settings/importBom/create/page.tsx Просмотреть файл

@@ -0,0 +1,22 @@
import { SearchParams } from "@/app/utils/fetchUtil";
import { TypeEnum } from "@/app/utils/typeEnum";
import CreateEquipmentType from "@/components/CreateEquipment";
import { I18nProvider, getServerI18n } from "@/i18n";
import { Typography } from "@mui/material";
import isString from "lodash/isString";

type Props = {} & SearchParams;

const materialSetting: React.FC<Props> = async ({ searchParams }) => {
// const type = TypeEnum.PRODUCT;
const { t } = await getServerI18n("common");
return (
<>
{/* <Typography variant="h4">{t("Create Material")}</Typography> */}
<I18nProvider namespaces={["common"]}>
<CreateEquipmentType />
</I18nProvider>
</>
);
};
export default materialSetting;

+ 29
- 0
src/app/(main)/settings/importBom/edit/page.tsx Просмотреть файл

@@ -0,0 +1,29 @@
import { SearchParams } from "@/app/utils/fetchUtil";
import { TypeEnum } from "@/app/utils/typeEnum";
import CreateEquipmentType from "@/components/CreateEquipment";
import { I18nProvider, getServerI18n } from "@/i18n";
import { Typography } from "@mui/material";
import isString from "lodash/isString";
import { notFound } from "next/navigation";

type Props = {} & SearchParams;

const productSetting: React.FC<Props> = async ({ searchParams }) => {
const type = "common";
const { t } = await getServerI18n(type);
const id = isString(searchParams["id"])
? parseInt(searchParams["id"])
: undefined;
if (!id) {
notFound();
}
return (
<>
{/* <Typography variant="h4">{t("Create Material")}</Typography> */}
<I18nProvider namespaces={[type]}>
<CreateEquipmentType id={id} />
</I18nProvider>
</>
);
};
export default productSetting;

+ 29
- 0
src/app/(main)/settings/importBom/page.tsx Просмотреть файл

@@ -0,0 +1,29 @@
import { Metadata } from "next";
import { I18nProvider } from "@/i18n";
import ImportBomWrapper from "@/components/ImportBom/ImportBomWrapper";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";

export const metadata: Metadata = {
title: "Import BOM",
};

export default async function ImportBomPage() {
return (
<>
<Stack
direction="row"
justifyContent="space-between"
flexWrap="wrap"
rowGap={2}
>
<Typography variant="h4" marginInlineEnd={2}>
Import BOM
</Typography>
</Stack>
<I18nProvider namespaces={["common"]}>
<ImportBomWrapper />
</I18nProvider>
</>
);
}

+ 27
- 0
src/app/(main)/settings/itemPrice/page.tsx Просмотреть файл

@@ -0,0 +1,27 @@
import { Metadata } from "next";
import { Suspense } from "react";
import { I18nProvider, getServerI18n } from "@/i18n";
import ItemPriceSearch from "@/components/ItemPriceSearch/ItemPriceSearch";
import PageTitleBar from "@/components/PageTitleBar";

export const metadata: Metadata = {
title: "Price Inquiry",
};

const ItemPriceSetting: React.FC = async () => {
const { t } = await getServerI18n("inventory", "common");

return (
<>
<PageTitleBar title={t("Price Inquiry", { ns: "common" })} className="mb-4" />

<I18nProvider namespaces={["common", "inventory"]}>
<Suspense fallback={<ItemPriceSearch.Loading />}>
<ItemPriceSearch />
</Suspense>
</I18nProvider>
</>
);
};

export default ItemPriceSetting;

+ 22
- 0
src/app/(main)/settings/printer/create/page.tsx Просмотреть файл

@@ -0,0 +1,22 @@
import { I18nProvider, getServerI18n } from "@/i18n";
import { Typography } from "@mui/material";
import { Suspense } from "react";
import CreatePrinter from "@/components/CreatePrinter";

const CreatePrinterPage: React.FC = async () => {
const { t } = await getServerI18n("common");

return (
<>
<Typography variant="h4">{t("Create Printer") || "新增列印機"}</Typography>
<I18nProvider namespaces={["common"]}>
<Suspense fallback={<CreatePrinter.Loading />}>
<CreatePrinter />
</Suspense>
</I18nProvider>
</>
);
};

export default CreatePrinterPage;


+ 38
- 0
src/app/(main)/settings/printer/edit/page.tsx Просмотреть файл

@@ -0,0 +1,38 @@
import { SearchParams } from "@/app/utils/fetchUtil";
import { I18nProvider, getServerI18n } from "@/i18n";
import { Typography } from "@mui/material";
import isString from "lodash/isString";
import { notFound } from "next/navigation";
import { Suspense } from "react";
import EditPrinter from "@/components/EditPrinter";
import { fetchPrinterDetails } from "@/app/api/settings/printer/actions";

type Props = {} & SearchParams;

const EditPrinterPage: React.FC<Props> = async ({ searchParams }) => {
const { t } = await getServerI18n("common");
const id = isString(searchParams["id"])
? parseInt(searchParams["id"])
: undefined;
if (!id) {
notFound();
}

const printer = await fetchPrinterDetails(id);
if (!printer) {
notFound();
}

return (
<>
<Typography variant="h4">{t("Edit")} {t("Printer")}</Typography>
<I18nProvider namespaces={["common"]}>
<Suspense fallback={<div>Loading...</div>}>
<EditPrinter printer={printer} />
</Suspense>
</I18nProvider>
</>
);
};

export default EditPrinterPage;

+ 47
- 0
src/app/(main)/settings/printer/page.tsx Просмотреть файл

@@ -0,0 +1,47 @@
import { Metadata } from "next";
import { getServerI18n, I18nProvider } from "@/i18n";
import Typography from "@mui/material/Typography";
import { Suspense } from "react";
import { Stack } from "@mui/material";
import { Button } from "@mui/material";
import Link from "next/link";
import PrinterSearch from "@/components/PrinterSearch";
import Add from "@mui/icons-material/Add";

export const metadata: Metadata = {
title: "Printer Management",
};

const Printer: React.FC = async () => {
const { t } = await getServerI18n("common");
return (
<>
<Stack
direction="row"
justifyContent="space-between"
flexWrap="wrap"
rowGap={2}
>
<Typography variant="h4" marginInlineEnd={2}>
{t("Printer")}
</Typography>
<Button
variant="contained"
startIcon={<Add />}
LinkComponent={Link}
href="/settings/printer/create"
>
{t("Create Printer") || "新增列印機"}
</Button>
</Stack>
<I18nProvider namespaces={["common", "dashboard"]}>
<Suspense fallback={<PrinterSearch.Loading />}>
<PrinterSearch />
</Suspense>
</I18nProvider>
</>
);
};

export default Printer;

+ 19
- 0
src/app/(main)/settings/qcItem copy/create/not-found.tsx Просмотреть файл

@@ -0,0 +1,19 @@
import { getServerI18n } from "@/i18n";
import { Stack, Typography, Link } from "@mui/material";
import NextLink from "next/link";

export default async function NotFound() {
const { t } = await getServerI18n("qcItem", "common");

return (
<Stack spacing={2}>
<Typography variant="h4">{t("Not Found")}</Typography>
<Typography variant="body1">
{t("The create qc item page was not found!")}
</Typography>
<Link href="/qcItems" component={NextLink} variant="body2">
{t("Return to all qc items")}
</Link>
</Stack>
);
}

+ 26
- 0
src/app/(main)/settings/qcItem copy/create/page.tsx Просмотреть файл

@@ -0,0 +1,26 @@
import { Metadata } from "next";
import { getServerI18n, I18nProvider } from "@/i18n";
import Typography from "@mui/material/Typography";
import { preloadQcItem } from "@/app/api/settings/qcItem";
import QcItemSave from "@/components/QcItemSave";

export const metadata: Metadata = {
title: "Qc Item",
};

const qcItem: React.FC = async () => {
const { t } = await getServerI18n("qcItem");

return (
<>
<Typography variant="h4" marginInlineEnd={2}>
{t("Create Qc Item")}
</Typography>
<I18nProvider namespaces={["qcItem"]}>
<QcItemSave />
</I18nProvider>
</>
);
};

export default qcItem;

+ 19
- 0
src/app/(main)/settings/qcItem copy/edit/not-found.tsx Просмотреть файл

@@ -0,0 +1,19 @@
import { getServerI18n } from "@/i18n";
import { Stack, Typography, Link } from "@mui/material";
import NextLink from "next/link";

export default async function NotFound() {
const { t } = await getServerI18n("qcItem", "common");

return (
<Stack spacing={2}>
<Typography variant="h4">{t("Not Found")}</Typography>
<Typography variant="body1">
{t("The edit qc item page was not found!")}
</Typography>
<Link href="/settings/qcItems" component={NextLink} variant="body2">
{t("Return to all qc items")}
</Link>
</Stack>
);
}

+ 53
- 0
src/app/(main)/settings/qcItem copy/edit/page.tsx Просмотреть файл

@@ -0,0 +1,53 @@
import { Metadata } from "next";
import { getServerI18n, I18nProvider } from "@/i18n";
import Typography from "@mui/material/Typography";
import { fetchQcItemDetails, preloadQcItem } from "@/app/api/settings/qcItem";
import QcItemSave from "@/components/QcItemSave";
import { isArray } from "lodash";
import { notFound } from "next/navigation";
import { ServerFetchError } from "@/app/utils/fetchUtil";

export const metadata: Metadata = {
title: "Qc Item",
};

interface Props {
searchParams: { [key: string]: string | string[] | undefined };
}

const qcItem: React.FC<Props> = async ({ searchParams }) => {
const { t } = await getServerI18n("qcItem");

const id = searchParams["id"];

if (!id || isArray(id)) {
notFound();
}

try {
console.log("first");
await fetchQcItemDetails(id);
console.log("firsts");
} catch (e) {
if (
e instanceof ServerFetchError &&
(e.response?.status === 404 || e.response?.status === 400)
) {
console.log(e);
notFound();
}
}

return (
<>
<Typography variant="h4" marginInlineEnd={2}>
{t("Edit Qc Item")}
</Typography>
<I18nProvider namespaces={["qcItem"]}>
<QcItemSave id={id} />
</I18nProvider>
</>
);
};

export default qcItem;

+ 48
- 0
src/app/(main)/settings/qcItem copy/page.tsx Просмотреть файл

@@ -0,0 +1,48 @@
import { Metadata } from "next";
import { getServerI18n, I18nProvider } from "@/i18n";
import Typography from "@mui/material/Typography";
import { Button, Link, Stack } from "@mui/material";
import { Add } from "@mui/icons-material";
import { Suspense } from "react";
import { preloadQcItem } from "@/app/api/settings/qcItem";
import QcItemSearch from "@/components/QcItemSearch";

export const metadata: Metadata = {
title: "Qc Item",
};

const qcItem: React.FC = async () => {
const { t } = await getServerI18n("qcItem");

preloadQcItem();

return (
<>
<Stack
direction="row"
justifyContent="space-between"
flexWrap="wrap"
rowGap={2}
>
<Typography variant="h4" marginInlineEnd={2}>
{t("Qc Item")}
</Typography>
<Button
variant="contained"
startIcon={<Add />}
LinkComponent={Link}
href="qcItem/create"
>
{t("Create Qc Item")}
</Button>
</Stack>
<Suspense fallback={<QcItemSearch.Loading />}>
<I18nProvider namespaces={["common", "qcItem"]}>
<QcItemSearch />
</I18nProvider>
</Suspense>
</>
);
};

export default qcItem;

+ 72
- 0
src/app/(main)/settings/qcItemAll/page.tsx Просмотреть файл

@@ -0,0 +1,72 @@
import { Metadata } from "next";
import { getServerI18n, I18nProvider } from "@/i18n";
import Typography from "@mui/material/Typography";
import { Stack } from "@mui/material";
import { Suspense } from "react";
import QcItemAllTabs from "@/components/QcItemAll/QcItemAllTabs";
import Tab0ItemQcCategoryMapping from "@/components/QcItemAll/Tab0ItemQcCategoryMapping";
import Tab1QcCategoryQcItemMapping from "@/components/QcItemAll/Tab1QcCategoryQcItemMapping";
import Tab2QcCategoryManagement from "@/components/QcItemAll/Tab2QcCategoryManagement";
import Tab3QcItemManagement from "@/components/QcItemAll/Tab3QcItemManagement";

export const metadata: Metadata = {
title: "Qc Item All",
};

const qcItemAll: React.FC = async () => {
const { t } = await getServerI18n("qcItemAll");

return (
<>
<Stack
direction="row"
justifyContent="space-between"
flexWrap="wrap"
rowGap={2}
sx={{ mb: 3 }}
>
<Typography variant="h4" marginInlineEnd={2}>
{t("Qc Item All")}
</Typography>
</Stack>
<Suspense fallback={<div>Loading...</div>}>
<I18nProvider namespaces={["common", "qcItemAll", "qcCategory", "qcItem"]}>
<QcItemAllTabs
tab0Content={<Tab0ItemQcCategoryMapping />}
tab1Content={<Tab1QcCategoryQcItemMapping />}
tab2Content={<Tab2QcCategoryManagement />}
tab3Content={<Tab3QcItemManagement />}
/>
</I18nProvider>
</Suspense>
</>
);
};

export default qcItemAll;



























+ 10
- 10
src/app/(main)/settings/warehouse/page.tsx Просмотреть файл

@@ -5,8 +5,10 @@ import { Suspense } from "react";
import { Stack } from "@mui/material";
import { Button } from "@mui/material";
import Link from "next/link";
import WarehouseHandle from "@/components/WarehouseHandle";
import Add from "@mui/icons-material/Add";
import WarehouseTabs from "@/components/Warehouse/WarehouseTabs";
import WarehouseHandleWrapper from "@/components/WarehouseHandle/WarehouseHandleWrapper";
import TabStockTakeSectionMapping from "@/components/Warehouse/TabStockTakeSectionMapping";

export const metadata: Metadata = {
title: "Warehouse Management",
@@ -16,12 +18,7 @@ const Warehouse: React.FC = async () => {
const { t } = await getServerI18n("warehouse");
return (
<>
<Stack
direction="row"
justifyContent="space-between"
flexWrap="wrap"
rowGap={2}
>
<Stack direction="row" justifyContent="space-between" flexWrap="wrap" rowGap={2}>
<Typography variant="h4" marginInlineEnd={2}>
{t("Warehouse")}
</Typography>
@@ -35,11 +32,14 @@ const Warehouse: React.FC = async () => {
</Button>
</Stack>
<I18nProvider namespaces={["warehouse", "common", "dashboard"]}>
<Suspense fallback={<WarehouseHandle.Loading />}>
<WarehouseHandle />
<Suspense fallback={null}>
<WarehouseTabs
tab0Content={<WarehouseHandleWrapper />}
tab1Content={<TabStockTakeSectionMapping />}
/>
</Suspense>
</I18nProvider>
</>
);
};
export default Warehouse;
export default Warehouse;

+ 3
- 3
src/app/(main)/stockIssue/page.tsx Просмотреть файл

@@ -7,17 +7,17 @@ import { Metadata } from "next";
import { Suspense } from "react";

export const metadata: Metadata = {
title: "Pick Order",
title: "Stock Issue",
};

const SearchView: React.FC = async () => {
const { t } = await getServerI18n("pickOrder");
const { t } = await getServerI18n("inventory");

PreloadList();

return (
<>
<I18nProvider namespaces={["pickOrder", "common"]}>
<I18nProvider namespaces={["inventory", "common"]}>
<Suspense fallback={<SearchPage.Loading />}>
<SearchPage />
</Suspense>


+ 1
- 1
src/app/(main)/stocktakemanagement/page.tsx Просмотреть файл

@@ -10,7 +10,7 @@ import { notFound } from "next/navigation";
export default async function InventoryManagementPage() {
const { t } = await getServerI18n("inventory");
return (
<I18nProvider namespaces={["inventory"]}>
<I18nProvider namespaces={["inventory","common"]}>
<Suspense fallback={<StockTakeManagementWrapper.Loading />}>
<StockTakeManagementWrapper />
</Suspense>


+ 206
- 36
src/app/(main)/testing/page.tsx Просмотреть файл

@@ -4,13 +4,48 @@ import React, { useState } from "react";
import {
Box, Grid, Paper, Typography, Button, Dialog, DialogTitle,
DialogContent, DialogActions, TextField, Stack, Table,
TableBody, TableCell, TableContainer, TableHead, TableRow
TableBody, TableCell, TableContainer, TableHead, TableRow,
Tabs, Tab // ← Added for tabs
} from "@mui/material";
import { FileDownload, Print, SettingsEthernet, Lan, Router } from "@mui/icons-material";
import dayjs from "dayjs";
import { NEXT_PUBLIC_API_URL } from "@/config/api";
import { clientAuthFetch } from "@/app/utils/clientAuthFetch";

// Simple TabPanel component for conditional rendering
interface TabPanelProps {
children?: React.ReactNode;
index: number;
value: number;
}

function TabPanel(props: TabPanelProps) {
const { children, value, index, ...other } = props;
return (
<div
role="tabpanel"
hidden={value !== index}
id={`simple-tabpanel-${index}`}
aria-labelledby={`simple-tab-${index}`}
{...other}
>
{value === index && (
<Box sx={{ p: 3 }}>
{children}
</Box>
)}
</div>
);
}

export default function TestingPage() {
// Tab state
const [tabValue, setTabValue] = useState(0);

const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
setTabValue(newValue);
};

// --- 1. TSC Section States ---
const [tscConfig, setTscConfig] = useState({ ip: '192.168.1.100', port: '9100' });
const [tscItems, setTscItems] = useState([
@@ -35,10 +70,22 @@ export default function TestingPage() {
});

// --- 4. Laser Section States ---
const [laserConfig, setLaserConfig] = useState({ ip: '192.168.1.102', port: '8080' });
const [laserItems, setLaserItems] = useState([
{ id: 1, templateId: 'JOB_001', lotNo: 'L-LASER-01', expiryDate: '2025-12-31', power: '50' },
]);
const [laserConfig, setLaserConfig] = useState({ ip: '192.168.1.102', port: '8080' });
const [laserItems, setLaserItems] = useState([
{ id: 1, templateId: 'JOB_001', lotNo: 'L-LASER-01', expiryDate: '2025-12-31', power: '50' },
]);

// --- 5. HANS600S-M Section States ---
const [hansConfig, setHansConfig] = useState({ ip: '192.168.76.10', port: '45678' });
const [hansItems, setHansItems] = useState([
{
id: 1,
textChannel3: 'SN-HANS-001-20260117', // channel 3 (e.g. serial / text1)
textChannel4: 'BATCH-HK-TEST-OK', // channel 4 (e.g. batch / text2)
text3ObjectName: 'Text3', // EZCAD object name for channel 3
text4ObjectName: 'Text4' // EZCAD object name for channel 4
},
]);

// Generic handler for inline table edits
const handleItemChange = (setter: any, id: number, field: string, value: string) => {
@@ -51,14 +98,14 @@ const [laserItems, setLaserItems] = useState([

// TSC Print (Section 1)
const handleTscPrint = async (row: any) => {
const token = localStorage.getItem("accessToken");
const payload = { ...row, printerIp: tscConfig.ip, printerPort: tscConfig.port };
try {
const response = await fetch(`${NEXT_PUBLIC_API_URL}/plastic/print-tsc`, {
const response = await clientAuthFetch(`${NEXT_PUBLIC_API_URL}/plastic/print-tsc`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (response.status === 401 || response.status === 403) return;
if (response.ok) alert(`TSC Print Command Sent for ${row.itemCode}!`);
else alert("TSC Print Failed");
} catch (e) { console.error("TSC Error:", e); }
@@ -66,14 +113,14 @@ const [laserItems, setLaserItems] = useState([

// DataFlex Print (Section 2)
const handleDfPrint = async (row: any) => {
const token = localStorage.getItem("accessToken");
const payload = { ...row, printerIp: dfConfig.ip, printerPort: dfConfig.port };
try {
const response = await fetch(`${NEXT_PUBLIC_API_URL}/plastic/print-dataflex`, {
const response = await clientAuthFetch(`${NEXT_PUBLIC_API_URL}/plastic/print-dataflex`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (response.status === 401 || response.status === 403) return;
if (response.ok) alert(`DataFlex Print Command Sent for ${row.itemCode}!`);
else alert("DataFlex Print Failed");
} catch (e) { console.error("DataFlex Error:", e); }
@@ -81,14 +128,13 @@ const [laserItems, setLaserItems] = useState([

// OnPack Zip Download (Section 3)
const handleDownloadPrintJob = async () => {
const token = localStorage.getItem("accessToken");
const params = new URLSearchParams(printerFormData);
try {
const response = await fetch(`${NEXT_PUBLIC_API_URL}/plastic/get-printer6?${params.toString()}`, {
const response = await clientAuthFetch(`${NEXT_PUBLIC_API_URL}/plastic/get-printer6?${params.toString()}`, {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` }
});

if (response.status === 401 || response.status === 403) return;
if (!response.ok) throw new Error('Download failed');

const blob = await response.blob();
@@ -105,51 +151,85 @@ const [laserItems, setLaserItems] = useState([
} catch (e) { console.error("OnPack Error:", e); }
};

// Laser Print (Section 4 - original)
const handleLaserPrint = async (row: any) => {
const token = localStorage.getItem("accessToken");
const payload = { ...row, printerIp: laserConfig.ip, printerPort: laserConfig.port };
try {
const response = await fetch(`${NEXT_PUBLIC_API_URL}/plastic/print-laser`, {
const response = await clientAuthFetch(`${NEXT_PUBLIC_API_URL}/plastic/print-laser`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (response.status === 401 || response.status === 403) return;
if (response.ok) alert(`Laser Command Sent: ${row.templateId}`);
} catch (e) { console.error(e); }
};

const handleLaserPreview = async (row: any) => {
const token = localStorage.getItem("accessToken");
const payload = { ...row, printerIp: laserConfig.ip, printerPort: parseInt(laserConfig.port) };
try {
// We'll create this endpoint in the backend next
const response = await fetch(`${NEXT_PUBLIC_API_URL}/plastic/preview-laser`, {
const response = await clientAuthFetch(`${NEXT_PUBLIC_API_URL}/plastic/preview-laser`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (response.status === 401 || response.status === 403) return;
if (response.ok) alert("Red light preview active!");
} catch (e) { console.error("Preview Error:", e); }
};

// HANS600S-M TCP Print (Section 5)
const handleHansPrint = async (row: any) => {
const payload = {
printerIp: hansConfig.ip,
printerPort: hansConfig.port,
textChannel3: row.textChannel3,
textChannel4: row.textChannel4,
text3ObjectName: row.text3ObjectName,
text4ObjectName: row.text4ObjectName
};
try {
const response = await clientAuthFetch(`${NEXT_PUBLIC_API_URL}/plastic/print-laser-tcp`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (response.status === 401 || response.status === 403) return;
const result = await response.text();
if (response.ok) {
alert(`HANS600S-M Mark Success: ${result}`);
} else {
alert(`HANS600S-M Failed: ${result}`);
}
} catch (e) {
console.error("HANS600S-M Error:", e);
alert("HANS600S-M Connection Error");
}
};

// Layout Helper
const Section = ({ title, children }: { title: string, children?: React.ReactNode }) => (
<Grid item xs={12} md={6}>
<Paper sx={{ p: 3, minHeight: '450px', display: 'flex', flexDirection: 'column' }}>
<Typography variant="h5" gutterBottom color="primary" sx={{ borderBottom: '2px solid #f0f0f0', pb: 1, mb: 2 }}>
{title}
</Typography>
{children || <Typography color="textSecondary" sx={{ m: 'auto' }}>Waiting for implementation...</Typography>}
</Paper>
</Grid>
<Paper sx={{ p: 3, minHeight: '450px', display: 'flex', flexDirection: 'column' }}>
<Typography variant="h5" gutterBottom color="primary" sx={{ borderBottom: '2px solid #f0f0f0', pb: 1, mb: 2 }}>
{title}
</Typography>
{children || <Typography color="textSecondary" sx={{ m: 'auto' }}>Waiting for implementation...</Typography>}
</Paper>
);

return (
<Box sx={{ p: 4 }}>
<Typography variant="h4" sx={{ mb: 4, fontWeight: 'bold' }}>Printer Testing Dashboard</Typography>
<Typography variant="h4" sx={{ mb: 4, fontWeight: 'bold' }}>Printer Testing</Typography>
<Grid container spacing={3}>
{/* 1. TSC Section */}
<Tabs value={tabValue} onChange={handleTabChange} aria-label="printer sections tabs" centered variant="fullWidth">
<Tab label="1. TSC" />
<Tab label="2. DataFlex" />
<Tab label="3. OnPack" />
<Tab label="4. Laser" />
<Tab label="5. HANS600S-M" />
</Tabs>

<TabPanel value={tabValue} index={0}>
<Section title="1. TSC">
<Stack direction="row" spacing={2} sx={{ mb: 2 }}>
<TextField size="small" label="Printer IP" value={tscConfig.ip} onChange={e => setTscConfig({...tscConfig, ip: e.target.value})} />
@@ -181,8 +261,9 @@ const [laserItems, setLaserItems] = useState([
</Table>
</TableContainer>
</Section>
</TabPanel>

{/* 2. DataFlex Section */}
<TabPanel value={tabValue} index={1}>
<Section title="2. DataFlex">
<Stack direction="row" spacing={2} sx={{ mb: 2 }}>
<TextField size="small" label="Printer IP" value={dfConfig.ip} onChange={e => setDfConfig({...dfConfig, ip: e.target.value})} />
@@ -214,8 +295,9 @@ const [laserItems, setLaserItems] = useState([
</Table>
</TableContainer>
</Section>
</TabPanel>

{/* 3. OnPack Section */}
<TabPanel value={tabValue} index={2}>
<Section title="3. OnPack">
<Box sx={{ m: 'auto', textAlign: 'center' }}>
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
@@ -226,8 +308,9 @@ const [laserItems, setLaserItems] = useState([
</Button>
</Box>
</Section>
</TabPanel>

{/* 4. Laser Section (HANS600S-M) */}
<TabPanel value={tabValue} index={3}>
<Section title="4. Laser">
<Stack direction="row" spacing={2} sx={{ mb: 2 }}>
<TextField size="small" label="Laser IP" value={laserConfig.ip} onChange={e => setLaserConfig({...laserConfig, ip: e.target.value})} />
@@ -283,7 +366,94 @@ const [laserItems, setLaserItems] = useState([
Note: HANS Laser requires pre-saved templates on the controller.
</Typography>
</Section>
</Grid>
</TabPanel>

<TabPanel value={tabValue} index={4}>
<Section title="5. HANS600S-M">
<Stack direction="row" spacing={2} sx={{ mb: 2 }}>
<TextField
size="small"
label="Laser IP"
value={hansConfig.ip}
onChange={e => setHansConfig({...hansConfig, ip: e.target.value})}
/>
<TextField
size="small"
label="Port"
value={hansConfig.port}
onChange={e => setHansConfig({...hansConfig, port: e.target.value})}
/>
<Router color="action" sx={{ ml: 'auto' }} />
</Stack>
<TableContainer component={Paper} variant="outlined" sx={{ maxHeight: 300 }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell>Ch3 Text (SN)</TableCell>
<TableCell>Ch4 Text (Batch)</TableCell>
<TableCell>Obj3 Name</TableCell>
<TableCell>Obj4 Name</TableCell>
<TableCell align="center">Action</TableCell>
</TableRow>
</TableHead>
<TableBody>
{hansItems.map(row => (
<TableRow key={row.id}>
<TableCell>
<TextField
variant="standard"
value={row.textChannel3}
onChange={e => handleItemChange(setHansItems, row.id, 'textChannel3', e.target.value)}
sx={{ minWidth: 180 }}
/>
</TableCell>
<TableCell>
<TextField
variant="standard"
value={row.textChannel4}
onChange={e => handleItemChange(setHansItems, row.id, 'textChannel4', e.target.value)}
sx={{ minWidth: 140 }}
/>
</TableCell>
<TableCell>
<TextField
variant="standard"
value={row.text3ObjectName}
onChange={e => handleItemChange(setHansItems, row.id, 'text3ObjectName', e.target.value)}
size="small"
/>
</TableCell>
<TableCell>
<TextField
variant="standard"
value={row.text4ObjectName}
onChange={e => handleItemChange(setHansItems, row.id, 'text4ObjectName', e.target.value)}
size="small"
/>
</TableCell>
<TableCell align="center">
<Button
variant="contained"
color="error"
size="small"
startIcon={<Print />}
onClick={() => handleHansPrint(row)}
sx={{ minWidth: 80 }}
>
TCP Mark
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Typography variant="caption" sx={{ mt: 2, display: 'block', color: 'text.secondary', fontSize: '0.75rem' }}>
TCP Push to EZCAD3 (Ch3/Ch4 via E3_SetTextObject) | IP:192.168.76.10:45678 | Backend: /print-laser-tcp
</Typography>
</Section>
</TabPanel>

{/* Dialog for OnPack */}
<Dialog open={isPrinterModalOpen} onClose={() => setIsPrinterModalOpen(false)} fullWidth maxWidth="sm">


+ 24
- 1
src/app/api/bag/action.ts Просмотреть файл

@@ -118,4 +118,27 @@ export const fetchBagLotLines = cache(async (bagId: number) =>

export const fetchBagConsumptions = cache(async (bagLotLineId: number) =>
serverFetchJson<BagConsumptionResponse[]>(`${BASE_API_URL}/bag/lot-lines/${bagLotLineId}/consumptions`, { method: "GET" })
);
);

export interface SoftDeleteBagResponse {
id: number | null;
code: string | null;
name: string | null;
type: string | null;
message: string | null;
errorPosition: string | null;
entity: any | null;
}

export const softDeleteBagByItemId = async (itemId: number): Promise<SoftDeleteBagResponse> => {
const response = await serverFetchJson<SoftDeleteBagResponse>(
`${BASE_API_URL}/bag/by-item/${itemId}/soft-delete`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
}
);
revalidateTag("bagInfo");
revalidateTag("bags");
return response;
};

+ 82
- 0
src/app/api/bagPrint/actions.ts Просмотреть файл

@@ -0,0 +1,82 @@
"use client";

import { NEXT_PUBLIC_API_URL } from "@/config/api";
import { clientAuthFetch } from "@/app/utils/clientAuthFetch";

export interface JobOrderListItem {
id: number;
code: string | null;
planStart: string | null;
itemCode: string | null;
itemName: string | null;
reqQty: number | null;
stockInLineId: number | null;
itemId: number | null;
lotNo: string | null;
}

export interface PrinterStatusRequest {
printerType: "dataflex" | "laser";
printerIp?: string;
printerPort?: number;
}

export interface PrinterStatusResponse {
connected: boolean;
message: string;
}

export interface OnPackQrDownloadRequest {
jobOrders: {
jobOrderId: number;
itemCode: string;
}[];
}

/**
* Fetch job orders by plan date from GET /py/job-orders.
* Client-side only; uses auth token from localStorage.
*/
export async function fetchJobOrders(planStart: string): Promise<JobOrderListItem[]> {
const url = `${NEXT_PUBLIC_API_URL}/py/job-orders?planStart=${encodeURIComponent(planStart)}`;
const res = await clientAuthFetch(url, { method: "GET" });
if (!res.ok) {
throw new Error(`Failed to fetch job orders: ${res.status}`);
}
return res.json();
}

export async function checkPrinterStatus(
request: PrinterStatusRequest,
): Promise<PrinterStatusResponse> {
const url = `${NEXT_PUBLIC_API_URL}/plastic/check-printer`;
const res = await clientAuthFetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
});

const data = (await res.json()) as PrinterStatusResponse;
if (!res.ok) {
return data;
}

return data;
}

export async function downloadOnPackQrZip(
request: OnPackQrDownloadRequest,
): Promise<Blob> {
const url = `${NEXT_PUBLIC_API_URL}/plastic/download-onpack-qr`;
const res = await clientAuthFetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
});

if (!res.ok) {
throw new Error((await res.text()) || "Download failed");
}

return res.blob();
}

+ 106
- 0
src/app/api/bom/client.ts Просмотреть файл

@@ -0,0 +1,106 @@
"use client";

import axiosInstance from "@/app/(main)/axios/axiosInstance";
import { NEXT_PUBLIC_API_URL } from "@/config/api";
import type {
BomFormatCheckResponse,
BomUploadResponse,
ImportBomItemPayload,
BomCombo,
BomDetailResponse,
} from "./index";

export async function uploadBomFiles(
files: File[]
): Promise<BomUploadResponse> {
const formData = new FormData();
files.forEach((f) => formData.append("files", f, f.name));
const response = await axiosInstance.post<BomUploadResponse>(
`${NEXT_PUBLIC_API_URL}/bom/import-bom/upload`,
formData,
{
transformRequest: [
(data: unknown, headers?: Record<string, unknown>) => {
if (data instanceof FormData && headers && "Content-Type" in headers) {
delete headers["Content-Type"];
}
return data;
},
],
}
);
return response.data;
}

export async function checkBomFormat(
batchId: string
): Promise<BomFormatCheckResponse> {
const response = await axiosInstance.post<BomFormatCheckResponse>(
`${NEXT_PUBLIC_API_URL}/bom/import-bom/format-check`,
{ batchId }
);
return response.data;
}
export async function downloadBomFormatIssueLog(
batchId: string,
issueLogFileId: string
): Promise<Blob> {
const response = await axiosInstance.get(
`${NEXT_PUBLIC_API_URL}/bom/import-bom/format-issue-log`,
{
params: { batchId, issueLogFileId },
responseType: "blob",
}
);
return response.data as Blob;
}
export async function importBom(
batchId: string,
items: ImportBomItemPayload[]
): Promise<Blob> {
const response = await axiosInstance.post(
`${NEXT_PUBLIC_API_URL}/bom/import-bom`,
{ batchId, items },
{ responseType: "blob" }
);
return response.data as Blob;
}
import type { BomScoreResult } from "./index";

export const fetchBomScoresClient = async (): Promise<BomScoreResult[]> => {
const response = await axiosInstance.get<BomScoreResult[]>(
`${NEXT_PUBLIC_API_URL}/bom/scores`,
);
return response.data;
};

export async function fetchBomComboClient(): Promise<BomCombo[]> {
const response = await axiosInstance.get<BomCombo[]>(
`${NEXT_PUBLIC_API_URL}/bom/combo`
);
return response.data;
}
export async function fetchBomDetailClient(id: number): Promise<BomDetailResponse> {
const response = await axiosInstance.get<BomDetailResponse>(
`${NEXT_PUBLIC_API_URL}/bom/${id}/detail`
);
return response.data;
}
export type BomExcelCheckProgress = {
batchId: string;
totalFiles: number;
processedFiles: number;
currentFileName: string | null;
lastUpdateTime: number;
};
export async function getBomFormatProgress(
batchId: string
): Promise<BomExcelCheckProgress> {
const response = await axiosInstance.get<BomExcelCheckProgress>(
`${NEXT_PUBLIC_API_URL}/bom/import-bom/format-check/progress`,
{ params: { batchId } }
);
return response.data;
}

+ 86
- 10
src/app/api/bom/index.ts Просмотреть файл

@@ -3,22 +3,98 @@ import { BASE_API_URL } from "@/config/api";
import { cache } from "react";

export interface BomCombo {
id: number;
value: number;
label: string;
outputQty: number;
outputQtyUom: string;
description: string;
id: number;
value: number;
label: string;
outputQty: number;
outputQtyUom: string;
description: string;
}

export interface BomFormatFileGroup {
fileName: string;
problems: string[];
}

/** Format-check 回傳:正確檔名列表 + 失敗列表 */
export interface BomFormatCheckResponse {
correctFileNames: string[];
failList: BomFormatFileGroup[];
issueLogFileId: string;
}

export interface BomUploadResponse {
batchId: string;
fileNames: string[];
}

export interface ImportBomItemPayload {
fileName: string;
isAlsoWip: boolean;
isDrink: boolean;
}

export const preloadBomCombo = (() => {
fetchBomCombo()
})
export interface BomScoreResult {
id: number;
code: string;
name: string;
baseScore: number | string | { value?: number; [key: string]: any };
}



export const fetchBomCombo = cache(async () => {
return serverFetchJson<BomCombo[]>(`${BASE_API_URL}/bom/combo`, {
next: { tags: ["bomCombo"] },
})
})
return serverFetchJson<BomCombo[]>(`${BASE_API_URL}/bom/combo`, {
next: { tags: ["bomCombo"] },
});
});

export const fetchBomScores = cache(async () => {
return serverFetchJson<BomScoreResult[]>(`${BASE_API_URL}/bom/scores`, {
next: { tags: ["boms"] },
});
});

export interface BomMaterialDto {
itemCode?: string;
itemName?: string;
baseQty?: number;
baseUom?: string;
stockQty?: number;
stockUom?: string;
salesQty?: number;
salesUom?: string;
}

export interface BomProcessDto {
seqNo?: number;
processName?: string;
processDescription?: string;
equipmentName?: string;
durationInMinute?: number;
prepTimeInMinute?: number;
postProdTimeInMinute?: number;
}

export interface BomDetailResponse {
id: number;
itemCode?: string;
itemName?: string;
isDark?: number;
isFloat?: number;
isDense?: number;
isDrink?: boolean;
scrapRate?: number;
allergicSubstances?: number;
timeSequence?: number;
complexity?: number;
baseScore?: number;
description?: string;
outputQty?: number;
outputQtyUom?: string;
materials: BomMaterialDto[];
processes: BomProcessDto[];
}

+ 16
- 0
src/app/api/bom/recalculateClient.ts Просмотреть файл

@@ -0,0 +1,16 @@
"use client";

import axiosInstance from "@/app/(main)/axios/axiosInstance";
import { NEXT_PUBLIC_API_URL } from "@/config/api";

export interface BomScoreRecalcResponse {
updatedCount: number;
}

export const recalcBomScoresClient = async (): Promise<BomScoreRecalcResponse> => {
const response = await axiosInstance.post<BomScoreRecalcResponse>(
`${NEXT_PUBLIC_API_URL}/bom/scores/recalculate`,
);
return response.data;
};


+ 443
- 0
src/app/api/chart/client.ts Просмотреть файл

@@ -0,0 +1,443 @@
import { clientAuthFetch } from "@/app/utils/clientAuthFetch";
import { NEXT_PUBLIC_API_URL } from "@/config/api";

const BASE = `${NEXT_PUBLIC_API_URL}/chart`;

function buildParams(params: Record<string, string | number | undefined>) {
const p = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== "") p.set(k, String(v));
});
return p.toString();
}

export interface StockTransactionsByDateRow {
date: string;
inQty: number;
outQty: number;
totalQty: number;
}

export interface DeliveryOrderByDateRow {
date: string;
orderCount: number;
totalQty: number;
}

export interface PurchaseOrderByStatusRow {
status: string;
count: number;
}

export interface StockInOutByDateRow {
date: string;
inQty: number;
outQty: number;
}

export interface TopDeliveryItemsRow {
itemCode: string;
itemName: string;
totalQty: number;
}

export interface StockBalanceTrendRow {
date: string;
balance: number;
}

export interface ConsumptionTrendByMonthRow {
month: string;
outQty: number;
}

export interface StaffDeliveryPerformanceRow {
date: string;
staffName: string;
orderCount: number;
totalMinutes: number;
}

export interface StaffOption {
staffNo: string;
name: string;
}

export async function fetchStaffDeliveryPerformanceHandlers(): Promise<StaffOption[]> {
const res = await clientAuthFetch(`${BASE}/staff-delivery-performance-handlers`);
if (!res.ok) throw new Error("Failed to fetch staff list");
const data = await res.json();
if (!Array.isArray(data)) return [];
return (data as Record<string, unknown>[]).map((r: Record<string, unknown>) => ({
staffNo: String(r.staffNo ?? ""),
name: String(r.name ?? ""),
}));
}

// Job order
export interface JobOrderByStatusRow {
status: string;
count: number;
}

export interface JobOrderCountByDateRow {
date: string;
orderCount: number;
}

export interface JobOrderCreatedCompletedRow {
date: string;
createdCount: number;
completedCount: number;
}

export interface ProductionScheduleByDateRow {
date: string;
scheduledItemCount: number;
totalEstProdCount: number;
}

export interface PlannedDailyOutputRow {
itemCode: string;
itemName: string;
dailyQty: number;
}

export async function fetchJobOrderByStatus(
targetDate?: string
): Promise<JobOrderByStatusRow[]> {
const q = targetDate ? buildParams({ targetDate }) : "";
const res = await clientAuthFetch(
q ? `${BASE}/job-order-by-status?${q}` : `${BASE}/job-order-by-status`
);
if (!res.ok) throw new Error("Failed to fetch job order by status");
const data = await res.json();
return ((Array.isArray(data) ? data : []) as Record<string, unknown>[]).map((r: Record<string, unknown>) => ({
status: String(r.status ?? ""),
count: Number(r.count ?? 0),
}));
}

export async function fetchJobOrderCountByDate(
startDate?: string,
endDate?: string
): Promise<JobOrderCountByDateRow[]> {
const q = buildParams({ startDate: startDate ?? "", endDate: endDate ?? "" });
const res = await clientAuthFetch(`${BASE}/job-order-count-by-date?${q}`);
if (!res.ok) throw new Error("Failed to fetch job order count by date");
const data = await res.json();
return normalizeChartRows(data, "date", ["orderCount"]);
}

export async function fetchJobOrderCreatedCompletedByDate(
startDate?: string,
endDate?: string
): Promise<JobOrderCreatedCompletedRow[]> {
const q = buildParams({ startDate: startDate ?? "", endDate: endDate ?? "" });
const res = await clientAuthFetch(
`${BASE}/job-order-created-completed-by-date?${q}`
);
if (!res.ok) throw new Error("Failed to fetch job order created/completed");
const data = await res.json();
return ((Array.isArray(data) ? data : []) as Record<string, unknown>[]).map((r: Record<string, unknown>) => ({
date: String(r.date ?? ""),
createdCount: Number(r.createdCount ?? 0),
completedCount: Number(r.completedCount ?? 0),
}));
}

export interface JobMaterialPendingPickedRow {
date: string;
pendingCount: number;
pickedCount: number;
}

export async function fetchJobMaterialPendingPickedByDate(
startDate?: string,
endDate?: string
): Promise<JobMaterialPendingPickedRow[]> {
const q = buildParams({ startDate: startDate ?? "", endDate: endDate ?? "" });
const res = await clientAuthFetch(`${BASE}/job-material-pending-picked-by-date?${q}`);
if (!res.ok) throw new Error("Failed to fetch job material pending/picked");
const data = await res.json();
return ((Array.isArray(data) ? data : []) as Record<string, unknown>[]).map((r: Record<string, unknown>) => ({
date: String(r.date ?? ""),
pendingCount: Number(r.pendingCount ?? 0),
pickedCount: Number(r.pickedCount ?? 0),
}));
}

export interface JobProcessPendingCompletedRow {
date: string;
pendingCount: number;
completedCount: number;
}

export async function fetchJobProcessPendingCompletedByDate(
startDate?: string,
endDate?: string
): Promise<JobProcessPendingCompletedRow[]> {
const q = buildParams({ startDate: startDate ?? "", endDate: endDate ?? "" });
const res = await clientAuthFetch(`${BASE}/job-process-pending-completed-by-date?${q}`);
if (!res.ok) throw new Error("Failed to fetch job process pending/completed");
const data = await res.json();
return ((Array.isArray(data) ? data : []) as Record<string, unknown>[]).map((r: Record<string, unknown>) => ({
date: String(r.date ?? ""),
pendingCount: Number(r.pendingCount ?? 0),
completedCount: Number(r.completedCount ?? 0),
}));
}

export interface JobEquipmentWorkingWorkedRow {
date: string;
workingCount: number;
workedCount: number;
}

export async function fetchJobEquipmentWorkingWorkedByDate(
startDate?: string,
endDate?: string
): Promise<JobEquipmentWorkingWorkedRow[]> {
const q = buildParams({ startDate: startDate ?? "", endDate: endDate ?? "" });
const res = await clientAuthFetch(`${BASE}/job-equipment-working-worked-by-date?${q}`);
if (!res.ok) throw new Error("Failed to fetch job equipment working/worked");
const data = await res.json();
return ((Array.isArray(data) ? data : []) as Record<string, unknown>[]).map((r: Record<string, unknown>) => ({
date: String(r.date ?? ""),
workingCount: Number(r.workingCount ?? 0),
workedCount: Number(r.workedCount ?? 0),
}));
}

export async function fetchProductionScheduleByDate(
startDate?: string,
endDate?: string
): Promise<ProductionScheduleByDateRow[]> {
const q = buildParams({ startDate: startDate ?? "", endDate: endDate ?? "" });
const res = await clientAuthFetch(
`${BASE}/production-schedule-by-date?${q}`
);
if (!res.ok) throw new Error("Failed to fetch production schedule by date");
const data = await res.json();
return ((Array.isArray(data) ? data : []) as Record<string, unknown>[]).map((r: Record<string, unknown>) => ({
date: String(r.date ?? ""),
scheduledItemCount: Number(r.scheduledItemCount ?? r.scheduleCount ?? 0),
totalEstProdCount: Number(r.totalEstProdCount ?? 0),
}));
}

export async function fetchPlannedDailyOutputByItem(
limit = 20
): Promise<PlannedDailyOutputRow[]> {
const res = await clientAuthFetch(
`${BASE}/planned-daily-output-by-item?limit=${limit}`
);
if (!res.ok) throw new Error("Failed to fetch planned daily output");
const data = await res.json();
return ((Array.isArray(data) ? data : []) as Record<string, unknown>[]).map((r: Record<string, unknown>) => ({
itemCode: String(r.itemCode ?? ""),
itemName: String(r.itemName ?? ""),
dailyQty: Number(r.dailyQty ?? 0),
}));
}

/** Planned production by date and by item (production_schedule). */
export interface PlannedOutputByDateAndItemRow {
date: string;
itemCode: string;
itemName: string;
qty: number;
}

export async function fetchPlannedOutputByDateAndItem(
startDate?: string,
endDate?: string
): Promise<PlannedOutputByDateAndItemRow[]> {
const q = buildParams({ startDate: startDate ?? "", endDate: endDate ?? "" });
const res = await clientAuthFetch(
q ? `${BASE}/planned-output-by-date-and-item?${q}` : `${BASE}/planned-output-by-date-and-item`
);
if (!res.ok) throw new Error("Failed to fetch planned output by date and item");
const data = await res.json();
return ((Array.isArray(data) ? data : []) as Record<string, unknown>[]).map((r: Record<string, unknown>) => ({
date: String(r.date ?? ""),
itemCode: String(r.itemCode ?? ""),
itemName: String(r.itemName ?? ""),
qty: Number(r.qty ?? 0),
}));
}

export async function fetchStaffDeliveryPerformance(
startDate?: string,
endDate?: string,
staffNos?: string[]
): Promise<StaffDeliveryPerformanceRow[]> {
const p = new URLSearchParams();
if (startDate) p.set("startDate", startDate);
if (endDate) p.set("endDate", endDate);
(staffNos ?? []).forEach((no) => p.append("staffNo", no));
const q = p.toString();
const res = await clientAuthFetch(
q ? `${BASE}/staff-delivery-performance?${q}` : `${BASE}/staff-delivery-performance`
);
if (!res.ok) throw new Error("Failed to fetch staff delivery performance");
const data = await res.json();
return ((Array.isArray(data) ? data : []) as Record<string, unknown>[]).map((r: Record<string, unknown>) => {
// Accept camelCase or lowercase keys (JDBC/DB may return different casing)
const row = r as Record<string, unknown>;
return {
date: String(row.date ?? row.Date ?? ""),
staffName: String(row.staffName ?? row.staffname ?? ""),
orderCount: Number(row.orderCount ?? row.ordercount ?? 0),
totalMinutes: Number(row.totalMinutes ?? row.totalminutes ?? 0),
};
});
}

export async function fetchStockTransactionsByDate(
startDate?: string,
endDate?: string
): Promise<StockTransactionsByDateRow[]> {
const q = buildParams({ startDate: startDate ?? "", endDate: endDate ?? "" });
const res = await clientAuthFetch(`${BASE}/stock-transactions-by-date?${q}`);
if (!res.ok) throw new Error("Failed to fetch stock transactions by date");
const data = await res.json();
return normalizeChartRows(data, "date", ["inQty", "outQty", "totalQty"]);
}

export async function fetchDeliveryOrderByDate(
startDate?: string,
endDate?: string
): Promise<DeliveryOrderByDateRow[]> {
const q = buildParams({ startDate: startDate ?? "", endDate: endDate ?? "" });
const res = await clientAuthFetch(`${BASE}/delivery-order-by-date?${q}`);
if (!res.ok) throw new Error("Failed to fetch delivery order by date");
const data = await res.json();
return normalizeChartRows(data, "date", ["orderCount", "totalQty"]);
}

export async function fetchPurchaseOrderByStatus(
targetDate?: string
): Promise<PurchaseOrderByStatusRow[]> {
const q = targetDate
? buildParams({ targetDate })
: "";
const res = await clientAuthFetch(
q ? `${BASE}/purchase-order-by-status?${q}` : `${BASE}/purchase-order-by-status`
);
if (!res.ok) throw new Error("Failed to fetch purchase order by status");
const data = await res.json();
return ((Array.isArray(data) ? data : []) as Record<string, unknown>[]).map((r: Record<string, unknown>) => ({
status: String(r.status ?? ""),
count: Number(r.count ?? 0),
}));
}

export async function fetchStockInOutByDate(
startDate?: string,
endDate?: string
): Promise<StockInOutByDateRow[]> {
const q = buildParams({ startDate: startDate ?? "", endDate: endDate ?? "" });
const res = await clientAuthFetch(`${BASE}/stock-in-out-by-date?${q}`);
if (!res.ok) throw new Error("Failed to fetch stock in/out by date");
const data = await res.json();
return normalizeChartRows(data, "date", ["inQty", "outQty"]);
}

export interface TopDeliveryItemOption {
itemCode: string;
itemName: string;
}

export async function fetchTopDeliveryItemsItemOptions(
startDate?: string,
endDate?: string
): Promise<TopDeliveryItemOption[]> {
const q = buildParams({ startDate: startDate ?? "", endDate: endDate ?? "" });
const res = await clientAuthFetch(
q ? `${BASE}/top-delivery-items-item-options?${q}` : `${BASE}/top-delivery-items-item-options`
);
if (!res.ok) throw new Error("Failed to fetch item options");
const data = await res.json();
return ((Array.isArray(data) ? data : []) as Record<string, unknown>[]).map((r: Record<string, unknown>) => ({
itemCode: String(r.itemCode ?? ""),
itemName: String(r.itemName ?? ""),
}));
}

export async function fetchTopDeliveryItems(
startDate?: string,
endDate?: string,
limit = 10,
itemCodes?: string[]
): Promise<TopDeliveryItemsRow[]> {
const p = new URLSearchParams();
if (startDate) p.set("startDate", startDate);
if (endDate) p.set("endDate", endDate);
p.set("limit", String(limit));
(itemCodes ?? []).forEach((code) => p.append("itemCode", code));
const q = p.toString();
const res = await clientAuthFetch(`${BASE}/top-delivery-items?${q}`);
if (!res.ok) throw new Error("Failed to fetch top delivery items");
const data = await res.json();
return ((Array.isArray(data) ? data : []) as Record<string, unknown>[]).map((r: Record<string, unknown>) => ({
itemCode: String(r.itemCode ?? ""),
itemName: String(r.itemName ?? ""),
totalQty: Number(r.totalQty ?? 0),
}));
}

export async function fetchStockBalanceTrend(
startDate?: string,
endDate?: string,
itemCode?: string
): Promise<StockBalanceTrendRow[]> {
const q = buildParams({
startDate: startDate ?? "",
endDate: endDate ?? "",
itemCode: itemCode ?? "",
});
const res = await clientAuthFetch(`${BASE}/stock-balance-trend?${q}`);
if (!res.ok) throw new Error("Failed to fetch stock balance trend");
const data = await res.json();
return normalizeChartRows(data, "date", ["balance"]);
}

export async function fetchConsumptionTrendByMonth(
year?: number,
startDate?: string,
endDate?: string,
itemCode?: string
): Promise<ConsumptionTrendByMonthRow[]> {
const q = buildParams({
year: year ?? "",
startDate: startDate ?? "",
endDate: endDate ?? "",
itemCode: itemCode ?? "",
});
const res = await clientAuthFetch(`${BASE}/consumption-trend-by-month?${q}`);
if (!res.ok) throw new Error("Failed to fetch consumption trend");
const data = await res.json();
return ((Array.isArray(data) ? data : []) as Record<string, unknown>[]).map((r: Record<string, unknown>) => ({
month: String(r.month ?? ""),
outQty: Number(r.outQty ?? 0),
}));
}

/** Normalize rows: ensure date key is string and numeric keys are numbers (backend may return BigDecimal/Long). */
function normalizeChartRows<T>(
rows: unknown[],
dateKey: string,
numberKeys: string[]
): T[] {
if (!Array.isArray(rows)) return [];
return rows.map((r: unknown) => {
const row = r as Record<string, unknown>;
const out: Record<string, unknown> = {};
out[dateKey] = row[dateKey] != null ? String(row[dateKey]) : "";
numberKeys.forEach((k) => {
out[k] = Number(row[k]) || 0;
});
return out as T;
});
}

+ 24
- 0
src/app/api/dashboard/actions.ts Просмотреть файл

@@ -190,3 +190,27 @@ export const testing = cache(async (queryParams?: Record<string, any>) => {
);
}
});

export interface GoodsReceiptStatusRow {
supplierId: number | null;
supplierCode: string | null;
supplierName: string;
purchaseOrderCode: string | null;
statistics: string;
expectedNoOfDelivery: number;
noOfOrdersReceivedAtDock: number;
noOfItemsInspected: number;
noOfItemsWithIqcIssue: number;
noOfItemsCompletedPutAwayAtStore: number;
// When true, this PO should be hidden from the dashboard table,
// but still counted in the overall statistics (訂單已處理).
hideFromDashboard?: boolean;
}

export const fetchGoodsReceiptStatus = cache(async (date?: string) => {
const url = date
? `${BASE_API_URL}/dashboard/goods-receipt-status?date=${date}`
: `${BASE_API_URL}/dashboard/goods-receipt-status`;

return await serverFetchJson<GoodsReceiptStatusRow[]>(url, { method: "GET" });
});

+ 17
- 0
src/app/api/dashboard/client.ts Просмотреть файл

@@ -0,0 +1,17 @@
"use client";

import {
fetchGoodsReceiptStatus,
type GoodsReceiptStatusRow,
} from "./actions";

export const fetchGoodsReceiptStatusClient = async (
date?: string,
): Promise<GoodsReceiptStatusRow[]> => {
return await fetchGoodsReceiptStatus(date);
};

export type { GoodsReceiptStatusRow };

export default fetchGoodsReceiptStatusClient;


+ 146
- 13
src/app/api/do/actions.tsx Просмотреть файл

@@ -44,13 +44,17 @@ export interface DoSearchAll {
id: number;
code: string;
status: string;
estimatedArrivalDate: string;
orderDate: string;
estimatedArrivalDate: number[];
orderDate: number[];
supplierName: string;
shopName: string;
deliveryOrderLines: DoDetailLine[];
}
shopAddress?: string;

}
export interface DoSearchLiteResponse {
records: DoSearchAll[];
total: number;
}
export interface ReleaseDoRequest {
id: number;
}
@@ -197,9 +201,12 @@ export const fetchTicketReleaseTable = cache(async (startDate: string, endDate:
);
});

export const fetchTruckScheduleDashboard = cache(async () => {
export const fetchTruckScheduleDashboard = cache(async (date?: string) => {
const url = date
? `${BASE_API_URL}/doPickOrder/truck-schedule-dashboard?date=${date}`
: `${BASE_API_URL}/doPickOrder/truck-schedule-dashboard`;
return await serverFetchJson<TruckScheduleDashboardItem[]>(
`${BASE_API_URL}/doPickOrder/truck-schedule-dashboard`,
url,
{
method: "GET",
}
@@ -283,15 +290,74 @@ export const fetchDoDetail = cache(async (id: number) => {
});
});

export const fetchDoSearch = cache(async (code: string, shopName: string, status: string, orderStartDate: string, orderEndDate: string, estArrStartDate: string, estArrEndDate: string)=>{
console.log(`${BASE_API_URL}/do/search-DO/${code}&${shopName}&${status}&${orderStartDate}&${orderEndDate}&${estArrStartDate}&${estArrEndDate}`);
return serverFetchJson<DoSearchAll[]>(`${BASE_API_URL}/do/search-DO/${code}&${shopName}&${status}&${orderStartDate}&${orderEndDate}&${estArrStartDate}&${estArrEndDate}`,{
method: "GET",
next: { tags: ["doSearch"] }
export async function fetchDoSearch(
code: string,
shopName: string,
status: string,
orderStartDate: string,
orderEndDate: string,
estArrStartDate: string,
estArrEndDate: string,
pageNum?: number,
pageSize?: number,
truckLanceCode?: string
): Promise<DoSearchLiteResponse> {
// 构建请求体
const requestBody: any = {
code: code || null,
shopName: shopName || null,
status: status || null,
estimatedArrivalDate: estArrStartDate || null, // 使用单个日期字段
truckLanceCode: truckLanceCode || null,
pageNum: pageNum || 1,
pageSize: pageSize || 10,
};

// 如果日期不为空,转换为 LocalDateTime 格式
if (estArrStartDate) {
requestBody.estimatedArrivalDate = estArrStartDate; // 格式: "2026-01-19T00:00:00"
} else {
requestBody.estimatedArrivalDate = null;
}

const url = `${BASE_API_URL}/do/search-do-lite`;

const data = await serverFetchJson<DoSearchLiteResponse>(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(requestBody),
});
});

return data;
}
export async function fetchDoSearchList(
code: string,
shopName: string,
status: string,
orderStartDate: string,
orderEndDate: string,
etaFrom: string,
etaTo: string,
page = 0,
size = 500
): Promise<DoSearchAll[]> {
const params = new URLSearchParams();

if (code) params.append("code", code);
if (shopName) params.append("shopName", shopName);
if (status) params.append("status", status);
if (orderStartDate) params.append("orderFrom", orderStartDate);
if (orderEndDate) params.append("orderTo", orderEndDate);
if (etaFrom) params.append("etaFrom", etaFrom);
if (etaTo) params.append("etaTo", etaTo);

params.append("page", String(page));
params.append("size", String(size));

const res = await fetch(`/api/delivery-order/search-do-list?${params.toString()}`);
const pageData = await res.json(); // Spring Page 结构
return pageData.content; // 前端继续沿用你原来的 client-side 分页逻辑
}
export async function printDN(request: PrintDeliveryNoteRequest){
const params = new URLSearchParams();
params.append('doPickOrderId', request.doPickOrderId.toString());
@@ -342,6 +408,40 @@ export async function printDNLabels(request: PrintDNLabelsRequest){

return { success: true, message: "Print job sent successfully (labels)"} as PrintDeliveryNoteResponse
}

export interface ResetDoPickOrderResponse {
success: boolean;
message?: string;
}

export async function resetDoPickOrderToNonPick(doPickOrderRecordId: number): Promise<ResetDoPickOrderResponse> {
const params = new URLSearchParams();
params.append("doPickOrderRecordId", doPickOrderRecordId.toString());

try {
const response = await serverFetchWithNoContent(
`${BASE_API_URL}/doPickOrder/reset-to-non-pick?${params.toString()}`,
{
method: "POST",
},
);

if (response) {
return { success: true };
}

return {
success: false,
message: "Failed to reset DO pick order to non-pick state.",
};
} catch (error) {
console.error("Error in resetDoPickOrderToNonPick:", error);
return {
success: false,
message: "Error occurred while resetting DO pick order to non-pick state.",
};
}
}
export interface Check4FTruckBatchResponse {
hasProblem: boolean;
problems: ProblemDoDto[];
@@ -368,4 +468,37 @@ export const check4FTrucksBatch = cache(async (doIds: number[]) => {
});
});

export async function fetchAllDoSearch(
code: string,
shopName: string,
status: string,
estArrStartDate: string,
truckLanceCode?: string // 添加这个参数
): Promise<DoSearchAll[]> {
// 使用一个很大的 pageSize 来获取所有匹配的记录
const requestBody: any = {
code: code || null,
shopName: shopName || null,
status: status || null,
estimatedArrivalDate: estArrStartDate || null,
truckLanceCode: truckLanceCode || null, // 添加这个字段
pageNum: 1,
pageSize: 10000, // 使用一个很大的值来获取所有记录
};

if (estArrStartDate) {
requestBody.estimatedArrivalDate = estArrStartDate;
} else {
requestBody.estimatedArrivalDate = null;
}

const url = `${BASE_API_URL}/do/search-do-lite`;

const data = await serverFetchJson<DoSearchLiteResponse>(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(requestBody),
});

return data.records;
}

+ 2
- 2
src/app/api/do/client.ts Просмотреть файл

@@ -5,8 +5,8 @@ import {
type TruckScheduleDashboardItem
} from "./actions";

export const fetchTruckScheduleDashboardClient = async (): Promise<TruckScheduleDashboardItem[]> => {
return await fetchTruckScheduleDashboard();
export const fetchTruckScheduleDashboardClient = async (date?: string): Promise<TruckScheduleDashboardItem[]> => {
return await fetchTruckScheduleDashboard(date);
};

export type { TruckScheduleDashboardItem };


+ 2
- 0
src/app/api/escalation/index.ts Просмотреть файл

@@ -30,6 +30,8 @@ export interface EscalationResult {
qcFailCount?: number;
qcTotalCount?: number;
poCode?: string;
jobOrderId?: number;
jobOrderCode?: string;
itemCode?: string;
dnDate?: number[];
dnNo?: string;


+ 30
- 0
src/app/api/inventory/actions.ts Просмотреть файл

@@ -152,3 +152,33 @@ export const updateInventoryLotLineQuantities = async (data: {
revalidateTag("pickorder");
return result;
};

//STOCK TRANSFER
export interface CreateStockTransferRequest {
inventoryLotLineId: number;
transferredQty: number;
warehouseId: number;
}

export interface MessageResponse {
id: number | null;
name: string;
code: string;
type: string;
message: string | null;
errorPosition: string | null;
}

export const createStockTransfer = async (data: CreateStockTransferRequest) => {
const result = await serverFetchJson<MessageResponse>(
`${BASE_API_URL}/stockTransferRecord/create`,
{
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
},
);
revalidateTag("inventoryLotLines");
revalidateTag("inventories");
return result;
};

+ 2
- 0
src/app/api/inventory/index.ts Просмотреть файл

@@ -24,6 +24,8 @@ export interface InventoryResult {
price: number;
currencyName: string;
status: string;
latestMarketUnitPrice?: number;
latestMupUpdatedDate?: string;
}

export interface InventoryLotLineResult {


+ 160
- 23
src/app/api/jo/actions.ts Просмотреть файл

@@ -246,6 +246,7 @@ export interface ProductProcessLineResponse {
postProdTimeInMinutes: number,
startTime: string,
endTime: string,
isOringinal: boolean,
}

export interface ProductProcessWithLinesResponse {
@@ -343,10 +344,12 @@ export interface AllJoborderProductProcessInfoResponse {
pickOrderStatus: string;
itemCode: string;
itemName: string;
lotNo: string;
requiredQty: number;
jobOrderId: number;
timeNeedToComplete: number;
uom: string;
isDrink?: boolean | null;
stockInLineId: number;
jobOrderCode: string;
productProcessLineCount: number;
@@ -454,18 +457,29 @@ export interface JobOrderProcessLineDetailResponse {
}
export interface JobOrderLineInfo {
id: number,
jobOrderId: number,
jobOrderCode: string,
itemId: number,
itemCode: string,
itemName: string,
type: string,

reqQty: number,
baseReqQty: number,
stockReqQty: number,

stockQty: number,
uom: string,
shortUom: string,
baseStockQty: number,

reqUom: string,
reqBaseUom: string,

stockUom: string,
stockBaseUom: string,
availableStatus: string,
bomProcessId: number,
bomProcessSeqNo: number,
isOringinal: boolean

}
export interface ProductProcessLineInfoResponse {
@@ -496,6 +510,11 @@ export interface ProductProcessLineInfoResponse {
startTime: string,
endTime: string
}
export interface FloorPickCount {
floor: string;
finishedCount: number;
totalCount: number;
}
export interface AllJoPickOrderResponse {
id: number;
pickOrderId: number | null;
@@ -506,11 +525,13 @@ export interface AllJoPickOrderResponse {
jobOrderType: string | null;
itemId: number;
itemName: string;
lotNo: string | null;
reqQty: number;
uomId: number;
uomName: string;
jobOrderStatus: string;
finishedPickOLineCount: number;
floorPickCounts: FloorPickCount[];
}
export interface UpdateJoPickOrderHandledByRequest {
pickOrderId: number;
@@ -558,6 +579,18 @@ export interface PickOrderLineWithLotsResponse {
status: string | null;
handler: string | null;
lots: LotDetailResponse[];
stockouts?: StockOutLineDetailResponse[];
}

export interface StockOutLineDetailResponse {
id: number | null;
status: string | null;
qty: number | null;
lotId: number | null;
lotNo: string | null;
location: string | null;
availableQty: number | null;
noLot: boolean;
}

export interface LotDetailResponse {
@@ -575,6 +608,7 @@ export interface LotDetailResponse {
pickOrderConsoCode: string | null;
pickOrderLineId: number | null;
stockOutLineId: number | null;
stockInLineId: number | null;
suggestedPickLotId: number | null;
stockOutLineQty: number | null;
stockOutLineStatus: string | null;
@@ -655,12 +689,13 @@ export const fetchJobOrderLotsHierarchicalByPickOrderId = cache(async (pickOrder
},
);
});
export const fetchAllJoPickOrders = cache(async () => {
export const fetchAllJoPickOrders = cache(async (isDrink?: boolean | null) => {
const query = isDrink !== undefined && isDrink !== null
? `?isDrink=${isDrink}`
: "";
return serverFetchJson<AllJoPickOrderResponse[]>(
`${BASE_API_URL}/jo/AllJoPickOrder`,
{
method: "GET",
}
`${BASE_API_URL}/jo/AllJoPickOrder${query}`,
{ method: "GET" }
);
});
export const fetchProductProcessLineDetail = cache(async (lineId: number) => {
@@ -715,9 +750,13 @@ export const newUpdateProductProcessLineQrscan = cache(async (request: NewProduc
}
);
});
export const fetchAllJoborderProductProcessInfo = cache(async () => {
export const fetchAllJoborderProductProcessInfo = cache(async (isDrink?: boolean | null) => {
const query = isDrink !== undefined && isDrink !== null
? `?isDrink=${isDrink}`
: "";

return serverFetchJson<AllJoborderProductProcessInfoResponse[]>(
`${BASE_API_URL}/product-process/Demo/Process/all`,
`${BASE_API_URL}/product-process/Demo/Process/all${query}`,
{
method: "GET",
next: { tags: ["productProcess"] },
@@ -873,7 +912,7 @@ export const updateSecondQrScanStatus = cache(async (pickOrderId: number, itemId
export const submitSecondScanQuantity = cache(async (
pickOrderId: number,
itemId: number,
data: { qty: number; isMissing?: boolean; isBad?: boolean; reason?: string }
data: { qty: number; isMissing?: boolean; isBad?: boolean; reason?: string; userId?: number }
) => {
return serverFetchJson<any>(
`${BASE_API_URL}/jo/second-scan-submit/${pickOrderId}/${itemId}`,
@@ -1188,18 +1227,26 @@ export interface MaterialPickStatusItem {
pickStatus: string | null;
}

export const fetchMaterialPickStatus = cache(async (): Promise<MaterialPickStatusItem[]> => {
export const fetchMaterialPickStatus = cache(async (date?: string): Promise<MaterialPickStatusItem[]> => {
const params = new URLSearchParams();
if (date) params.set("date", date); // yyyy-MM-dd

const qs = params.toString();
const url = `${BASE_API_URL}/jo/material-pick-status${qs ? `?${qs}` : ""}`;
return await serverFetchJson<MaterialPickStatusItem[]>(
`${BASE_API_URL}/jo/material-pick-status`,
url,
{
method: "GET",
}
);
})
export interface ProcessStatusInfo {
processName?: string | null;
equipmentName?: string | null;
equipmentDetailName?: string | null;
startTime?: string | null;
endTime?: string | null;
equipmentCode?: string | null;
isRequired: boolean;
}

@@ -1208,6 +1255,7 @@ export interface JobProcessStatusResponse {
jobOrderCode: string;
itemCode: string;
itemName: string;
status: string;
processingTime: number | null;
setupTime: number | null;
changeoverTime: number | null;
@@ -1215,15 +1263,104 @@ export interface JobProcessStatusResponse {
processes: ProcessStatusInfo[];
}

// 添加API调用函数
export const fetchJobProcessStatus = cache(async () => {
return serverFetchJson<JobProcessStatusResponse[]>(
`${BASE_API_URL}/product-process/Demo/JobProcessStatus`,
export const fetchJobProcessStatus = cache(async (date?: string) => {
const params = new URLSearchParams();
if (date) params.set("date", date); // yyyy-MM-dd

const qs = params.toString();
const url = `${BASE_API_URL}/product-process/Demo/JobProcessStatus${qs ? `?${qs}` : ""}`;

return serverFetchJson<JobProcessStatusResponse[]>(url, {
method: "GET",
next: { tags: ["jobProcessStatus"] },
});
});

// ===== Operator KPI Dashboard =====

export interface OperatorKpiProcessInfo {
jobOrderId?: number | null;
jobOrderCode?: string | null;
productProcessId?: number | null;
productProcessLineId?: number | null;
processName?: string | null;
equipmentName?: string | null;
equipmentDetailName?: string | null;
startTime?: string | number[] | null;
endTime?: string | number[] | null;
processingTime?: number | null;
itemCode?: string | null;
itemName?: string | null;
}

export interface OperatorKpiResponse {
operatorId: number;
operatorName?: string | null;
staffNo?: string | null;
totalProcessingMinutes: number;
totalJobOrderCount: number;
currentProcesses: OperatorKpiProcessInfo[];
}

export const fetchOperatorKpi = cache(async (date?: string) => {
const params = new URLSearchParams();
if (date) params.set("date", date);
const qs = params.toString();
const url = `${BASE_API_URL}/product-process/Demo/OperatorKpi${qs ? `?${qs}` : ""}`;

return serverFetchJson<OperatorKpiResponse[]>(url, {
method: "GET",
next: { tags: ["operatorKpi"] },
});
});

// ===== Equipment Status Dashboard =====

export interface EquipmentStatusProcessInfo {
jobOrderId?: number | null;
jobOrderCode?: string | null;
productProcessId?: number | null;
productProcessLineId?: number | null;
processName?: string | null;
operatorName?: string | null;
startTime?: string | number[] | null;
processingTime?: number | null;
}

export interface EquipmentStatusPerDetail {
equipmentDetailId: number;
equipmentDetailCode?: string | null;
equipmentDetailName?: string | null;
equipmentId?: number | null;
equipmentTypeName?: string | null;
status: string;
repairAndMaintenanceStatus?: boolean | null;
latestRepairAndMaintenanceDate?: string | null;
lastRepairAndMaintenanceDate?: string | null;
repairAndMaintenanceRemarks?: string | null;
currentProcess?: EquipmentStatusProcessInfo | null;
}

export interface EquipmentStatusByTypeResponse {
equipmentTypeId: number;
equipmentTypeName?: string | null;
details: EquipmentStatusPerDetail[];
}

export const fetchEquipmentStatus = cache(async () => {
const url = `${BASE_API_URL}/product-process/Demo/EquipmentStatus`;
return serverFetchJson<EquipmentStatusByTypeResponse[]>(url, {
method: "GET",
next: { tags: ["equipmentStatus"] },
});
});
export const deleteProductProcessLine = async (lineId: number) => {
return serverFetchJson<any>(
`${BASE_API_URL}/product-process/Demo/ProcessLine/delete/${lineId}`,
{
method: "GET",
next: { tags: ["jobProcessStatus"] },
method: "POST",
headers: { "Content-Type": "application/json" },
}
);
});

};
;

+ 2
- 0
src/app/api/jo/index.ts Просмотреть файл

@@ -21,6 +21,7 @@ export interface JobOrder {
reqQty: number;
item: Item;
itemName: string;
bomId: number;
// uom: Uom;
pickLines?: JoDetailPickLine[];
status: JoStatus;
@@ -37,6 +38,7 @@ export interface JobOrder {
stockInLineId?: number;
stockInLineStatus?: string;
silHandlerId?: number;
lotNo?: string;
}

export interface Machine {


+ 22
- 3
src/app/api/pdf/actions.ts Просмотреть файл

@@ -2,7 +2,7 @@

// import { serverFetchBlob } from "@/app/utils/fetchUtil";
// import { BASE_API_URL } from "@/config/api";
import { serverFetchBlob } from "../../utils/fetchUtil";
import { serverFetchBlob, serverFetchWithNoContent } from "../../utils/fetchUtil";
import { BASE_API_URL } from "../../../config/api";

export interface FileResponse {
@@ -12,7 +12,7 @@ export interface FileResponse {

export const fetchPoQrcode = async (data: any) => {
const reportBlob = await serverFetchBlob<FileResponse>(
`${BASE_API_URL}/stockInLine/print-label`,
`${BASE_API_URL}/stockInLine/download-label`,
{
method: "POST",
body: JSON.stringify(data),
@@ -27,7 +27,7 @@ export interface LotLineToQrcode {
}
export const fetchQrCodeByLotLineId = async (data: LotLineToQrcode) => {
const reportBlob = await serverFetchBlob<FileResponse>(
`${BASE_API_URL}/inventoryLotLine/print-label`,
`${BASE_API_URL}/inventoryLotLine/download-label`,
{
method: "POST",
body: JSON.stringify(data),
@@ -37,3 +37,22 @@ export const fetchQrCodeByLotLineId = async (data: LotLineToQrcode) => {

return reportBlob;
}

export interface PrintLabelForInventoryLotLineRequest {
inventoryLotLineId: number;
printerId: number;
printQty?: number;
}

export async function printLabelForInventoryLotLine(data: PrintLabelForInventoryLotLineRequest) {
const params = new URLSearchParams();
params.append("inventoryLotLineId", data.inventoryLotLineId.toString());
params.append("printerId", data.printerId.toString());
if (data.printQty != null && data.printQty !== undefined) {
params.append("printQty", data.printQty.toString());
}
return serverFetchWithNoContent(
`${BASE_API_URL}/inventoryLotLine/print-label?${params.toString()}`,
{ method: "GET" }
);
}

+ 116
- 7
src/app/api/pickOrder/actions.ts Просмотреть файл

@@ -207,9 +207,12 @@ export interface PickExecutionIssueData {
actualPickQty: number;
missQty: number;
badItemQty: number;
badPackageQty?: number;
issueRemark: string;
pickerName: string;
handledBy?: number;
badReason?: string;
reason?: string;
}
export type AutoAssignReleaseResponse = {
id: number | null;
@@ -440,6 +443,7 @@ export interface UpdatePickExecutionIssueRequest {
export interface StoreLaneSummary {
storeId: string;
rows: LaneRow[];
defaultTruckCount: number | null;
}

export interface LaneRow {
@@ -470,6 +474,7 @@ export interface QrPickSubmitLineRequest {
export interface UpdateStockOutLineStatusByQRCodeAndLotNoRequest {
pickOrderLineId: number,
inventoryLotNo: string,
stockInLineId?: number | null,
stockOutLineId: number,
itemId: number,
status: string
@@ -542,7 +547,37 @@ export const batchQrSubmit = async (data: QrPickBatchSubmitRequest) => {
);
return response;
};
export interface BatchScanRequest {
userId: number;
lines: BatchScanLineRequest[];
}
export interface BatchScanLineRequest {
pickOrderLineId: number;
inventoryLotLineId: number | null; // 如果有 lot,提供 lotId;如果没有则为 null
pickOrderConsoCode: string;
lotNo: string | null; // 用于日志和验证
itemId: number;
itemCode: string;
stockOutLineId: number | null; // ✅ 新增:如果已有 stockOutLineId,直接使用
}

export const batchScan = async (data: BatchScanRequest) => {
console.log("📤 batchScan - Request body:", JSON.stringify(data, null, 2));
const response = await serverFetchJson<PostPickOrderResponse<BatchScanRequest>>(
`${BASE_API_URL}/stockOutLine/batchScan`,
{
method: "POST",
body: JSON.stringify(data),
headers: {
"Content-Type": "application/json",
},
},
);
console.log("📥 batchScan - Response:", response);
return response;
};
export const fetchDoPickOrderDetail = async (
doPickOrderId: number,
selectedPickOrderId?: number
@@ -573,16 +608,22 @@ export const updatePickExecutionIssueStatus = async (
};
export async function fetchStoreLaneSummary(storeId: string, requiredDate?: string, releaseType?: string): Promise<StoreLaneSummary> {
const dateToUse = requiredDate || dayjs().format('YYYY-MM-DD');

const url = `${BASE_API_URL}/doPickOrder/summary-by-store?storeId=${encodeURIComponent(storeId)}&requiredDate=${encodeURIComponent(dateToUse)}&releaseType=${encodeURIComponent(releaseType || 'all')}`;
const response = await serverFetchJson<StoreLaneSummary>(
url,
{
const label = `[API] fetchStoreLaneSummary ${storeId}`;
console.time(label);
try {
const response = await serverFetchJson<StoreLaneSummary>(url, {
method: "GET",
cache: "no-store",
next: { revalidate: 0 }
}
);
return response;
next: { revalidate: 0 },
});
console.timeEnd(label);
return response;
} catch (error) {
console.error(`[API] Error in fetchStoreLaneSummary ${storeId}:`, error);
throw error;
}
}

// 按车道分配订单
@@ -964,6 +1005,7 @@ export interface LotSubstitutionConfirmRequest {
stockOutLineId: number;
originalSuggestedPickLotId: number;
newInventoryLotNo: string;
newStockInLineId: number;
}
export const confirmLotSubstitution = async (data: LotSubstitutionConfirmRequest) => {
const response = await serverFetchJson<PostPickOrderResponse>(
@@ -1350,4 +1392,71 @@ export const fetchReleasedDoPickOrders = async (): Promise<ReleasedDoPickOrderRe
},
);
return response;
};
// 新增:Released Do Pick Order 列表項目(對應後端 ReleasedDoPickOrderListItem)
export interface ReleasedDoPickOrderListItem {
id: number;
requiredDeliveryDate: string | null;
shopCode: string | null;
shopName: string | null;
storeId: string | null;
truckLanceCode: string | null;
truckDepartureTime: string | null;
deliveryOrderCodes: string[];
}

// 修改:fetchReleasedDoPickOrders 支援 shopName 篩選,並回傳新結構
export const fetchReleasedDoPickOrdersForSelection = async (
shopName?: string,
storeId?: string,
truck?: string
): Promise<ReleasedDoPickOrderListItem[]> => {
const params = new URLSearchParams();
if (shopName?.trim()) params.append("shopName", shopName.trim());
if (storeId?.trim()) params.append("storeId", storeId.trim());
if (truck?.trim()) params.append("truck", truck.trim());
const query = params.toString();
const url = `${BASE_API_URL}/doPickOrder/released${query ? `?${query}` : ""}`;
const response = await serverFetchJson<ReleasedDoPickOrderListItem[]>(url, {
method: "GET",
});
return response ?? [];
};
export const fetchReleasedDoPickOrdersForSelectionToday = async (
shopName?: string,
storeId?: string,
truck?: string
): Promise<ReleasedDoPickOrderListItem[]> => {
const params = new URLSearchParams();
if (shopName?.trim()) params.append("shopName", shopName.trim());
if (storeId?.trim()) params.append("storeId", storeId.trim());
if (truck?.trim()) params.append("truck", truck.trim());
const query = params.toString();
const url = `${BASE_API_URL}/doPickOrder/released-today${query ? `?${query}` : ""}`;
const response = await serverFetchJson<ReleasedDoPickOrderListItem[]>(url, {
method: "GET",
});
return response ?? [];
};
export const fetchReleasedDoPickOrderCountByStore = async (
storeId: string
): Promise<number> => {
const list = await fetchReleasedDoPickOrdersForSelection(undefined, storeId);
return list.length;
};
// 新增:依 doPickOrderId 分配
export const assignByDoPickOrderId = async (
userId: number,
doPickOrderId: number
): Promise<PostPickOrderResponse> => {
const response = await serverFetchJson<PostPickOrderResponse>(
`${BASE_API_URL}/doPickOrder/assign-by-id`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, doPickOrderId }),
}
);
revalidateTag("pickorder");
return response;
};

+ 16
- 1
src/app/api/po/actions.ts Просмотреть файл

@@ -200,6 +200,21 @@ export const fetchPoInClient = cache(async (id: number) => {
});
});

export interface PurchaseOrderSummary {
id: number;
code: string;
status: string;
orderDate: string;
estimatedArrivalDate: string;
supplierName: string;
escalated: boolean;
}
export const fetchPoSummariesClient = cache(async (ids: number[]) => {
return serverFetchJson<PurchaseOrderSummary[]>(`${BASE_API_URL}/po/summary`, {
next: { tags: ["po"] },
});
});

export const fetchPoListClient = cache(
async (queryParams?: Record<string, any>) => {
if (queryParams) {
@@ -250,7 +265,7 @@ export const testing = cache(async (queryParams?: Record<string, any>) => {
// DEPRECIATED
export const printQrCodeForSil = cache(async(data: PrintQrCodeForSilRequest) => {
const params = convertObjToURLSearchParams(data)
return serverFetchWithNoContent(`${BASE_API_URL}/stockInLine/printQrCode?${params}`,
return serverFetchWithNoContent(`${BASE_API_URL}/stockInLine/print-label?${params}`,
{
method: "GET",
headers: { "Content-Type": "application/json" },


+ 1
- 1
src/app/api/po/index.ts Просмотреть файл

@@ -33,7 +33,7 @@ export interface PoResult {
status: string;
pol?: PurchaseOrderLine[];
}
export type { StockInLine } from "../stockIn";
export interface PurchaseOrderLine {
id: number;
purchaseOrderId: number;


+ 3
- 3
src/app/api/qc/index.ts Просмотреть файл

@@ -29,9 +29,9 @@ export interface QcData {
name?: string,
order?: number,
description?: string,
// qcPassed: boolean | undefined
// failQty: number | undefined
// remarks: string | undefined
qcPassed?: boolean,
failQty?: number,
remarks?: string,
}
export interface QcResult extends QcData{
id?: number;


+ 30
- 0
src/app/api/settings/bomWeighting/actions.ts Просмотреть файл

@@ -0,0 +1,30 @@
"use server";

import { serverFetchJson } from "@/app/utils/fetchUtil";
import { BASE_API_URL } from "@/config/api";
import { revalidatePath, revalidateTag } from "next/cache";
import { BomWeightingScoreResult } from ".";

export interface UpdateBomWeightingScoreInputs {
id: number;
name: string;
range: number;
weighting: number;
remarks?: string;
}

export const updateBomWeightingScore = async (data: UpdateBomWeightingScoreInputs) => {
const response = await serverFetchJson<BomWeightingScoreResult>(
`${BASE_API_URL}/bomWeightingScores/${data.id}`,
{
method: "PUT",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
},
);

revalidateTag("bomWeightingScores");
revalidatePath("/(main)/settings/bomWeighting");

return response;
};

+ 30
- 0
src/app/api/settings/bomWeighting/client.ts Просмотреть файл

@@ -0,0 +1,30 @@
"use client";

import axiosInstance from "@/app/(main)/axios/axiosInstance";
import { NEXT_PUBLIC_API_URL } from "@/config/api";
import { BomWeightingScoreResult } from "./index";

export interface UpdateBomWeightingScoreInputs {
id: number;
name: string;
range: number;
weighting: number;
remarks?: string;
}

export const fetchBomWeightingScoresClient = async (): Promise<BomWeightingScoreResult[]> => {
const response = await axiosInstance.get<BomWeightingScoreResult[]>(
`${NEXT_PUBLIC_API_URL}/bomWeightingScores`
);
return response.data;
};

export const updateBomWeightingScoreClient = async (
data: UpdateBomWeightingScoreInputs
): Promise<BomWeightingScoreResult> => {
const response = await axiosInstance.put<BomWeightingScoreResult>(
`${NEXT_PUBLIC_API_URL}/bomWeightingScores/${data.id}`,
data
);
return response.data;
};

+ 23
- 0
src/app/api/settings/bomWeighting/index.ts Просмотреть файл

@@ -0,0 +1,23 @@
import { serverFetchJson } from "@/app/utils/fetchUtil";
import { BASE_API_URL } from "@/config/api";
import { cache } from "react";
import "server-only";

export interface BomWeightingScoreResult {
id: number;
code: string;
name: string;
range: number;
weighting: number | string | { value?: number; [key: string]: any };
remarks?: string;
}

export const preloadBomWeightingScores = () => {
fetchBomWeightingScores();
};

export const fetchBomWeightingScores = cache(async () => {
return serverFetchJson<BomWeightingScoreResult[]>(`${BASE_API_URL}/bomWeightingScores`, {
next: { tags: ["bomWeightingScores"] },
});
});

+ 25
- 0
src/app/api/settings/bomWeighting/page.tsx Просмотреть файл

@@ -0,0 +1,25 @@
import { Metadata } from "next";
import { getServerI18n, I18nProvider } from "@/i18n";
import PageTitleBar from "@/components/PageTitleBar";
import BomWeightingScoreTable from "@/components/BomWeightingScoreTable";
import { fetchBomWeightingScores } from "@/app/api/settings/bomWeighting";

export const metadata: Metadata = {
title: "BOM Weighting Score",
};

const BomWeightingScorePage: React.FC = async () => {
const { t } = await getServerI18n("common");
const bomWeightingScores = await fetchBomWeightingScores();

return (
<>
<PageTitleBar title={t("BOM Weighting Score List")} className="mb-4" />
<I18nProvider namespaces={["common"]}>
<BomWeightingScoreTable bomWeightingScores={bomWeightingScores} />
</I18nProvider>
</>
);
};

export default BomWeightingScorePage;

+ 1
- 0
src/app/api/settings/item/actions.ts Просмотреть файл

@@ -45,6 +45,7 @@ export type CreateItemInputs = {
isEgg?: boolean | undefined;
isFee?: boolean | undefined;
isBag?: boolean | undefined;
qcType?: string | undefined;
};

export const saveItem = async (data: CreateItemInputs) => {


+ 5
- 0
src/app/api/settings/item/index.ts Просмотреть файл

@@ -62,11 +62,16 @@ export type ItemsResult = {
isEgg?: boolean | undefined;
isFee?: boolean | undefined;
isBag?: boolean | undefined;
averageUnitPrice?: number | string;
latestMarketUnitPrice?: number;
latestMupUpdatedDate?: string;
purchaseUnit?: string;
};

export type Result = {
item: ItemsResult;
qcChecks: ItemQc[];
qcType?: string;
};
export const fetchAllItems = cache(async () => {
return serverFetchJson<ItemsResult[]>(`${BASE_API_URL}/items`, {


+ 53
- 2
src/app/api/settings/m18ImportTesting/actions.ts Просмотреть файл

@@ -2,17 +2,21 @@

// import { serverFetchWithNoContent } from '@/app/utils/fetchUtil';
// import { BASE_API_URL } from "@/config/api";
import { serverFetchWithNoContent } from "../../../utils/fetchUtil";
import { serverFetch, serverFetchWithNoContent } from "../../../utils/fetchUtil";
import { BASE_API_URL } from "../../../../config/api";

export interface M18ImportPoForm {
modifiedDateFrom: string;
modifiedDateTo: string;
dDateFrom: string;
dDateTo: string;
}

export interface M18ImportDoForm {
modifiedDateFrom: string;
modifiedDateTo: string;
dDateFrom: string;
dDateTo: string;
}

export interface M18ImportPqForm {
@@ -49,10 +53,13 @@ export const testM18ImportDo = async (data: M18ImportDoForm) => {
};

export const testM18ImportPq = async (data: M18ImportPqForm) => {
const token = localStorage.getItem("accessToken");
return serverFetchWithNoContent(`${BASE_API_URL}/m18/pq`, {
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}`, },
});
};

@@ -65,3 +72,47 @@ export const testM18ImportMasterData = async (
headers: { "Content-Type": "application/json" },
});
};

export const triggerScheduler = async (type: 'po' | 'do1' | 'do2' | 'master-data' | 'refresh-cron') => {
try {
// IMPORTANT: 'refresh-cron' is a direct endpoint /api/scheduler/refresh-cron
// Others are /api/scheduler/trigger/{type}
const path = type === 'refresh-cron'
? 'refresh-cron'
: `trigger/${type}`;

const url = `${BASE_API_URL}/scheduler/${path}`;
console.log("Fetching URL:", url);

const response = await serverFetch(url, {
method: "GET",
cache: "no-store",
});

if (!response.ok) throw new Error(`Failed: ${response.status}`);

return await response.text();
} catch (error) {
console.error("Scheduler Action Error:", error);
return null;
}
};

export const refreshCronSchedules = async () => {
// Simply reuse the triggerScheduler logic to avoid duplication
// or call serverFetch directly as shown below:
try {
const response = await serverFetch(`${BASE_API_URL}/scheduler/refresh-cron`, {
method: "GET",
cache: "no-store",
});

if (!response.ok) throw new Error(`Failed to refresh: ${response.status}`);

return await response.text();
} catch (error) {
console.error("Refresh Cron Error:", error);
return "Refresh failed. Check server logs.";
}
};

+ 61
- 0
src/app/api/settings/printer/actions.ts Просмотреть файл

@@ -0,0 +1,61 @@
"use server";

import {
serverFetchJson,
serverFetchWithNoContent,
} from "../../../utils/fetchUtil";
import { BASE_API_URL } from "../../../../config/api";
import { revalidateTag } from "next/cache";
import { PrinterResult } from ".";

export interface PrinterInputs {
name?: string;
code?: string;
type?: string;
brand?: string;
description?: string;
ip?: string;
port?: number;
dpi?: number;
}

export const fetchPrinterDetails = async (id: number) => {
return serverFetchJson<PrinterResult>(`${BASE_API_URL}/printers/${id}`, {
next: { tags: ["printers"] },
});
};

export const editPrinter = async (id: number, data: PrinterInputs) => {
const result = await serverFetchWithNoContent(`${BASE_API_URL}/printers/${id}`, {
method: "PUT",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
});
revalidateTag("printers");
return result;
};

export const createPrinter = async (data: PrinterInputs) => {
const result = await serverFetchWithNoContent(`${BASE_API_URL}/printers`, {
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
});
revalidateTag("printers");
return result;
};

export const deletePrinter = async (id: number) => {
const result = await serverFetchWithNoContent(`${BASE_API_URL}/printers/${id}`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
});
revalidateTag("printers");
return result;
};

export const fetchPrinterDescriptions = async () => {
return serverFetchJson<string[]>(`${BASE_API_URL}/printers/descriptions`, {
next: { tags: ["printers"] },
});
};

+ 28
- 2
src/app/api/settings/printer/index.ts Просмотреть файл

@@ -10,13 +10,39 @@ export interface PrinterCombo {
code?: string;
name?: string;
type?: string;
brand?: string;
description?: string;
ip?: string;
port?: number;
}

export interface PrinterResult {
action: any;
id: number;
name?: string;
code?: string;
type?: string;
brand?: string;
description?: string;
ip?: string;
port?: number;
dpi?: number;
}

export const fetchPrinterCombo = cache(async () => {
return serverFetchJson<PrinterCombo[]>(`${BASE_API_URL}/printers/combo`, {
next: { tags: ["qcItems"] },
next: { tags: ["printers"] },
})
})
})

export const fetchPrinters = cache(async () => {
return serverFetchJson<PrinterResult[]>(`${BASE_API_URL}/printers`, {
next: { tags: ["printers"] },
});
});

export const fetchPrinterDescriptions = cache(async () => {
return serverFetchJson<string[]>(`${BASE_API_URL}/printers/descriptions`, {
next: { tags: ["printers"] },
});
});

+ 28
- 0
src/app/api/settings/qcCategory/client.ts Просмотреть файл

@@ -0,0 +1,28 @@
"use client";

import { NEXT_PUBLIC_API_URL } from "@/config/api";
import { QcItemInfo } from "./index";

export const fetchQcItemsByCategoryId = async (categoryId: number): Promise<QcItemInfo[]> => {
const token = localStorage.getItem("accessToken");
const response = await fetch(`${NEXT_PUBLIC_API_URL}/qcCategories/${categoryId}/items`, {
method: "GET",
headers: {
"Content-Type": "application/json",
...(token && { Authorization: `Bearer ${token}` }),
},
});

if (!response.ok) {
if (response.status === 401) {
throw new Error("Unauthorized: Please log in again");
}
throw new Error(`Failed to fetch QC items: ${response.status} ${response.statusText}`);
}

return response.json();
};




+ 9
- 0
src/app/api/settings/qcCategory/index.ts Просмотреть файл

@@ -17,6 +17,15 @@ export interface QcCategoryCombo {
label: string;
}

export interface QcItemInfo {
id: number;
qcItemId: number;
code: string;
name?: string;
order: number;
description?: string;
}

export const preloadQcCategory = () => {
fetchQcCategories();
};


+ 283
- 0
src/app/api/settings/qcItemAll/actions.ts Просмотреть файл

@@ -0,0 +1,283 @@
"use server";

import { serverFetchJson ,serverFetchWithNoContent} from "@/app/utils/fetchUtil";
import { BASE_API_URL } from "@/config/api";
import { revalidatePath, revalidateTag } from "next/cache";
import {
ItemQcCategoryMappingInfo,
QcItemInfo,
DeleteResponse,
QcCategoryResult,
ItemsResult,
QcItemResult,
} from ".";

export interface SaveQcCategoryInputs {
id?: number;
code: string;
name: string;
description?: string;
}

export interface SaveQcCategoryResponse {
id?: number;
code: string;
name: string;
description?: string;
errors: Record<string, string> | null;
}

export interface SaveQcItemInputs {
id?: number;
code: string;
name: string;
description?: string;
}

export interface SaveQcItemResponse {
id?: number;
code: string;
name: string;
description?: string;
errors: Record<string, string> | null;
}

// Item and QcCategory mapping
export const getItemQcCategoryMappings = async (
qcCategoryId?: number,
itemId?: number
): Promise<ItemQcCategoryMappingInfo[]> => {
const params = new URLSearchParams();
if (qcCategoryId) params.append("qcCategoryId", qcCategoryId.toString());
if (itemId) params.append("itemId", itemId.toString());
return serverFetchJson<ItemQcCategoryMappingInfo[]>(
`${BASE_API_URL}/qcItemAll/itemMappings?${params.toString()}`
);
};

export const saveItemQcCategoryMapping = async (
itemId: number,
qcCategoryId: number,
type: string
): Promise<ItemQcCategoryMappingInfo> => {
const params = new URLSearchParams();
params.append("itemId", itemId.toString());
params.append("qcCategoryId", qcCategoryId.toString());
params.append("type", type);
const response = await serverFetchJson<ItemQcCategoryMappingInfo>(
`${BASE_API_URL}/qcItemAll/itemMapping?${params.toString()}`,
{
method: "POST",
}
);
revalidateTag("qcItemAll");
return response;
};

export const deleteItemQcCategoryMapping = async (
mappingId: number
): Promise<void> => {
await serverFetchJson<void>(
`${BASE_API_URL}/qcItemAll/itemMapping/${mappingId}`,
{
method: "DELETE",
}
);
revalidateTag("qcItemAll");
};

// QcCategory and QcItem mapping
export const getQcCategoryQcItemMappings = async (
qcCategoryId: number
): Promise<QcItemInfo[]> => {
return serverFetchJson<QcItemInfo[]>(
`${BASE_API_URL}/qcItemAll/qcItemMappings/${qcCategoryId}`
);
};

export const saveQcCategoryQcItemMapping = async (
qcCategoryId: number,
qcItemId: number,
order: number,
description?: string
): Promise<QcItemInfo> => {
const params = new URLSearchParams();
params.append("qcCategoryId", qcCategoryId.toString());
params.append("qcItemId", qcItemId.toString());
params.append("order", order.toString());
if (description) params.append("description", description);
const response = await serverFetchJson<QcItemInfo>(
`${BASE_API_URL}/qcItemAll/qcItemMapping?${params.toString()}`,
{
method: "POST",
}
);
revalidateTag("qcItemAll");
return response;
};

export const deleteQcCategoryQcItemMapping = async (
mappingId: number
): Promise<void> => {
await serverFetchJson<void>(
`${BASE_API_URL}/qcItemAll/qcItemMapping/${mappingId}`,
{
method: "DELETE",
}
);
revalidateTag("qcItemAll");
};

// Counts
export const getItemCountByQcCategory = async (
qcCategoryId: number
): Promise<number> => {
return serverFetchJson<number>(
`${BASE_API_URL}/qcItemAll/itemCount/${qcCategoryId}`
);
};

export const getQcItemCountByQcCategory = async (
qcCategoryId: number
): Promise<number> => {
return serverFetchJson<number>(
`${BASE_API_URL}/qcItemAll/qcItemCount/${qcCategoryId}`
);
};

// Validation
export const canDeleteQcCategory = async (id: number): Promise<boolean> => {
return serverFetchJson<boolean>(
`${BASE_API_URL}/qcItemAll/canDeleteQcCategory/${id}`
);
};

export const canDeleteQcItem = async (id: number): Promise<boolean> => {
return serverFetchJson<boolean>(
`${BASE_API_URL}/qcItemAll/canDeleteQcItem/${id}`
);
};

// Save and delete with validation
export const saveQcCategoryWithValidation = async (
data: SaveQcCategoryInputs
): Promise<SaveQcCategoryResponse> => {
const response = await serverFetchJson<SaveQcCategoryResponse>(
`${BASE_API_URL}/qcItemAll/saveQcCategory`,
{
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
}
);
revalidateTag("qcCategories");
revalidateTag("qcItemAll");
return response;
};

export const deleteQcCategoryWithValidation = async (
id: number
): Promise<DeleteResponse> => {
const response = await serverFetchJson<DeleteResponse>(
`${BASE_API_URL}/qcItemAll/deleteQcCategory/${id}`,
{
method: "DELETE",
}
);
revalidateTag("qcCategories");
revalidateTag("qcItemAll");
revalidatePath("/(main)/settings/qcItemAll");
return response;
};

export const saveQcItemWithValidation = async (
data: SaveQcItemInputs
): Promise<SaveQcItemResponse> => {
const response = await serverFetchJson<SaveQcItemResponse>(
`${BASE_API_URL}/qcItemAll/saveQcItem`,
{
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
}
);
revalidateTag("qcItems");
revalidateTag("qcItemAll");
return response;
};

export const deleteQcItemWithValidation = async (
id: number
): Promise<DeleteResponse> => {
const response = await serverFetchJson<DeleteResponse>(
`${BASE_API_URL}/qcItemAll/deleteQcItem/${id}`,
{
method: "DELETE",
}
);
revalidateTag("qcItems");
revalidateTag("qcItemAll");
revalidatePath("/(main)/settings/qcItemAll");
return response;
};

// Server actions for fetching data (to be used in client components)
export const fetchQcCategoriesForAll = async (): Promise<QcCategoryResult[]> => {
return serverFetchJson<QcCategoryResult[]>(
`${BASE_API_URL}/qcItemAll/categoriesWithItemCountsAndType`,
{ next: { tags: ["qcItemAll", "qcCategories"] } }
);
};
type CategoryTypeResponse = { type: string | null };
export const getCategoryType = async (qcCategoryId: number): Promise<string | null> => {
const res = await serverFetchJson<CategoryTypeResponse>(
`${BASE_API_URL}/qcItemAll/categoryType/${qcCategoryId}`
);
return res.type ?? null;
};

export const updateCategoryType = async (
qcCategoryId: number,
type: string
): Promise<void> => {
await serverFetchWithNoContent(
`${BASE_API_URL}/qcItemAll/categoryType?qcCategoryId=${qcCategoryId}&type=${encodeURIComponent(type)}`,
{ method: "PUT" }
);
revalidateTag("qcItemAll");
};
export const fetchItemsForAll = async (): Promise<ItemsResult[]> => {
return serverFetchJson<ItemsResult[]>(`${BASE_API_URL}/items`, {
next: { tags: ["items"] },
});
};

export const fetchQcItemsForAll = async (): Promise<QcItemResult[]> => {
return serverFetchJson<QcItemResult[]>(`${BASE_API_URL}/qcItems`, {
next: { tags: ["qcItems"] },
});
};

// Get item by code (for Tab 0 - validate item code input)
export const getItemByCode = async (code: string): Promise<ItemsResult | null> => {
try {
return await serverFetchJson<ItemsResult>(`${BASE_API_URL}/qcItemAll/itemByCode/${encodeURIComponent(code)}`);
} catch (error) {
// Item not found
return null;
}
};




+ 107
- 0
src/app/api/settings/qcItemAll/index.ts Просмотреть файл

@@ -0,0 +1,107 @@
// Type definitions that can be used in both client and server components
export interface ItemQcCategoryMappingInfo {
id: number;
itemId: number;
itemCode?: string;
itemName?: string;
qcCategoryId: number;
qcCategoryCode?: string;
qcCategoryName?: string;
type?: string;
}
export interface QcCategoryResult {
id: number;
code: string;
name: string;
description?: string;
type?: string | null; // add this: items_qc_category_mapping.type for this category
}
export interface QcItemInfo {
id: number;
order: number;
qcItemId: number;
code: string;
name?: string;
description?: string;
}

export interface DeleteResponse {
success: boolean;
message?: string;
canDelete: boolean;
}

export interface QcCategoryWithCounts {
id: number;
code: string;
name: string;
description?: string;
itemCount: number;
qcItemCount: number;
}

export interface QcCategoryWithItemCount {
id: number;
code: string;
name: string;
description?: string;
itemCount: number;
}

export interface QcCategoryWithQcItemCount {
id: number;
code: string;
name: string;
description?: string;
qcItemCount: number;
}

export interface QcItemWithCounts {
id: number;
code: string;
name: string;
description?: string;
qcCategoryCount: number;
}

// Type definitions that match the server-only types
export interface QcCategoryResult {
id: number;
code: string;
name: string;
description?: string;
}

export interface QcItemResult {
id: number;
code: string;
name: string;
description: string;
}

export interface ItemsResult {
id: string | number;
code: string;
name: string;
description: string | undefined;
remarks: string | undefined;
shelfLife: number | undefined;
countryOfOrigin: string | undefined;
maxQty: number | undefined;
type: string;
qcChecks: any[];
action?: any;
fgName?: string;
excludeDate?: string;
qcCategory?: QcCategoryResult;
store_id?: string | undefined;
warehouse?: string | undefined;
area?: string | undefined;
slot?: string | undefined;
LocationCode?: string | undefined;
locationCode?: string | undefined;
isEgg?: boolean | undefined;
isFee?: boolean | undefined;
isBag?: boolean | undefined;
}


+ 48
- 0
src/app/api/stockAdjustment/actions.ts Просмотреть файл

@@ -0,0 +1,48 @@
"use server";
import { BASE_API_URL } from "@/config/api";
import { revalidateTag } from "next/cache";
import { serverFetchJson } from "@/app/utils/fetchUtil";

export interface StockAdjustmentLineRequest {
id: number;
lotNo?: string | null;
adjustedQty: number;
productlotNo?: string | null;
dnNo?: string | null;
isOpeningInventory: boolean;
isNew: boolean;
itemId: number;
itemNo: string;
expiryDate: string;
warehouseId: number;
uom?: string | null;
}

export interface StockAdjustmentRequest {
itemId: number;
originalLines: StockAdjustmentLineRequest[];
currentLines: StockAdjustmentLineRequest[];
}

export interface MessageResponse {
id: number | null;
name: string;
code: string;
type: string;
message: string | null;
errorPosition: string | null;
}

export const submitStockAdjustment = async (data: StockAdjustmentRequest) => {
const result = await serverFetchJson<MessageResponse>(
`${BASE_API_URL}/stockAdjustment/submit`,
{
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
},
);
revalidateTag("inventoryLotLines");
revalidateTag("inventories");
return result;
};

+ 8
- 1
src/app/api/stockIn/actions.ts Просмотреть файл

@@ -12,6 +12,7 @@ import { RecordsRes } from "../utils";
import { Uom } from "../settings/uom";
import { convertObjToURLSearchParams } from "@/app/utils/commonUtil";
// import { BASE_API_URL } from "@/config/api";
import { Result } from "../settings/item";

export interface PostStockInLineResponse<T> {
id: number | null;
@@ -232,7 +233,7 @@ export const testing = cache(async (queryParams?: Record<string, any>) => {

export const printQrCodeForSil = cache(async(data: PrintQrCodeForSilRequest) => {
const params = convertObjToURLSearchParams(data)
return serverFetchWithNoContent(`${BASE_API_URL}/stockInLine/printQrCode?${params}`,
return serverFetchWithNoContent(`${BASE_API_URL}/stockInLine/print-label?${params}`,
{
method: "GET",
headers: { "Content-Type": "application/json" },
@@ -242,3 +243,9 @@ export const printQrCodeForSil = cache(async(data: PrintQrCodeForSilRequest) =>
},
)
})
// 添加服务器端 action 用于从客户端组件获取 item 信息
export const fetchItemForPutAway = cache(async (id: number): Promise<Result> => {
return serverFetchJson<Result>(`${BASE_API_URL}/items/details/${id}`, {
next: { tags: ["items"] },
});
});

+ 8
- 0
src/app/api/stockIn/index.ts Просмотреть файл

@@ -109,6 +109,8 @@ export interface StockInLine {
itemType: string;
demandQty: number;
acceptedQty: number;
purchaseDemandQty?: number;
purchaseAcceptedQty?: number;
qty?: number;
receivedQty?: number;
processed?: number;
@@ -124,7 +126,12 @@ export interface StockInLine {
lotNo?: string;
poCode?: string;
uom?: Uom;
purchaseUomDesc?: string;
stockUomDesc?: string;
joCode?: string;
warehouseCode?: string;
defaultWarehouseId: number; // id for now
locationCode?: string;
dnNo?: string;
dnDate?: number[];
stockQty?: number;
@@ -147,6 +154,7 @@ export interface EscalationInput {
export interface PutAwayLine {
id?: number
qty: number
stockQty?: number
warehouseId: number;
warehouse: string;
printQty: number;


+ 229
- 0
src/app/api/stockIssue/actions.ts Просмотреть файл

@@ -0,0 +1,229 @@
"use server";

import { BASE_API_URL } from "@/config/api";
import { serverFetchJson } from "@/app/utils/fetchUtil";
import { cache } from "react";
import type { MessageResponse } from "@/app/api/shop/actions";

// Export types/interfaces (these are safe to import in client components)
export interface StockIssueResult {
id: number;
itemId: number;
itemCode: string;
itemDescription: string;
lotId: number;
lotNo: string;
storeLocation: string | null;
requiredQty: number | null;
actualPickQty: number | null;
missQty: number;
badItemQty: number;
bookQty: number;
issueQty: number;
issueRemark: string | null;
pickerName: string | null;
handleStatus: string;
handleDate: string | null;
handledBy: number | null;
uomDesc: string | null;
}
export interface ExpiryItemResult {
id: number;
itemId: number;
itemCode: string;
itemDescription: string | null;
lotId: number;
lotNo: string | null;
storeLocation: string | null;
expiryDate: string | null;
remainingQty: number;
}

export interface StockIssueLists {
missItems: StockIssueResult[];
badItems: StockIssueResult[];
expiryItems: ExpiryItemResult[];
}

// Server actions (these work from both server and client components)
export const PreloadList = () => {
fetchList();
};

export const fetchMissItemList = cache(async (issueCategory: string = "lot_issue") => {
return serverFetchJson<StockIssueResult[]>(
`${BASE_API_URL}/pickExecution/issues/missItem?issueCategory=${issueCategory}`,
{
next: { tags: ["Miss Item List"] },
},
);
});

export const fetchBadItemList = cache(async (issueCategory: string = "lot_issue") => {
return serverFetchJson<StockIssueResult[]>(
`${BASE_API_URL}/pickExecution/issues/badItem?issueCategory=${issueCategory}`,
{
next: { tags: ["Bad Item List"] },
},
);
});


export const fetchExpiryItemList = cache(async () => {
return serverFetchJson<ExpiryItemResult[]>(
`${BASE_API_URL}/pickExecution/issues/expiryItem`,
{
next: { tags: ["Expiry Item List"] },
},
);
});

export const fetchList = cache(async (issueCategory: string = "lot_issue"): Promise<StockIssueLists> => {
const [missItems, badItems, expiryItems] = await Promise.all([
fetchMissItemList(issueCategory),
fetchBadItemList(issueCategory),
fetchExpiryItemList(),
]);

return {
missItems,
badItems,
expiryItems,
};
});

export async function submitMissItem(issueId: number, handler: number) {
return serverFetchJson<MessageResponse>(
`${BASE_API_URL}/pickExecution/submitMissItem`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ issueId, handler }),
},
);
}
export async function batchSubmitMissItem(issueIds: number[], handler: number) {
return serverFetchJson<MessageResponse>(
`${BASE_API_URL}/pickExecution/batchSubmitMissItem`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ issueIds, handler }),
},
);
}
export async function submitBadItem(issueId: number, handler: number) {
return serverFetchJson<MessageResponse>(
`${BASE_API_URL}/pickExecution/submitBadItem`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ issueId, handler }),
},
);
}
export async function batchSubmitBadItem(issueIds: number[], handler: number) {
return serverFetchJson<MessageResponse>(
`${BASE_API_URL}/pickExecution/batchSubmitBadItem`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ issueIds, handler }),
},
);
}
export async function submitExpiryItem(lotLineId: number, handler: number) {
return serverFetchJson<MessageResponse>(
`${BASE_API_URL}/pickExecution/submitExpiryItem`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ lotLineId, handler }),
},
);
}
export async function batchSubmitExpiryItem(lotLineIds: number[], handler: number) {
return serverFetchJson<MessageResponse>(
`${BASE_API_URL}/pickExecution/batchSubmitExpiryItem`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ lotLineIds, handler }),
},
);
}


export interface LotIssueDetailResponse {
lotId: number | null;
lotNo: string | null;
itemId: number;
itemCode: string | null;
itemDescription: string | null;
storeLocation: string | null;
issues: IssueDetailItem[];
bookQty: number;
uomDesc: string | null;
}
export interface IssueDetailItem {
issueId: number;
pickerName: string | null;
missQty: number | null;
issueQty: number | null;
pickOrderCode: string;
doOrderCode: string | null;
joOrderCode: string | null;
issueRemark: string | null;
}
export async function getLotIssueDetails(
lotId: number,
itemId: number,
issueType: "miss" | "bad"
) {
return serverFetchJson<LotIssueDetailResponse>(
`${BASE_API_URL}/pickExecution/lotIssueDetails?lotId=${lotId}&itemId=${itemId}&issueType=${issueType}`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
},
}
);
}
export async function submitIssueWithQty(
lotId: number,
itemId: number,
issueType: "miss" | "bad",
submitQty: number,
handler: number
){return serverFetchJson<MessageResponse>(
`${BASE_API_URL}/pickExecution/submitIssueWithQty`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ lotId, itemId, issueType, submitQty, handler }),
}
);
}

+ 70
- 4
src/app/api/stockTake/actions.ts Просмотреть файл

@@ -40,6 +40,7 @@ export interface InventoryLotDetailResponse {
approverQty: number | null;
approverBadQty: number | null;
finalQty: number | null;
bookQty: number | null;
}

export const getInventoryLotDetailsBySection = async (
@@ -94,9 +95,33 @@ export interface AllPickedStockTakeListReponse {
totalItemNumber: number;
startTime: string | null;
endTime: string | null;
planStartDate: string | null;
stockTakeSectionDescription: string | null;
reStockTakeTrueFalse: boolean;
}

export const getApproverInventoryLotDetailsAll = async (
stockTakeId?: number | null,
pageNum: number = 0,
pageSize: number = 100
) => {
const params = new URLSearchParams();
params.append("pageNum", String(pageNum));
params.append("pageSize", String(pageSize));
if (stockTakeId != null && stockTakeId > 0) {
params.append("stockTakeId", String(stockTakeId));
}

const url = `${BASE_API_URL}/stockTakeRecord/approverInventoryLotDetailsAll?${params.toString()}`;
const response = await serverFetchJson<RecordsRes<InventoryLotDetailResponse>>(
url,
{
method: "GET",
},
);
return response;
}

export const importStockTake = async (data: FormData) => {
const importStockTake = await serverFetchJson<string>(
`${BASE_API_URL}/stockTake/import`,
@@ -118,6 +143,24 @@ export const getStockTakeRecords = async () => {
);
return stockTakeRecords;
}
export const getStockTakeRecordsPaged = async (
pageNum: number,
pageSize: number,
params?: { sectionDescription?: string; stockTakeSections?: string }
) => {
const searchParams = new URLSearchParams();
searchParams.set("pageNum", String(pageNum));
searchParams.set("pageSize", String(pageSize));
if (params?.sectionDescription && params.sectionDescription !== "All") {
searchParams.set("sectionDescription", params.sectionDescription);
}
if (params?.stockTakeSections?.trim()) {
searchParams.set("stockTakeSections", params.stockTakeSections.trim());
}
const url = `${BASE_API_URL}/stockTakeRecord/AllPickedStockOutRecordList?${searchParams.toString()}`;
const res = await serverFetchJson<RecordsRes<AllPickedStockTakeListReponse>>(url, { method: "GET" });
return res;
};
export const getApproverStockTakeRecords = async () => {
const stockTakeRecords = await serverFetchJson<AllPickedStockTakeListReponse[]>( // 改为 serverFetchJson
`${BASE_API_URL}/stockTakeRecord/AllApproverStockTakeList`,
@@ -207,6 +250,7 @@ export interface BatchSaveApproverStockTakeRecordRequest {
stockTakeId: number;
stockTakeSection: string;
approverId: number;
variancePercentTolerance?: number | null;
}

export interface BatchSaveApproverStockTakeRecordResponse {
@@ -215,6 +259,12 @@ export interface BatchSaveApproverStockTakeRecordResponse {
errors: string[];
}

export interface BatchSaveApproverStockTakeAllRequest {
stockTakeId: number;
approverId: number;
variancePercentTolerance?: number | null;
}


export const saveApproverStockTakeRecord = async (
request: SaveApproverStockTakeRecordRequest,
@@ -259,6 +309,17 @@ export const batchSaveApproverStockTakeRecords = cache(async (data: BatchSaveApp
}
)

export const batchSaveApproverStockTakeRecordsAll = cache(async (data: BatchSaveApproverStockTakeAllRequest) => {
return serverFetchJson<BatchSaveApproverStockTakeRecordResponse>(
`${BASE_API_URL}/stockTakeRecord/batchSaveApproverStockTakeRecordsAll`,
{
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
}
)
})

export const updateStockTakeRecordStatusToNotMatch = async (
stockTakeRecordId: number
) => {
@@ -312,7 +373,10 @@ export const getInventoryLotDetailsBySectionNotMatch = async (
);
return response;
}

export interface SearchStockTransactionResult {
records: StockTransactionResponse[];
total: number;
}
export interface SearchStockTransactionRequest {
startDate: string | null;
endDate: string | null;
@@ -345,7 +409,6 @@ export interface StockTransactionListResponse {
}

export const searchStockTransactions = cache(async (request: SearchStockTransactionRequest) => {
// 构建查询字符串
const params = new URLSearchParams();
if (request.itemCode) params.append("itemCode", request.itemCode);
@@ -366,7 +429,10 @@ export const searchStockTransactions = cache(async (request: SearchStockTransact
next: { tags: ["Stock Transaction List"] },
}
);
// 确保返回正确的格式
return response?.records || [];
// 回傳 records 與 total,供分頁正確顯示
return {
records: response?.records || [],
total: response?.total ?? 0,
};
});


+ 2
- 1
src/app/api/user/actions.ts Просмотреть файл

@@ -13,10 +13,11 @@ export interface UserInputs {
username: string;
name: string;
staffNo?: string;
locked?: boolean;
addAuthIds?: number[];
removeAuthIds?: number[];
password?: string;
confirmPassword?: string;
confirmPassword?: string;
}

export interface PasswordInputs {


+ 67
- 5
src/app/api/warehouse/actions.ts Просмотреть файл

@@ -3,7 +3,7 @@
import { serverFetchString, serverFetchWithNoContent, serverFetchJson } from "@/app/utils/fetchUtil";
import { BASE_API_URL } from "@/config/api";
import { revalidateTag } from "next/cache";
import { WarehouseResult } from "./index";
import { WarehouseResult, StockTakeSectionInfo } from "./index";
import { cache } from "react";

export interface WarehouseInputs {
@@ -15,7 +15,9 @@ export interface WarehouseInputs {
warehouse?: string;
area?: string;
slot?: string;
order?: string;
stockTakeSection?: string;
stockTakeSectionDescription?: string;
}

export const fetchWarehouseDetail = cache(async (id: number) => {
@@ -35,9 +37,11 @@ export const createWarehouse = async (data: WarehouseInputs) => {
};

export const editWarehouse = async (id: number, data: WarehouseInputs) => {
const updatedWarehouse = await serverFetchWithNoContent(`${BASE_API_URL}/warehouse/${id}`, {
method: "PUT",
body: JSON.stringify(data),
// Backend uses the same /warehouse/save POST endpoint for both create and update,
// distinguished by presence of id in the payload.
const updatedWarehouse = await serverFetchWithNoContent(`${BASE_API_URL}/warehouse/save`, {
method: "POST",
body: JSON.stringify({ id, ...data }),
headers: { "Content-Type": "application/json" },
});
revalidateTag("warehouse");
@@ -78,4 +82,62 @@ export const importNewWarehouse = async (data: FormData) => {
},
);
return importWarehouse;
}
}

export const fetchStockTakeSections = cache(async () => {
return serverFetchJson<StockTakeSectionInfo[]>(`${BASE_API_URL}/warehouse/stockTakeSections`, {
next: { tags: ["warehouse"] },
});
});

export const updateSectionDescription = async (section: string, stockTakeSectionDescription: string | null) => {
await serverFetchWithNoContent(
`${BASE_API_URL}/warehouse/section/${encodeURIComponent(section)}/description`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ stockTakeSectionDescription }),
}
);
revalidateTag("warehouse");
};

export const clearWarehouseSection = async (warehouseId: number) => {
const result = await serverFetchJson<WarehouseResult>(
`${BASE_API_URL}/warehouse/${warehouseId}/clearSection`,
{ method: "POST" }
);
revalidateTag("warehouse");
return result;
};
export const getWarehousesBySection = cache(async (stockTakeSection: string) => {
const list = await serverFetchJson<WarehouseResult[]>(`${BASE_API_URL}/warehouse`, {
next: { tags: ["warehouse"] },
});
const items = Array.isArray(list) ? list : [];
return items.filter((w) => w.stockTakeSection === stockTakeSection);
});
export const searchWarehousesForAddToSection = cache(async (
params: { store_id?: string; warehouse?: string; area?: string; slot?: string },
currentSection: string
) => {
const list = await serverFetchJson<WarehouseResult[]>(`${BASE_API_URL}/warehouse`, {
next: { tags: ["warehouse"] },
});
const items = Array.isArray(list) ? list : [];
const storeId = params.store_id?.trim();
const warehouse = params.warehouse?.trim();
const area = params.area?.trim();
const slot = params.slot?.trim();

return items.filter((w) => {
if (w.stockTakeSection != null && w.stockTakeSection !== currentSection) return false;
if (!w.code) return true;
const parts = w.code.split("-");
if (storeId && parts[0] !== storeId) return false;
if (warehouse && parts[1] !== warehouse) return false;
if (area && parts[2] !== area) return false;
if (slot && parts[3] !== slot) return false;
return true;
});
});

+ 22
- 0
src/app/api/warehouse/client.ts Просмотреть файл

@@ -31,3 +31,25 @@ export const exportWarehouseQrCode = async (warehouseIds: number[]): Promise<{ b

return { blobValue, filename };
};

export const fetchWarehouseListClient = async (): Promise<WarehouseResult[]> => {
const token = localStorage.getItem("accessToken");
const response = await fetch(`${NEXT_PUBLIC_API_URL}/warehouse`, {
method: "GET",
headers: {
"Content-Type": "application/json",
...(token && { Authorization: `Bearer ${token}` }),
},
});

if (!response.ok) {
if (response.status === 401) {
throw new Error("Unauthorized: Please log in again");
}
throw new Error(`Failed to fetch warehouse list: ${response.status} ${response.statusText}`);
}

return response.json();
};
//test

+ 8
- 1
src/app/api/warehouse/index.ts Просмотреть файл

@@ -13,8 +13,9 @@ export interface WarehouseResult {
warehouse?: string;
area?: string;
slot?: string;
order?: number;
order?: string;
stockTakeSection?: string;
stockTakeSectionDescription?: string;
}

export interface WarehouseCombo {
@@ -34,3 +35,9 @@ export const fetchWarehouseCombo = cache(async () => {
next: { tags: ["warehouseCombo"] },
});
});
export interface StockTakeSectionInfo {
id: string;
stockTakeSection: string;
stockTakeSectionDescription: string | null;
warehouseCount: number;
}

+ 85
- 3
src/app/global.css Просмотреть файл

@@ -1,7 +1,89 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

html, body {
/* UI standard: light default, primary #3b82f6, accent #10b981 */
@layer base {
:root {
--primary: #3b82f6;
--accent: #10b981;
--background: #f8fafc;
--foreground: #0f172a;
--card: #ffffff;
--card-foreground: #0f172a;
--border: #e2e8f0;
--muted: #64748b;
}
.dark {
--background: #0f172a;
--foreground: #f1f5f9;
--card: #1e293b;
--card-foreground: #f1f5f9;
--border: #334155;
--muted: #94a3b8;
}
}

html,
body {
overscroll-behavior: none;
}
}

/* Tablet/mobile: stable layout when virtual keyboard opens */
html {
/* Prefer dynamic viewport height so layout can adapt to keyboard (if browser resizes) */
height: 100%;
/* Base font size: slightly larger for readability */
font-size: 16px;
}
@media (min-width: 640px) {
html {
font-size: 17px;
}
}
@media (min-width: 1024px) {
html {
font-size: 18px;
}
}
body {
min-height: 100%;
min-height: 100dvh;
background-color: var(--background);
color: var(--foreground);
font-size: 1rem;
line-height: 1.6;
}

/* Full-height containers: use dvh so keyboard doesn’t squash the layout when overlay is used */
@media (max-width: 1024px) {
.min-h-screen {
min-height: 100dvh;
}
}

/* Avoid iOS zoom on input focus (keep inputs ≥16px where possible) */
@media (max-width: 1024px) {
input,
select,
textarea {
font-size: max(16px, 1rem);
}
}

.app-search-criteria {
border-radius: 8px;
border: 1px solid var(--border);
border-left-width: 4px;
border-left-color: var(--primary);
background-color: var(--card);
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
}

.app-search-criteria-label {
font-size: 0.75rem;
font-weight: 500;
color: #334155;
text-transform: uppercase;
letter-spacing: 0.05em;
}

+ 9
- 1
src/app/layout.tsx Просмотреть файл

@@ -1,4 +1,4 @@
import type { Metadata } from "next";
import type { Metadata, Viewport } from "next";
// import { detectLanguage } from "@/i18n";
// import ThemeRegistry from "@/theme/ThemeRegistry";
import { detectLanguage } from "../i18n";
@@ -9,6 +9,14 @@ export const metadata: Metadata = {
description: "FPSMS - xxxx Management System",
};

/** Tablet/mobile: virtual keyboard overlays content instead of resizing viewport (avoids "half screen gone"). */
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
interactiveWidget: "overlays-content",
};

export default async function RootLayout({
children,
}: {


+ 12
- 12
src/app/login/page.tsx Просмотреть файл

@@ -1,19 +1,19 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { authOptions } from "@/config/authConfig";
import { I18nProvider } from "@/i18n";
import LoginPage from "@/components/LoginPage/LoginPage";
import LoginRedirectIfAuthenticated from "@/components/LoginPage/LoginRedirectIfAuthenticated";

const Login: React.FC = async () => {
const session = await getServerSession(authOptions);
if (session?.user) {
redirect("/");
}
/**
* Redirect when already authenticated is done in LoginRedirectIfAuthenticated
* (client-side with useSearchParams) so it works in production where server
* searchParams can be undefined after build.
*/
const Login: React.FC = () => {
return (
<I18nProvider namespaces={["login"]}>
<LoginPage />
</I18nProvider>
<LoginRedirectIfAuthenticated>
<I18nProvider namespaces={["login"]}>
<LoginPage />
</I18nProvider>
</LoginRedirectIfAuthenticated>
);
};



+ 31
- 0
src/app/utils/clientAuthFetch.ts Просмотреть файл

@@ -0,0 +1,31 @@
"use client";

const LOGIN_REDIRECT = "/login?session=expired";

/**
* Client-side fetch that adds Bearer token from localStorage and redirects
* to /login?session=expired on 401 or 403 (session timeout / unauthorized).
* Use this for all authenticated API requests so session expiry is handled consistently.
*/
export async function clientAuthFetch(
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response> {
const token =
typeof window !== "undefined" ? localStorage.getItem("accessToken") : null;
const headers = new Headers(init?.headers);
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}

const response = await fetch(input, { ...init, headers });

if (response.status === 401 || response.status === 403) {
if (typeof window !== "undefined") {
console.warn(`Auth error ${response.status} → redirecting to login`);
window.location.href = LOGIN_REDIRECT;
}
}

return response;
}

+ 28
- 8
src/app/utils/fetchUtil.ts Просмотреть файл

@@ -35,16 +35,36 @@ export async function serverFetchWithNoContent(...args: FetchParams) {
const response = await serverFetch(...args);

if (response.ok) {
return response.status; // 204 No Content, e.g. for delete data
return response.status;
} else {
switch (response.status) {
case 401:
signOutUser();
default:
const errorText = await response.text();
console.error(`Server error (${response.status}):`, errorText);
let errorMessage = "Something went wrong fetching data in server.";
try {
const contentType = response.headers.get("content-type");
if (contentType && contentType.includes("application/json")) {
const errorJson = await response.json();
if (errorJson.error) {
errorMessage = errorJson.error;
} else if (errorJson.message) {
errorMessage = errorJson.message;
} else if (errorJson.traceId) {
errorMessage = `Error occurred (traceId: ${errorJson.traceId}). Check server logs for details.`;
}
} else {
const errorText = await response.text();
if (errorText && errorText.trim()) {
errorMessage = errorText;
}
}
} catch (e) {
console.error("Error parsing error response:", e);
}
console.error(`Server error (${response.status}):`, errorMessage);
throw new ServerFetchError(
`Server error: ${response.status} ${response.statusText}. ${errorText || "Something went wrong fetching data in server."}`,
`Server error: ${response.status} ${response.statusText}. ${errorMessage}`,
response
);
}
@@ -52,7 +72,6 @@ export async function serverFetchWithNoContent(...args: FetchParams) {
}

export const serverFetch: typeof fetch = async (input, init) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const session = await getServerSession<any, SessionWithTokens>(authOptions);
const accessToken = session?.accessToken;

@@ -75,7 +94,7 @@ type FetchParams = Parameters<typeof fetch>;

export async function serverFetchJson<T>(...args: FetchParams) {
const response = await serverFetch(...args);
console.log(response.status);
console.log("serverFetchJson - Status:", response.status, "URL:", args[0]);
if (response.ok) {
if (response.status === 204) {
return response.status as T;
@@ -83,12 +102,14 @@ export async function serverFetchJson<T>(...args: FetchParams) {

return response.json() as T;
} else {
const errorText = await response.text().catch(() => "Unable to read error response");
console.error("serverFetchJson - Error response:", response.status, errorText);
switch (response.status) {
case 401:
signOutUser();
default:
throw new ServerFetchError(
"Something went wrong fetching data in server.",
`Server error: ${response.status} ${response.statusText}. ${errorText}`,
response,
);
}
@@ -129,7 +150,6 @@ export async function serverFetchBlob<T extends BlobResponse>(...args: FetchPara
while (!done) {
const read = await reader?.read();

// version 1
if (read?.done) {
done = true;
} else {


+ 9
- 0
src/app/utils/formatUtil.ts Просмотреть файл

@@ -26,12 +26,21 @@ export const decimalFormatter = new Intl.NumberFormat("en-HK", {
maximumFractionDigits: 5,
});

/** Use for prices (e.g. market unit price): 2 decimal places only */
export const priceFormatter = new Intl.NumberFormat("en-HK", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});

export const integerFormatter = new Intl.NumberFormat("en-HK", {});

export const INPUT_DATE_FORMAT = "YYYY-MM-DD";

export const OUTPUT_DATE_FORMAT = "YYYY-MM-DD";

/** Date and time for display, e.g. "YYYY-MM-DD HH:mm" */
export const OUTPUT_DATETIME_FORMAT = "YYYY-MM-DD HH:mm";

export const INPUT_TIME_FORMAT = "HH:mm:ss";

export const OUTPUT_TIME_FORMAT = "HH:mm:ss";


Некоторые файлы не были показаны из-за большого количества измененных файлов

Загрузка…
Отмена
Сохранить