Compare commits

...

285 Commits

Author SHA1 Message Date
  CANCERYS\kw093 081ccb9f8f update job order search and cacel job order 16 hours ago
  CANCERYS\kw093 947f471ed8 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 22 hours ago
  CANCERYS\kw093 eabbc39c57 update 22 hours ago
  CANCERYS\kw093 430ace8d76 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 1 day ago
  CANCERYS\kw093 87d32c728a update 1 day ago
  PC-20260115JRSN\Administrator a358b79d8f change the PO with m18 uom and qty, included stock in PO, putaway process and GRN 1 day ago
  CANCERYS\kw093 cef025fae8 update 1 day ago
  PC-20260115JRSN\Administrator c9f05abfb0 added po number syn m18 1 day ago
  B.E.N.S.O.N 05eab73a5b StockItemConsumptionTrendReport Excel Version 2 days ago
  CANCERYS\kw093 8b2ab939e8 update switch lot 2 days ago
  Tommy\2Fi-Staff cc14a5e100 no message 2 days ago
  Tommy\2Fi-Staff e6afcf40cc no message 2 days ago
  B.E.N.S.O.N c9ce1e30af User Page Update 2 days ago
  B.E.N.S.O.N c7c5727e36 SemiFGProductionAnalysisReport Excel Version 2 days ago
  Tommy\2Fi-Staff 1f56e1b5bd update shop and truck 2 days ago
  B.E.N.S.O.N e00f711845 Update 2 days ago
  PC-20260115JRSN\Administrator 5c215ece1b no message 3 days ago
  CANCERYS\kw093 8421e66ec4 update 3 days ago
  CANCERYS\kw093 0947fd181d update dashboard, job order list 3 days ago
  kelvin.yau 6fe4889b02 better UX in inventory search 3 days ago
  kelvin.yau 1799088819 no message 3 days ago
  CANCERYS\kw093 6bec9ce850 update 3 days ago
  PC-20260115JRSN\Administrator 1343362dc5 no message 3 days ago
  kelvin.yau 080aed9316 Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/derek/FPSMS-frontend into MergeProblem1 3 days ago
  kelvin.yau 93e61dddbc no message 3 days ago
  kelvin.yau f7a8c882a0 no message 3 days ago
  CANCERYS\kw093 f58875a3e9 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 4 days ago
  CANCERYS\kw093 5af4f5ac6e update 4 days ago
  PC-20260115JRSN\Administrator 284aaaaf85 no message 4 days ago
  PC-20260115JRSN\Administrator 58c44b2987 fixing it cannot build 4 days ago
  CANCERYS\kw093 d65e3db136 update 4 days ago
  kelvin.yau 9bd475e306 stock transfer ui fix 4 days ago
  kelvin.yau a167e79a74 Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/derek/FPSMS-frontend into MergeProblem1 4 days ago
  kelvin.yau ebc4cdfdee search lot by scanning qr code 4 days ago
  CANCERYS\kw093 a249363da4 updated 4 days ago
  CANCERYS\kw093 88d1b60fc7 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 4 days ago
  CANCERYS\kw093 44d51c8390 update 4 days ago
  PC-20260115JRSN\Administrator 67ee15b312 no message 4 days ago
  CANCERYS\kw093 76ad78f126 update 5 days ago
  CANCERYS\kw093 ad127b39ac update 5 days ago
  CANCERYS\kw093 fc8b94c562 update 5 days ago
  kelvin.yau de65686192 UPDATE OPEN INVENTORY FOR ITEMS WITH NO INVENTORY 5 days ago
  CANCERYS\kw093 b7ccfe3574 update 5 days ago
  B.E.N.S.O.N 56e5c937af Good Pick Issue Fixing 5 days ago
  CANCERYS\kw093 25cfed96d6 update qR scan 5 days ago
  B.E.N.S.O.N c5d79de697 Login Page Update 6 days ago
  Tommy\2Fi-Staff d536dbb8d3 update variance report config 6 days ago
  CANCERYS\kw093 1c737822c5 update 6 days ago
  CANCERYS\kw093 d1423bdd29 update 6 days ago
  PC-20260115JRSN\Administrator 7801b32fcd no message 6 days ago
  Tommy\2Fi-Staff 84088c143d Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/jason/FPSMS-frontend into MergeProblem1 6 days ago
  Tommy\2Fi-Staff bde63fdd4d fix putaway 6 days ago
  CANCERYS\kw093 bbfc821d44 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 6 days ago
  CANCERYS\kw093 77ad12967b jo edit 6 days ago
  PC-20260115JRSN\Administrator 457e4f101f no message 6 days ago
  CANCERYS\kw093 5e83e2c8e6 update 1 week ago
  PC-20260115JRSN\Administrator c59949643e no message 1 week ago
  PC-20260115JRSN\Administrator 49e11a72ee no message 1 week ago
  kelvin.yau 3598941032 build bug fix 1 week ago
  kelvin.yau 953cb0783e no message 1 week ago
  kelvin.yau 5e4c8c46e7 no message 1 week ago
  kelvin.yau 060de0d2f6 no message 1 week ago
  kelvin.yau 84baa17e9f no message 1 week ago
  kelvin.yau dc221be8b8 no message 1 week ago
  kelvin.yau d91928082f fix frontend build error 1 week ago
  TASTEOFASIA\MTMS f2a2337e1a no message 1 week ago
  CANCERYS\kw093 7564ee01eb update stock take drop down 1 week ago
  CANCERYS\kw093 92a0a894cc Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 1 week ago
  CANCERYS\kw093 842aa9ffec update 1 week ago
  kelvin.yau 5a0b3a43d0 update default store location for FA and WIP 1 week ago
  kelvin.yau f60c702e74 no message 1 week ago
  kelvin.yau 062a268bc8 bug fix 1 week ago
  kelvin.yau f82bb5e056 Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/derek/FPSMS-frontend into MergeProblem1 1 week ago
  kelvin.yau 3675c90342 price inqury 1 week ago
  PC-20260115JRSN\Administrator da9f8b277e adding some charts to test 1 week ago
  B.E.N.S.O.N e1bda42014 Dashboard UpDATE 1 week ago
  CANCERYS\kw093 9b5d1306d9 stocktakeALL 1 week ago
  CANCERYS\kw093 37f9eeed01 update stock take search 1 week ago
  PC-20260115JRSN\Administrator 190d78c6df adding PS settings 1 week ago
  CANCERYS\kw093 9b4db0dde5 update 1 week ago
  CANCERYS\kw093 e4f0273a0e product process list and warehouse 1 week ago
  kelvin.yau 4fa7bc2b8e translation issue 1 week ago
  CANCERYS\kw093 4b264a82a8 update bom ui 1 week ago
  CANCERYS\kw093 15592a176a Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 1 week ago
  CANCERYS\kw093 a494673402 update 1 week ago
  kelvin.yau 086cc40c0a Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/derek/FPSMS-frontend into MergeProblem1 1 week ago
  kelvin.yau 806c8c1242 A4 printer routing and register, see backend 1 week ago
  CANCERYS\kw093 081c76581c update stock in line lotNo and joborder show lotNo 1 week ago
  Tommy\2Fi-Staff 86bf59e675 make putaway smaller 1 week ago
  B.E.N.S.O.N 2b7ff5d2ea Warehouse Supporting Function Update 1 week ago
  B.E.N.S.O.N 5dbbe07614 Warehouse Supporting Function Update 1 week ago
  TASTEOFASIA\MTMS d31012af63 update the new server ip and setting in env-prod 1 week ago
  PC-20260115JRSN\Administrator edd947c227 try fixing the pages 1 week ago
  PC-20260115JRSN\Administrator 6d802eddf4 try fixing the page problem 1 week ago
  PC-20260115JRSN\Administrator 10fca7bc19 try to fix the page problem 1 week ago
  PC-20260115JRSN\Administrator 9e6cb8345e try to fix the redirect problem in server 1 week ago
  CANCERYS\kw093 2548b7a007 update stock in line 1 week ago
  vluk@2fi-solutions.com.hk 9d376e4857 trying to build on server 1 week ago
  vluk@2fi-solutions.com.hk 4f04ddde6e fixing the code the make project failed to build 1 week ago
  CANCERYS\kw093 d7a34cf064 updaate import bom 1 week ago
  CANCERYS\kw093 1e346fa9b8 Merge remote-tracking branch 'origin/MergeProblem1' into MergeProblem1 1 week ago
  CANCERYS\kw093 bf2b7f1101 update 1 week ago
  vluk@2fi-solutions.com.hk d5fb8294ef adding bag printing page, copy from Bag1.py 1 week ago
  Tommy\2Fi-Staff f9499d9a37 no message 1 week ago
  B.E.N.S.O.N d5f19a7057 Bom Supporting Function 1 week ago
  CANCERYS\kw093 4f0df8f5f8 update 2 weeks ago
  CANCERYS\kw093 6dc9687949 Merge remote-tracking branch 'origin/MergeProblem1' into MergeProblem1 2 weeks ago
  CANCERYS\kw093 dfbd808b3a update bom import ,epqc, 2 weeks ago
  CANCERYS\kw093 dff5000125 update 2 weeks ago
  CANCERYS\kw093 9ad4009bc3 update 2 weeks ago
  B.E.N.S.O.N 51e4f705c3 Bom Supporting Function 2 weeks ago
  kelvin.yau 88513e744b No longer refresh after QC 2 weeks ago
  B.E.N.S.O.N aa4f0fff29 Update 2 weeks ago
  vluk@2fi-solutions.com.hk 435d041f5c no message 2 weeks ago
  vluk@2fi-solutions.com.hk 0a24dc116f no message 2 weeks ago
  CANCERYS\kw093 42cb203514 update truck X 2 weeks ago
  vluk@2fi-solutions.com.hk 9d00348946 For my testing use, use the cam instead of barcode scanner for putaway 2 weeks ago
  CANCERYS\kw093 c60f80fe1d update 2 weeks ago
  CANCERYS\kw093 fb271f9209 update 2 weeks ago
  CANCERYS\kw093 48a0fbb924 update 2 weeks ago
  kelvin.yau 703ac2ba72 dashboard fix (FG + equipment) 2 weeks ago
  kelvin.yau 86b2c12321 dashboard fix 2 weeks ago
  kelvin.yau 19b4ed534c dashboards formatting (keep same) 2 weeks ago
  Tommy\2Fi-Staff ad53e1a701 no message 2 weeks ago
  Tommy\2Fi-Staff e59d79797a update 2 weeks ago
  Tommy\2Fi-Staff d74f5d184b update 2 weeks ago
  Tommy\2Fi-Staff 656a222976 upDATE 2 weeks ago
  Tommy\2Fi-Staff f3c480b983 qcstockin ui update 2 weeks ago
  kelvin.yau c5c6d61af2 Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/derek/FPSMS-frontend into MergeProblem1 2 weeks ago
  kelvin.yau f4a3c12d99 title updates 2 weeks ago
  CANCERYS\kw093 7cd54de584 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 2 weeks ago
  CANCERYS\kw093 5e5fa63ce8 update 2 weeks ago
  Tommy\2Fi-Staff 8daf185e60 trucklane dashboard 2 weeks ago
  CANCERYS\kw093 66b8912ff0 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 2 weeks ago
  CANCERYS\kw093 75f3e6a819 update 2 weeks ago
  B.E.N.S.O.N 329830e09b New Goods Receipt Status Dashboard 2 weeks ago
  B.E.N.S.O.N 35ee724b0f Merge remote-tracking branch 'origin/MergeProblem1' into MergeProblem1 2 weeks ago
  B.E.N.S.O.N dc4767c312 New Goods Receipt Status Dashboard 2 weeks ago
  Tommy\2Fi-Staff f650492e27 reportconfig update 2 weeks ago
  Tommy\2Fi-Staff 0cf603a7e1 add handler filter 2 weeks ago
  CANCERYS\kw093 2b3752d64f update 2 weeks ago
  CANCERYS\kw093 09e8bdff0d update pucahseorder speed 2 weeks ago
  Tommy\2Fi-Staff 89b0effbf4 trucklane dashboard update 2 weeks ago
  CANCERYS\kw093 131893efa0 stock take input fix 2 weeks ago
  B.E.N.S.O.N c81aed0950 User QR-Code Update 2 weeks ago
  B.E.N.S.O.N 526058cbb9 update 2 weeks ago
  CANCERYS\kw093 33cf1752b4 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 2 weeks ago
  CANCERYS\kw093 1ee123ddb5 auto stock in "%FA%" and stock record page fix 2 weeks ago
  B.E.N.S.O.N 9ead9d244e Bom Supporting Function 2 weeks ago
  B.E.N.S.O.N 5c243b376b Merge remote-tracking branch 'origin/MergeProblem1' into MergeProblem1 2 weeks ago
  B.E.N.S.O.N 66061a5837 update 2 weeks ago
  CANCERYS\kw093 59b4a88735 update bag 2 weeks ago
  vluk@2fi-solutions.com.hk 2da62e9bc7 no message 3 weeks ago
  CANCERYS\kw093 bcadb14423 fix stock reocrd 3 weeks ago
  CANCERYS\kw093 a4a4075087 update confirm 3 weeks ago
  CANCERYS\kw093 2040ef798e Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 3 weeks ago
  CANCERYS\kw093 a40305f880 update stock take 3 weeks ago
  vluk@2fi-solutions.com.hk 2de29a9a8c make access right with STOCK can do stock take 3 weeks ago
  kelvin.yau ae3fa7993c translation issue 3 weeks ago
  kelvin.yau 26abb13a6c Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/derek/FPSMS-frontend into MergeProblem1 3 weeks ago
  kelvin.yau 72b92cc6e7 stock adj + stocktrf 3 weeks ago
  vluk@2fi-solutions.com.hk b0e5aaa72a no message 3 weeks ago
  CANCERYS\kw093 7e831edcf3 update jo,po,i18n 3 weeks ago
  CANCERYS\kw093 42ee4a6d92 update 3 weeks ago
  vluk@2fi-solutions.com.hk 3236f144cd fix the /ps overlap problem 3 weeks ago
  vluk@2fi-solutions.com.hk f17ed17f87 no message 3 weeks ago
  CANCERYS\kw093 7c93b9f880 Merge remote-tracking branch 'origin/MergeProblem1' into MergeProblem1 3 weeks ago
  CANCERYS\kw093 0d0a05ed55 update zh 3 weeks ago
  Tommy\2Fi-Staff a9833d424a translation & alignment 3 weeks ago
  CANCERYS\kw093 eaa9477faa update jobmatch 3 weeks ago
  vluk@2fi-solutions.com.hk febf75eb38 it says it can control the popup keyboard size in tablet 3 weeks ago
  B.E.N.S.O.N eac95c343c update 3 weeks ago
  vluk@2fi-solutions.com.hk 765491197f no message 3 weeks ago
  vluk@2fi-solutions.com.hk f0ddd56381 changed the look and feel slightly 3 weeks ago
  PC-20260115JRSN\Administrator 3579a83ff7 no message 3 weeks ago
  PC-20260115JRSN\Administrator b0356b7a8a Fix the files that make project failed to compile 3 weeks ago
  B.E.N.S.O.N 0eb0936e45 Update 1 month ago
  CANCERYS\kw093 1544f3f653 Merge remote-tracking branch 'origin/MergeProblem1' into MergeProblem1 1 month ago
  CANCERYS\kw093 eb9714a79b update 1 month ago
  B.E.N.S.O.N d726d933b5 Report Page Update 1 month ago
  Tommy\2Fi-Staff 1059b8770a update 1 month ago
  CANCERYS\kw093 e8ef71601f update 1 month ago
  CANCERYS\kw093 ca8b3ea050 update 1 month ago
  CANCERYS\kw093 e5feedc2a7 update 1 month ago
  CANCERYS\kw093 263d12e248 update job pick dashboard 1 month ago
  CANCERYS\kw093 d56cd6e69f update 1 month ago
  Tommy\2Fi-Staff b320307a51 update 1 month ago
  Tommy\2Fi-Staff 3303de63d7 update search sorting 1 month ago
  Tommy\2Fi-Staff 4446c8503f Update StockBalanceReport & StockInTracabilityReport 1 month ago
  B.E.N.S.O.N 8b3f8fc6e9 Report Update 1 month ago
  Tommy\2Fi-Staff 6479034e62 TruckScheduleDashboard & StockInTraceability report update 1 month ago
  B.E.N.S.O.N 6d9ec7b372 Report Update 1 month ago
  CANCERYS\kw093 cb4c0aa11f Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 1 month ago
  CANCERYS\kw093 f408aba874 update 1 month ago
  kelvin.yau 754ef92046 translation 1 month ago
  CANCERYS\kw093 316d2fcdb1 update 1 month ago
  B.E.N.S.O.N e7c273ba0e Stock Item Consumption Trend Report 1 month ago
  CANCERYS\kw093 a71f0cc9a9 Merge remote-tracking branch 'origin/MergeProblem1' into MergeProblem1 1 month ago
  CANCERYS\kw093 c06ee2e543 update 1 month ago
  kelvin.yau 28fe834ab0 enson update 1 month ago
  B.E.N.S.O.N 8987046f00 Dashboard: Goods Receipt Status Update 1 month ago
  B.E.N.S.O.N 329ccc22bd FG/SemiFG Production Analysis Report Update 1 month ago
  CANCERYS\kw093 bdde9644f0 Merge remote-tracking branch 'origin/MergeProblem1' into MergeProblem1 1 month ago
  CANCERYS\kw093 5e6a440aae update dashBoard 1 month ago
  kelvin.yau 626a13ee60 Stock TRF UI update 1 month ago
  B.E.N.S.O.N 1600995bc1 FG/SemiFG Production Analysis Report Update 1 month ago
  CANCERYS\kw093 4f4a5baf75 update 1 month ago
  B.E.N.S.O.N a3c07650f8 FG/SemiFG Production Analysis Report 1 month ago
  CANCERYS\kw093 757ccc5cbd update select unit 1 month ago
  CANCERYS\kw093 a0675af6e0 upate select unit 1 month ago
  CANCERYS\kw093 b006a1115c update 1 month ago
  CANCERYS\kw093 e3f2b06561 update pick record user and putaway default warehouse 1 month ago
  CANCERYS\kw093 3501863943 update 1 month ago
  CANCERYS\kw093 8cbbdf5714 update 1 month ago
  vluk@2fi-solutions.com.hk bdf7d52cd9 no message 1 month ago
  vluk@2fi-solutions.com.hk fc398b038b no message 1 month ago
  vluk@2fi-solutions.com.hk f747984479 make some chinese looks better 1 month ago
  CANCERYS\kw093 30823cee8e update scan lot 1 month ago
  CANCERYS\kw093 26302151c3 update qc putaway 1 month ago
  Tommy\2Fi-Staff 53cc1692ad fix fg goods status dasboard bug 1 month ago
  CANCERYS\kw093 878eaedfb6 update new stokc issue handle 1 month ago
  vluk@2fi-solutions.com.hk b541872d24 no message 1 month ago
  CANCERYS\kw093 4fc7e87375 update some jo qr 1 month ago
  CANCERYS\kw093 549481e71a benson want remove / 1 month ago
  CANCERYS\kw093 4b1ed59261 dashboard 1 month ago
  CANCERYS\kw093 468e907db9 update 1 month ago
  CANCERYS\kw093 55d9e24f83 update qr code scan 1 month ago
  CANCERYS\kw093 c45802fb76 test 1 month ago
  CANCERYS\kw093 667cc5f184 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 1 month ago
  CANCERYS\kw093 0aedd3b83d update 1 month ago
  CANCERYS\kw093 29bdcf6c1a update do pick confirm 1 month ago
  CANCERYS\kw093 9e9c8d073c update 1 month ago
  CANCERYS\kw093 f807fcee82 update 1 month ago
  CANCERYS\kw093 5473ff820d update bar 1 month ago
  B.E.N.S.O.N 927485e8d3 Dashboard Page Update 1 month ago
  B.E.N.S.O.N feb162ae60 Dashboard: Goods Receipt Status Update 1 month ago
  B.E.N.S.O.N b58947b1e5 Dashboard: Goods Receipt Status 1 month ago
  CANCERYS\kw093 bb5f3d2584 update do issue form 1 month ago
  CANCERYS\kw093 d04e2eeadc update 1 month ago
  CANCERYS\kw093 8576172e8e fix scan lot and scan not match lt and new issue handle 1 month ago
  CANCERYS\kw093 be2fdb6a3b update 1 month ago
  CANCERYS\kw093 3fa46072fd Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 2 months ago
  CANCERYS\kw093 7cd450ef1b update printer select 2 months ago
  PC-20260115JRSN\Administrator 3930cd7f39 fixing the merged i18 master syn request 2 months ago
  CANCERYS\kw093 c02a6956c4 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 2 months ago
  CANCERYS\kw093 a32e2b30bc printer 2 months ago
  Tommy\2Fi-Staff e317d18821 Stock In Traceability Report 2 months ago
  B.E.N.S.O.N 09d269f2b7 Update: Printer Handle 2 months ago
  B.E.N.S.O.N 321927854e Supporting function: Printer Handle 2 months ago
  CANCERYS\kw093 3c014abbff update approve can 0 2 months ago
  CANCERYS\kw093 f903dae3c1 update skip button 2 months ago
  CANCERYS\kw093 483577ed0d update do search 2 months ago
  B.E.N.S.O.N d09ee3a962 Update 2 months ago
  B.E.N.S.O.N e62830e1e2 Merge remote-tracking branch 'origin/MergeProblem1' into MergeProblem1 2 months ago
  B.E.N.S.O.N 4702c93a93 path 2 months ago
  kelvin.yau 88d1354944 fix 2 months ago
  kelvin.yau de2f012c24 stock transfer ui 2 months ago
  Tommy\2Fi-Staff cc68dfbb65 update item 2 months ago
  vluk@2fi-solutions.com.hk 363306c98e fixing the ps export path 2 months ago
  CANCERYS\kw093 bc5d88699c update page control 2 months ago
  CANCERYS\kw093 b24ae5dfea stockissue 2 months ago
  CANCERYS\kw093 d7e139dd2c i18n 2 months ago
  vluk@2fi-solutions.com.hk 7ce84920e2 fixing the GET type 2 months ago
  vluk@2fi-solutions.com.hk 30eb8517d1 refining the data syn 2 months ago
  Tommy\2Fi-Staff 4cb751740c update shop and truck lazy load 2 months ago
  Tommy\2Fi-Staff 289e59d2b5 update missing item, update FG pick status dashboard 2 months ago
  vluk@2fi-solutions.com.hk c48d070a77 refining the m18 import testing params 2 months ago
  CANCERYS\kw093 a0febe7794 update qcitem combine page 2 months ago
  CANCERYS\kw093 d240e23bab Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 2 months ago
  CANCERYS\kw093 8f9e94530e update path 2 months ago
  PC-20260115JRSN\Administrator 063faba2e7 adding printer testing for HANS 2 months ago
  B.E.N.S.O.N d92242ea2c Dashboard: Goods Receipt Status UI 2 months ago
  Tommy\2Fi-Staff d50aebb674 Dashboard ui 2 months ago
  B.E.N.S.O.N 1d921e105d Dashboard: Goods Receipt Status UI 2 months ago
  Tommy\2Fi-Staff 0008e1471f Missing Item supporting function &report 2 months ago
  CANCERYS\kw093 770d569f9b productprocess 2 months ago
  CANCERYS\kw093 6aefd923c5 updatestock issue 2 months ago
  CANCERYS\kw093 a661b1dfc2 update putasway show 2 months ago
  CANCERYS\kw093 1dbe9c67c1 upate i18n 2 months ago
  CANCERYS\kw093 8b12ae623b Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 2 months ago
  CANCERYS\kw093 1f07b8ea5a update stockissue api 2 months ago
  kelvin.yau 2ffa66c4a3 updated inventorylotline table 2 months ago
  kelvin.yau 9f635df2eb Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/derek/FPSMS-frontend into MergeProblem1 2 months ago
  kelvin.yau e76073f36e test 2 months ago
  vluk@2fi-solutions.com.hk 44d6b8f823 no message 2 months ago
100 changed files with 8531 additions and 706 deletions
Split View
  1. +91
    -0
      .cursor/rules.md
  2. +3
    -3
      .env.production
  3. +856
    -136
      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. +311
    -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. +1084
    -221
      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. +205
    -0
      src/app/(main)/report/SemiFGProductionAnalysisReport.tsx
  30. +99
    -0
      src/app/(main)/report/grnReportApi.ts
  31. +423
    -42
      src/app/(main)/report/page.tsx
  32. +141
    -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. +343
    -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. +112
    -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. +31
    -0
      src/app/api/inventory/actions.ts
  65. +2
    -0
      src/app/api/inventory/index.ts
  66. +232
    -24
      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. +118
    -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. +77
    -0
      src/app/error.tsx
  96. +88
    -0
      src/app/global-error.tsx
  97. +85
    -3
      src/app/global.css
  98. +9
    -1
      src/app/layout.tsx
  99. +12
    -12
      src/app/login/page.tsx
  100. +31
    -0
      src/app/utils/clientAuthFetch.ts

+ 91
- 0
.cursor/rules.md View File

@@ -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 View File

@@ -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

+ 856
- 136
package-lock.json
File diff suppressed because it is too large
View File


+ 3
- 2
package.json View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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>
);
}

+ 311
- 0
src/app/(main)/chart/forecast/page.tsx View File

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

import React, { useCallback, useState } from "react";
import {
Box,
Typography,
Skeleton,
Alert,
FormControl,
InputLabel,
Select,
MenuItem,
Checkbox,
ListItemText,
} 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 = "預測與計劃";

const DISTINCT_ITEM_COLORS = [
"#d60000",
"#018700",
"#b500ff",
"#05acc6",
"#97ff00",
"#ffa52f",
"#ff8ec8",
"#79525f",
"#00fdcf",
"#afa5ff",
"#93ac83",
"#9a6900",
"#366962",
"#d3008c",
"#fdf490",
"#c86e66",
"#9ee2ff",
"#00c846",
"#ffa6b8",
"#5f7a78",
"#da81ff",
"#ffc93d",
"#4b5600",
"#ff54a8",
"#25bfff",
"#4b3b00",
"#ff7a00",
"#8ed4a8",
"#6e4b87",
"#91b8ff",
"#a03f00",
"#00b395",
"#c8a2c8",
"#e67e22",
"#16a085",
"#8e44ad",
"#2ecc71",
"#f1c40f",
"#e74c3c",
"#2980b9",
"#27ae60",
"#f39c12",
"#c0392b",
"#1abc9c",
"#9b59b6",
"#34495e",
"#ff1493",
"#00ced1",
"#7fff00",
"#ff4500",
"#00ff7f",
"#4169e1",
"#ff00ff",
"#00bfff",
"#ff6347",
"#32cd32",
"#ffd700",
"#8b0000",
"#006400",
"#4b0082",
"#b22222",
"#228b22",
"#00008b",
"#ff69b4",
"#20b2aa",
"#ffb6c1",
"#87cefa",
"#adff2f",
"#ffdead",
"#40e0d0",
"#ff7f50",
"#7b68ee",
];

function getItemCodeColor(itemCode: string): string {
let hash = 0;
for (let i = 0; i < itemCode.length; i += 1) {
hash = (hash * 31 + itemCode.charCodeAt(i)) | 0;
}
return DISTINCT_ITEM_COLORS[Math.abs(hash) % DISTINCT_ITEM_COLORS.length];
}

type Criteria = {
prodSchedule: { rangeDays: number };
plannedOutputByDate: { rangeDays: number; itemCodes: string[] };
};

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

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.rangeDays, setChartLoading]);

const plannedOutputRows = chartData.plannedOutputByDate;
const plannedOutputItemOptions = Array.from(
new Map(plannedOutputRows.map((r) => [r.itemCode, { itemCode: r.itemCode, itemName: r.itemName || "" }])).values()
).sort((a, b) => a.itemCode.localeCompare(b.itemCode));
const filteredPlannedOutputRows =
criteria.plannedOutputByDate.itemCodes.length === 0
? plannedOutputRows
: plannedOutputRows.filter((r) => criteria.plannedOutputByDate.itemCodes.includes(r.itemCode));

const plannedOutputChart = React.useMemo(() => {
const rows = filteredPlannedOutputRows;
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 != null && r.qty != null ? Number(r.qty) : 0;
}),
}));
const colors = items.map(({ itemCode }) => getItemCodeColor(itemCode));
const hasData = dates.length > 0 && series.length > 0;
// Remount chart when structure changes — avoids ApexCharts internal series/colors desync ("reading 'data'").
const chartKey = `${dates.join(",")}|${items.map((i) => i.itemCode).join(",")}|${series.length}`;
return { dates, series, colors, hasData, chartKey };
}, [filteredPlannedOutputRows]);

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={filteredPlannedOutputRows.map((r) => ({ 日期: r.date, 物料編碼: r.itemCode, 物料名稱: r.itemName, 數量: r.qty }))}
filters={
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
<DateRangeSelect
value={criteria.plannedOutputByDate.rangeDays}
onChange={(v) => updateCriteria("plannedOutputByDate", (c) => ({ ...c, rangeDays: v }))}
/>
<FormControl size="small" sx={{ minWidth: 220 }}>
<InputLabel>物料編碼</InputLabel>
<Select
multiple
value={criteria.plannedOutputByDate.itemCodes}
label="物料編碼"
renderValue={(selected) =>
(selected as string[]).length === 0 ? "全部物料" : (selected as string[]).join(", ")
}
onChange={(e) =>
updateCriteria("plannedOutputByDate", (c) => ({
...c,
itemCodes: typeof e.target.value === "string" ? e.target.value.split(",") : e.target.value,
}))
}
>
{plannedOutputItemOptions.map((item) => (
<MenuItem key={item.itemCode} value={item.itemCode}>
<Checkbox checked={criteria.plannedOutputByDate.itemCodes.includes(item.itemCode)} />
<ListItemText primary={[item.itemCode, item.itemName].filter(Boolean).join(" - ")} />
</MenuItem>
))}
</Select>
</FormControl>
</Box>
}
>
{loadingCharts.plannedOutputByDate ? (
<Skeleton variant="rectangular" height={320} />
) : !plannedOutputChart.hasData ? (
<Typography color="text.secondary" sx={{ py: 3 }}>
此日期範圍內尚無排程資料。
</Typography>
) : (
<ApexCharts
key={plannedOutputChart.chartKey}
options={{
chart: { type: "bar", animations: { enabled: false } },
colors: plannedOutputChart.colors,
xaxis: { categories: plannedOutputChart.dates },
yaxis: { title: { text: "數量" } },
plotOptions: { bar: { columnWidth: "60%" } },
dataLabels: { enabled: false },
legend: { position: "top", horizontalAlign: "left" },
}}
series={plannedOutputChart.series}
type="bar"
width="100%"
height={Math.max(320, plannedOutputChart.dates.length * 24)}
/>
)}
</ChartCard>

<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>
</Box>
);
}

+ 367
- 0
src/app/(main)/chart/joborder/page.tsx View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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>
</>


+ 1084
- 221
src/app/(main)/ps/page.tsx
File diff suppressed because it is too large
View File


+ 37
- 0
src/app/(main)/putAwayCam/page.tsx View File

@@ -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 View File

@@ -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.

+ 205
- 0
src/app/(main)/report/SemiFGProductionAnalysisReport.tsx View File

@@ -0,0 +1,205 @@
"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 DownloadIcon from '@mui/icons-material/Download';
import {
fetchSemiFGItemCodes,
fetchSemiFGItemCodesWithCategory,
generateSemiFGProductionAnalysisReport,
generateSemiFGProductionAnalysisReportExcel,
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>>({});
const [exportFormat, setExportFormat] = useState<'pdf' | 'excel'>('pdf');

// 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 handleExportClick = async (format: 'pdf' | 'excel') => {
setExportFormat(format);
// Validate required fields
if (requiredFieldLabels.length > 0) {
alert(`缺少必填條件:\n- ${requiredFieldLabels.join('\n- ')}`);
return;
}

// If no itemCode is selected, export directly without confirmation
if (!criteria.itemCode) {
await executeExport(format);
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 executeExport = async (format: 'pdf' | 'excel' = exportFormat) => {
setLoading(true);
try {
if (format === 'excel') {
await generateSemiFGProductionAnalysisReportExcel(criteria, reportTitle);
} else {
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 (
<>
<div style={{ display: 'flex', gap: 16 }}>
<Button
variant="contained"
size="large"
startIcon={<DownloadIcon />}
onClick={() => handleExportClick('pdf')}
disabled={loading}
sx={{ px: 4 }}
>
{loading ? '生成 PDF...' : '下載報告 (PDF)'}
</Button>
<Button
variant="outlined"
size="large"
onClick={() => handleExportClick('excel')}
disabled={loading}
sx={{ px: 4 }}
>
{loading ? '生成 Excel...' : '下載報告 (Excel)'}
</Button>
</div>

{/* 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={() => executeExport()}
disabled={loading}
startIcon={<DownloadIcon />}
>
{loading
? exportFormat === 'excel'
? '生成 Excel...'
: '生成 PDF...'
: exportFormat === 'excel'
? '確認下載 Excel'
: '確認下載 PDF'}
</Button>
</DialogActions>
</Dialog>
</>
);
}

+ 99
- 0
src/app/(main)/report/grnReportApi.ts View File

@@ -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");
}

+ 423
- 42
src/app/(main)/report/page.tsx View File

@@ -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 DownloadIcon from '@mui/icons-material/Download';
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,38 +47,196 @@ 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 handlePrint = async () => {
if (!currentReport) return;
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}`;
}

// 1. Mandatory Field Validation
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 validateRequiredFields = () => {
if (!currentReport) return true;

// Mandatory Field Validation
const missingFields = currentReport.fields
.filter(field => field.required && !criteria[field.name])
.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 false;
}

return true;
};

const handlePrint = async () => {
if (!currentReport) return;
if (!validateRequiredFields()) 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 handleExcelPrint = async () => {
if (!currentReport) return;
if (!validateRequiredFields()) return;
await executeExcelReport();
};

const executeExcelReport = async () => {
if (!currentReport) return;
setLoading(true);
try {
if (currentReport.id === 'rep-014') {
await generateGrnReportExcel(criteria, currentReport.title);
} else {
// Backend returns actual .xlsx bytes for this Excel endpoint.
const queryParams = new URLSearchParams(criteria).toString();
const excelUrl = `${currentReport.apiEndpoint}-excel?${queryParams}`;

const response = await clientAuthFetch(excelUrl, {
method: 'GET',
headers: { Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' },
});

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);
const link = document.createElement('a');
link.href = downloadUrl;

const contentDisposition = response.headers.get('Content-Disposition');
let fileName = `${currentReport.title}.xlsx`;
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);
}
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 +254,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 +267,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 +296,249 @@ 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>
<Box sx={{ mt: 4, display: 'flex', gap: 2, justifyContent: 'flex-end' }}>
{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.id === 'rep-006' ? (
<>
<Button
variant="contained"
size="large"
startIcon={<DownloadIcon />}
onClick={handlePrint}
disabled={loading}
sx={{ px: 4 }}
>
{loading ? "生成 PDF..." : "下載報告 (PDF)"}
</Button>
<Button
variant="outlined"
size="large"
startIcon={<DownloadIcon />}
onClick={handleExcelPrint}
disabled={loading}
sx={{ px: 4 }}
>
{loading ? "生成 Excel..." : "下載報告 (Excel)"}
</Button>
</>
) : currentReport.responseType === 'excel' ? (
<Button
variant="contained"
size="large"
startIcon={<DownloadIcon />}
onClick={handlePrint}
disabled={loading}
sx={{ px: 4 }}
>
{loading ? "生成 Excel..." : "下載報告 (Excel)"}
</Button>
) : (
<Button
variant="contained"
size="large"
startIcon={<DownloadIcon />}
onClick={handlePrint}
disabled={loading}
sx={{ px: 4 }}
>
{loading ? "生成報告..." : "下載報告 (PDF)"}
</Button>
)}
</Box>
</CardContent>
</Card>


+ 141
- 0
src/app/(main)/report/semiFGProductionAnalysisApi.ts View File

@@ -0,0 +1,141 @@
"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);
};

/**
* Generate and download the SemiFG Production Analysis Report as Excel
* @param criteria - Report criteria parameters
* @param reportTitle - Title of the report for filename
* @returns Promise that resolves when download is complete
*/
export const generateSemiFGProductionAnalysisReportExcel = 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-excel?${queryParams}`;

const response = await clientAuthFetch(url, {
method: 'GET',
headers: { Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' },
});

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}.xlsx`;
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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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>


+ 343
- 36
src/app/(main)/testing/page.tsx View File

@@ -4,13 +4,49 @@ 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";
import * as XLSX from "xlsx";

// 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 +71,29 @@ 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
},
]);

// --- 6. GRN Preview (M18) ---
const [grnPreviewReceiptDate, setGrnPreviewReceiptDate] = useState("2026-03-16");
// --- 7. M18 PO Sync by Code ---
const [m18PoCode, setM18PoCode] = useState("");
const [isSyncingM18Po, setIsSyncingM18Po] = useState(false);
const [m18PoSyncResult, setM18PoSyncResult] = useState<string>("");

// Generic handler for inline table edits
const handleItemChange = (setter: any, id: number, field: string, value: string) => {
@@ -51,14 +106,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 +121,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 +136,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 +159,150 @@ 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");
}
};

// GRN Preview CSV Download (Section 6)
const handleDownloadGrnPreviewXlsx = async () => {
try {
const response = await clientAuthFetch(
`${NEXT_PUBLIC_API_URL}/report/grn-preview-m18?receiptDate=${encodeURIComponent(grnPreviewReceiptDate)}`,
{ method: "GET" },
);
if (response.status === 401 || response.status === 403) return;
if (!response.ok) throw new Error(`Download failed: ${response.status}`);

const data = await response.json();
const rows = Array.isArray(data?.rows) ? data.rows : [];

const ws = XLSX.utils.json_to_sheet(rows);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "GRN Preview");

const xlsxArrayBuffer = XLSX.write(wb, { bookType: "xlsx", type: "array" });
const blob = new Blob([xlsxArrayBuffer], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});

const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", `grn-preview-m18-${grnPreviewReceiptDate}.xlsx`);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
} catch (e) {
console.error("GRN Preview XLSX Download Error:", e);
alert("GRN Preview XLSX download failed. Check console/network.");
}
};

// M18 PO Sync By Code (Section 7)
const handleSyncM18PoByCode = async () => {
if (!m18PoCode.trim()) {
alert("Please enter PO code.");
return;
}
setIsSyncingM18Po(true);
setM18PoSyncResult("");
try {
const response = await clientAuthFetch(
`${NEXT_PUBLIC_API_URL}/m18/test/po-by-code?code=${encodeURIComponent(m18PoCode.trim())}`,
{ method: "GET" },
);
if (response.status === 401 || response.status === 403) return;
const text = await response.text();
setM18PoSyncResult(text);
if (!response.ok) {
alert(`Sync failed: ${response.status}`);
}
} catch (e) {
console.error("M18 PO Sync By Code Error:", e);
alert("M18 PO sync failed. Check console/network.");
} finally {
setIsSyncingM18Po(false);
}
};

// 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" />
<Tab label="6. GRN Preview" />
<Tab label="7. M18 PO Sync" />
</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 +334,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 +368,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 +381,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 +439,158 @@ 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>

<TabPanel value={tabValue} index={5}>
<Section title="6. GRN Preview (M18)">
<Stack direction="row" spacing={2} sx={{ mb: 2, alignItems: "center" }}>
<TextField
size="small"
label="Receipt Date"
type="date"
value={grnPreviewReceiptDate}
onChange={(e) => setGrnPreviewReceiptDate(e.target.value)}
InputLabelProps={{ shrink: true }}
/>
<Button
variant="contained"
color="success"
size="medium"
startIcon={<FileDownload />}
onClick={handleDownloadGrnPreviewXlsx}
>
Download GRN Preview XLSX
</Button>
</Stack>
<Typography variant="body2" color="textSecondary">
Backend endpoint: <code>/report/grn-preview-m18?receiptDate=YYYY-MM-DD</code>
</Typography>
</Section>
</TabPanel>

<TabPanel value={tabValue} index={6}>
<Section title="7. M18 PO Sync by Code">
<Stack direction="row" spacing={2} sx={{ mb: 2, alignItems: "center" }}>
<TextField
size="small"
label="PO Code"
value={m18PoCode}
onChange={(e) => setM18PoCode(e.target.value)}
placeholder="e.g. PFP002PO26030341"
sx={{ minWidth: 320 }}
/>
<Button
variant="contained"
color="primary"
onClick={handleSyncM18PoByCode}
disabled={isSyncingM18Po}
>
{isSyncingM18Po ? "Syncing..." : "Sync PO from M18"}
</Button>
</Stack>
<Typography variant="body2" color="textSecondary">
Backend endpoint: <code>/m18/test/po-by-code?code=YOUR_CODE</code>
</Typography>
{m18PoSyncResult ? (
<TextField
fullWidth
multiline
minRows={4}
margin="normal"
label="Sync Result"
value={m18PoSyncResult}
InputProps={{ readOnly: true }}
/>
) : null}
</Section>
</TabPanel>

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


+ 24
- 1
src/app/api/bag/action.ts View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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;


+ 112
- 13
src/app/api/do/actions.tsx View File

@@ -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());
@@ -368,4 +434,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 View File

@@ -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 View File

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


+ 31
- 0
src/app/api/inventory/actions.ts View File

@@ -28,6 +28,7 @@ export interface SearchInventory extends Pageable {
code: string;
name: string;
type: string;
lotNo?: string;
}

export interface InventoryResultByPage {
@@ -152,3 +153,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 View File

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

export interface InventoryLotLineResult {


+ 232
- 24
src/app/api/jo/actions.ts View File

@@ -29,6 +29,7 @@ export interface SearchJoResultRequest extends Pageable {
planStart?: string;
planStartTo?: string;
jobTypeName?: string;
joSearchStatus?: string;
}

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

export interface ProductProcessWithLinesResponse {
@@ -343,16 +345,25 @@ 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;
FinishedProductProcessLineCount: number;
lines: ProductProcessInfoResponse[];
}

export interface JobOrderProductProcessPageResponse {
content: AllJoborderProductProcessInfoResponse[];
totalJobOrders: number;
page: number;
size: number;
}
export interface ProductProcessInfoResponse {
id: number;
operatorId?: number;
@@ -454,18 +465,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 +518,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 +533,15 @@ 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[];
noLotPickCount?: FloorPickCount | null;
suggestedFailCount?: number;
}
export interface UpdateJoPickOrderHandledByRequest {
pickOrderId: number;
@@ -553,11 +584,24 @@ export interface PickOrderLineWithLotsResponse {
itemCode: string | null;
itemName: string | null;
requiredQty: number | null;
totalAvailableQty?: number | null;
uomCode: string | null;
uomDesc: string | null;
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 +619,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;
@@ -628,6 +673,16 @@ export const deleteJobOrder=cache(async (jobOrderId: number) => {
}
);
});

export const setJobOrderHidden = cache(async (jobOrderId: number, hidden: boolean) => {
const response = await serverFetchJson<any>(`${BASE_API_URL}/jo/set-hidden`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: jobOrderId, hidden }),
});
revalidateTag("jos");
return response;
});
export const fetchAllJobTypes = cache(async () => {
return serverFetchJson<JobTypeResponse[]>(
`${BASE_API_URL}/jo/jobTypes`,
@@ -655,14 +710,19 @@ export const fetchJobOrderLotsHierarchicalByPickOrderId = cache(async (pickOrder
},
);
});
export const fetchAllJoPickOrders = cache(async () => {
// NOTE: Do NOT wrap in `cache()` because the list needs to reflect just-completed lines
// immediately when navigating back from JobPickExecution.
export const fetchAllJoPickOrders = async (isDrink?: boolean | null, floor?: string | null) => {
const params = new URLSearchParams();
if (isDrink !== undefined && isDrink !== null) params.set("isDrink", String(isDrink));
if (floor) params.set("floor", floor);
const query = params.toString() ? `?${params.toString()}` : "";
return serverFetchJson<AllJoPickOrderResponse[]>(
`${BASE_API_URL}/jo/AllJoPickOrder`,
{
method: "GET",
}
`${BASE_API_URL}/jo/AllJoPickOrder${query}`,
// Force re-fetch. This page reflects real-time pick completion state.
{ method: "GET", cache: "no-store" }
);
});
};
export const fetchProductProcessLineDetail = cache(async (lineId: number) => {
return serverFetchJson<JobOrderProcessLineDetailResponse>(
`${BASE_API_URL}/product-process/Demo/ProcessLine/detail/${lineId}`,
@@ -715,9 +775,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"] },
@@ -725,6 +789,52 @@ export const fetchAllJoborderProductProcessInfo = cache(async () => {
);
});

export const fetchJoborderProductProcessesPage = cache(async (params: {
/** Job order planStart 區間起(YYYY-MM-DD,含當日) */
date?: string | null;
itemCode?: string | null;
jobOrderCode?: string | null;
bomIds?: number[] | null;
qcReady?: boolean | null;
isDrink?: boolean | null;
page?: number;
size?: number;
}) => {
const {
date,
itemCode,
jobOrderCode,
bomIds,
qcReady,
isDrink,
page = 0,
size = 50,
} = params;

const queryParts: string[] = [];
if (date) {
queryParts.push(`date=${encodeURIComponent(date)}`);
}
if (itemCode) queryParts.push(`itemCode=${encodeURIComponent(itemCode)}`);
if (jobOrderCode) queryParts.push(`jobOrderCode=${encodeURIComponent(jobOrderCode)}`);
if (bomIds && bomIds.length > 0) queryParts.push(`bomIds=${bomIds.join(",")}`);
if (qcReady !== undefined && qcReady !== null) queryParts.push(`qcReady=${qcReady}`);
if (isDrink !== undefined && isDrink !== null) queryParts.push(`isDrink=${isDrink}`);

queryParts.push(`page=${page}`);
queryParts.push(`size=${size}`);

const query = queryParts.length > 0 ? `?${queryParts.join("&")}` : "";

return serverFetchJson<JobOrderProductProcessPageResponse>(
`${BASE_API_URL}/product-process/Demo/Process/search${query}`,
{
method: "GET",
next: { tags: ["productProcessSearch"] },
}
);
});

/*
export const updateProductProcessLineQty = async (request: UpdateProductProcessLineQtyRequest) => {
return serverFetchJson<UpdateProductProcessLineQtyResponse>(
@@ -873,7 +983,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 +1298,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 +1326,7 @@ export interface JobProcessStatusResponse {
jobOrderCode: string;
itemCode: string;
itemName: string;
status: string;
processingTime: number | null;
setupTime: number | null;
changeoverTime: number | null;
@@ -1215,15 +1334,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 View File

@@ -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 View File

@@ -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" }
);
}

+ 118
- 7
src/app/api/pickOrder/actions.ts View File

@@ -207,9 +207,14 @@ export interface PickExecutionIssueData {
actualPickQty: number;
missQty: number;
badItemQty: number;
badPackageQty?: number;
/** Optional: frontend-only reference to stock_out_line.id for the picked lot. */
stockOutLineId?: number;
issueRemark: string;
pickerName: string;
handledBy?: number;
badReason?: string;
reason?: string;
}
export type AutoAssignReleaseResponse = {
id: number | null;
@@ -440,6 +445,7 @@ export interface UpdatePickExecutionIssueRequest {
export interface StoreLaneSummary {
storeId: string;
rows: LaneRow[];
defaultTruckCount: number | null;
}

export interface LaneRow {
@@ -470,6 +476,7 @@ export interface QrPickSubmitLineRequest {
export interface UpdateStockOutLineStatusByQRCodeAndLotNoRequest {
pickOrderLineId: number,
inventoryLotNo: string,
stockInLineId?: number | null,
stockOutLineId: number,
itemId: number,
status: string
@@ -542,7 +549,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 +610,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 +1007,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 +1394,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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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;
}

+ 77
- 0
src/app/error.tsx View File

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

import { useEffect, useMemo, useState } from "react";

const STORAGE_KEY = "fpsms_server_wait_until_ms";
const WAIT_SECONDS = 30;
const RELOAD_INTERVAL_SECONDS = 5;

function formatSeconds(s: number) {
const v = Math.max(0, Math.floor(s));
return `${v} 秒`;
}

/**
* When a server-side exception occurs (e.g. backend down during deploy),
* show a reconnect countdown and retry instead of immediate redirect.
*/
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
const [remaining, setRemaining] = useState(WAIT_SECONDS);

const waitUntilMs = useMemo(() => {
if (typeof window === "undefined") return Date.now() + WAIT_SECONDS * 1000;
const existing = Number(window.localStorage.getItem(STORAGE_KEY) || "0");
const now = Date.now();
if (existing && existing > now) return existing;
const next = now + WAIT_SECONDS * 1000;
window.localStorage.setItem(STORAGE_KEY, String(next));
return next;
}, []);

useEffect(() => {
const start = Date.now();
const tick = () => {
const now = Date.now();
const msLeft = Math.max(0, waitUntilMs - now);
setRemaining(msLeft / 1000);

// When waiting time ends, go to login.
if (msLeft <= 0) {
window.localStorage.removeItem(STORAGE_KEY);
window.location.href = "/login";
}
};

tick();
const interval = window.setInterval(tick, 250);

// Reload periodically to give backend time to come back.
const reloadTimer = window.setInterval(() => {
const elapsedSec = (Date.now() - start) / 1000;
if (elapsedSec >= WAIT_SECONDS) return;
window.location.reload();
}, RELOAD_INTERVAL_SECONDS * 1000);

return () => {
window.clearInterval(interval);
window.clearInterval(reloadTimer);
};
}, [waitUntilMs]);

return (
<div className="flex min-h-[200px] flex-col items-center justify-center gap-2 p-6 text-center">
<p className="text-sm font-semibold text-slate-700 dark:text-slate-200">
連線異常,伺服器暫停中
</p>
<p className="text-sm text-slate-600 dark:text-slate-400">
系統會在後台恢復後自動重試。倒數中:{formatSeconds(remaining)}
</p>
</div>
);
}

+ 88
- 0
src/app/global-error.tsx View File

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

import { useEffect, useMemo, useState } from "react";

const STORAGE_KEY = "fpsms_server_wait_until_ms";
const WAIT_SECONDS = 30;
const RELOAD_INTERVAL_SECONDS = 5;

function formatSeconds(s: number) {
const v = Math.max(0, Math.floor(s));
return `${v} 秒`;
}

/**
* Catches root-level errors (e.g. backend down during deploy).
* Shows a reconnect countdown and retries, then forwards to login if still failing.
* Must define <html> and <body> because this replaces the root layout.
*/
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
const [remaining, setRemaining] = useState(WAIT_SECONDS);

const waitUntilMs = useMemo(() => {
const existing = Number(window.localStorage.getItem(STORAGE_KEY) || "0");
const now = Date.now();
if (existing && existing > now) return existing;
const next = now + WAIT_SECONDS * 1000;
window.localStorage.setItem(STORAGE_KEY, String(next));
return next;
}, []);

useEffect(() => {
const start = Date.now();
const tick = () => {
const now = Date.now();
const msLeft = Math.max(0, waitUntilMs - now);
setRemaining(msLeft / 1000);

if (msLeft <= 0) {
window.localStorage.removeItem(STORAGE_KEY);
window.location.href = "/login";
}
};

tick();
const interval = window.setInterval(tick, 250);

const reloadTimer = window.setInterval(() => {
const elapsedSec = (Date.now() - start) / 1000;
if (elapsedSec >= WAIT_SECONDS) return;
window.location.reload();
}, RELOAD_INTERVAL_SECONDS * 1000);

return () => {
window.clearInterval(interval);
window.clearInterval(reloadTimer);
};
}, [waitUntilMs]);

return (
<html lang="zh-TW">
<body>
<div
style={{
display: "flex",
minHeight: "100vh",
alignItems: "center",
justifyContent: "center",
fontFamily: "system-ui, sans-serif",
padding: "1rem",
}}
>
<p style={{ color: "#334155", fontSize: "0.95rem", fontWeight: 700 }}>
連線異常,伺服器暫停中
</p>
<p style={{ color: "#64748b", fontSize: "0.875rem", marginTop: 8 }}>
系統會在後台恢復後自動重試。倒數中:{formatSeconds(remaining)}
</p>
</div>
</body>
</html>
);
}

+ 85
- 3
src/app/global.css View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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;
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save