Compare commits

...

552 Commitit

Tekijä SHA1 Viesti Päivämäärä
  tommy 2fbcbe0f74 no message 5 päivää sitten
  tommy 633be96898 label printer tracking update 5 päivää sitten
  tommy 08d62d4f77 label printer monitor 5 päivää sitten
  tommy fa17492940 stocktake report update 5 päivää sitten
  CANCERYS\kw093 9017748a51 i18n 5 päivää sitten
  [email protected] 93472687bd no message 6 päivää sitten
  B.E.N.S.O.N 0132bbd30f 成品出倉執貨時 標籤列印時頁數顯示空白 1 viikko sitten
  tommy 8df6e68854 Merge branch 'production' of https://git.2fi-solutions.com/jason/FPSMS-frontend into production 1 viikko sitten
  tommy 10ad3fc3ea DO補貨 search itemName 1 viikko sitten
  [email protected] dd3b55711d added a report to see the bom sync history 1 viikko sitten
  kelvin.yau 2aaca9d37b no message 1 viikko sitten
  kelvin.yau f6e8dc8b45 no message 1 viikko sitten
  kelvin.yau f381ba931e compile fix 1 viikko sitten
  kelvin.yau 96c8ac643b replenishment update 1 viikko sitten
  kelvin.yau a81fff570b improvement replenishment 1 viikko sitten
  tommy bde159732d 補貨UI update 1 viikko sitten
  CANCERYS\kw093 80e685d68d report fix 2 1 viikko sitten
  CANCERYS\kw093 e9d7eb51c9 補貨V1 1 viikko sitten
  tommy bc1784fffc Merge branch 'production' of https://git.2fi-solutions.com/jason/FPSMS-frontend into production 1 viikko sitten
  tommy ef46091858 補貨 turkc scheduler update 1 viikko sitten
  CANCERYS\kw093 9b113dd98f Merge branch 'production' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into production 1 viikko sitten
  CANCERYS\kw093 0e24d8588e so merge button 1 viikko sitten
  kelvin.yau 3617a02292 replenishment setup 1 viikko sitten
  tommy 4d9cbb49dd translate and re-schedule truck 1 viikko sitten
  tommy 1b23594ae8 report permission 1 viikko sitten
  tommy e79b1ed018 truck routeboard default navigation 1 viikko sitten
  tommy 39f044a7ed Merge branch 'production' of https://git.2fi-solutions.com/jason/FPSMS-frontend into production 1 viikko sitten
  tommy 7fabcc3e37 transaltion 1 viikko sitten
  CANCERYS\kw093 4942d57e75 TI-M merage fix 1 viikko sitten
  tommy 0abfd8627d no message 1 viikko sitten
  CANCERYS\kw093 936d96b696 Merge branch 'production' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into production 1 viikko sitten
  CANCERYS\kw093 59c464ae3f job order bom status 1 viikko sitten
  tommy b40acc5190 Merge branch 'production' of https://git.2fi-solutions.com/jason/FPSMS-frontend into production 1 viikko sitten
  tommy 66f3589d34 translateeee 1 viikko sitten
  CANCERYS\kw093 752857e74d update 2 viikkoa sitten
  CANCERYS\kw093 cd6c2ca450 do scan fix 2 viikkoa sitten
  CANCERYS\kw093 ba510b8808 update do is extra same order 2 viikkoa sitten
  CANCERYS\kw093 5ca2461c07 do scan fix 2 viikkoa sitten
  CANCERYS\kw093 ddf5c541df Merge branch 'production' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into production 2 viikkoa sitten
  CANCERYS\kw093 c530b43b70 m18 2 viikkoa sitten
  tommy 96d773f43a translateee 2 viikkoa sitten
  kelvin.yau e1521bb9cd Merge branch 'production' of https://git.2fi-solutions.com/derek/FPSMS-frontend into production 2 viikkoa sitten
  kelvin.yau ea9ec91527 new po ui 2 viikkoa sitten
  tommy ec072b6e9a translate 2 viikkoa sitten
  tommy 4dec39b2d2 translate 2 viikkoa sitten
  tommy 2ebd736e8b translate 2 viikkoa sitten
  tommy 0a758038be translate 2 viikkoa sitten
  tommy 7176946f3c translateeeee 2 viikkoa sitten
  tommy 228312633a translate again 2 viikkoa sitten
  tommy 44b2d30ac8 translate and route schedule 2 viikkoa sitten
  B.E.N.S.O.N 22614b63e5 Report Page UI Update 2 viikkoa sitten
  CANCERYS\kw093 a1e25d83bc bug fix 2 viikkoa sitten
  CANCERYS\kw093 c5507fa4e0 all select /unselect do sarch 2 viikkoa sitten
  CANCERYS\kw093 91ce7ec396 bom import fix 2 viikkoa sitten
  CANCERYS\kw093 e02d984d84 update i18n 3 viikkoa sitten
  CANCERYS\kw093 d3b37c95a5 do fix 3 viikkoa sitten
  CANCERYS\kw093 96450a515b do update 3 viikkoa sitten
  CANCERYS\kw093 bf6c0f43e6 stock take issue fix 3 viikkoa sitten
  CANCERYS\kw093 04c7e2f315 miss push 3 viikkoa sitten
  CANCERYS\kw093 c5c50d5fa6 do search fix 3 viikkoa sitten
  CANCERYS\kw093 402925ea0f stock take record hide show 3 viikkoa sitten
  CANCERYS\kw093 4f374cdff9 stock take batch save fix 3 viikkoa sitten
  CANCERYS\kw093 33755901f3 stock take batch save fix 3 viikkoa sitten
  CANCERYS\kw093 92675215dd stock take input max limit 3 viikkoa sitten
  CANCERYS\kw093 4b6aeef008 update stock tkae fix 3 viikkoa sitten
  CANCERYS\kw093 5624639013 stock take update 3 viikkoa sitten
  CANCERYS\kw093 67ec825b6a update 1 kuukausi sitten
  [email protected] 4a74565083 no message 1 kuukausi sitten
  [email protected] 611c4eecc1 no message 1 kuukausi sitten
  B.E.N.S.O.N 0beaf342e4 膠茜數目使用數量 Update 1 kuukausi sitten
  [email protected] 2e0ede2a4d no message 1 kuukausi sitten
  [email protected] 1432ac6aef fixing for compile 1 kuukausi sitten
  [email protected] 85073b6ca0 fixing for compile 1 kuukausi sitten
  CANCERYS\kw093 895b92fc71 jo dashboard update 1 kuukausi sitten
  [email protected] 2857d04f61 refining the device monitoring page 1 kuukausi sitten
  [email protected] 85c0962a2f no message 1 kuukausi sitten
  [email protected] c0ca782e4c added export do qty in /ps for daily delivery qty; added monitor page for production use 1 kuukausi sitten
  CANCERYS\kw093 9b9d31e4c4 doworkbench ticket select keep 1 kuukausi sitten
  CANCERYS\kw093 ffad6d2f5d chart improve 1 kuukausi sitten
  kelvin.yau 0e9b1da7ec fix frontend po details 1 kuukausi sitten
  tommy 500828fa97 routeboard 1 kuukausi sitten
  tommy c90cee778f expiry modal 1 kuukausi sitten
  CANCERYS\kw093 f4063d5a61 Merge branch 'production' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into production 1 kuukausi sitten
  CANCERYS\kw093 0eaee16db7 jo search 1 kuukausi sitten
  tommy 8d3d43b4ab no message 1 kuukausi sitten
  tommy 066ebd092c routeboard 1 kuukausi sitten
  CANCERYS\kw093 1bbaa24c00 new supplier 1 kuukausi sitten
  CANCERYS\kw093 939d50348f stock ledger 1 kuukausi sitten
  [email protected] faa2bc687e adding the sync of BOM testing 1 kuukausi sitten
  B.E.N.S.O.N 88a48237b0 User Page Update 1 kuukausi sitten
  CANCERYS\kw093 5f68820824 update i18n 1 kuukausi sitten
  CANCERYS\kw093 16d2fd7941 udpate bom search for WIP/FG 1 kuukausi sitten
  CANCERYS\kw093 30492338ee update pass to just pass 1 kuukausi sitten
  [email protected] f6d7c3ac7c no message 1 kuukausi sitten
  [email protected] b2084bd4ad added isExtra to DO 1 kuukausi sitten
  CANCERYS\kw093 80c6a4145d Merge branch 'production' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into production 1 kuukausi sitten
  CANCERYS\kw093 11dc855bab update second scan 1 kuukausi sitten
  tommy 0c82a8c79b truckdashboard update 1 kuukausi sitten
  CANCERYS\kw093 b9d2c0cb00 Merge branch 'production' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into production 1 kuukausi sitten
  CANCERYS\kw093 2064f7b380 update miss do 1 kuukausi sitten
  [email protected] 16da5ef9c1 remove floorParam to compile 1 kuukausi sitten
  CANCERYS\kw093 c8fe2cb962 update do search and jo bom name coe 1 kuukausi sitten
  CANCERYS\kw093 6159a435b8 update translate, do finish jump page, fix jo expiry can not submit, 1 kuukausi sitten
  kelvin.yau abe9fa16be Fix Label Printer Selection 1 kuukausi sitten
  CANCERYS\kw093 8e6dd474ec dodetail fix 1 kuukausi sitten
  CANCERYS\kw093 bded62fa3a update jo matching 1 kuukausi sitten
  B.E.N.S.O.N 7e936d2fe5 DO CARTON QTY EXCEL VER DOWN 1 kuukausi sitten
  kelvin.yau 9b05487235 STK ADJ translations 1 kuukausi sitten
  CANCERYS\kw093 57605a9f51 remove jo useless api 1 kuukausi sitten
  CANCERYS\kw093 0c97254c74 update truck X and singal relesae 1 kuukausi sitten
  CANCERYS\kw093 819087108b Merge branch 'production' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into production 1 kuukausi sitten
  CANCERYS\kw093 2af6099dfb remove old lot confirm mdoel and not lot order 1 kuukausi sitten
  [email protected] 26edddb80d fix the cannot compile problem 1 kuukausi sitten
  B.E.N.S.O.N 3f208b7fb1 Merge remote-tracking branch 'origin/production' into production 1 kuukausi sitten
  B.E.N.S.O.N d6f7044b5a Due to server explosion , delete unnecessary old flow api 1 kuukausi sitten
  tommy 1e23efc100 update type 1 kuukausi sitten
  CANCERYS\kw093 2151248932 Merge branch 'production' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into production 1 kuukausi sitten
  CANCERYS\kw093 cdab9068da update do 4F assign by lance 1 kuukausi sitten
  B.E.N.S.O.N 17240ec369 QR Code Printing Update 1 kuukausi sitten
  CANCERYS\kw093 0fc9f78a21 update back form page 1 kuukausi sitten
  CANCERYS\kw093 16d1d0f193 update stock take and stock take report 1 kuukausi sitten
  CANCERYS\kw093 de775f4215 fix ticket release table ui and can not continue use lot print model 1 kuukausi sitten
  DESKTOP-064TTA1\Fai LUK 5c1903be35 Merge branch 'MergeProblem1' into production 1 kuukausi sitten
  CANCERYS\kw093 6fc11d68e6 update job order type 1 kuukausi sitten
  CANCERYS\kw093 3c1b180148 update jo search 1 kuukausi sitten
  CANCERYS\kw093 0b895e7238 fix do reprint 1 kuukausi sitten
  CANCERYS\kw093 7079e2f9d0 fix stock take efficient 1 kuukausi sitten
  kelvin.yau 379d9bf053 New PO Workbench, UI only. 1 kuukausi sitten
  CANCERYS\kw093 169584e2c9 update workbenchprintlabel to show QRcode 1 kuukausi sitten
  CANCERYS\kw093 3a87b366f2 update 1 kuukausi sitten
  CANCERYS\kw093 ad34ead367 update 1 kuukausi sitten
  CANCERYS\kw093 f8239f4de3 update i18n 1 kuukausi sitten
  CANCERYS\kw093 88211ac37c update 1 kuukausi sitten
  CANCERYS\kw093 0da71b2d24 hide old do batch release 1 kuukausi sitten
  CANCERYS\kw093 73d9270e82 update 1 kuukausi sitten
  CANCERYS\kw093 758101fb51 update consumable print label model in put 1 kuukausi sitten
  CANCERYS\kw093 7d3e122635 update expirylot handle like no lot, 1 kuukausi sitten
  CANCERYS\kw093 5cd90840c9 hide normal do and do workbench i18n 1 kuukausi sitten
  CANCERYS\kw093 7a63698f86 update 1 kuukausi sitten
  CANCERYS\kw093 d90a57cb16 update job order list part , can use record part again. and updated but not yet finish consuble pick order 1 kuukausi sitten
  CANCERYS\kw093 629a0cdcb8 doworbench,joworkbech,nonefinish consumable worbnech 1 kuukausi sitten
  CANCERYS\kw093 6866832a18 putaway fix 2 kuukautta sitten
  CANCERYS\kw093 86ff846ad7 Merge remote-tracking branch 'origin/MergeProblem1' into MergeProblem1 2 kuukautta sitten
  CANCERYS\kw093 f31675640b merage 2 kuukautta sitten
  CANCERYS\kw093 646320a3cf merage jo actions 2 kuukautta sitten
  [email protected] e03c97ba13 Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/jason/FPSMS-frontend into MergeProblem1 2 kuukautta sitten
  [email protected] 05feee9c76 no message 2 kuukautta sitten
  B.E.N.S.O.N b9a9deb1d4 工單板頭紙更新 2 kuukautta sitten
  B.E.N.S.O.N 510d3fd831 成品出倉出箱數量 Update 2 kuukautta sitten
  CANCERYS\kw093 4fb0be7c9e update job order record 2 kuukautta sitten
  CANCERYS\kw093 42b058d86f update 4F ticket hander name 2 kuukautta sitten
  [email protected] 20ce8ffddf simplifiy the view in laser printer (lemon) 2 kuukautta sitten
  Tommy\2Fi-Staff 53cf2eed2a only show label機option 2 kuukautta sitten
  CANCERYS\kw093 89421afaf7 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 2 kuukautta sitten
  CANCERYS\kw093 ead0e19c57 productprcoess page tab 0 search by today 2 kuukautta sitten
  Tommy\2Fi-Staff 1e93537e2b autofill 來貨編號, 一鍵print label, move the alert message to top right corner because it hinder user operation 2 kuukautta sitten
  kelvin.yau 4cc5a43529 translation + stock transfer noti 2 kuukautta sitten
  kelvin.yau 3632421474 Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/derek/FPSMS-frontend into MergeProblem1 2 kuukautta sitten
  kelvin.yau 29dc796f43 補印Label 2 kuukautta sitten
  CANCERYS\kw093 4386624533 update 2 kuukautta sitten
  CANCERYS\kw093 1037d8eb5a search expiry item 2 kuukautta sitten
  CANCERYS\kw093 53b3d97097 update pick order efficient 2 kuukautta sitten
  B.E.N.S.O.N 6b090cf60a Update 2 kuukautta sitten
  B.E.N.S.O.N 673b6818bf PO Update 2 kuukautta sitten
  B.E.N.S.O.N 4e5d0215ab Update 2 kuukautta sitten
  B.E.N.S.O.N 9be15c0d1c Update 2 kuukautta sitten
  B.E.N.S.O.N 54f1dec265 Update 2 kuukautta sitten
  B.E.N.S.O.N 85f922d413 Update 2 kuukautta sitten
  CANCERYS\kw093 14a1a6d4e2 update 2 kuukautta sitten
  CANCERYS\kw093 f79d6716b2 update product process list tab 0 2 kuukautta sitten
  CANCERYS\kw093 d988ab92b5 do record add handler 2 kuukautta sitten
  kelvin.yau 4fe5bfbba3 translation 2 kuukautta sitten
  CANCERYS\kw093 5eb62bffe0 proudctionprocesslist efficnet import and tab 0 no limit date 2 kuukautta sitten
  [email protected] fd406c3d3c Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/jason/FPSMS-frontend into MergeProblem1 2 kuukautta sitten
  [email protected] 5c07df417f no message 2 kuukautta sitten
  B.E.N.S.O.N 5084455318 Update 2 kuukautta sitten
  CANCERYS\kw093 7577db551b update 2 kuukautta sitten
  CANCERYS\kw093 d9c3f2c3bb update 2 kuukautta sitten
  CANCERYS\kw093 e1cd48df21 update do ticket ui 2 kuukautta sitten
  CANCERYS\kw093 fd68182fd2 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 2 kuukautta sitten
  CANCERYS\kw093 6639610f02 update 2 kuukautta sitten
  Tommy\2Fi-Staff 8c724ede0b no message 2 kuukautta sitten
  Tommy\2Fi-Staff dbce92cccd pick order order sequence 2 kuukautta sitten
  CANCERYS\kw093 90fbaf3673 jo auto put away 2 kuukautta sitten
  B.E.N.S.O.N f9dbfd9c3a Truck Routing Summary Update 2 kuukautta sitten
  B.E.N.S.O.N 66e07c5b3e Truck Routing Summary Update 2 kuukautta sitten
  kelvin.yau de25934531 Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/derek/FPSMS-frontend into MergeProblem1 2 kuukautta sitten
  kelvin.yau 1458db7e20 translate 2 kuukautta sitten
  Tommy\2Fi-Staff 181140738d lotlabelprintmodal update 2 kuukautta sitten
  CANCERYS\kw093 91f5ea88b2 update put away & jo qc/putawayed 2 kuukautta sitten
  Tommy\2Fi-Staff c58898e8bd excels button 2 kuukautta sitten
  [email protected] 2ae88f29e9 no message 2 kuukautta sitten
  Tommy\2Fi-Staff cbc103bf47 Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/jason/FPSMS-frontend into MergeProblem1 2 kuukautta sitten
  Tommy\2Fi-Staff b005b1c2fb excel version for stocktakevaiance report, lotlabelprint modal show locatioln update 2 kuukautta sitten
  CANCERYS\kw093 b64fe6f25f Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 2 kuukautta sitten
  CANCERYS\kw093 850bec67aa update 2 kuukautta sitten
  Tommy\2Fi-Staff 1272409bbb lot label print modal for pick order 2 kuukautta sitten
  CANCERYS\kw093 0389cbbc31 update finshed QC part 2 kuukautta sitten
  CANCERYS\kw093 e1a0f56b80 update jo edit and jo compelte reocrd search 2 kuukautta sitten
  CANCERYS\kw093 6e1c2e3d7c updated jo efficient 2 kuukautta sitten
  CANCERYS\kw093 27f062341e update truck X part 2 kuukautta sitten
  CANCERYS\kw093 672fdcd87d update do swtich lot V2 2 kuukautta sitten
  CANCERYS\kw093 270763a2ae do switch lot update V1 2 kuukautta sitten
  CANCERYS\kw093 e0d7404898 update jo edit form 2 kuukautta sitten
  CANCERYS\kw093 36c7216fbd efficient improve V3 2 kuukautta sitten
  kelvin.yau 8164ea3dea new po Animation Fix 2 kuukautta sitten
  kelvin.yau 9fb88afbd7 New PO Testing page + Testing data (cannot be used in prod yet) 2 kuukautta sitten
  CANCERYS\kw093 f74760bb93 disbale edit button again 2 kuukautta sitten
  CANCERYS\kw093 c4154ebc11 update 2 kuukautta sitten
  B.E.N.S.O.N 632d0de6eb TruckRoutingSummary Update 2 kuukautta sitten
  CANCERYS\kw093 dfab4524c4 update 2 kuukautta sitten
  CANCERYS\kw093 4851a4d4c5 update 2 kuukautta sitten
  CANCERYS\kw093 cdad533861 update 2 kuukautta sitten
  CANCERYS\kw093 8a262831b2 update 2 kuukautta sitten
  CANCERYS\kw093 00233d5353 update 2 kuukautta sitten
  B.E.N.S.O.N 9737d94e49 Truck Routing Summary List 2 kuukautta sitten
  CANCERYS\kw093 09917026c0 update 2 kuukautta sitten
  CANCERYS\kw093 ac423981bd update 2 kuukautta sitten
  CANCERYS\kw093 ad35404904 update 2 kuukautta sitten
  CANCERYS\kw093 9c9888a44f update 2 kuukautta sitten
  CANCERYS\kw093 b826baabe2 update 2 kuukautta sitten
  Tommy\2Fi-Staff 0db2ede83c Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/jason/FPSMS-frontend into MergeProblem1 2 kuukautta sitten
  Tommy\2Fi-Staff f3a6822ca8 update 2 kuukautta sitten
  CANCERYS\kw093 82eedd7e80 update 2 kuukautta sitten
  PC-20260115JRSN\Administrator 790a8c8a60 added red spot for stock in po 2 kuukautta sitten
  CANCERYS\kw093 9aecdbbf88 udpate 2 kuukautta sitten
  CANCERYS\kw093 a4512e90a1 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 2 kuukautta sitten
  CANCERYS\kw093 e79b060f32 update expiry lot handle in jo/do and show qty will submit and no partly compelte by fronetend 2 kuukautta sitten
  PC-20260115JRSN\Administrator 65329be227 added jo process and job order board as chart 2 kuukautta sitten
  CANCERYS\kw093 045f9a6bd5 update 2 kuukautta sitten
  DESKTOP-064TTA1\Fai LUK 519a2fb82c no message 2 kuukautta sitten
  DESKTOP-064TTA1\Fai LUK cf9fb4c527 no message 2 kuukautta sitten
  DESKTOP-064TTA1\Fai LUK df91d458ba no message 2 kuukautta sitten
  PC-20260115JRSN\Administrator 10d4e795a5 no message 2 kuukautta sitten
  PC-20260115JRSN\Administrator 991cfa72d0 no message 2 kuukautta sitten
  DESKTOP-064TTA1\Fai LUK b4cf6ab714 no message 2 kuukautta sitten
  CANCERYS\kw093 ebb5845324 update 2 kuukautta sitten
  kelvin.yau 6bea17fdd0 Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/derek/FPSMS-frontend into MergeProblem1 2 kuukautta sitten
  kelvin.yau 691bce388f new PO testing 2 kuukautta sitten
  CANCERYS\kw093 e3df7b3975 update 2 kuukautta sitten
  CANCERYS\kw093 f1fe469ccb update 2 kuukautta sitten
  PC-20260115JRSN\Administrator 6d3583a938 no message 2 kuukautta sitten
  PC-20260115JRSN\Administrator 3df19f9a0b no message 2 kuukautta sitten
  CANCERYS\kw093 507742157e Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 2 kuukautta sitten
  CANCERYS\kw093 d2854953a8 update 2 kuukautta sitten
  PC-20260115JRSN\Administrator 548548f453 adding onpack 2nd machine zip download, added DO syn test for single DO code 2 kuukautta sitten
  CANCERYS\kw093 bd92bf2492 update 2 kuukautta sitten
  CANCERYS\kw093 7dc1fbf323 update 2 kuukautta sitten
  CANCERYS\kw093 6720687726 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 3 kuukautta sitten
  CANCERYS\kw093 b01f7eda9f update 3 kuukautta sitten
  PC-20260115JRSN\Administrator 4c9632ee7b no message 3 kuukautta sitten
  B.E.N.S.O.N 159cfbbc44 FGStockOutTraceabilityReport Excel Version 3 kuukautta sitten
  PC-20260115JRSN\Administrator 7415dbe4b6 added some purchase chart 3 kuukautta sitten
  kelvin.yau b537d3433a fix breadcrumb 3 kuukautta sitten
  kelvin.yau e808eff9cb JO tesing page + set JO creation page search result to default showing 100 results 3 kuukautta sitten
  CANCERYS\kw093 484d96203f update 3 kuukautta sitten
  CANCERYS\kw093 aae9839da9 update stock take record 3 kuukautta sitten
  CANCERYS\kw093 10dbc666f2 update 3 kuukautta sitten
  B.E.N.S.O.N 8ac38b7398 ItemQCFailReport Excel Version 3 kuukautta sitten
  Tommy\2Fi-Staff 804d7ea9d1 no message 3 kuukautta sitten
  CANCERYS\kw093 fae1803c21 update 3 kuukautta sitten
  B.E.N.S.O.N d9d175fb76 MaterialStockOutTraceabilityReport Excel Version 3 kuukautta sitten
  B.E.N.S.O.N 6250eb2a4a Merge remote-tracking branch 'origin/MergeProblem1' into MergeProblem1 3 kuukautta sitten
  B.E.N.S.O.N 98f0919439 Update 3 kuukautta sitten
  CANCERYS\kw093 ab31d6a74c Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 3 kuukautta sitten
  CANCERYS\kw093 9bcfe9c380 revert/ just complete do pick order 3 kuukautta sitten
  PC-20260115JRSN\Administrator 5b465b0abb Refining the PO Stock in report 3 kuukautta sitten
  CANCERYS\kw093 081ccb9f8f update job order search and cacel job order 3 kuukautta sitten
  CANCERYS\kw093 947f471ed8 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 3 kuukautta sitten
  CANCERYS\kw093 eabbc39c57 update 3 kuukautta sitten
  CANCERYS\kw093 430ace8d76 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 3 kuukautta sitten
  CANCERYS\kw093 87d32c728a update 3 kuukautta sitten
  PC-20260115JRSN\Administrator a358b79d8f change the PO with m18 uom and qty, included stock in PO, putaway process and GRN 3 kuukautta sitten
  CANCERYS\kw093 cef025fae8 update 3 kuukautta sitten
  PC-20260115JRSN\Administrator c9f05abfb0 added po number syn m18 3 kuukautta sitten
  B.E.N.S.O.N 05eab73a5b StockItemConsumptionTrendReport Excel Version 3 kuukautta sitten
  CANCERYS\kw093 8b2ab939e8 update switch lot 3 kuukautta sitten
  Tommy\2Fi-Staff cc14a5e100 no message 3 kuukautta sitten
  Tommy\2Fi-Staff e6afcf40cc no message 3 kuukautta sitten
  B.E.N.S.O.N c9ce1e30af User Page Update 3 kuukautta sitten
  B.E.N.S.O.N c7c5727e36 SemiFGProductionAnalysisReport Excel Version 3 kuukautta sitten
  Tommy\2Fi-Staff 1f56e1b5bd update shop and truck 3 kuukautta sitten
  B.E.N.S.O.N e00f711845 Update 3 kuukautta sitten
  PC-20260115JRSN\Administrator 5c215ece1b no message 3 kuukautta sitten
  CANCERYS\kw093 8421e66ec4 update 3 kuukautta sitten
  CANCERYS\kw093 0947fd181d update dashboard, job order list 3 kuukautta sitten
  kelvin.yau 6fe4889b02 better UX in inventory search 3 kuukautta sitten
  kelvin.yau 1799088819 no message 3 kuukautta sitten
  CANCERYS\kw093 6bec9ce850 update 3 kuukautta sitten
  PC-20260115JRSN\Administrator 1343362dc5 no message 3 kuukautta sitten
  kelvin.yau 080aed9316 Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/derek/FPSMS-frontend into MergeProblem1 3 kuukautta sitten
  kelvin.yau 93e61dddbc no message 3 kuukautta sitten
  kelvin.yau f7a8c882a0 no message 3 kuukautta sitten
  CANCERYS\kw093 f58875a3e9 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 3 kuukautta sitten
  CANCERYS\kw093 5af4f5ac6e update 3 kuukautta sitten
  PC-20260115JRSN\Administrator 284aaaaf85 no message 3 kuukautta sitten
  PC-20260115JRSN\Administrator 58c44b2987 fixing it cannot build 3 kuukautta sitten
  CANCERYS\kw093 d65e3db136 update 3 kuukautta sitten
  kelvin.yau 9bd475e306 stock transfer ui fix 3 kuukautta sitten
  kelvin.yau a167e79a74 Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/derek/FPSMS-frontend into MergeProblem1 3 kuukautta sitten
  kelvin.yau ebc4cdfdee search lot by scanning qr code 3 kuukautta sitten
  CANCERYS\kw093 a249363da4 updated 3 kuukautta sitten
  CANCERYS\kw093 88d1b60fc7 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 3 kuukautta sitten
  CANCERYS\kw093 44d51c8390 update 3 kuukautta sitten
  PC-20260115JRSN\Administrator 67ee15b312 no message 3 kuukautta sitten
  CANCERYS\kw093 76ad78f126 update 3 kuukautta sitten
  CANCERYS\kw093 ad127b39ac update 3 kuukautta sitten
  CANCERYS\kw093 fc8b94c562 update 3 kuukautta sitten
  kelvin.yau de65686192 UPDATE OPEN INVENTORY FOR ITEMS WITH NO INVENTORY 3 kuukautta sitten
  CANCERYS\kw093 b7ccfe3574 update 3 kuukautta sitten
  B.E.N.S.O.N 56e5c937af Good Pick Issue Fixing 3 kuukautta sitten
  CANCERYS\kw093 25cfed96d6 update qR scan 3 kuukautta sitten
  B.E.N.S.O.N c5d79de697 Login Page Update 3 kuukautta sitten
  Tommy\2Fi-Staff d536dbb8d3 update variance report config 3 kuukautta sitten
  CANCERYS\kw093 1c737822c5 update 3 kuukautta sitten
  CANCERYS\kw093 d1423bdd29 update 3 kuukautta sitten
  PC-20260115JRSN\Administrator 7801b32fcd no message 3 kuukautta sitten
  Tommy\2Fi-Staff 84088c143d Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/jason/FPSMS-frontend into MergeProblem1 3 kuukautta sitten
  Tommy\2Fi-Staff bde63fdd4d fix putaway 3 kuukautta sitten
  CANCERYS\kw093 bbfc821d44 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 3 kuukautta sitten
  CANCERYS\kw093 77ad12967b jo edit 3 kuukautta sitten
  PC-20260115JRSN\Administrator 457e4f101f no message 3 kuukautta sitten
  CANCERYS\kw093 5e83e2c8e6 update 3 kuukautta sitten
  PC-20260115JRSN\Administrator c59949643e no message 3 kuukautta sitten
  PC-20260115JRSN\Administrator 49e11a72ee no message 3 kuukautta sitten
  kelvin.yau 3598941032 build bug fix 3 kuukautta sitten
  kelvin.yau 953cb0783e no message 3 kuukautta sitten
  kelvin.yau 5e4c8c46e7 no message 3 kuukautta sitten
  kelvin.yau 060de0d2f6 no message 3 kuukautta sitten
  kelvin.yau 84baa17e9f no message 3 kuukautta sitten
  kelvin.yau dc221be8b8 no message 3 kuukautta sitten
  kelvin.yau d91928082f fix frontend build error 3 kuukautta sitten
  TASTEOFASIA\MTMS f2a2337e1a no message 3 kuukautta sitten
  CANCERYS\kw093 7564ee01eb update stock take drop down 3 kuukautta sitten
  CANCERYS\kw093 92a0a894cc Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 3 kuukautta sitten
  CANCERYS\kw093 842aa9ffec update 3 kuukautta sitten
  kelvin.yau 5a0b3a43d0 update default store location for FA and WIP 3 kuukautta sitten
  kelvin.yau f60c702e74 no message 3 kuukautta sitten
  kelvin.yau 062a268bc8 bug fix 3 kuukautta sitten
  kelvin.yau f82bb5e056 Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/derek/FPSMS-frontend into MergeProblem1 3 kuukautta sitten
  kelvin.yau 3675c90342 price inqury 3 kuukautta sitten
  PC-20260115JRSN\Administrator da9f8b277e adding some charts to test 3 kuukautta sitten
  B.E.N.S.O.N e1bda42014 Dashboard UpDATE 3 kuukautta sitten
  CANCERYS\kw093 9b5d1306d9 stocktakeALL 3 kuukautta sitten
  CANCERYS\kw093 37f9eeed01 update stock take search 3 kuukautta sitten
  PC-20260115JRSN\Administrator 190d78c6df adding PS settings 3 kuukautta sitten
  CANCERYS\kw093 9b4db0dde5 update 3 kuukautta sitten
  CANCERYS\kw093 e4f0273a0e product process list and warehouse 3 kuukautta sitten
  kelvin.yau 4fa7bc2b8e translation issue 3 kuukautta sitten
  CANCERYS\kw093 4b264a82a8 update bom ui 3 kuukautta sitten
  CANCERYS\kw093 15592a176a Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 3 kuukautta sitten
  CANCERYS\kw093 a494673402 update 3 kuukautta sitten
  kelvin.yau 086cc40c0a Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/derek/FPSMS-frontend into MergeProblem1 3 kuukautta sitten
  kelvin.yau 806c8c1242 A4 printer routing and register, see backend 3 kuukautta sitten
  CANCERYS\kw093 081c76581c update stock in line lotNo and joborder show lotNo 3 kuukautta sitten
  Tommy\2Fi-Staff 86bf59e675 make putaway smaller 3 kuukautta sitten
  B.E.N.S.O.N 2b7ff5d2ea Warehouse Supporting Function Update 3 kuukautta sitten
  B.E.N.S.O.N 5dbbe07614 Warehouse Supporting Function Update 3 kuukautta sitten
  TASTEOFASIA\MTMS d31012af63 update the new server ip and setting in env-prod 3 kuukautta sitten
  PC-20260115JRSN\Administrator edd947c227 try fixing the pages 3 kuukautta sitten
  PC-20260115JRSN\Administrator 6d802eddf4 try fixing the page problem 3 kuukautta sitten
  PC-20260115JRSN\Administrator 10fca7bc19 try to fix the page problem 3 kuukautta sitten
  PC-20260115JRSN\Administrator 9e6cb8345e try to fix the redirect problem in server 3 kuukautta sitten
  CANCERYS\kw093 2548b7a007 update stock in line 3 kuukautta sitten
  [email protected] 9d376e4857 trying to build on server 3 kuukautta sitten
  [email protected] 4f04ddde6e fixing the code the make project failed to build 3 kuukautta sitten
  CANCERYS\kw093 d7a34cf064 updaate import bom 3 kuukautta sitten
  CANCERYS\kw093 1e346fa9b8 Merge remote-tracking branch 'origin/MergeProblem1' into MergeProblem1 3 kuukautta sitten
  CANCERYS\kw093 bf2b7f1101 update 3 kuukautta sitten
  [email protected] d5fb8294ef adding bag printing page, copy from Bag1.py 3 kuukautta sitten
  Tommy\2Fi-Staff f9499d9a37 no message 3 kuukautta sitten
  B.E.N.S.O.N d5f19a7057 Bom Supporting Function 3 kuukautta sitten
  CANCERYS\kw093 4f0df8f5f8 update 3 kuukautta sitten
  CANCERYS\kw093 6dc9687949 Merge remote-tracking branch 'origin/MergeProblem1' into MergeProblem1 3 kuukautta sitten
  CANCERYS\kw093 dfbd808b3a update bom import ,epqc, 3 kuukautta sitten
  CANCERYS\kw093 dff5000125 update 3 kuukautta sitten
  CANCERYS\kw093 9ad4009bc3 update 3 kuukautta sitten
  B.E.N.S.O.N 51e4f705c3 Bom Supporting Function 3 kuukautta sitten
  kelvin.yau 88513e744b No longer refresh after QC 3 kuukautta sitten
  B.E.N.S.O.N aa4f0fff29 Update 3 kuukautta sitten
  [email protected] 435d041f5c no message 3 kuukautta sitten
  [email protected] 0a24dc116f no message 3 kuukautta sitten
  CANCERYS\kw093 42cb203514 update truck X 3 kuukautta sitten
  [email protected] 9d00348946 For my testing use, use the cam instead of barcode scanner for putaway 3 kuukautta sitten
  CANCERYS\kw093 c60f80fe1d update 3 kuukautta sitten
  CANCERYS\kw093 fb271f9209 update 3 kuukautta sitten
  CANCERYS\kw093 48a0fbb924 update 3 kuukautta sitten
  kelvin.yau 703ac2ba72 dashboard fix (FG + equipment) 3 kuukautta sitten
  kelvin.yau 86b2c12321 dashboard fix 3 kuukautta sitten
  kelvin.yau 19b4ed534c dashboards formatting (keep same) 3 kuukautta sitten
  Tommy\2Fi-Staff ad53e1a701 no message 3 kuukautta sitten
  Tommy\2Fi-Staff e59d79797a update 3 kuukautta sitten
  Tommy\2Fi-Staff d74f5d184b update 3 kuukautta sitten
  Tommy\2Fi-Staff 656a222976 upDATE 3 kuukautta sitten
  Tommy\2Fi-Staff f3c480b983 qcstockin ui update 3 kuukautta sitten
  kelvin.yau c5c6d61af2 Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/derek/FPSMS-frontend into MergeProblem1 3 kuukautta sitten
  kelvin.yau f4a3c12d99 title updates 3 kuukautta sitten
  CANCERYS\kw093 7cd54de584 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 3 kuukautta sitten
  CANCERYS\kw093 5e5fa63ce8 update 3 kuukautta sitten
  Tommy\2Fi-Staff 8daf185e60 trucklane dashboard 3 kuukautta sitten
  CANCERYS\kw093 66b8912ff0 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 3 kuukautta sitten
  CANCERYS\kw093 75f3e6a819 update 3 kuukautta sitten
  B.E.N.S.O.N 329830e09b New Goods Receipt Status Dashboard 3 kuukautta sitten
  B.E.N.S.O.N 35ee724b0f Merge remote-tracking branch 'origin/MergeProblem1' into MergeProblem1 3 kuukautta sitten
  B.E.N.S.O.N dc4767c312 New Goods Receipt Status Dashboard 3 kuukautta sitten
  Tommy\2Fi-Staff f650492e27 reportconfig update 3 kuukautta sitten
  Tommy\2Fi-Staff 0cf603a7e1 add handler filter 3 kuukautta sitten
  CANCERYS\kw093 2b3752d64f update 3 kuukautta sitten
  CANCERYS\kw093 09e8bdff0d update pucahseorder speed 3 kuukautta sitten
  Tommy\2Fi-Staff 89b0effbf4 trucklane dashboard update 3 kuukautta sitten
  CANCERYS\kw093 131893efa0 stock take input fix 3 kuukautta sitten
  B.E.N.S.O.N c81aed0950 User QR-Code Update 3 kuukautta sitten
  B.E.N.S.O.N 526058cbb9 update 3 kuukautta sitten
  CANCERYS\kw093 33cf1752b4 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 3 kuukautta sitten
  CANCERYS\kw093 1ee123ddb5 auto stock in "%FA%" and stock record page fix 3 kuukautta sitten
  B.E.N.S.O.N 9ead9d244e Bom Supporting Function 3 kuukautta sitten
  B.E.N.S.O.N 5c243b376b Merge remote-tracking branch 'origin/MergeProblem1' into MergeProblem1 3 kuukautta sitten
  B.E.N.S.O.N 66061a5837 update 3 kuukautta sitten
  CANCERYS\kw093 59b4a88735 update bag 3 kuukautta sitten
  [email protected] 2da62e9bc7 no message 3 kuukautta sitten
  CANCERYS\kw093 bcadb14423 fix stock reocrd 3 kuukautta sitten
  CANCERYS\kw093 a4a4075087 update confirm 3 kuukautta sitten
  CANCERYS\kw093 2040ef798e Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 3 kuukautta sitten
  CANCERYS\kw093 a40305f880 update stock take 3 kuukautta sitten
  [email protected] 2de29a9a8c make access right with STOCK can do stock take 3 kuukautta sitten
  kelvin.yau ae3fa7993c translation issue 3 kuukautta sitten
  kelvin.yau 26abb13a6c Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/derek/FPSMS-frontend into MergeProblem1 3 kuukautta sitten
  kelvin.yau 72b92cc6e7 stock adj + stocktrf 3 kuukautta sitten
  [email protected] b0e5aaa72a no message 3 kuukautta sitten
  CANCERYS\kw093 7e831edcf3 update jo,po,i18n 3 kuukautta sitten
  CANCERYS\kw093 42ee4a6d92 update 3 kuukautta sitten
  [email protected] 3236f144cd fix the /ps overlap problem 3 kuukautta sitten
  [email protected] f17ed17f87 no message 3 kuukautta sitten
  CANCERYS\kw093 7c93b9f880 Merge remote-tracking branch 'origin/MergeProblem1' into MergeProblem1 3 kuukautta sitten
  CANCERYS\kw093 0d0a05ed55 update zh 3 kuukautta sitten
  Tommy\2Fi-Staff a9833d424a translation & alignment 3 kuukautta sitten
  CANCERYS\kw093 eaa9477faa update jobmatch 3 kuukautta sitten
  [email protected] febf75eb38 it says it can control the popup keyboard size in tablet 3 kuukautta sitten
  B.E.N.S.O.N eac95c343c update 3 kuukautta sitten
  [email protected] 765491197f no message 3 kuukautta sitten
  [email protected] f0ddd56381 changed the look and feel slightly 3 kuukautta sitten
  PC-20260115JRSN\Administrator 3579a83ff7 no message 3 kuukautta sitten
  PC-20260115JRSN\Administrator b0356b7a8a Fix the files that make project failed to compile 3 kuukautta sitten
  B.E.N.S.O.N 0eb0936e45 Update 4 kuukautta sitten
  CANCERYS\kw093 1544f3f653 Merge remote-tracking branch 'origin/MergeProblem1' into MergeProblem1 4 kuukautta sitten
  CANCERYS\kw093 eb9714a79b update 4 kuukautta sitten
  B.E.N.S.O.N d726d933b5 Report Page Update 4 kuukautta sitten
  Tommy\2Fi-Staff 1059b8770a update 4 kuukautta sitten
  CANCERYS\kw093 e8ef71601f update 4 kuukautta sitten
  CANCERYS\kw093 ca8b3ea050 update 4 kuukautta sitten
  CANCERYS\kw093 e5feedc2a7 update 4 kuukautta sitten
  CANCERYS\kw093 263d12e248 update job pick dashboard 4 kuukautta sitten
  CANCERYS\kw093 d56cd6e69f update 4 kuukautta sitten
  Tommy\2Fi-Staff b320307a51 update 4 kuukautta sitten
  Tommy\2Fi-Staff 3303de63d7 update search sorting 4 kuukautta sitten
  Tommy\2Fi-Staff 4446c8503f Update StockBalanceReport & StockInTracabilityReport 4 kuukautta sitten
  B.E.N.S.O.N 8b3f8fc6e9 Report Update 4 kuukautta sitten
  Tommy\2Fi-Staff 6479034e62 TruckScheduleDashboard & StockInTraceability report update 4 kuukautta sitten
  B.E.N.S.O.N 6d9ec7b372 Report Update 4 kuukautta sitten
  CANCERYS\kw093 cb4c0aa11f Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 4 kuukautta sitten
  CANCERYS\kw093 f408aba874 update 4 kuukautta sitten
  kelvin.yau 754ef92046 translation 4 kuukautta sitten
  CANCERYS\kw093 316d2fcdb1 update 4 kuukautta sitten
  B.E.N.S.O.N e7c273ba0e Stock Item Consumption Trend Report 4 kuukautta sitten
  CANCERYS\kw093 a71f0cc9a9 Merge remote-tracking branch 'origin/MergeProblem1' into MergeProblem1 4 kuukautta sitten
  CANCERYS\kw093 c06ee2e543 update 4 kuukautta sitten
  kelvin.yau 28fe834ab0 enson update 4 kuukautta sitten
  B.E.N.S.O.N 8987046f00 Dashboard: Goods Receipt Status Update 4 kuukautta sitten
  B.E.N.S.O.N 329ccc22bd FG/SemiFG Production Analysis Report Update 4 kuukautta sitten
  CANCERYS\kw093 bdde9644f0 Merge remote-tracking branch 'origin/MergeProblem1' into MergeProblem1 4 kuukautta sitten
  CANCERYS\kw093 5e6a440aae update dashBoard 4 kuukautta sitten
  kelvin.yau 626a13ee60 Stock TRF UI update 4 kuukautta sitten
  B.E.N.S.O.N 1600995bc1 FG/SemiFG Production Analysis Report Update 4 kuukautta sitten
  CANCERYS\kw093 4f4a5baf75 update 4 kuukautta sitten
  B.E.N.S.O.N a3c07650f8 FG/SemiFG Production Analysis Report 4 kuukautta sitten
  CANCERYS\kw093 757ccc5cbd update select unit 4 kuukautta sitten
  CANCERYS\kw093 a0675af6e0 upate select unit 4 kuukautta sitten
  CANCERYS\kw093 b006a1115c update 4 kuukautta sitten
  CANCERYS\kw093 e3f2b06561 update pick record user and putaway default warehouse 4 kuukautta sitten
  CANCERYS\kw093 3501863943 update 4 kuukautta sitten
  CANCERYS\kw093 8cbbdf5714 update 4 kuukautta sitten
  [email protected] bdf7d52cd9 no message 4 kuukautta sitten
  [email protected] fc398b038b no message 4 kuukautta sitten
  [email protected] f747984479 make some chinese looks better 4 kuukautta sitten
  CANCERYS\kw093 30823cee8e update scan lot 4 kuukautta sitten
  CANCERYS\kw093 26302151c3 update qc putaway 4 kuukautta sitten
  Tommy\2Fi-Staff 53cc1692ad fix fg goods status dasboard bug 4 kuukautta sitten
  CANCERYS\kw093 878eaedfb6 update new stokc issue handle 4 kuukautta sitten
  [email protected] b541872d24 no message 4 kuukautta sitten
  CANCERYS\kw093 4fc7e87375 update some jo qr 4 kuukautta sitten
  CANCERYS\kw093 549481e71a benson want remove / 4 kuukautta sitten
  CANCERYS\kw093 4b1ed59261 dashboard 4 kuukautta sitten
  CANCERYS\kw093 468e907db9 update 4 kuukautta sitten
  CANCERYS\kw093 55d9e24f83 update qr code scan 4 kuukautta sitten
  CANCERYS\kw093 c45802fb76 test 4 kuukautta sitten
  CANCERYS\kw093 667cc5f184 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 4 kuukautta sitten
  CANCERYS\kw093 0aedd3b83d update 4 kuukautta sitten
  CANCERYS\kw093 29bdcf6c1a update do pick confirm 4 kuukautta sitten
  CANCERYS\kw093 9e9c8d073c update 4 kuukautta sitten
  CANCERYS\kw093 f807fcee82 update 4 kuukautta sitten
  CANCERYS\kw093 5473ff820d update bar 4 kuukautta sitten
  B.E.N.S.O.N 927485e8d3 Dashboard Page Update 4 kuukautta sitten
  B.E.N.S.O.N feb162ae60 Dashboard: Goods Receipt Status Update 4 kuukautta sitten
  B.E.N.S.O.N b58947b1e5 Dashboard: Goods Receipt Status 4 kuukautta sitten
  CANCERYS\kw093 bb5f3d2584 update do issue form 4 kuukautta sitten
  CANCERYS\kw093 d04e2eeadc update 5 kuukautta sitten
  CANCERYS\kw093 8576172e8e fix scan lot and scan not match lt and new issue handle 5 kuukautta sitten
  CANCERYS\kw093 be2fdb6a3b update 5 kuukautta sitten
  CANCERYS\kw093 3fa46072fd Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 5 kuukautta sitten
  CANCERYS\kw093 7cd450ef1b update printer select 5 kuukautta sitten
  PC-20260115JRSN\Administrator 3930cd7f39 fixing the merged i18 master syn request 5 kuukautta sitten
  CANCERYS\kw093 c02a6956c4 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 5 kuukautta sitten
  CANCERYS\kw093 a32e2b30bc printer 5 kuukautta sitten
  Tommy\2Fi-Staff e317d18821 Stock In Traceability Report 5 kuukautta sitten
  B.E.N.S.O.N 09d269f2b7 Update: Printer Handle 5 kuukautta sitten
  B.E.N.S.O.N 321927854e Supporting function: Printer Handle 5 kuukautta sitten
  CANCERYS\kw093 3c014abbff update approve can 0 5 kuukautta sitten
  CANCERYS\kw093 f903dae3c1 update skip button 5 kuukautta sitten
  CANCERYS\kw093 483577ed0d update do search 5 kuukautta sitten
  B.E.N.S.O.N d09ee3a962 Update 5 kuukautta sitten
  B.E.N.S.O.N e62830e1e2 Merge remote-tracking branch 'origin/MergeProblem1' into MergeProblem1 5 kuukautta sitten
  B.E.N.S.O.N 4702c93a93 path 5 kuukautta sitten
  kelvin.yau 88d1354944 fix 5 kuukautta sitten
  kelvin.yau de2f012c24 stock transfer ui 5 kuukautta sitten
  Tommy\2Fi-Staff cc68dfbb65 update item 5 kuukautta sitten
  [email protected] 363306c98e fixing the ps export path 5 kuukautta sitten
  CANCERYS\kw093 bc5d88699c update page control 5 kuukautta sitten
  CANCERYS\kw093 b24ae5dfea stockissue 5 kuukautta sitten
  CANCERYS\kw093 d7e139dd2c i18n 5 kuukautta sitten
  [email protected] 7ce84920e2 fixing the GET type 5 kuukautta sitten
  [email protected] 30eb8517d1 refining the data syn 5 kuukautta sitten
  Tommy\2Fi-Staff 4cb751740c update shop and truck lazy load 5 kuukautta sitten
  Tommy\2Fi-Staff 289e59d2b5 update missing item, update FG pick status dashboard 5 kuukautta sitten
  [email protected] c48d070a77 refining the m18 import testing params 5 kuukautta sitten
  CANCERYS\kw093 a0febe7794 update qcitem combine page 5 kuukautta sitten
  CANCERYS\kw093 d240e23bab Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 5 kuukautta sitten
  CANCERYS\kw093 8f9e94530e update path 5 kuukautta sitten
  PC-20260115JRSN\Administrator 063faba2e7 adding printer testing for HANS 5 kuukautta sitten
  B.E.N.S.O.N d92242ea2c Dashboard: Goods Receipt Status UI 5 kuukautta sitten
  Tommy\2Fi-Staff d50aebb674 Dashboard ui 5 kuukautta sitten
  B.E.N.S.O.N 1d921e105d Dashboard: Goods Receipt Status UI 5 kuukautta sitten
  Tommy\2Fi-Staff 0008e1471f Missing Item supporting function &report 5 kuukautta sitten
  CANCERYS\kw093 770d569f9b productprocess 5 kuukautta sitten
  CANCERYS\kw093 6aefd923c5 updatestock issue 5 kuukautta sitten
  CANCERYS\kw093 a661b1dfc2 update putasway show 5 kuukautta sitten
  CANCERYS\kw093 1dbe9c67c1 upate i18n 5 kuukautta sitten
  CANCERYS\kw093 8b12ae623b Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 5 kuukautta sitten
  CANCERYS\kw093 1f07b8ea5a update stockissue api 5 kuukautta sitten
  kelvin.yau 2ffa66c4a3 updated inventorylotline table 5 kuukautta sitten
  kelvin.yau 9f635df2eb Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/derek/FPSMS-frontend into MergeProblem1 5 kuukautta sitten
  kelvin.yau e76073f36e test 5 kuukautta sitten
  [email protected] 44d6b8f823 no message 5 kuukautta sitten
100 muutettua tiedostoa jossa 12093 lisäystä ja 664 poistoa
  1. +91
    -0
      .cursor/rules.md
  2. +2
    -1
      .env.development
  3. +3
    -3
      .env.production
  4. +3
    -0
      .gitignore
  5. +952
    -136
      package-lock.json
  6. +4
    -2
      package.json
  7. BIN
      public/PP Staff List v.7.xlsx
  8. +267
    -0
      scripts/update-chart-i18n.js
  9. +72
    -0
      src/app/(main)/MainContentArea.tsx
  10. +40
    -0
      src/app/(main)/MainLayoutBody.tsx
  11. +47
    -0
      src/app/(main)/axios/AxiosProvider.tsx
  12. +2
    -2
      src/app/(main)/bag/page.tsx
  13. +21
    -0
      src/app/(main)/bagPrint/page.tsx
  14. +51
    -0
      src/app/(main)/chart/_components/ChartCard.tsx
  15. +31
    -0
      src/app/(main)/chart/_components/DateRangeSelect.tsx
  16. +161
    -0
      src/app/(main)/chart/_components/EXCEL_EXPORT_STANDARD.md
  17. +12
    -0
      src/app/(main)/chart/_components/constants.ts
  18. +182
    -0
      src/app/(main)/chart/_components/exportChartToXlsx.ts
  19. +88
    -0
      src/app/(main)/chart/chartBoardRefreshPrefs.ts
  20. +468
    -0
      src/app/(main)/chart/delivery/page.tsx
  21. +535
    -0
      src/app/(main)/chart/equipment/board/page.tsx
  22. +309
    -0
      src/app/(main)/chart/forecast/page.tsx
  23. +1047
    -0
      src/app/(main)/chart/joborder/board/page.tsx
  24. +386
    -0
      src/app/(main)/chart/joborder/page.tsx
  25. +13
    -0
      src/app/(main)/chart/layout.tsx
  26. +5
    -0
      src/app/(main)/chart/page.tsx
  27. +1309
    -0
      src/app/(main)/chart/process/board/page.tsx
  28. +54
    -0
      src/app/(main)/chart/purchase/exportPurchaseChartMaster.ts
  29. +1129
    -0
      src/app/(main)/chart/purchase/page.tsx
  30. +61
    -0
      src/app/(main)/chart/useChartBoardRefreshPrefs.ts
  31. +360
    -0
      src/app/(main)/chart/warehouse/page.tsx
  32. +1
    -1
      src/app/(main)/dashboard/page.tsx
  33. +36
    -0
      src/app/(main)/do copy 2/edit/page.tsx
  34. +35
    -0
      src/app/(main)/do copy 2/page.tsx
  35. +36
    -0
      src/app/(main)/do copy/edit/page.tsx
  36. +29
    -0
      src/app/(main)/do copy/page.tsx
  37. +20
    -22
      src/app/(main)/do/edit/page.tsx
  38. +3
    -9
      src/app/(main)/do/page.tsx
  39. +46
    -0
      src/app/(main)/doworkbench/edit/page.tsx
  40. +25
    -0
      src/app/(main)/doworkbench/page.tsx
  41. +6
    -0
      src/app/(main)/doworkbench/pick/page.tsx
  42. +31
    -0
      src/app/(main)/doworkbenchsearch/page.tsx
  43. +4
    -4
      src/app/(main)/finishedGood/detail/page.tsx
  44. +30
    -0
      src/app/(main)/finishedGood/management/page.tsx
  45. +3
    -3
      src/app/(main)/finishedGood/page.tsx
  46. +2
    -2
      src/app/(main)/inventory/page.tsx
  47. +14
    -0
      src/app/(main)/isFullBleedMainRoute.ts
  48. +7
    -0
      src/app/(main)/isPoWorkbenchRoute.ts
  49. +34
    -35
      src/app/(main)/jo/edit/page.tsx
  50. +41
    -30
      src/app/(main)/jo/page.tsx
  51. +21
    -0
      src/app/(main)/jo/testing/page.tsx
  52. +30
    -0
      src/app/(main)/jo/workbench/page.tsx
  53. +31
    -30
      src/app/(main)/jodetail/edit/page.tsx
  54. +21
    -30
      src/app/(main)/jodetail/page.tsx
  55. +21
    -0
      src/app/(main)/laserPrint/page.tsx
  56. +16
    -26
      src/app/(main)/layout.tsx
  57. +13
    -0
      src/app/(main)/m18Syn/layout.tsx
  58. +415
    -0
      src/app/(main)/m18Syn/page.tsx
  59. +1
    -1
      src/app/(main)/pickOrder/detail/page.tsx
  60. +2
    -2
      src/app/(main)/pickOrder/page.tsx
  61. +1
    -1
      src/app/(main)/po/edit/page.tsx
  62. +1
    -1
      src/app/(main)/po/page.tsx
  63. +20
    -0
      src/app/(main)/po/workbench/PoWorkbenchPageClient.tsx
  64. +32
    -0
      src/app/(main)/po/workbench/layout.tsx
  65. +25
    -0
      src/app/(main)/po/workbench/page.tsx
  66. +2
    -2
      src/app/(main)/production/page.tsx
  67. +6
    -6
      src/app/(main)/productionProcess/page.tsx
  68. +2
    -2
      src/app/(main)/projects/create/page.tsx
  69. +2
    -2
      src/app/(main)/projects/page.tsx
  70. +13
    -0
      src/app/(main)/ps/layout.tsx
  71. +1273
    -219
      src/app/(main)/ps/page.tsx
  72. +1
    -1
      src/app/(main)/putAway/page.tsx
  73. +37
    -0
      src/app/(main)/putAwayCam/page.tsx
  74. +70
    -0
      src/app/(main)/report/GRN_REPORT_BACKEND_SPEC.md
  75. +189
    -0
      src/app/(main)/report/ReportSelectionDashboard.tsx
  76. +209
    -0
      src/app/(main)/report/SemiFGProductionAnalysisReport.tsx
  77. +205
    -0
      src/app/(main)/report/bomShopSyncReportApi.ts
  78. +238
    -0
      src/app/(main)/report/grnReportApi.ts
  79. +26
    -0
      src/app/(main)/report/layout.tsx
  80. +628
    -67
      src/app/(main)/report/page.tsx
  81. +38
    -0
      src/app/(main)/report/reportCategories.ts
  82. +141
    -0
      src/app/(main)/report/semiFGProductionAnalysisApi.ts
  83. +84
    -0
      src/app/(main)/report/truckRoutingSummaryApi.ts
  84. +1
    -1
      src/app/(main)/scheduling/detailed/edit/page.tsx
  85. +1
    -1
      src/app/(main)/scheduling/detailed/page.tsx
  86. +1
    -1
      src/app/(main)/scheduling/rough/edit/page.tsx
  87. +1
    -1
      src/app/(main)/scheduling/rough/page.tsx
  88. +25
    -0
      src/app/(main)/settings/bomWeighting/page.tsx
  89. +20
    -0
      src/app/(main)/settings/clientMonitor/page.tsx
  90. +21
    -0
      src/app/(main)/settings/deliveryOrderFloor/page.tsx
  91. +1
    -1
      src/app/(main)/settings/equipment/EquipmentTabs.tsx
  92. +3
    -3
      src/app/(main)/settings/equipment/MaintenanceEdit/page.tsx
  93. +2
    -2
      src/app/(main)/settings/equipment/create/page.tsx
  94. +3
    -3
      src/app/(main)/settings/equipment/edit/page.tsx
  95. +3
    -3
      src/app/(main)/settings/equipment/page.tsx
  96. +2
    -2
      src/app/(main)/settings/equipmentType/create/page.tsx
  97. +3
    -3
      src/app/(main)/settings/equipmentType/edit/page.tsx
  98. +3
    -3
      src/app/(main)/settings/equipmentType/page.tsx
  99. +52
    -0
      src/app/(main)/settings/importBom/EquipmentTabs.tsx
  100. +29
    -0
      src/app/(main)/settings/importBom/MaintenanceEdit/page.tsx

+ 91
- 0
.cursor/rules.md Näytä tiedosto

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

+ 2
- 1
.env.development Näytä tiedosto

@@ -1,3 +1,4 @@
API_URL=http://localhost:8090/api
NEXTAUTH_SECRET=secret
NEXT_PUBLIC_API_URL=http://localhost:8090/api
NEXT_PUBLIC_API_URL=http://localhost:8090/api
NEXT_PUBLIC_MONITORING_ENABLED=false

+ 3
- 3
.env.production Näytä tiedosto

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

+ 3
- 0
.gitignore Näytä tiedosto

@@ -37,6 +37,9 @@ next-env.d.ts

.vscode

# Cursor (local-only rules)
.cursor/rules/local/

#fpsms.zip
fpsms.zip



+ 952
- 136
package-lock.json
File diff suppressed because it is too large
Näytä tiedosto


+ 4
- 2
package.json Näytä tiedosto

@@ -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,9 @@
"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",
"xlsx-js-style": "^1.2.0"
},
"devDependencies": {
"@types/lodash": "^4.14.202",


BIN
public/PP Staff List v.7.xlsx Näytä tiedosto


+ 267
- 0
scripts/update-chart-i18n.js Näytä tiedosto

@@ -0,0 +1,267 @@
const fs = require('fs');
const path = require('path');

const en = {
"pageTitle_delivery": "Delivery & Dispatch",
"pageTitle_jobOrder": "Job Orders",
"pageTitle_forecast": "Forecast & Planning",
"pageTitle_warehouse": "Inventory & Warehouse",
"pageTitle_equipmentBoard": "Equipment Usage Board",
"pageTitle_processBoard": "Process Real-time Board",
"pageTitle_jobOrderBoard": "Job Order Real-time Board",
"pageTitle_purchase": "Purchase",

"all": "All",
"noData": "No data",
"exportExcel": "Export Excel",
"show": "Show",
"laneX": "Lane X",
"today": "Today",
"yesterday": "Yesterday",
"selectDate": "Select Date",
"refresh": "Refresh",
"otherBoards": "Other Boards",
"autoRefresh": "Auto Refresh",
"on": "On",
"off": "Off",
"intervalSeconds": "Interval (sec)",
"minutes": "minutes",
"minutesWithVal": "{{val}} min",
"minuteAbbr": "min",

"delivery_staffPerfDateError": "Staff performance start date cannot be later than end date",
"delivery_ordersByDate": "Delivery Orders by Date",
"delivery_ordersByDate_export": "Delivery_Orders_By_Date",
"delivery_orderCount": "Order Count",
"delivery_topItemsByCount": "Top Items by Delivery Count",
"delivery_item": "Item",
"delivery_itemPlaceholder": "Leave empty for all",
"delivery_staffPerformanceTitle": "Staff Delivery Performance (Daily Pick Count & Duration)",
"delivery_startDate": "Start Date",
"delivery_endDate": "End Date",
"delivery_store": "Store",
"delivery_staff": "Staff",
"delivery_staffPlaceholder": "Leave empty for all",
"delivery_staffPerfCaption": "Per-person pick count & total duration for period",
"delivery_colStaff": "Staff",
"delivery_colPickCount": "Pick Count",
"delivery_colTotalMin": "Total Min",
"delivery_colAvgMin": "Avg Min/Order",
"delivery_dailyByStaff": "Daily by Staff",
"delivery_noDataDesc": "No delivery data available for the selected period.",

"jo_byStatus": "Job Orders by Status",
"jo_byStatus_export": "Job_Orders_By_Status",
"jo_datePlanStart": "Date (Plan Start)",
"jo_createdVsCompleted": "Job Orders Created vs Completed by Date",
"jo_createdVsCompleted_export": "Job_Orders_Created_vs_Completed",
"jo_detailSection": "Job Order Material / Process / Equipment",
"jo_materialPendingPicked": "Material Pending / Picked (by Plan Date)",
"jo_materialPendingPicked_export": "Material_Pending_vs_Picked",
"jo_processPendingCompleted": "Process Pending / Completed (by Plan Date)",
"jo_processPendingCompleted_export": "Process_Pending_vs_Completed",
"jo_equipmentWorkingWorked": "Equipment In Use / Used (by Job Order)",
"jo_equipmentWorkingWorked_export": "Equipment_In_Use_vs_Used",

"board_jobOrderLive": "Job Order Live Board",
"board_equipmentUsage": "Equipment Usage Board",
"board_processLive": "Process Live Board",
"board_jobOrderChart": "Job Order Chart",

"forecast_plannedOutputByDate": "Planned Daily Output by Item (Forecast)",
"forecast_plannedOutputByDate_export": "Planned_Daily_Output_By_Item",
"forecast_itemCode": "Item Code",
"forecast_noScheduleData": "No scheduling data for this date range.",
"forecast_productionSchedule": "Production Schedule by Date (Estimated Output)",
"forecast_productionSchedule_export": "Production_Schedule_By_Date",

"warehouse_stockTxnByDate": "Stock Transactions by Date (In / Out / Total)",
"warehouse_stockTxnByDate_export": "Stock_Transactions_By_Date",
"warehouse_stockInOutByDate": "Stock In vs Out by Date",
"warehouse_stockInOutByDate_export": "Stock_In_vs_Out",
"warehouse_balanceTrend": "Stock Balance Trend",
"warehouse_balanceTrend_export": "Stock_Balance_Trend",
"warehouse_consumptionTrend": "Monthly Consumption Trend (Outbound)",
"warehouse_consumptionTrend_export": "Monthly_Consumption_Trend",
"warehouse_qty": "Qty",
"warehouse_optional": "Optional",
"warehouse_sumAll": "Sum all if empty",
"warehouse_addItem": "Add item to split",
"warehouse_exportFail": "Master export failed",

"equipment_status": "Status",
"equipment_equipment": "Equipment",
"equipment_jobOrder": "Job Order",
"equipment_process": "Process",
"equipment_planStart": "Plan Start",
"equipment_startTime": "Start Time",
"equipment_endTime": "End Time",
"equipment_operator": "Operator",
"equipment_boardTitle": "Equipment Usage Board",
"equipment_infoDescription1": "Shows equipment status in real-time: working, idle, or under maintenance.",
"equipment_infoDescription2": "Each equipment card displays the current job order and process.",
"equipment_infoDescription3": "Equipment with unclosed hours or missing time entries will be flagged.",
"equipment_searchAndList": "Search & List",
"equipment_notToday": "Not Today",
"equipment_unclosedHours": "Equipment hours not closed",
"equipment_missingHours": "Missing equipment hours",
"equipment_completed": "Completed",

"process_notStarted": "Not Started",
"process_inProgress": "In Progress",
"process_completed": "Completed",
"process_nonToday": "Not Today",

"dateRange_lastDays": "Last {{d}} days",

"series_inbound": "Inbound",
"series_outbound": "Outbound",
"series_total": "Total",
"series_balance": "Balance",
"series_consumption": "Consumption",
"series_created": "Created",
"series_completed": "Completed",
"series_month": "Month",

"requestFailed": "Request failed"
};

const zh = {
"pageTitle_delivery": "發貨與配送",
"pageTitle_jobOrder": "工單",
"pageTitle_forecast": "預測與計劃",
"pageTitle_warehouse": "庫存與倉儲",
"pageTitle_equipmentBoard": "設備使用看板",
"pageTitle_processBoard": "工序即時看板",
"pageTitle_jobOrderBoard": "工單即時看板",
"pageTitle_purchase": "採購",

"all": "全部",
"noData": "無數據",
"exportExcel": "匯出 Excel",
"show": "顯示",
"laneX": "車線-X",
"today": "今日",
"yesterday": "昨日",
"selectDate": "選擇日期",
"refresh": "重新整理",
"otherBoards": "其他看板",
"autoRefresh": "自動重新整理",
"on": "開啟",
"off": "關閉",
"intervalSeconds": "間隔(秒)",
"minutes": "分鐘",
"minutesWithVal": "{{val}} 分鐘",
"minuteAbbr": "分",

"delivery_staffPerfDateError": "員工發貨績效的起始日期不能晚於結束日期",
"delivery_ordersByDate": "按日期發貨單數量",
"delivery_ordersByDate_export": "發貨單數量_按日期",
"delivery_orderCount": "單數",
"delivery_topItemsByCount": "發貨數量排行(按物料)",
"delivery_item": "物料",
"delivery_itemPlaceholder": "不選則全部",
"delivery_staffPerformanceTitle": "員工發貨績效(每日揀貨數量與耗時)",
"delivery_startDate": "開始日期",
"delivery_endDate": "結束日期",
"delivery_store": "倉別",
"delivery_staff": "員工",
"delivery_staffPlaceholder": "不選則全部",
"delivery_staffPerfCaption": "週期內每人揀單數及總耗時(首揀至完成)",
"delivery_colStaff": "員工",
"delivery_colPickCount": "揀單數",
"delivery_colTotalMin": "總分鐘",
"delivery_colAvgMin": "平均分鐘/單",
"delivery_dailyByStaff": "每日按員工單數",
"delivery_noDataDesc": "所選期間內暫無發貨記錄,請調整日期範圍後再試。",

"jo_byStatus": "工單按狀態",
"jo_byStatus_export": "工單_按狀態",
"jo_datePlanStart": "日期(計劃開始)",
"jo_createdVsCompleted": "工單創建與完成按日期",
"jo_createdVsCompleted_export": "工單_創建與完成_按日期",
"jo_detailSection": "工單物料/工序/設備",
"jo_materialPendingPicked": "物料待領/已揀(按工單計劃日)",
"jo_materialPendingPicked_export": "物料_待領_已揀",
"jo_processPendingCompleted": "工序待完成/已完成(按工單計劃日)",
"jo_processPendingCompleted_export": "工序_待完成_已完成",
"jo_equipmentWorkingWorked": "設備使用中/已使用(按工單)",
"jo_equipmentWorkingWorked_export": "設備_使用中_已使用",

"board_jobOrderLive": "工單即時看板",
"board_equipmentUsage": "設備使用看板",
"board_processLive": "工序即時看板",
"board_jobOrderChart": "工單圖表",

"forecast_plannedOutputByDate": "按物料計劃日產量(預測)",
"forecast_plannedOutputByDate_export": "計劃日產量_按物料",
"forecast_itemCode": "物料編碼",
"forecast_noScheduleData": "此日期範圍內尚無排程資料。",
"forecast_productionSchedule": "按日期生產排程(預估產量)",
"forecast_productionSchedule_export": "生產排程_按日期",

"warehouse_stockTxnByDate": "按日期庫存流水(入/出/合計)",
"warehouse_stockTxnByDate_export": "庫存流水_按日期",
"warehouse_stockInOutByDate": "按日期入庫與出庫",
"warehouse_stockInOutByDate_export": "入庫_出庫_按日期",
"warehouse_balanceTrend": "庫存餘額趨勢",
"warehouse_balanceTrend_export": "庫存餘額趨勢",
"warehouse_consumptionTrend": "按月考勤消耗趨勢(出庫量)",
"warehouse_consumptionTrend_export": "月考勤消耗趨勢",
"warehouse_qty": "數量",
"warehouse_optional": "可選",
"warehouse_sumAll": "不選則全部合計",
"warehouse_addItem": "新增物料以分項顯示",
"warehouse_exportFail": "總表匯出失敗",

"equipment_status": "狀態",
"equipment_equipment": "設備",
"equipment_jobOrder": "工單",
"equipment_process": "工序",
"equipment_planStart": "工單計劃開始",
"equipment_startTime": "開工時間",
"equipment_endTime": "完工時間",
"equipment_operator": "操作員",
"equipment_boardTitle": "設備使用看板",
"equipment_infoDescription1": "即時顯示設備狀態:使用中、閒置或維護中。",
"equipment_infoDescription2": "每張設備卡片顯示當前工單和工序。",
"equipment_infoDescription3": "設備工時未結案或未填寫將被標記。",
"equipment_searchAndList": "查詢與列表",
"equipment_notToday": "非今日",
"equipment_unclosedHours": "設備工時未結案",
"equipment_missingHours": "未填設備工時",
"equipment_completed": "已完工",

"process_notStarted": "未開工",
"process_inProgress": "進行中",
"process_completed": "已完工",
"process_nonToday": "非今日",

"dateRange_lastDays": "最近 {{d}} 天",

"series_inbound": "入庫",
"series_outbound": "出庫",
"series_total": "合計",
"series_balance": "餘額",
"series_consumption": "消耗",
"series_created": "新建",
"series_completed": "完成",
"series_month": "月份",

"requestFailed": "請求失敗"
};

const i18nDir = path.join(__dirname, '..', 'src', 'i18n');
const enPath = path.join(i18nDir, 'en', 'chart.json');
const zhPath = path.join(i18nDir, 'zh', 'chart.json');

// Sort keys alphabetically
const sortKeys = (obj) => {
const sorted = {};
Object.keys(obj).sort().forEach(k => { sorted[k] = obj[k]; });
return sorted;
};

fs.writeFileSync(enPath, JSON.stringify(sortKeys(en), null, 2) + '\n');
fs.writeFileSync(zhPath, JSON.stringify(sortKeys(zh), null, 2) + '\n');
console.log('Updated chart.json with', Object.keys(en).length, 'keys for each language');

+ 72
- 0
src/app/(main)/MainContentArea.tsx Näytä tiedosto

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

import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import { usePathname } from "next/navigation";
import { isFullBleedMainRoute } from "@/app/(main)/isFullBleedMainRoute";
import { NAVIGATION_CONTENT_WIDTH } from "@/config/uiConfig";

const MAIN_SURFACE = "min-h-screen bg-slate-50 dark:bg-slate-900";
/**
* Workbench route: fixed height under the AppBar (`100dvh` minus toolbar min-height).
* Avoids `min-h-screen` on `<main>`, which would stack below the bar and introduce body scroll.
*/
/** Height lives in `sx` when full-bleed workbench so it matches MUI flex chain (avoids Tailwind vs % rounding gaps). */
const WORKBENCH_MAIN = "bg-slate-50 dark:bg-slate-900 p-0 overflow-hidden";
const MAIN_PADDING = "p-4 sm:p-4 md:p-6 lg:p-8";

/**
* Wraps authenticated app content in `<main>` with responsive padding.
*
* For the PO Workbench route, padding is removed so the grid can use the full content width
* without applying compensating negative margins.
*/
export default function MainContentArea({
children,
}: {
children: React.ReactNode;
}) {
const pathname = usePathname();
/** True when the active route is PO Workbench (full-bleed main area). */
const fullBleedWorkbench = isFullBleedMainRoute(pathname);

return (
<Box
component="main"
sx={{
marginInlineStart: { xs: 0, xl: NAVIGATION_CONTENT_WIDTH },
...(fullBleedWorkbench
? {
display: "flex",
flexDirection: "column",
boxSizing: "border-box",
flex: 1,
minHeight: 0,
height: "100%",
}
: {}),
}}
className={
fullBleedWorkbench ? WORKBENCH_MAIN : `${MAIN_SURFACE} ${MAIN_PADDING}`
}
>
<Stack
spacing={fullBleedWorkbench ? 0 : 2}
sx={
fullBleedWorkbench
? {
flex: 1,
minHeight: 0,
height: "100%",
overflow: "hidden",
display: "flex",
flexDirection: "column",
}
: undefined
}
>
{children}
</Stack>
</Box>
);
}

+ 40
- 0
src/app/(main)/MainLayoutBody.tsx Näytä tiedosto

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

import { isFullBleedMainRoute } from "@/app/(main)/isFullBleedMainRoute";
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";

type MainLayoutBodyProps = {
appBar: ReactNode;
mainContent: ReactNode;
};

/**
* On `/po/workbench`, wraps the AppBar and main in a `100dvh` flex column so `<main>` can
* use `flex: 1` instead of `calc(100dvh - 56/64px)` (which misses the real AppBar height).
*/
export default function MainLayoutBody({
appBar,
mainContent,
}: MainLayoutBodyProps) {
const pathname = usePathname();
const isWorkbench = isFullBleedMainRoute(pathname);

if (isWorkbench) {
return (
<div className="flex h-[100dvh] min-h-0 w-full flex-col overflow-hidden">
<div className="shrink-0">{appBar}</div>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{mainContent}
</div>
</div>
);
}

return (
<>
{appBar}
{mainContent}
</>
);
}

+ 47
- 0
src/app/(main)/axios/AxiosProvider.tsx Näytä tiedosto

@@ -3,6 +3,12 @@
"use client";

import React, { createContext, useContext, useEffect, useState, useCallback } from "react";
import { getSession } from "next-auth/react";
import { SessionWithTokens } from "@/config/authConfig";
import {
isBackendJwtExpired,
LOGIN_SESSION_EXPIRED_HREF,
} from "@/app/utils/authToken";
import axiosInstance, { SetupAxiosInterceptors } from "./axiosInstance";

const AxiosContext = createContext(axiosInstance);
@@ -29,6 +35,47 @@ export const AxiosProvider: React.FC<{ children: React.ReactNode }> = ({ childre
}
}, []);

/**
* Detect expired/missing backend JWT before user actions (e.g. /report search).
* Sync accessToken from next-auth session into localStorage if missing, then
* redirect to login when the Bearer token is absent or past `exp`.
*/
useEffect(() => {
if (!isHydrated || typeof window === "undefined") return;

let cancelled = false;
(async () => {
try {
let token = localStorage.getItem("accessToken")?.trim() ?? "";

if (!token) {
const session = (await getSession()) as SessionWithTokens | null;
if (cancelled) return;
if (session?.accessToken) {
token = session.accessToken;
localStorage.setItem("accessToken", token);
setAccessToken(token);
}
}

if (!token) {
window.location.href = LOGIN_SESSION_EXPIRED_HREF;
return;
}

if (isBackendJwtExpired(token)) {
window.location.href = LOGIN_SESSION_EXPIRED_HREF;
}
} catch (e) {
console.warn("Auth token check failed", e);
}
})();

return () => {
cancelled = true;
};
}, [isHydrated]);

// Apply token + interceptors
useEffect(() => {
if (accessToken) {


+ 2
- 2
src/app/(main)/bag/page.tsx Näytä tiedosto

@@ -9,7 +9,7 @@ export const metadata: Metadata = {
}

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

return (
<>
@@ -23,7 +23,7 @@ const bagPage: React.FC = async () => {
{t("Bag Usage")}
</Typography>
</Stack>
<I18nProvider namespaces={["jo", "common"]}>
<I18nProvider namespaces={["bagUsage","navigation","common","jo"]}>
<Suspense fallback={<BagSearchWrapper.Loading />}>
<BagSearchWrapper />
</Suspense>


+ 21
- 0
src/app/(main)/bagPrint/page.tsx Näytä tiedosto

@@ -0,0 +1,21 @@
import { I18nProvider } from "@/i18n";
import { Metadata } from "next";
import BagPrintSearch from "@/components/BagPrint/BagPrintSearch";
import { Stack, Typography } from "@mui/material";

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

export default async function BagPrintPage() {
return (
<I18nProvider namespaces={["bagPrint", "navigation", "common"]}>
<Stack direction="row" justifyContent="space-between" flexWrap="wrap" rowGap={2}>
<Typography variant="h4" marginInlineEnd={2}>
打袋機
</Typography>
</Stack>
<BagPrintSearch />
</I18nProvider>
);
}

+ 51
- 0
src/app/(main)/chart/_components/ChartCard.tsx Näytä tiedosto

@@ -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 Näytä tiedosto

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

+ 161
- 0
src/app/(main)/chart/_components/EXCEL_EXPORT_STANDARD.md Näytä tiedosto

@@ -0,0 +1,161 @@
# Excel export standard (FPSMS frontend)

This document defines how **client-side** `.xlsx` exports should look and behave. **Implementation:** `exportChartToXlsx.ts` and `exportMultiSheetToXlsx()` — use these helpers for new reports so styling stays consistent.

## Scope (important)

| Export path | Follows this `.md`? |
|-------------|---------------------|
| **Next.js** builds the file via `exportChartToXlsx` / `exportMultiSheetToXlsx` (e.g. **PO 入倉記錄** / `rep-014`) | **Yes** — rules are enforced in code. |
| **Backend** returns ready-made `.xlsx` or Excel bytes (JasperReports, Apache POI, etc.; most `print-*` report endpoints) | **No — not automatically.** That code does **not** use this TypeScript module. To match the same **look** (grey headers, number formats, alignment), implement equivalent styling in Java/Kotlin or Jasper templates. See the backend companion doc below. |

**Backend companion (visual parity):**
`FPSMS-backend/docs/EXCEL_EXPORT_STANDARD.md` — same *rules*, for POI/Jasper implementers.

---

## 1. Library

| Item | Value |
|------|--------|
| Package | **`xlsx-js-style`** (not the plain `xlsx` community build) |
| Reason | Plain SheetJS **does not persist** cell styles (`fill`, `alignment`, `numFmt`) in the written file. `xlsx-js-style` is a compatible fork that **does**. |

---

## 2. Data shape

- Rows are **`Record<string, unknown>[]`** (array of plain objects).
- **First object’s keys** become the **header row** (column titles). Every row should use the **same keys** in the same order for a rectangular sheet.
- Prefer **real JavaScript `number`** values for amounts where possible; the exporter will apply number formats. Strings that look like numbers (e.g. `"1,234.56"`) are parsed for money columns.

---

## 3. Processing order (per sheet)

After `json_to_sheet(rows)`:

1. **`!cols`** — column width heuristic (see §4).
2. **`applyHeaderRowStyle`** — header row styling (see §5).
3. **`applyMoneyColumnNumberFormats`** — money columns only, data rows (see §6).
4. **`applyNumericColumnRightAlign`** — money + quantity columns, **all rows including header** (see §7).

---

## 4. Column width (`!cols`)

- For each column: `wch = max(12, headerText.length + 4)`.
- Adjust if a report needs fixed widths; default keeps bilingual headers readable.

---

## 5. Header row style (row 0)

Applied to **every** header cell first; numeric columns get alignment overridden in step 4.

| Property | Value |
|----------|--------|
| Font | Bold, black `rgb: "000000"` |
| Fill | Solid, `fgColor: "D9D9D9"` (light grey) |
| Alignment (default) | Horizontal **center**, vertical **center**, `wrapText: true` |

---

## 6. Money / amount columns — number format

**Detection:** header label matches (case-insensitive):

```text
金額 | 單價 | Amount | Unit Price | Total Amount
```

(Also matches bilingual headers that contain these fragments, e.g. `Amount / 金額`, `Unit Price / 單價`, `Total Amount / 金額`.)

**Rules:**

- Applies to **data rows only** (not row 0).
- Excel format string: **`#,##0.00`** (thousands separator + 2 decimals). Stored on the cell as `z`.
- Cell type `t: "n"` for numeric values.
- If the cell is a **string**, commas are stripped and the value is parsed to a number when possible.

**Naming new reports:** use header text that matches the patterns above so columns pick up formatting automatically.

---

## 7. Quantity columns — alignment only

**Detection:** header label matches:

```text
Qty | 數量 | Demand
```

(Covers e.g. `Qty / 數量`, `Demand Qty / 訂單數量`.)

- No default thousands format (quantities may have up to 4 decimals in app code).
- These columns are **right-aligned** together with money columns (see §8).

---

## 8. Alignment — numeric columns (header + data)

**Detection:** union of **money** (§6) and **quantity** (§7) header patterns.

| Alignment | Value |
|-----------|--------|
| Horizontal | **`right`** |
| Vertical | **`center`** |
| `wrapText` | Preserved / defaulted to `true` where applicable |

Existing style objects are **merged** (fill, font from header styling are kept).

---

## 9. Multi-sheet workbook

| Rule | Detail |
|------|--------|
| API | `exportMultiSheetToXlsx({ name, rows }[], filename)` |
| Sheet name length | Truncated to **31** characters (Excel limit) |
| Each sheet | Same pipeline as §3 if `rows.length > 0` |

---

## 10. Empty sheets

If `rows.length === 0`, a minimal sheet is written; no header styling pipeline runs.

---

## 11. Reports using this standard today

| Feature | Location |
|---------|----------|
| Chart export | Uses `exportChartToXlsx` |
| GRN / PO 入倉記錄 (`rep-014`) | `src/app/(main)/report/grnReportApi.ts` — builds row objects, calls `exportChartToXlsx` / `exportMultiSheetToXlsx` |

Most other reports on `/report` download Excel/PDF **generated on the server** (Jasper, etc.). Those **do not** run this TypeScript pipeline; use **`FPSMS-backend/docs/EXCEL_EXPORT_STANDARD.md`** if you want the same visual rules there.

---

## 12. Checklist for new Excel exports

1. Import from **`xlsx-js-style`** only if building sheets manually; otherwise call **`exportChartToXlsx`** or **`exportMultiSheetToXlsx`**.
2. Use **stable header strings** that match §6 / §7 if the column is amount or quantity.
3. Pass **numbers** for amounts when possible.
4. If you need a **new** category (e.g. “Rate”, “折扣”), extend the regex constants in `exportChartToXlsx.ts` and **update this document**.
5. Keep filenames and sheet names user-readable; remember the **31-character** sheet limit.

---

## 13. Related files

| File | Role |
|------|------|
| `exportChartToXlsx.ts` | Single-sheet export + styling pipeline |
| `grnReportApi.ts` | Example: bilingual headers, money values, multi-sheet GRN report |
| `FPSMS-backend/docs/EXCEL_EXPORT_STANDARD.md` | Backend Excel (POI/Jasper) — same *rules*, separate code |

---

*Last aligned with implementation in `exportChartToXlsx.ts` (header fill `#D9D9D9`, money format `#,##0.00`, right-align numeric columns).*

+ 12
- 0
src/app/(main)/chart/_components/constants.ts Näytä tiedosto

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

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

+ 182
- 0
src/app/(main)/chart/_components/exportChartToXlsx.ts Näytä tiedosto

@@ -0,0 +1,182 @@
/**
* Client-side .xlsx export with shared styling (headers, money format, alignment).
* @see ./EXCEL_EXPORT_STANDARD.md — conventions for new Excel exports
*/
import * as XLSX from "xlsx-js-style";

/** Light grey header background + bold text (exported by xlsx-js-style). */
const HEADER_CELL_STYLE: XLSX.CellStyle = {
font: { bold: true, color: { rgb: "000000" } },
fill: {
patternType: "solid",
fgColor: { rgb: "D9D9D9" },
},
alignment: { vertical: "center", horizontal: "center", wrapText: true },
};

function applyHeaderRowStyle(
ws: XLSX.WorkSheet,
columnCount: number
): void {
for (let colIdx = 0; colIdx < columnCount; colIdx++) {
const cellRef = XLSX.utils.encode_cell({ r: 0, c: colIdx });
const cell = ws[cellRef];
if (cell) {
cell.s = { ...HEADER_CELL_STYLE };
}
}
}

/** Headers for money columns (GRN report & similar): thousands separator in Excel. */
const MONEY_COLUMN_HEADER =
/金額|單價|Amount|Unit Price|Total Amount|Total Amount \/ 金額|Amount \/ 金額|Unit Price \/ 單價/i;

/** Quantity / numeric columns (right-align with amounts). */
const QTY_COLUMN_HEADER = /Qty|數量|Demand/i;

function isNumericDataColumnHeader(h: string): boolean {
return MONEY_COLUMN_HEADER.test(h) || QTY_COLUMN_HEADER.test(h);
}

/**
* Apply Excel number format `#,##0.00` to money columns so values show with comma separators.
* Handles numeric cells and pre-formatted strings like "1,234.56".
*/
function applyMoneyColumnNumberFormats(ws: XLSX.WorkSheet, headerLabels: string[]): void {
const moneyColIdx = new Set<number>();
headerLabels.forEach((h, c) => {
if (MONEY_COLUMN_HEADER.test(h)) moneyColIdx.add(c);
});
if (moneyColIdx.size === 0 || !ws["!ref"]) return;

const range = XLSX.utils.decode_range(ws["!ref"]);
for (let r = range.s.r + 1; r <= range.e.r; r++) {
moneyColIdx.forEach((c) => {
const cellRef = XLSX.utils.encode_cell({ r, c });
const cell = ws[cellRef];
if (!cell) return;

if (typeof cell.v === "number" && Number.isFinite(cell.v)) {
cell.t = "n";
cell.z = "#,##0.00";
return;
}
if (typeof cell.v === "string") {
const cleaned = cell.v.replace(/,/g, "").trim();
if (cleaned === "") return;
const n = Number.parseFloat(cleaned);
if (!Number.isNaN(n)) {
cell.v = n;
cell.t = "n";
cell.z = "#,##0.00";
}
}
});
}
}

/** Right-align amount / quantity column headers and data (merge with existing header fill). */
function applyNumericColumnRightAlign(
ws: XLSX.WorkSheet,
headerLabels: string[]
): void {
const numericColIdx = new Set<number>();
headerLabels.forEach((h, c) => {
if (isNumericDataColumnHeader(h)) numericColIdx.add(c);
});
if (numericColIdx.size === 0 || !ws["!ref"]) return;

const range = XLSX.utils.decode_range(ws["!ref"]);
for (let r = range.s.r; r <= range.e.r; r++) {
numericColIdx.forEach((c) => {
const cellRef = XLSX.utils.encode_cell({ r, c });
const cell = ws[cellRef];
if (!cell) return;
const prev = (cell.s || {}) as XLSX.CellStyle;
cell.s = {
...prev,
font: prev.font,
fill: prev.fill,
border: prev.border,
numFmt: prev.numFmt,
alignment: {
...prev.alignment,
horizontal: "right",
vertical: "center",
wrapText: prev.alignment?.wrapText ?? true,
},
};
});
}
}

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

applyHeaderRowStyle(ws, header.length);
applyMoneyColumnNumberFormats(ws, header);
applyNumericColumnRightAlign(ws, header);
}

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

export type MultiSheetSpec = { name: string; rows: Record<string, unknown>[] };

/**
* Export multiple worksheets in one .xlsx file.
* Sheet names are truncated to 31 characters (Excel limit).
*/
export function exportMultiSheetToXlsx(
sheets: MultiSheetSpec[],
filename: string
): void {
const wb = XLSX.utils.book_new();
for (const { name, rows } of sheets) {
const safeName = name.slice(0, 31);
let ws: ReturnType<typeof XLSX.utils.json_to_sheet>;
if (rows.length === 0) {
ws = XLSX.utils.aoa_to_sheet([[]]);
} else {
ws = XLSX.utils.json_to_sheet(rows);
const header = Object.keys(rows[0] ?? {});
if (header.length > 0) {
ws["!cols"] = header.map((h) => ({
wch: Math.max(12, h.length + 4),
}));
applyHeaderRowStyle(ws, header.length);
applyMoneyColumnNumberFormats(ws, header);
applyNumericColumnRightAlign(ws, header);
}
}
XLSX.utils.book_append_sheet(wb, ws, safeName);
}
XLSX.writeFile(wb, `${filename}.xlsx`);
}

+ 88
- 0
src/app/(main)/chart/chartBoardRefreshPrefs.ts Näytä tiedosto

@@ -0,0 +1,88 @@
export type ChartBoardId = "joborder" | "process" | "equipment";

export interface ChartBoardRefreshPrefs {
autoRefreshOn: boolean;
refreshIntervalSec: number;
}

export const CHART_BOARD_REFRESH_INTERVAL_SEC_OPTIONS = [30, 45, 60, 90, 120, 300] as const;
export const CHART_BOARD_DEFAULT_REFRESH_INTERVAL_SEC = 45;

const ALLOWED_INTERVALS = new Set<number>(CHART_BOARD_REFRESH_INTERVAL_SEC_OPTIONS);

function storageKeySession(boardId: ChartBoardId): string {
return `fpsms:chartBoardRefresh:${boardId}`;
}

function storageKeyUser(boardId: ChartBoardId, userKey: string): string {
return `fpsms:chartBoardRefresh:${boardId}:user:${userKey}`;
}

export function sanitizeChartBoardRefreshInterval(sec: number): number {
const n = Number(sec);
if (ALLOWED_INTERVALS.has(n)) return n;
return CHART_BOARD_DEFAULT_REFRESH_INTERVAL_SEC;
}

function parsePrefs(raw: string | null): ChartBoardRefreshPrefs | null {
if (!raw) return null;
try {
const p = JSON.parse(raw) as Partial<ChartBoardRefreshPrefs>;
return {
autoRefreshOn: Boolean(p.autoRefreshOn),
refreshIntervalSec: sanitizeChartBoardRefreshInterval(Number(p.refreshIntervalSec)),
};
} catch {
return null;
}
}

/**
* Logged in: read/write **localStorage** per account key.
* Not logged in: **sessionStorage** for this browser tab/session.
*/
export function loadChartBoardRefreshPrefs(
boardId: ChartBoardId,
userKeyPart: string | undefined,
): ChartBoardRefreshPrefs {
const defaults: ChartBoardRefreshPrefs = {
autoRefreshOn: false,
refreshIntervalSec: CHART_BOARD_DEFAULT_REFRESH_INTERVAL_SEC,
};
if (typeof window === "undefined") {
return defaults;
}
try {
if (userKeyPart) {
const u = parsePrefs(localStorage.getItem(storageKeyUser(boardId, userKeyPart)));
if (u) return u;
}
const s = parsePrefs(sessionStorage.getItem(storageKeySession(boardId)));
if (s) return s;
} catch {
/* ignore quota / private mode */
}
return defaults;
}

export function saveChartBoardRefreshPrefs(
boardId: ChartBoardId,
userKeyPart: string | undefined,
prefs: ChartBoardRefreshPrefs,
): void {
if (typeof window === "undefined") return;
const payload = JSON.stringify({
autoRefreshOn: prefs.autoRefreshOn,
refreshIntervalSec: sanitizeChartBoardRefreshInterval(prefs.refreshIntervalSec),
});
try {
if (userKeyPart) {
localStorage.setItem(storageKeyUser(boardId, userKeyPart), payload);
sessionStorage.removeItem(storageKeySession(boardId));
} else {
sessionStorage.setItem(storageKeySession(boardId), payload);
}
} catch {
/* ignore */
}
}

+ 468
- 0
src/app/(main)/chart/delivery/page.tsx Näytä tiedosto

@@ -0,0 +1,468 @@
"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 LocalShipping from "@mui/icons-material/LocalShipping";
import {
fetchDeliveryOrderByDate,
fetchTopDeliveryItems,
fetchTopDeliveryItemsItemOptions,
fetchStaffDeliveryPerformance,
fetchStaffDeliveryPerformanceHandlers,
type StaffDeliveryPerformanceStoreFilter,
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";
import SafeApexCharts from "@/components/charts/SafeApexCharts";

const PAGE_TITLE = "發貨與配送";

const STAFF_PERF_STORE_FILTER_OPTIONS: {
value: StaffDeliveryPerformanceStoreFilter;
label: string;
}[] = [
{ value: "all", label: "全部" },
{ value: "2/F", label: "2/F" },
{ value: "4/F", label: "4/F" },
{ value: "null_only", label: "車線-X" },
];

type Criteria = {
delivery: { rangeDays: number };
topItems: { rangeDays: number; limit: number };
staffPerf: {
rangeDays: number;
startDate: string;
endDate: string;
storeFilter: StaffDeliveryPerformanceStoreFilter;
};
};

const defaultStaffPerfDateRange = toDateRange(DEFAULT_RANGE_DAYS);

const defaultCriteria: Criteria = {
delivery: { rangeDays: DEFAULT_RANGE_DAYS },
topItems: { rangeDays: DEFAULT_RANGE_DAYS, limit: 10 },
staffPerf: {
rangeDays: DEFAULT_RANGE_DAYS,
startDate: defaultStaffPerfDateRange.startDate,
endDate: defaultStaffPerfDateRange.endDate,
storeFilter: "all",
},
};

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 s = criteria.staffPerf.startDate;
const e = criteria.staffPerf.endDate;
if (!s || !e) {
setChartData((prev) => ({ ...prev, staffPerf: [] }));
return;
}
if (s > e) {
setError("員工發貨績效的起始日期不能晚於結束日期");
setChartData((prev) => ({ ...prev, staffPerf: [] }));
return;
}
const staffNos = staffSelected.length > 0 ? staffSelected.map((o) => o.staffNo) : undefined;
setChartLoading("staffPerf", true);
fetchStaffDeliveryPerformance(s, e, staffNos, criteria.staffPerf.storeFilter)
.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} />
) : (
<SafeApexCharts
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} />
) : (
<SafeApexCharts
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) => {
const { startDate, endDate } = toDateRange(v);
return { ...c, rangeDays: v, startDate, endDate };
})
}
/>
<TextField
size="small"
label="開始日期"
type="date"
value={criteria.staffPerf.startDate}
onChange={(e) =>
updateCriteria("staffPerf", (c) => ({ ...c, startDate: e.target.value }))
}
InputLabelProps={{ shrink: true }}
/>
<TextField
size="small"
label="結束日期"
type="date"
value={criteria.staffPerf.endDate}
onChange={(e) =>
updateCriteria("staffPerf", (c) => ({ ...c, endDate: e.target.value }))
}
InputLabelProps={{ shrink: true }}
/>
<FormControl size="small" sx={{ minWidth: 120 }}>
<InputLabel>倉別</InputLabel>
<Select
label="倉別"
value={criteria.staffPerf.storeFilter}
onChange={(e) =>
updateCriteria("staffPerf", (c) => ({
...c,
storeFilter: e.target.value as StaffDeliveryPerformanceStoreFilter,
}))
}
>
{STAFF_PERF_STORE_FILTER_OPTIONS.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>
{opt.label}
</MenuItem>
))}
</Select>
</FormControl>
<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>
<SafeApexCharts
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>
);
}

+ 535
- 0
src/app/(main)/chart/equipment/board/page.tsx Näytä tiedosto

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

import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Box,
Typography,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
TextField,
Alert,
CircularProgress,
Stack,
Button,
Chip,
Tooltip,
FormControl,
FormControlLabel,
InputLabel,
MenuItem,
Select,
Switch,
} from "@mui/material";
import { alpha, useTheme } from "@mui/material/styles";
import Link from "next/link";
import dayjs from "dayjs";
import Microwave from "@mui/icons-material/Microwave";
import AccountTree from "@mui/icons-material/AccountTree";
import FilterAltOutlined from "@mui/icons-material/FilterAltOutlined";
import { fetchEquipmentUsageBoard, type EquipmentUsageBoardRow } from "@/app/api/chart/client";
import SafeApexCharts from "@/components/charts/SafeApexCharts";
import { CHART_BOARD_REFRESH_INTERVAL_SEC_OPTIONS } from "@/app/(main)/chart/chartBoardRefreshPrefs";
import { useChartBoardRefreshPrefs } from "@/app/(main)/chart/useChartBoardRefreshPrefs";

const EQUIPMENT_CHART_MAX = 35;

/** Stable key for grouping / filter (master equipment id or free-text label). */
function rowEquipmentKey(r: EquipmentUsageBoardRow): string {
if (r.equipmentId > 0) return `id:${r.equipmentId}`;
const c = (r.equipmentCode ?? "").trim();
const n = (r.equipmentName ?? "").trim();
return `txt:${c}\u0001${n}`;
}

/** Single display line when code/name may duplicate (equipment or process). */
function formatCodeNameLine(code: string, name: string): string {
const c = (code ?? "").trim();
const n = (name ?? "").trim();
if (!c && !n) return "—";
if (!c) return n;
if (!n) return c;
if (c.toLowerCase() === n.toLowerCase()) return n;
if (n.toLowerCase().startsWith(`${c.toLowerCase()} `) || n.toLowerCase().startsWith(`${c.toLowerCase()} `)) return n;
if (n.length > c.length && n.toLowerCase().startsWith(c.toLowerCase())) {
const after = n.slice(c.length, c.length + 1);
if (/[\s\-–—::·.|//]/.test(after)) return n;
}
return `${c} ${n}`;
}

function formatUsageMinutes(m: number): string {
if (!Number.isFinite(m) || m <= 0) return "—";
const rounded = Math.round(m * 10) / 10;
if (rounded < 60) return `${rounded} 分`;
const h = Math.floor(rounded / 60);
const mm = Math.round((rounded % 60) * 10) / 10;
return mm > 0 ? `${h} 小時 ${mm} 分` : `${h} 小時`;
}

export default function EquipmentUsageBoardPage() {
const theme = useTheme();
/** Calendar day for API (local). Default: today. */
const [viewDate, setViewDate] = useState(() => dayjs().format("YYYY-MM-DD"));
const [rows, setRows] = useState<EquipmentUsageBoardRow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState("");
/** null = all equipment; otherwise rowEquipmentKey */
const [selectedEquipmentKey, setSelectedEquipmentKey] = useState<string | null>(null);
const { autoRefreshOn, setAutoRefreshOn, refreshIntervalSec, setRefreshIntervalSec } =
useChartBoardRefreshPrefs("equipment");

const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await fetchEquipmentUsageBoard(viewDate);
setRows(data);
setLastUpdated(dayjs().format("YYYY-MM-DD HH:mm:ss"));
} catch (e) {
setError(e instanceof Error ? e.message : "Request failed");
} finally {
setLoading(false);
}
}, [viewDate]);

useEffect(() => {
void load();
}, [load]);

useEffect(() => {
if (!autoRefreshOn || refreshIntervalSec < 10) return;
const t = setInterval(() => void load(), refreshIntervalSec * 1000);
return () => clearInterval(t);
}, [load, autoRefreshOn, refreshIntervalSec]);

useEffect(() => {
setSelectedEquipmentKey(null);
}, [viewDate]);

useEffect(() => {
const onKey = (ev: KeyboardEvent) => {
const t = ev.target as HTMLElement | null;
if (
t &&
(t.tagName === "INPUT" ||
t.tagName === "TEXTAREA" ||
t.tagName === "SELECT" ||
t.isContentEditable)
) {
return;
}
if (ev.ctrlKey || ev.metaKey || ev.altKey) return;
if (ev.key === "t" || ev.key === "T") {
ev.preventDefault();
setViewDate(dayjs().format("YYYY-MM-DD"));
}
if (ev.key === "y" || ev.key === "Y") {
ev.preventDefault();
setViewDate(dayjs().subtract(1, "day").format("YYYY-MM-DD"));
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);

const equipmentUsageChart = useMemo(() => {
const map = new Map<string, { key: string; label: string; minutes: number }>();
rows.forEach((r) => {
const k = rowEquipmentKey(r);
const label = formatCodeNameLine(r.equipmentCode, r.equipmentName);
const add = Number.isFinite(r.usageMinutes) ? r.usageMinutes : 0;
const cur = map.get(k) ?? { key: k, label, minutes: 0 };
cur.minutes += add;
map.set(k, cur);
});
const list = Array.from(map.values())
.filter((x) => x.minutes > 0)
.sort((a, b) => b.minutes - a.minutes)
.slice(0, EQUIPMENT_CHART_MAX);
return {
keys: list.map((x) => x.key),
categories: list.map((x) => (x.label.length > 34 ? `${x.label.slice(0, 32)}…` : x.label)),
data: list.map((x) => Math.round(x.minutes * 10) / 10),
};
}, [rows]);

const barClickRef = useRef<(index: number) => void>(() => {});
barClickRef.current = (index: number) => {
const key = equipmentUsageChart.keys[index];
if (key == null) return;
setSelectedEquipmentKey((prev) => (prev === key ? null : key));
};

const barOptions = useMemo(
() => ({
chart: {
type: "bar" as const,
toolbar: { show: false },
events: {
dataPointSelection: (_e: unknown, _ctx: unknown, cfg: { dataPointIndex?: number }) => {
const i = cfg?.dataPointIndex;
if (typeof i !== "number" || i < 0) return;
barClickRef.current(i);
},
},
},
plotOptions: {
bar: {
horizontal: true,
barHeight: "72%",
borderRadius: 4,
distributed: true,
},
},
colors: ["#1976d2", "#0288d1", "#0097a7", "#00838f", "#00695c", "#2e7d32", "#558b2f", "#827717"],
dataLabels: { enabled: true, formatter: (val: number) => (Number.isFinite(val) ? `${val} 分` : "") },
xaxis: { categories: equipmentUsageChart.categories, title: { text: "分鐘" } },
yaxis: { labels: { maxWidth: 260 } },
legend: { show: false },
tooltip: { y: { formatter: (val: number) => `${val} 分鐘` } },
}),
[equipmentUsageChart.categories],
);

const displayRows = useMemo(() => {
if (!selectedEquipmentKey) return rows;
return rows.filter((r) => rowEquipmentKey(r) === selectedEquipmentKey);
}, [rows, selectedEquipmentKey]);

const selectedLabel = useMemo(() => {
if (!selectedEquipmentKey) return "";
const hit = rows.find((r) => rowEquipmentKey(r) === selectedEquipmentKey);
return hit ? formatCodeNameLine(hit.equipmentCode, hit.equipmentName) : selectedEquipmentKey;
}, [rows, selectedEquipmentKey]);

const stats = useMemo(() => {
const working = displayRows.filter((r) => r.workingNow === 1).length;
const eqKeys = new Set(displayRows.map((r) => rowEquipmentKey(r)));
const totalMins = displayRows.reduce((s, r) => s + (Number.isFinite(r.usageMinutes) ? r.usageMinutes : 0), 0);
return { working, sessions: displayRows.length, equipmentTouched: eqKeys.size, totalMins };
}, [displayRows]);

const isToday = viewDate === dayjs().format("YYYY-MM-DD");
const weekdayZh = ["日", "一", "二", "三", "四", "五", "六"][dayjs(viewDate).day()] ?? "";

return (
<Box
sx={{
width: "100%",
maxWidth: "100%",
mx: "auto",
p: { xs: 0.5, sm: 1 },
boxSizing: "border-box",
}}
>
<Typography variant="h5" sx={{ mb: 1, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}>
<Microwave /> 設備使用看板
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
資料來源與<strong>工單編輯/工藝流程</strong>一致。上方<strong>使用時間(分鐘)</strong>為各設備當日明細加總(有起訖則相減;產線 Pass/無完工時間時用預設生產分鐘)。
點<strong>長條圖</strong>可篩選下方列表,再點同一項取消。
<strong> 快捷鍵</strong>(不在輸入框內時):<kbd style={{ padding: "1px 6px", borderRadius: 4, border: "1px solid #ccc" }}>T</kbd>{" "}
今日、<kbd style={{ padding: "1px 6px", borderRadius: 4, border: "1px solid #ccc" }}>Y</kbd> 昨日。
{autoRefreshOn
? ` 已開啟自動重新整理(每 ${refreshIntervalSec} 秒)`
: " 預設不自動更新,可開啟「自動重新整理」並選擇間隔。"}
{" 設定自動儲存在本機(登入時依帳號;未登入則為此分頁工作階段)。"}
{lastUpdated ? ` · 最後更新 ${lastUpdated}` : ""}
</Typography>

{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}

<Stack
direction={{ xs: "column", lg: "row" }}
spacing={2}
alignItems={{ xs: "stretch", lg: "stretch" }}
justifyContent={{ lg: "space-between" }}
sx={{ mb: 2 }}
>
<Paper
variant="outlined"
sx={{
p: 1.5,
flex: { lg: "1 1 0" },
minWidth: { xs: "100%", lg: 280 },
borderColor: "divider",
bgcolor: alpha(theme.palette.primary.main, 0.03),
}}
>
<Typography
variant="overline"
color="text.secondary"
sx={{ display: "block", mb: 1, lineHeight: 1.4, letterSpacing: 0.6 }}
>
查詢與列表
</Typography>
<Stack spacing={1.25}>
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 600 }}>
歸屬日 {viewDate}(週{weekdayZh})
{!isToday && <Chip size="small" label="非今日" sx={{ ml: 1 }} variant="outlined" />}
</Typography>
<Stack direction="row" flexWrap="wrap" useFlexGap alignItems="center" gap={1.25}>
<Tooltip title="快捷鍵 T">
<Button
size="small"
variant={isToday ? "contained" : "outlined"}
onClick={() => setViewDate(dayjs().format("YYYY-MM-DD"))}
>
今日
</Button>
</Tooltip>
<Tooltip title="快捷鍵 Y">
<Button size="small" variant="outlined" onClick={() => setViewDate(dayjs().subtract(1, "day").format("YYYY-MM-DD"))}>
昨日
</Button>
</Tooltip>
<TextField
size="small"
label="選擇日期"
type="date"
value={viewDate}
onChange={(e) => setViewDate(e.target.value)}
InputLabelProps={{ shrink: true }}
sx={{ minWidth: 178 }}
/>
<Button variant="outlined" size="small" onClick={() => void load()} disabled={loading}>
重新整理
</Button>
</Stack>
</Stack>
</Paper>

<Paper
variant="outlined"
sx={{
p: 1.5,
flex: { lg: "0 0 auto" },
width: { xs: "100%", lg: "auto" },
minWidth: { lg: 200 },
borderColor: "divider",
bgcolor: alpha(theme.palette.grey[500], 0.06),
}}
>
<Typography
variant="overline"
color="text.secondary"
sx={{ display: "block", mb: 1, lineHeight: 1.4, letterSpacing: 0.6 }}
>
其他看板
</Typography>
<Stack direction="column" spacing={1} sx={{ maxWidth: 220 }}>
<Button component={Link} href="/chart/joborder/board" size="small" variant="outlined" fullWidth>
工單即時看板
</Button>
<Button component={Link} href="/chart/process/board" size="small" variant="outlined" fullWidth startIcon={<AccountTree />}>
工序即時看板
</Button>
<Button component={Link} href="/chart/joborder" size="small" variant="outlined" fullWidth>
工單圖表
</Button>
</Stack>
</Paper>

<Paper
variant="outlined"
sx={{
p: 1.5,
flex: { lg: "0 0 auto" },
width: { xs: "100%", lg: "auto" },
minWidth: { xs: "100%", lg: 300 },
borderColor: "divider",
bgcolor: alpha(theme.palette.info.main, 0.04),
}}
>
<Typography
variant="overline"
color="text.secondary"
sx={{ display: "block", mb: 1, lineHeight: 1.4, letterSpacing: 0.6 }}
>
自動重新整理
</Typography>
<Stack direction="row" flexWrap="wrap" useFlexGap alignItems="center" gap={1.25}>
<FormControlLabel
control={
<Switch
size="small"
checked={autoRefreshOn}
onChange={(_, v) => setAutoRefreshOn(v)}
inputProps={{ "aria-label": "自動重新整理" }}
/>
}
label="開啟"
sx={{ ml: 0, mr: 0 }}
/>
<FormControl size="small" sx={{ minWidth: 124 }} disabled={!autoRefreshOn}>
<InputLabel id="eq-board-refresh-interval-label">間隔(秒)</InputLabel>
<Select
labelId="eq-board-refresh-interval-label"
label="間隔(秒)"
value={refreshIntervalSec}
onChange={(e) => setRefreshIntervalSec(Number(e.target.value))}
>
{CHART_BOARD_REFRESH_INTERVAL_SEC_OPTIONS.map((sec) => (
<MenuItem key={sec} value={sec}>
{sec} 秒
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
</Paper>
</Stack>

{selectedEquipmentKey && (
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 2 }}>
<FilterAltOutlined fontSize="small" color="action" />
<Chip
label={`篩選設備:${selectedLabel}`}
onDelete={() => setSelectedEquipmentKey(null)}
color="primary"
variant="outlined"
/>
</Stack>
)}

{!loading && rows.length > 0 && equipmentUsageChart.data.length > 0 && (
<Paper variant="outlined" sx={{ p: 2, mb: 3 }}>
<Typography variant="subtitle2" fontWeight={600} gutterBottom>
使用時間(分鐘)— {viewDate}
</Typography>
<Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 1 }}>
點擊長條篩選下方明細(最多顯示 {EQUIPMENT_CHART_MAX} 台,依分鐘數由高到低)。
</Typography>
<SafeApexCharts
chartRevision={JSON.stringify(equipmentUsageChart.keys)}
options={barOptions}
series={[{ name: "分鐘", data: equipmentUsageChart.data }]}
type="bar"
height={Math.min(520, 120 + equipmentUsageChart.data.length * 28)}
/>
</Paper>
)}

{!loading && rows.length > 0 && equipmentUsageChart.data.length === 0 && (
<Alert severity="info" sx={{ mb: 3 }}>
當日有明細但無法加總使用分鐘(多數為缺開/完工時間且無預設生產分鐘)。仍可在下方表格檢視。
</Alert>
)}

{!loading && (
<Stack direction="row" spacing={2} useFlexGap flexWrap="wrap" sx={{ mb: 2 }}>
<Paper variant="outlined" sx={{ px: 2, py: 1.5 }}>
<Typography variant="caption" color="text.secondary">
{selectedEquipmentKey ? "篩選後筆數" : "該日總筆數"}
</Typography>
<Typography variant="h6">{stats.sessions}</Typography>
</Paper>
<Paper variant="outlined" sx={{ px: 2, py: 1.5 }}>
<Typography variant="caption" color="text.secondary">
使用分鐘合計(篩選範圍)
</Typography>
<Typography variant="h6">{formatUsageMinutes(stats.totalMins)}</Typography>
</Paper>
<Paper variant="outlined" sx={{ px: 2, py: 1.5 }}>
<Typography variant="caption" color="text.secondary">
涉及設備數(篩選範圍)
</Typography>
<Typography variant="h6">{stats.equipmentTouched}</Typography>
</Paper>
{stats.working > 0 && (
<Paper variant="outlined" sx={{ px: 2, py: 1.5 }}>
<Typography variant="caption" color="text.secondary">
設備工時未結案
</Typography>
<Typography variant="h6">{stats.working}</Typography>
</Paper>
)}
</Stack>
)}

{loading && rows.length === 0 ? (
<Box sx={{ display: "flex", justifyContent: "center", py: 6 }}>
<CircularProgress />
</Box>
) : rows.length === 0 ? (
<Paper variant="outlined" sx={{ p: 4, textAlign: "center" }}>
<Typography color="text.secondary">
此日期沒有符合歸屬日的設備使用紀錄(含工藝流程明細),或該日尚無已完工且已填設備的步驟。
</Typography>
</Paper>
) : displayRows.length === 0 ? (
<Paper variant="outlined" sx={{ p: 4, textAlign: "center" }}>
<Typography color="text.secondary" sx={{ mb: 2 }}>
此篩選下沒有明細。
</Typography>
<Button size="small" onClick={() => setSelectedEquipmentKey(null)}>
清除設備篩選
</Button>
</Paper>
) : (
<TableContainer component={Paper} variant="outlined">
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell>狀態</TableCell>
<TableCell>設備</TableCell>
<TableCell align="right">使用(分)</TableCell>
<TableCell>工單</TableCell>
<TableCell>工序</TableCell>
<TableCell>工單計劃開始</TableCell>
<TableCell>開工時間</TableCell>
<TableCell>完工時間</TableCell>
<TableCell>操作員</TableCell>
<TableCell align="center">開啟</TableCell>
</TableRow>
</TableHead>
<TableBody>
{displayRows.map((r) => (
<TableRow key={`${r.jobOrderId}-${r.jopdId}-${r.operatingEnd}-${r.operatingStart}`} hover>
<TableCell>
{r.workingNow === 1 ? (
<Chip label="設備工時未結案" size="small" color="warning" variant="outlined" />
) : !r.operatingStart?.trim() && !r.operatingEnd?.trim() ? (
<Chip label="未填設備工時" size="small" color="default" variant="outlined" />
) : (
<Chip label="已完工" size="small" variant="outlined" />
)}
</TableCell>
<TableCell sx={{ fontWeight: 600 }}>{formatCodeNameLine(r.equipmentCode, r.equipmentName)}</TableCell>
<TableCell align="right">{formatUsageMinutes(r.usageMinutes)}</TableCell>
<TableCell>{r.jobOrderCode || "—"}</TableCell>
<TableCell sx={{ maxWidth: 220 }}>{formatCodeNameLine(r.processCode, r.processName)}</TableCell>
<TableCell>{r.jobPlanStart || "—"}</TableCell>
<TableCell>{r.operatingStart || "—"}</TableCell>
<TableCell>{r.operatingEnd || "—"}</TableCell>
<TableCell>{r.operatorName || r.operatorUsername || "—"}</TableCell>
<TableCell align="center">
<Button
component={Link}
href={`/jo/edit?id=${r.jobOrderId}`}
target="_blank"
rel="noopener noreferrer"
size="small"
>
開啟
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</Box>
);
}

+ 309
- 0
src/app/(main)/chart/forecast/page.tsx Näytä tiedosto

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

import React, { useCallback, useState } from "react";
import {
Box,
Typography,
Skeleton,
Alert,
FormControl,
InputLabel,
Select,
MenuItem,
Checkbox,
ListItemText,
} from "@mui/material";
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";
import SafeApexCharts from "@/components/charts/SafeApexCharts";

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>
) : (
<SafeApexCharts
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} />
) : (
<SafeApexCharts
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>
);
}

+ 1047
- 0
src/app/(main)/chart/joborder/board/page.tsx
File diff suppressed because it is too large
Näytä tiedosto


+ 386
- 0
src/app/(main)/chart/joborder/page.tsx Näytä tiedosto

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

import React, { useCallback, useState } from "react";
import { Box, Typography, Skeleton, Alert, TextField, Button, Stack } from "@mui/material";
import Link from "next/link";
import dayjs from "dayjs";
import Assignment from "@mui/icons-material/Assignment";
import Microwave from "@mui/icons-material/Microwave";
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";
import SafeApexCharts from "@/components/charts/SafeApexCharts";

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" }}>
<Stack direction="row" alignItems="center" justifyContent="space-between" flexWrap="wrap" gap={1} sx={{ mb: 2 }}>
<Typography variant="h5" sx={{ fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}>
<Assignment /> {PAGE_TITLE}
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
<Button component={Link} href="/chart/joborder/board" variant="outlined" size="small">
工單即時看板
</Button>
<Button
component={Link}
href="/chart/equipment/board"
variant="outlined"
size="small"
startIcon={<Microwave />}
>
設備使用看板
</Button>
<Button component={Link} href="/chart/process/board" variant="outlined" size="small">
工序即時看板
</Button>
</Stack>
</Stack>
{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} />
) : (
<SafeApexCharts
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} />
) : (
<SafeApexCharts
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} />
) : (
<SafeApexCharts
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} />
) : (
<SafeApexCharts
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} />
) : (
<SafeApexCharts
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} />
) : (
<SafeApexCharts
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>
);
}

+ 13
- 0
src/app/(main)/chart/layout.tsx Näytä tiedosto

@@ -0,0 +1,13 @@
import { I18nProvider } from "@/i18n";

export default function ChartLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<I18nProvider namespaces={["chart", "navigation", "common"]}>
{children}
</I18nProvider>
);
}

+ 5
- 0
src/app/(main)/chart/page.tsx Näytä tiedosto

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

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

+ 1309
- 0
src/app/(main)/chart/process/board/page.tsx
File diff suppressed because it is too large
Näytä tiedosto


+ 54
- 0
src/app/(main)/chart/purchase/exportPurchaseChartMaster.ts Näytä tiedosto

@@ -0,0 +1,54 @@
/**
* Multi-sheet 總表 export for the 採購 chart page — mirrors on-screen charts and drill-down data.
*/
import { exportMultiSheetToXlsx, type MultiSheetSpec } from "../_components/exportChartToXlsx";

export type PurchaseChartMasterExportPayload = {
/** ISO timestamp for audit */
exportedAtIso: string;
/** 篩選與情境 — key-value rows */
metaRows: Record<string, unknown>[];
/** 預計送貨 donut (依預計到貨日、上方篩選) */
estimatedDonutRows: Record<string, unknown>[];
/** 實際已送貨 donut (依訂單日期、上方篩選) */
actualStatusDonutRows: Record<string, unknown>[];
/** 貨品摘要表 (當前 drill) */
itemSummaryRows: Record<string, unknown>[];
/** 供應商分佈 (由採購單明細彙總) */
supplierDistributionRows: Record<string, unknown>[];
/** 採購單列表 */
purchaseOrderListRows: Record<string, unknown>[];
/** 全量採購單行明細 (每張 PO 所有行) */
purchaseOrderLineRows: Record<string, unknown>[];
};

function sheetOrPlaceholder(name: string, rows: Record<string, unknown>[], emptyMessage: string): MultiSheetSpec {
if (rows.length > 0) return { name, rows };
return {
name,
rows: [{ 說明: emptyMessage }],
};
}

/**
* Build worksheet specs (used by {@link exportPurchaseChartMasterToFile}).
*/
export function buildPurchaseChartMasterSheets(payload: PurchaseChartMasterExportPayload): MultiSheetSpec[] {
return [
{ name: "篩選條件與情境", rows: payload.metaRows },
sheetOrPlaceholder("預計送貨", payload.estimatedDonutRows, "無資料(請確認訂單日期與篩選)"),
sheetOrPlaceholder("實際已送貨", payload.actualStatusDonutRows, "無資料"),
sheetOrPlaceholder("貨品摘要", payload.itemSummaryRows, "無資料(可能為篩選交集為空或未載入)"),
sheetOrPlaceholder("供應商分佈", payload.supplierDistributionRows, "無資料"),
sheetOrPlaceholder("採購單列表", payload.purchaseOrderListRows, "無採購單明細可匯出"),
sheetOrPlaceholder("採購單行明細", payload.purchaseOrderLineRows, "無行資料(採購單列表為空)"),
];
}

export function exportPurchaseChartMasterToFile(
payload: PurchaseChartMasterExportPayload,
filenameBase: string
): void {
const sheets = buildPurchaseChartMasterSheets(payload);
exportMultiSheetToXlsx(sheets, filenameBase);
}

+ 1129
- 0
src/app/(main)/chart/purchase/page.tsx
File diff suppressed because it is too large
Näytä tiedosto


+ 61
- 0
src/app/(main)/chart/useChartBoardRefreshPrefs.ts Näytä tiedosto

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

import { useSession } from "next-auth/react";
import { useEffect, useState } from "react";
import type { SessionWithTokens } from "@/config/authConfig";
import {
type ChartBoardId,
CHART_BOARD_DEFAULT_REFRESH_INTERVAL_SEC,
loadChartBoardRefreshPrefs,
saveChartBoardRefreshPrefs,
} from "./chartBoardRefreshPrefs";

/** Session id may be string or number from JWT / callbacks — never assume .trim exists. */
function normalizeKeyPart(v: unknown): string | undefined {
if (v == null) return undefined;
const s = typeof v === "string" ? v : String(v);
const t = s.trim();
return t.length > 0 ? t : undefined;
}

function resolveUserKey(session: SessionWithTokens | null): string | undefined {
const id = normalizeKeyPart(session?.id);
if (id) return `id:${id}`;
const email = normalizeKeyPart(session?.user?.email);
if (email) return `email:${email.toLowerCase()}`;
return undefined;
}

export function useChartBoardRefreshPrefs(boardId: ChartBoardId) {
const { data: session } = useSession() as { data: SessionWithTokens | null };
const userKeyPart = resolveUserKey(session);

const [autoRefreshOn, setAutoRefreshOn] = useState(false);
const [refreshIntervalSec, setRefreshIntervalSec] = useState(CHART_BOARD_DEFAULT_REFRESH_INTERVAL_SEC);
const [hydrated, setHydrated] = useState(false);

useEffect(() => {
const prefs = loadChartBoardRefreshPrefs(boardId, userKeyPart);
setAutoRefreshOn(prefs.autoRefreshOn);
setRefreshIntervalSec(prefs.refreshIntervalSec);
setHydrated(true);
}, [boardId, userKeyPart]);

useEffect(() => {
if (!hydrated) return;
saveChartBoardRefreshPrefs(boardId, userKeyPart, {
autoRefreshOn,
refreshIntervalSec,
});
}, [hydrated, boardId, userKeyPart, autoRefreshOn, refreshIntervalSec]);

return {
autoRefreshOn,
setAutoRefreshOn,
refreshIntervalSec,
setRefreshIntervalSec,
/** Logged-in user key for storage; undefined if not available */
userKeyPart,
hydrated,
};
}

+ 360
- 0
src/app/(main)/chart/warehouse/page.tsx Näytä tiedosto

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

import React, { useCallback, useState } from "react";
import { Box, Typography, Skeleton, Alert, TextField, Button, Chip, Stack } from "@mui/material";
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";
import SafeApexCharts from "@/components/charts/SafeApexCharts";

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} />
) : (
<SafeApexCharts
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} />
) : (
<SafeApexCharts
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} />
) : (
<SafeApexCharts
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 ? (
<SafeApexCharts
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}
/>
) : (
<SafeApexCharts
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 Näytä tiedosto

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

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


+ 36
- 0
src/app/(main)/do copy 2/edit/page.tsx Näytä tiedosto

@@ -0,0 +1,36 @@
import { SearchParams } from "@/app/utils/fetchUtil";
import DoDetail from "@/components/DoDetail/DoDetailWrapper";
import PageTitleBar from "@/components/PageTitleBar";
import { I18nProvider, getServerI18n } from "@/i18n";
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",
};

type Props = SearchParams;

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

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

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

export default DoEdit;

+ 35
- 0
src/app/(main)/do copy 2/page.tsx Näytä tiedosto

@@ -0,0 +1,35 @@
import DoSearchWorkbench from "@/components/DoSearchWorkbench/DoSearchWorkbench";
import { getServerI18n } from "@/i18n";
import PageTitleBar from "@/components/PageTitleBar";
import { I18nProvider } from "@/i18n";
import { Metadata } from "next";
import { Suspense } from "react";
import GeneralLoading from "@/components/General/GeneralLoading";
import Link from "next/link";

export const metadata: Metadata = {
title: "DO Workbench (copy)",
};

/** Dev alias — prefer canonical route `/doworkbench`. */
const Page: React.FC = async () => {
const { t } = await getServerI18n("do");

return (
<>
<PageTitleBar title={t("DO Workbench (dev)", { defaultValue: "DO Workbench (dev)" })} className="mb-4" />
<p className="mb-2 text-sm text-gray-600">
<Link href="/doworkbench" className="underline">
/doworkbench
</Link>
</p>
<I18nProvider namespaces={["do","navigation","common","home"]}>
<Suspense fallback={<GeneralLoading />}>
<DoSearchWorkbench workbenchHrefBase="/do copy 2" />
</Suspense>
</I18nProvider>
</>
);
};

export default Page;

+ 36
- 0
src/app/(main)/do copy/edit/page.tsx Näytä tiedosto

@@ -0,0 +1,36 @@
import { SearchParams } from "@/app/utils/fetchUtil";
import DoDetail from "@/components/DoDetail/DoDetailWrapper";
import PageTitleBar from "@/components/PageTitleBar";
import { I18nProvider, getServerI18n } from "@/i18n";
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",
};

type Props = SearchParams;

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

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

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

export default DoEdit;

+ 29
- 0
src/app/(main)/do copy/page.tsx Näytä tiedosto

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

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

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

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

export default DeliveryOrder;

+ 20
- 22
src/app/(main)/do/edit/page.tsx Näytä tiedosto

@@ -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","navigation","common","home"]}>
<Suspense fallback={<DoDetail.Loading />}>
<DoDetail id={parseInt(id)} />
</Suspense>
</I18nProvider>
</>
);
};

export default DoEdit;

+ 3
- 9
src/app/(main)/do/page.tsx Näytä tiedosto

@@ -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,14 +16,8 @@ const DeliveryOrder: React.FC = async () => {

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

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


+ 46
- 0
src/app/(main)/doworkbench/edit/page.tsx Näytä tiedosto

@@ -0,0 +1,46 @@
import { SearchParams } from "@/app/utils/fetchUtil";
import DoDetail from "@/components/DoDetail/DoDetailWrapper";
import PageTitleBar from "@/components/PageTitleBar";
import { I18nProvider, getServerI18n } from "@/i18n";
import { isArray } from "lodash";
import { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Suspense } from "react";

export const metadata: Metadata = {
title: "DO Workbench — Delivery Order Detail",
};

type Props = SearchParams;

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

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

return (
<>
<PageTitleBar title={t("Edit Delivery Order Detail")} className="mb-4" />
<p className="mb-4 text-sm">
<Link href="/doworkbench" className="text-primary underline">
{t("DO Workbench", { defaultValue: "DO Workbench" })}
</Link>
{" · "}
<Link href="/doworkbenchsearch" className="text-primary underline">
{t("DO Workbench Search", { defaultValue: "DO Workbench Search" })}
</Link>
</p>
<I18nProvider namespaces={["doWorkbench","navigation","common","do"]}>
<Suspense fallback={<DoDetail.Loading />}>
<DoDetail id={parseInt(id)} workbenchRelease />
</Suspense>
</I18nProvider>
</>
);
};

export default Page;

+ 25
- 0
src/app/(main)/doworkbench/page.tsx Näytä tiedosto

@@ -0,0 +1,25 @@
import DoWorkbenchTabs from "@/components/DoWorkbench/DoWorkbenchTabs";
import PageTitleBar from "@/components/PageTitleBar";
import { getServerI18n, I18nProvider } from "@/i18n";
import { Metadata } from "next";
import { fetchPrinterCombo } from "@/app/api/settings/printer";

export const metadata: Metadata = {
title: "DO Workbench",
};

const DoWorkbenchPage: React.FC = async () => {
const { t } = await getServerI18n("doWorkbench");
const printerCombo = await fetchPrinterCombo();

return (
<>
<PageTitleBar title={t("DO Workbench", { defaultValue: "DO Workbench" })} className="mb-4" />
<I18nProvider namespaces={["doWorkbench","navigation","common","pickOrder","ticketReleaseTable","do"]}>
<DoWorkbenchTabs printerCombo={printerCombo ?? []} />
</I18nProvider>
</>
);
};

export default DoWorkbenchPage;

+ 6
- 0
src/app/(main)/doworkbench/pick/page.tsx Näytä tiedosto

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

/** 揀貨工作台已合併至 `/doworkbench`,保留此路徑以利舊連結。 */
export default function DoWorkbenchPickLegacyRedirect() {
redirect("/doworkbench");
}

+ 31
- 0
src/app/(main)/doworkbenchsearch/page.tsx Näytä tiedosto

@@ -0,0 +1,31 @@
import DoSearchWorkbench from "@/components/DoSearchWorkbench";
import { getServerI18n } from "@/i18n";
import PageTitleBar from "@/components/PageTitleBar";
import { I18nProvider } from "@/i18n";
import { Metadata } from "next";
import { Suspense } from "react";
import GeneralLoading from "@/components/General/GeneralLoading";

export const metadata: Metadata = {
title: "DO Workbench Search",
};

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

return (
<>
<PageTitleBar
title={t("DO Workbench Search", { defaultValue: "DO Workbench Search" })}
className="mb-4"
/>
<I18nProvider namespaces={["doWorkbench","navigation","common","do","home"]}>
<Suspense fallback={<GeneralLoading />}>
<DoSearchWorkbench />
</Suspense>
</I18nProvider>
</>
);
};

export default DoWorkbenchSearchPage;

+ 4
- 4
src/app/(main)/finishedGood/detail/page.tsx Näytä tiedosto

@@ -1,4 +1,4 @@
import { PreloadPickOrder } from "@/app/api/pickOrder";
import { SearchParams } from "@/app/utils/fetchUtil";
import FinishedGoodSearchWrapper from "@/components/FinishedGoodSearch";
import { getServerI18n, I18nProvider } from "@/i18n";
@@ -12,13 +12,13 @@ export const metadata: Metadata = {
type Props = {} & SearchParams;

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


PreloadPickOrder();

return (
<>
<I18nProvider namespaces={["pickOrder", "common", "ticketReleaseTable"]}>
<I18nProvider namespaces={["finishedGood","navigation","common","pickOrder","ticketReleaseTable","purchaseOrder","home","item"]}>
<Suspense fallback={<FinishedGoodSearchWrapper.Loading />}>
<FinishedGoodSearchWrapper />
</Suspense>


+ 30
- 0
src/app/(main)/finishedGood/management/page.tsx Näytä tiedosto

@@ -0,0 +1,30 @@
import { I18nProvider } from "@/i18n";
import { Metadata } from "next";
import { Suspense } from "react";
import FinishedGoodManagement from "@/components/FinishedGoodManagement";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { authOptions } from "@/config/authConfig";
import { AUTH } from "@/authorities";

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

const Page = async () => {
const session = await getServerSession(authOptions);
const abilities = session?.user?.abilities ?? [];
if (!abilities.includes(AUTH.ADMIN)) {
redirect("/dashboard");
}
return (
<I18nProvider namespaces={["finishedgoodmanagement","navigation","common"]}>
<Suspense fallback={<FinishedGoodManagement.Loading />}>
<FinishedGoodManagement />
</Suspense>
</I18nProvider>
);
};

export default Page;


+ 3
- 3
src/app/(main)/finishedGood/page.tsx Näytä tiedosto

@@ -11,13 +11,13 @@ export const metadata: Metadata = {
};

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

PreloadPickOrder();
//PreloadPickOrder();

return (
<>
<I18nProvider namespaces={["pickOrder", "common", "ticketReleaseTable"]}>
<I18nProvider namespaces={["finishedGood","navigation","common","pickOrder","ticketReleaseTable","purchaseOrder","home","item"]}>
<Suspense fallback={<FinishedGoodSearch.Loading />}>
<FinishedGoodSearch />
</Suspense>


+ 2
- 2
src/app/(main)/inventory/page.tsx Näytä tiedosto

@@ -14,7 +14,7 @@ export const metadata: Metadata = {
};

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

preloadInventory();

@@ -30,7 +30,7 @@ const Inventory: React.FC = async () => {
{t("Inventory")}
</Typography>
</Stack>
<I18nProvider namespaces={["common", "inventory"]}>
<I18nProvider namespaces={["inventory","navigation","common","item"]}>
<Suspense fallback={<InventorySearch.Loading />}>
<InventorySearch />
</Suspense>


+ 14
- 0
src/app/(main)/isFullBleedMainRoute.ts Näytä tiedosto

@@ -0,0 +1,14 @@
import { isPoWorkbenchRoute } from "@/app/(main)/isPoWorkbenchRoute";

/** MTMS route board (`/settings/shop/board`). */
export function isRouteBoardRoute(pathname: string | null): boolean {
if (!pathname) {
return false;
}
return pathname === "/settings/shop/board";
}

/** Routes that use the full viewport under the AppBar (no main padding / min-h-screen gap). */
export function isFullBleedMainRoute(pathname: string | null): boolean {
return isPoWorkbenchRoute(pathname) || isRouteBoardRoute(pathname);
}

+ 7
- 0
src/app/(main)/isPoWorkbenchRoute.ts Näytä tiedosto

@@ -0,0 +1,7 @@
/** True when the active route is PO Workbench (or nested). */
export function isPoWorkbenchRoute(pathname: string | null): boolean {
if (!pathname) {
return false;
}
return pathname === "/po/workbench" || pathname.startsWith("/po/workbench/");
}

+ 34
- 35
src/app/(main)/jo/edit/page.tsx Näytä tiedosto

@@ -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","navigation","common"]}>
<Suspense fallback={<JoSave.Loading />}>
<JoSave id={parseInt(id)} />
</Suspense>
</I18nProvider>
</>
);
};

export default JoEdit;

+ 41
- 30
src/app/(main)/jo/page.tsx Näytä tiedosto

@@ -1,38 +1,49 @@
import { preloadBomCombo } from "@/app/api/bom";
import JoSearch from "@/components/JoSearch";
import { fetchBomCombo } from "@/app/api/bom";
import { fetchPrinterCombo } from "@/app/api/settings/printer";
import { fetchAllJobTypes, type SearchJoResultRequest } from "@/app/api/jo/actions";
import GeneralLoading from "@/components/General/GeneralLoading";
import PageTitleBar from "@/components/PageTitleBar";
import JoWorkbenchSearch from "@/components/JoWorkbench/JoWorkbenchSearch";
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");
const today = new Date();
const todayStr = today.toISOString().split("T")[0];
const defaultInputs: SearchJoResultRequest = {
code: "",
itemName: "",
planStart: `${todayStr}T00:00`,
planStartTo: `${todayStr}T23:59:59`,
joSearchStatus: "all",
};
const [bomCombo, printerCombo, jobTypes] = await Promise.all([
fetchBomCombo(),
fetchPrinterCombo(),
fetchAllJobTypes(),
]);

preloadBomCombo()
return (
<>
<PageTitleBar title={t("Search Job Order/ Create Job Order")} className="mb-4" />
<I18nProvider namespaces={["jo","navigation","common","purchaseOrder","dashboard"]}>
<Suspense fallback={<GeneralLoading />}>
<JoWorkbenchSearch
defaultInputs={defaultInputs}
bomCombo={bomCombo ?? []}
printerCombo={printerCombo ?? []}
jobTypes={jobTypes ?? []}
/>
</Suspense>
</I18nProvider>
</>
);
};

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

export default jo;
export default Jo;

+ 21
- 0
src/app/(main)/jo/testing/page.tsx Näytä tiedosto

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

import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";

/**
* Dev / R&D sandbox for Job Order. Not listed in NavigationContent — open via /jo/testing only.
* Later: call APIs with clientAuthFetch + NEXT_PUBLIC_API_URL like src/app/(main)/testing/page.tsx.
*/
export default function JoTestingPage() {
return (
<Box sx={{ p: 4 }}>
<Typography variant="h5" gutterBottom fontWeight="bold">
Job order testing
</Typography>
<Typography color="text.secondary">
Empty page. This route is intentionally omitted from the navigation bar.
</Typography>
</Box>
);
}

+ 30
- 0
src/app/(main)/jo/workbench/page.tsx Näytä tiedosto

@@ -0,0 +1,30 @@
import GeneralLoading from "@/components/General/GeneralLoading";
import PageTitleBar from "@/components/PageTitleBar";
import JoPickOrderList from "@/components/JoWorkbench/JoPickOrderList";
import { fetchPrinterCombo } from "@/app/api/settings/printer";
import { I18nProvider, getServerI18n } from "@/i18n";
import { Metadata } from "next";
import React, { Suspense } from "react";

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

const JoWorkbenchPage = async () => {
const { t } = await getServerI18n("jo");
const printerCombo = await fetchPrinterCombo();
//console.log("[JO Workbench Page] printerCombo count:", printerCombo?.length ?? 0);

return (
<>
<PageTitleBar title={t("Job Order Pickexcution", { defaultValue: "Job Order Pickexcution" })} className="mb-4" />
<I18nProvider namespaces={["jo","navigation","common","pickOrder","purchaseOrder","dashboard"]}>
<Suspense fallback={<GeneralLoading />}>
<JoPickOrderList printerCombo={printerCombo ?? []} />
</Suspense>
</I18nProvider>
</>
);
};

export default JoWorkbenchPage;

+ 31
- 30
src/app/(main)/jodetail/edit/page.tsx Näytä tiedosto

@@ -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","navigation","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 Näytä tiedosto

@@ -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","navigation","common","pickOrder","purchaseOrder","home","item"]}>
<Suspense fallback={<GeneralLoading />}>
<JodetailSearchWrapper />
</Suspense>
</I18nProvider>
</>
);
};

export default jo;
export default Jodetail;

+ 21
- 0
src/app/(main)/laserPrint/page.tsx Näytä tiedosto

@@ -0,0 +1,21 @@
import { I18nProvider } from "@/i18n";
import { Metadata } from "next";
import LaserPrintSearch from "@/components/LaserPrint/LaserPrintSearch";
import { Stack, Typography } from "@mui/material";

export const metadata: Metadata = {
title: "檸檬機(激光機)",
};

export default async function LaserPrintPage() {
return (
<I18nProvider namespaces={["laserPrint", "navigation", "common"]}>
<Stack direction="row" justifyContent="space-between" flexWrap="wrap" rowGap={2}>
<Typography variant="h4" marginInlineEnd={2}>
檸檬機(激光機)
</Typography>
</Stack>
<LaserPrintSearch />
</I18nProvider>
);
}

+ 16
- 26
src/app/(main)/layout.tsx Näytä tiedosto

@@ -2,18 +2,15 @@ import AppBar from "@/components/AppBar";
import { AuthOptions, getServerSession } from "next-auth";
import { authOptions, SessionWithTokens } from "@/config/authConfig";
import { redirect } from "next/navigation";
import Box from "@mui/material/Box";
import { NAVIGATION_CONTENT_WIDTH } from "@/config/uiConfig";
import Stack from "@mui/material/Stack";
import Breadcrumb from "@/components/Breadcrumb";
import MainContentArea from "@/app/(main)/MainContentArea";
import MainLayoutBody from "@/app/(main)/MainLayoutBody";
import { AxiosProvider } from "@/app/(main)/axios/AxiosProvider";
import { SetupAxiosInterceptors } from "@/app/(main)/axios/axiosInstance";
import { CameraProvider } from "@/components/Cameras/CameraProvider";
import { UploadProvider } from "@/components/UploadProvider/UploadProvider";
import SessionProviderWrapper from "@/components/SessionProviderWrapper/SessionProviderWrapper";
import QrCodeScannerProvider from "@/components/QrCodeScannerProvider/QrCodeScannerProvider";
import { I18nProvider } from "@/i18n";
import "src/app/global.css"
import "src/app/global.css";
export default async function MainLayout({
children,
}: {
@@ -38,30 +35,23 @@ export default async function MainLayout({
<SessionProviderWrapper session={session}>
<UploadProvider>
{/* <CameraProvider> */}
<AxiosProvider>
<QrCodeScannerProvider>
<>
<AxiosProvider>
<QrCodeScannerProvider>
<MainLayoutBody
appBar={
<AppBar
profileName={session.user.name!}
avatarImageSrc={session.user.image || undefined}
/>
<Box
component="main"
sx={{
marginInlineStart: { xs: 0, xl: NAVIGATION_CONTENT_WIDTH },
padding: { xs: "1rem", sm: "1.5rem", lg: "3rem" },
}}
>
<Stack spacing={2}>
<I18nProvider namespaces={["common"]}>
{/* <Breadcrumb /> */}
{children}
</I18nProvider>
</Stack>
</Box>
</>
</QrCodeScannerProvider>
</AxiosProvider>
}
mainContent={
<I18nProvider namespaces={["navigation", "common"]}>
<MainContentArea>{children}</MainContentArea>
</I18nProvider>
}
/>
</QrCodeScannerProvider>
</AxiosProvider>
{/* </CameraProvider> */}
</UploadProvider>
</SessionProviderWrapper>


+ 13
- 0
src/app/(main)/m18Syn/layout.tsx Näytä tiedosto

@@ -0,0 +1,13 @@
import { I18nProvider } from "@/i18n";

export default function M18SyncLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<I18nProvider namespaces={["m18Sync", "navigation", "common"]}>
{children}
</I18nProvider>
);
}

+ 415
- 0
src/app/(main)/m18Syn/page.tsx Näytä tiedosto

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

import React, { useRef, useState } from "react";
import { Box, Button, Paper, Stack, Tab, Tabs, TextField, Typography } from "@mui/material";
import { NEXT_PUBLIC_API_URL } from "@/config/api";
import { clientAuthFetch } from "@/app/utils/clientAuthFetch";

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={`m18syn-tabpanel-${index}`}
aria-labelledby={`m18syn-tab-${index}`}
{...other}
>
{value === index && <Box sx={{ p: 3 }}>{children}</Box>}
</div>
);
}

export default function M18SynPage() {
const [tabValue, setTabValue] = useState(0);

type SyncResultDto = {
totalProcessed: number;
totalSuccess: number;
totalFail: number;
query?: string | null;
};

type ParsedSyncResult =
| { kind: "exists"; message: string }
| { kind: "not_found"; message: string }
| { kind: "success"; message: string; totalSuccess: number }
| { kind: "fail"; message: string; totalFail: number }
| { kind: "mixed"; message: string; totalSuccess: number; totalFail: number }
| { kind: "raw"; message: string };

const parseSyncResult = (docName: string, rawText: string): ParsedSyncResult => {
const text = rawText?.trim() ?? "";
if (!text) return { kind: "raw", message: "未收到回應" };
try {
const obj = JSON.parse(text) as Partial<SyncResultDto>;
const totalSuccess = Number(obj.totalSuccess ?? 0);
const totalFail = Number(obj.totalFail ?? 0);
const totalProcessed = Number(obj.totalProcessed ?? 0);
const query = (obj.query ?? "").toString();

// newOnly skip message from backend
if (query.includes("skipped") && query.includes("already exists")) {
return { kind: "exists", message: `${docName}已存在系統` };
}

// "not found in M18" (sync-by-code returns processed=1, fail=1)
if (
docName === "送貨訂單" &&
totalProcessed === 1 &&
totalSuccess === 0 &&
totalFail === 1 &&
query.includes("code=equal=")
) {
return { kind: "not_found", message: `在M18找不到${docName}` };
}

if (totalSuccess > 0 && totalFail === 0) {
return { kind: "success", totalSuccess, message: `成功同步: ${totalSuccess}張${docName}` };
}
if (totalSuccess === 0 && totalFail > 0) {
return { kind: "fail", totalFail, message: `失敗: 無法同步${docName}` };
}
return { kind: "mixed", totalSuccess, totalFail, message: `完成: 成功 ${totalSuccess} / 失敗 ${totalFail} (${docName})` };
} catch {
// Non-JSON error body or plain text
return { kind: "raw", message: text };
}
};

const formatSyncResultText = (docName: string, rawText: string): string => parseSyncResult(docName, rawText).message;

const [m18PoCode, setM18PoCode] = useState("");
const [isSyncingM18Po, setIsSyncingM18Po] = useState(false);
const [m18PoSyncResult, setM18PoSyncResult] = useState<string>("");
const m18PoInFlightRef = useRef(false);

const [m18DoCode, setM18DoCode] = useState("");
const [isSyncingM18Do, setIsSyncingM18Do] = useState(false);
const [m18DoSyncResult, setM18DoSyncResult] = useState<string>("");
const m18DoInFlightRef = useRef(false);

const [m18DoExtraCode, setM18DoExtraCode] = useState("");
const [isSyncingM18DoExtra, setIsSyncingM18DoExtra] = useState(false);
const [m18DoExtraSyncResult, setM18DoExtraSyncResult] = useState<string>("");
const m18DoExtraInFlightRef = useRef(false);

const [m18ProductCode, setM18ProductCode] = useState("");
const [isSyncingM18Product, setIsSyncingM18Product] = useState(false);
const [m18ProductSyncResult, setM18ProductSyncResult] = useState<string>("");
const m18ProductInFlightRef = useRef(false);

const handleSyncM18PoByCode = async () => {
if (m18PoInFlightRef.current) return;
if (!m18PoCode.trim()) {
alert("Please enter PO code.");
return;
}
m18PoInFlightRef.current = true;
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(formatSyncResultText("採購單", 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);
m18PoInFlightRef.current = false;
}
};

const handleSyncM18DoByCode = async () => {
if (m18DoInFlightRef.current) return;
if (!m18DoCode.trim()) {
alert("Please enter DO / shop PO code.");
return;
}
m18DoInFlightRef.current = true;
setIsSyncingM18Do(true);
setM18DoSyncResult("");
try {
const response = await clientAuthFetch(
`${NEXT_PUBLIC_API_URL}/m18/test/do-by-code?code=${encodeURIComponent(m18DoCode.trim())}`,
{ method: "GET" },
);
if (response.status === 401 || response.status === 403) return;
const text = await response.text();
setM18DoSyncResult(formatSyncResultText("送貨訂單", text));
if (!response.ok) {
alert(`Sync failed: ${response.status}`);
}
} catch (e) {
console.error("M18 DO Sync By Code Error:", e);
alert("M18 DO sync failed. Check console/network.");
} finally {
setIsSyncingM18Do(false);
m18DoInFlightRef.current = false;
}
};

/** DO(加單):手動按 code 同步,並寫入本地 isExtra=true(可輸入多個 code,用逗號或換行分隔) */
const handleSyncM18DoExtraByCode = async () => {
if (m18DoExtraInFlightRef.current) return;
const raw = m18DoExtraCode.trim();
if (!raw) {
alert("Please enter DO / shop PO code(s) (加單).");
return;
}
const codes = raw
.split(/[\n,]+/g)
.map((s) => s.trim())
.filter(Boolean);
if (codes.length === 0) {
alert("Please enter at least one code.");
return;
}
m18DoExtraInFlightRef.current = true;
setIsSyncingM18DoExtra(true);
setM18DoExtraSyncResult("");
try {
const outputs: string[] = [];
const okCodes: string[] = [];
const existsCodes: string[] = [];
const notFoundCodes: string[] = [];
const otherFailCodes: string[] = [];
for (const code of codes) {
const response = await clientAuthFetch(
`${NEXT_PUBLIC_API_URL}/m18/test/do-by-code-extra?code=${encodeURIComponent(code)}`,
{ method: "GET" },
);
if (response.status === 401 || response.status === 403) return;
const text = await response.text();
const parsed = parseSyncResult("送貨訂單", text || "");
if (parsed.kind === "success") okCodes.push(code);
else if (parsed.kind === "exists") existsCodes.push(code);
else if (parsed.kind === "not_found") notFoundCodes.push(code);
else otherFailCodes.push(code);

const perCodeMsg =
parsed.kind === "success" ? "成功同步" :
parsed.kind === "exists" ? `失敗: ${parsed.message}` :
parsed.kind === "not_found" ? parsed.message :
parsed.message;
outputs.push(`${code}: ${perCodeMsg}${response.ok ? "" : ` (HTTP ${response.status})`}`);
}

// Summary
outputs.push("");
outputs.push(`共${okCodes.length}張送貨訂單成功`);
if (notFoundCodes.length > 0) outputs.push(`共${notFoundCodes.length}張在M18找不到訂單 (${notFoundCodes.join(", ")})`);
if (existsCodes.length > 0) outputs.push(`共${existsCodes.length}張訂單已存在系統 (${existsCodes.join(", ")})`);
if (otherFailCodes.length > 0) outputs.push(`共${otherFailCodes.length}張同步失敗 (${otherFailCodes.join(", ")})`);

setM18DoExtraSyncResult(outputs.join("\n"));
} catch (e) {
console.error("M18 DO (加單) Sync By Code Error:", e);
alert("M18 DO (加單) sync failed. Check console/network.");
} finally {
setIsSyncingM18DoExtra(false);
m18DoExtraInFlightRef.current = false;
}
};

const handleSyncM18ProductByCode = async () => {
if (m18ProductInFlightRef.current) return;
if (!m18ProductCode.trim()) {
alert("Please enter M18 item / product code.");
return;
}
m18ProductInFlightRef.current = true;
setIsSyncingM18Product(true);
setM18ProductSyncResult("");
try {
const response = await clientAuthFetch(
`${NEXT_PUBLIC_API_URL}/m18/test/product-by-code?code=${encodeURIComponent(m18ProductCode.trim())}`,
{ method: "GET" },
);
if (response.status === 401 || response.status === 403) return;
const text = await response.text();
setM18ProductSyncResult(formatSyncResultText("產品", text));
if (!response.ok) {
alert(`Sync failed: ${response.status}`);
}
} catch (e) {
console.error("M18 Product Sync By Code Error:", e);
alert("M18 product sync failed. Check console/network.");
} finally {
setIsSyncingM18Product(false);
m18ProductInFlightRef.current = false;
}
};

const Section = ({ title, children }: { title: string; children?: React.ReactNode }) => (
<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" }}>
M18 Sync (by code)
</Typography>
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
ADMIN only. Sync Purchase Order, Delivery Order, or product/material from M18 using document or item code.
</Typography>

<Tabs value={tabValue} onChange={(_, v) => setTabValue(v)} aria-label="M18 sync by code" centered variant="fullWidth">
<Tab label="1. 採購單" id="m18syn-tab-0" />
<Tab label="2. 送貨訂單" id="m18syn-tab-1" />
<Tab label="3. 送貨訂單 (加單)" id="m18syn-tab-2" />
<Tab label="4. Product" id="m18syn-tab-3" />
</Tabs>

<TabPanel value={tabValue} index={0}>
<Section title="M18 採購單 — 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>
{m18PoSyncResult ? (
<TextField
fullWidth
multiline
minRows={4}
margin="normal"
label="Sync Result"
value={m18PoSyncResult}
InputProps={{ readOnly: true }}
/>
) : null}
</Section>
</TabPanel>

<TabPanel value={tabValue} index={1}>
<Section title="M18 送貨訂單 — sync by code">
<Stack direction="row" spacing={2} sx={{ mb: 2, alignItems: "center" }}>
<TextField
size="small"
label="DO / Shop PO Code"
value={m18DoCode}
onChange={(e) => setM18DoCode(e.target.value)}
placeholder="e.g. same document code as M18 shop PO"
sx={{ minWidth: 320 }}
/>
<Button variant="contained" color="primary" onClick={handleSyncM18DoByCode} disabled={isSyncingM18Do}>
{isSyncingM18Do ? "Syncing..." : "Sync DO from M18"}
</Button>
</Stack>
{m18DoSyncResult ? (
<TextField
fullWidth
multiline
minRows={4}
margin="normal"
label="Sync Result"
value={m18DoSyncResult}
InputProps={{ readOnly: true }}
/>
) : null}
</Section>
</TabPanel>

<TabPanel value={tabValue} index={2}>
<Section title="M18 送貨訂單 — 加單 (isExtra)">
<Stack spacing={2} sx={{ mb: 2 }}>
<TextField
label="DO / Shop PO Code(加單)"
value={m18DoExtraCode}
onChange={(e) => setM18DoExtraCode(e.target.value)}
placeholder="可輸入多個 code,用逗號或換行分隔"
fullWidth
multiline
minRows={6}
maxRows={14}
/>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Button
variant="contained"
color="primary"
onClick={handleSyncM18DoExtraByCode}
disabled={isSyncingM18DoExtra}
>
{isSyncingM18DoExtra ? "Syncing..." : "Sync 送貨訂單(加單)from M18"}
</Button>
</Box>
</Stack>
{m18DoExtraSyncResult ? (
<TextField
fullWidth
multiline
minRows={4}
margin="normal"
label="Sync Result"
value={m18DoExtraSyncResult}
InputProps={{ readOnly: true }}
/>
) : null}
</Section>
</TabPanel>

<TabPanel value={tabValue} index={3}>
<Section title="M18 Product / material — sync by code">
<Stack direction="row" spacing={2} sx={{ mb: 2, alignItems: "center" }}>
<TextField
size="small"
label="Item / product code"
value={m18ProductCode}
onChange={(e) => setM18ProductCode(e.target.value)}
placeholder="e.g. PP1175 (M18 item code)"
sx={{ minWidth: 320 }}
/>
<Button
variant="contained"
color="primary"
onClick={handleSyncM18ProductByCode}
disabled={isSyncingM18Product}
>
{isSyncingM18Product ? "Syncing..." : "Sync product from M18"}
</Button>
</Stack>
{m18ProductSyncResult ? (
<TextField
fullWidth
multiline
minRows={4}
margin="normal"
label="Sync Result"
value={m18ProductSyncResult}
InputProps={{ readOnly: true }}
/>
) : null}
</Section>
</TabPanel>

</Box>
);
}

+ 1
- 1
src/app/(main)/pickOrder/detail/page.tsx Näytä tiedosto

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

return (
<>
<I18nProvider namespaces={["pickOrder"]}>
<I18nProvider namespaces={["pickOrder","navigation","common","purchaseOrder"]}>
<Suspense fallback={<PickOrderDetail.Loading />}>
<PickOrderDetail consoCode={`${searchParams["consoCode"]}`} />
</Suspense>


+ 2
- 2
src/app/(main)/pickOrder/page.tsx Näytä tiedosto

@@ -13,11 +13,11 @@ export const metadata: Metadata = {
const PickOrder: React.FC = async () => {
const { t } = await getServerI18n("pickOrder");

PreloadPickOrder();
//PreloadPickOrder();

return (
<>
<I18nProvider namespaces={["pickOrder", "common"]}>
<I18nProvider namespaces={["pickOrder","navigation","common","purchaseOrder","home","item"]}>
<Suspense fallback={<PickOrderSearch.Loading />}>
<PickOrderSearch />
</Suspense>


+ 1
- 1
src/app/(main)/po/edit/page.tsx Näytä tiedosto

@@ -31,7 +31,7 @@ const PoEdit: React.FC<Props> = async ({ searchParams }) => {
return (
<>
{/* <Typography variant="h4">{t("Create Material")}</Typography> */}
<I18nProvider namespaces={[type, "dashboard"]}>
<I18nProvider namespaces={[type, "navigation", "common", "dashboard", "home"]}>
<Suspense fallback={<PoDetail.Loading />}>
<PoDetail id={id} />
</Suspense>


+ 1
- 1
src/app/(main)/po/page.tsx Näytä tiedosto

@@ -17,7 +17,7 @@ const PurchaseOrder: React.FC = async () => {
// preloadClaims();
return (
<>
<I18nProvider namespaces={["purchaseOrder", "common", "items","dashboard"]}>
<I18nProvider namespaces={["purchaseOrder","navigation","common","dashboard"]}>
<Stack
direction="row"
justifyContent="space-between"


+ 20
- 0
src/app/(main)/po/workbench/PoWorkbenchPageClient.tsx Näytä tiedosto

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

import Box from "@mui/material/Box";
import PoWorkbenchShell from "@/components/PoWorkbench/PoWorkbenchShell";

export default function PoWorkbenchPageClient() {
return (
<Box
sx={{
flex: 1,
alignSelf: "stretch",
height: "100%",
minHeight: 0,
overflow: "hidden",
}}
>
<PoWorkbenchShell />
</Box>
);
}

+ 32
- 0
src/app/(main)/po/workbench/layout.tsx Näytä tiedosto

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

import Box from "@mui/material/Box";

/**
* Segment layout for `/po/workbench`: constrains children to the main content height
* established by `MainContentArea` (viewport minus the AppBar toolbar) and prevents
* overflow from propagating to the document scroll.
*
* Document `overflow: hidden` for this route is set in `global.css` via
* `html:has([data-po-workbench-layout])` (no per-route `useEffect` on `html`/`body`).
*/
export default function PoWorkbenchLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<Box
data-po-workbench-layout=""
sx={{
boxSizing: "border-box",
flex: 1,
height: "100%",
minHeight: 0,
overflow: "hidden",
}}
>
{children}
</Box>
);
}

+ 25
- 0
src/app/(main)/po/workbench/page.tsx Näytä tiedosto

@@ -0,0 +1,25 @@
import Box from "@mui/material/Box";
import { I18nProvider } from "@/i18n";
import PoWorkbenchPageClient from "./PoWorkbenchPageClient";

/**
* Purchase Order Workbench page (`/po/workbench`).
* Translations: `poWorkbench` (nested keys), shared PO filters: `purchaseOrder`, plus `common`.
*/
export default function PoWorkbenchPage() {
return (
<Box
sx={{
flex: 1,
minHeight: 0,
height: "100%",
display: "flex",
flexDirection: "column",
}}
>
<I18nProvider namespaces={["poWorkbench","navigation","common","purchaseOrder"]}>
<PoWorkbenchPageClient />
</I18nProvider>
</Box>
);
}

+ 2
- 2
src/app/(main)/production/page.tsx Näytä tiedosto

@@ -15,7 +15,7 @@ export const metadata: Metadata = {
};

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


+ 6
- 6
src/app/(main)/productionProcess/page.tsx Näytä tiedosto

@@ -1,12 +1,10 @@
import ProductionProcessPage from "../../../components/ProductionProcess/ProductionProcessPage";
import ProductionProcessLoading from "../../../components/ProductionProcess/ProductionProcessLoading";
import { I18nProvider, getServerI18n } from "../../../i18n";

import Add from "@mui/icons-material/Add";
import Button from "@mui/material/Button";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { Metadata } from "next";
import Link from "next/link";
import { Suspense } from "react";
import { fetchPrinterCombo } from "@/app/api/settings/printer";

@@ -15,7 +13,7 @@ export const metadata: Metadata = {
};

const productionProcess: React.FC = async () => {
const { t } = await getServerI18n("common");
const { t } = await getServerI18n("productionProcess", "navigation", "common");
const printerCombo = await fetchPrinterCombo();
return (
<>
@@ -38,8 +36,10 @@ const productionProcess: React.FC = async () => {
{t("Create Process")}
</Button> */}
</Stack>
<I18nProvider namespaces={["common", "production","purchaseOrder","jo"]}>
<ProductionProcessPage printerCombo={printerCombo} />
<I18nProvider namespaces={["productionProcess","navigation","common","purchaseOrder","jo","dashboard"]}>
<Suspense fallback={<ProductionProcessLoading />}>
<ProductionProcessPage printerCombo={printerCombo} />
</Suspense>
</I18nProvider>
</>
);


+ 2
- 2
src/app/(main)/projects/create/page.tsx Näytä tiedosto

@@ -11,12 +11,12 @@ export const metadata: Metadata = {
};

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

return (
<>
<Typography variant="h4">{t("Create Project")}</Typography>
<I18nProvider namespaces={["projects"]}>
<I18nProvider namespaces={["project","navigation","common"]}>
<CreateProject />
</I18nProvider>
</>


+ 2
- 2
src/app/(main)/projects/page.tsx Näytä tiedosto

@@ -20,7 +20,7 @@ export const metadata: Metadata = {
};

const Projects: React.FC = async () => {
const { t } = await getServerI18n("projects");
const { t } = await getServerI18n("project");
preloadProjects();

return (
@@ -43,7 +43,7 @@ const Projects: React.FC = async () => {
{t("Create Project")}
</Button>
</Stack>
<I18nProvider namespaces={["project"]}>
<I18nProvider namespaces={["project","navigation","common"]}>
<Suspense fallback={<ProjectSearch.Loading />}>
<ProjectSearch />
</Suspense>


+ 13
- 0
src/app/(main)/ps/layout.tsx Näytä tiedosto

@@ -0,0 +1,13 @@
import { I18nProvider } from "@/i18n";

export default function PsLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<I18nProvider namespaces={["scheduling", "navigation", "common"]}>
{children}
</I18nProvider>
);
}

+ 1273
- 219
src/app/(main)/ps/page.tsx
File diff suppressed because it is too large
Näytä tiedosto


+ 1
- 1
src/app/(main)/putAway/page.tsx Näytä tiedosto

@@ -28,7 +28,7 @@ const PutAway: React.FC = async () => {
{t("Put Away")}
</Typography>
</Stack>
<I18nProvider namespaces={["putAway", "purchaseOrder", "common"]}>
<I18nProvider namespaces={["putAway","navigation","common","purchaseOrder"]}>
<Suspense fallback={<PutAwayScan.Loading />}>
<PutAwayScan />
</Suspense>


+ 37
- 0
src/app/(main)/putAwayCam/page.tsx Näytä tiedosto

@@ -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","navigation","common","purchaseOrder"]}>
<Suspense fallback={<PutAwayCamScanWrapper.Loading />}>
<PutAwayCamScanWrapper />
</Suspense>
</I18nProvider>
</>
);
};

export default PutAwayCamPage;


+ 70
- 0
src/app/(main)/report/GRN_REPORT_BACKEND_SPEC.md Näytä tiedosto

@@ -0,0 +1,70 @@
# 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",
"supplierCode": "P06",
"supplier": "Supplier Name",
"status": "completed",
"grnCode": "PPP004GRN26030298",
"grnId": 7854617
}
]
}
```

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. Columns include: PO No., Delivery Note No., Receipt Date, Item Code, Item Name, Qty, Demand Qty, UOM, Supplier Lot No. 供應商批次, Expiry Date, Supplier Code, Supplier, 入倉狀態, **GRN Code** (`m18_goods_receipt_note_log.grn_code`), **GRN Id** (`m18_record_id`).

## Frontend Excel styling (shared standard)

Header colours, number formats (`#,##0.00` for amounts), and column alignment are defined in:

**[`../chart/_components/EXCEL_EXPORT_STANDARD.md`](../chart/_components/EXCEL_EXPORT_STANDARD.md)**

Use that document when adding or changing Excel exports so formatting stays consistent.

+ 189
- 0
src/app/(main)/report/ReportSelectionDashboard.tsx Näytä tiedosto

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

import React from "react";
import {
Box,
Card,
CardContent,
Grid,
Typography,
} from "@mui/material";
import Inventory2OutlinedIcon from "@mui/icons-material/Inventory2Outlined";
import MonetizationOnOutlinedIcon from "@mui/icons-material/MonetizationOnOutlined";
import LayersOutlinedIcon from "@mui/icons-material/LayersOutlined";
import SearchOutlinedIcon from "@mui/icons-material/SearchOutlined";
import LocalShippingOutlinedIcon from "@mui/icons-material/LocalShippingOutlined";
import OutboundOutlinedIcon from "@mui/icons-material/OutboundOutlined";
import BarChartOutlinedIcon from "@mui/icons-material/BarChartOutlined";
import PieChartOutlineOutlinedIcon from "@mui/icons-material/PieChartOutlineOutlined";
import type { SvgIconComponent } from "@mui/icons-material";
import { REPORTS } from "@/config/reportConfig";
import { REPORT_CATEGORIES, type ReportCategoryConfig } from "./reportCategories";

const REPORT_ICON_MAP: Record<string, SvgIconComponent> = {
"rep-011": Inventory2OutlinedIcon,
"rep-007": MonetizationOnOutlinedIcon,
"rep-012": LayersOutlinedIcon,
"rep-010": SearchOutlinedIcon,
"rep-004": LocalShippingOutlinedIcon,
"rep-014": LocalShippingOutlinedIcon,
"rep-008": OutboundOutlinedIcon,
"rep-009": OutboundOutlinedIcon,
"rep-013": LocalShippingOutlinedIcon,
"rep-006": BarChartOutlinedIcon,
"rep-005": PieChartOutlineOutlinedIcon,
"rep-015": LayersOutlinedIcon,
};

const reportById = Object.fromEntries(REPORTS.map((r) => [r.id, r]));

interface ReportSelectionDashboardProps {
selectedReportId: string;
onSelectReport: (reportId: string) => void;
}

function ReportCard({
reportId,
title,
accent,
selected,
onClick,
}: {
reportId: string;
title: string;
accent: string;
selected: boolean;
onClick: () => void;
}) {
const Icon = REPORT_ICON_MAP[reportId] ?? Inventory2OutlinedIcon;

return (
<Box
component="button"
type="button"
onClick={onClick}
sx={{
display: "flex",
alignItems: "center",
gap: 1.5,
width: "100%",
p: 1.5,
textAlign: "left",
cursor: "pointer",
border: "1px solid",
borderColor: selected ? accent : "divider",
borderRadius: 1.5,
bgcolor: "background.paper",
boxShadow: selected ? 2 : "0 1px 3px rgba(0,0,0,0.06)",
transition: "border-color 0.15s, box-shadow 0.15s",
"&:hover": {
borderColor: accent,
boxShadow: 2,
},
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 40,
height: 40,
borderRadius: 1,
bgcolor: `${accent}18`,
color: accent,
flexShrink: 0,
}}
>
<Icon fontSize="small" />
</Box>
<Typography variant="body2" fontWeight={selected ? 600 : 400} sx={{ lineHeight: 1.35 }}>
{title}
</Typography>
</Box>
);
}

function CategoryColumn({
category,
selectedReportId,
onSelectReport,
}: {
category: ReportCategoryConfig;
selectedReportId: string;
onSelectReport: (reportId: string) => void;
}) {
const reports = category.reportIds
.map((id) => reportById[id])
.filter(Boolean);

return (
<Box
sx={{
display: "flex",
flexDirection: "column",
height: "100%",
borderRadius: 1.5,
overflow: "hidden",
border: "1px solid",
borderColor: "divider",
}}
>
<Box
sx={{
px: 2,
py: 1.25,
bgcolor: category.headerBg,
}}
>
<Typography variant="subtitle1" fontWeight="bold">
{category.title}
</Typography>
</Box>
<Box
sx={{
flex: 1,
p: 1.5,
bgcolor: category.bodyBg,
}}
>
<Grid container spacing={1.5}>
{reports.map((report) => (
<Grid item xs={6} key={report.id}>
<ReportCard
reportId={report.id}
title={report.title}
accent={category.accent}
selected={selectedReportId === report.id}
onClick={() => onSelectReport(report.id)}
/>
</Grid>
))}
</Grid>
</Box>
</Box>
);
}

export default function ReportSelectionDashboard({
selectedReportId,
onSelectReport,
}: ReportSelectionDashboardProps) {
return (
<Card sx={{ mb: 4, boxShadow: 2, border: "1px solid", borderColor: "divider" }}>
<CardContent sx={{ p: { xs: 2, sm: 3 } }}>
<Grid container spacing={2}>
{REPORT_CATEGORIES.map((category) => (
<Grid item xs={12} md={4} key={category.id}>
<CategoryColumn
category={category}
selectedReportId={selectedReportId}
onSelectReport={onSelectReport}
/>
</Grid>
))}
</Grid>
</CardContent>
</Card>
);
}

+ 209
- 0
src/app/(main)/report/SemiFGProductionAnalysisReport.tsx Näytä tiedosto

@@ -0,0 +1,209 @@
"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;
onExportSuccess?: (format: "pdf" | "excel") => void;
}

export default function SemiFGProductionAnalysisReport({
criteria,
requiredFieldLabels,
loading,
setLoading,
reportTitle = '成品/半成品生產分析報告',
onExportSuccess,
}: 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);
}
onExportSuccess?.(format);
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"
startIcon={<DownloadIcon />}
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>
</>
);
}

+ 205
- 0
src/app/(main)/report/bomShopSyncReportApi.ts Näytä tiedosto

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

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

export interface BomShopSyncReportSummary {
totalAttempts?: number;
success?: number;
skippedUnchanged?: number;
failed?: number;
syncDateStart?: string;
syncDateEnd?: string;
}

export interface BomShopSyncRow {
syncLogId?: number;
syncDateTime?: string;
bomId?: number;
bomRoutingCode?: string;
finishedItemCode?: string;
finishedItemName?: string;
m18HeaderCode?: string;
version?: string;
m18RecordId?: number;
syncStatus?: string;
synced?: boolean;
m18ApiStatus?: boolean;
failureReason?: string;
message?: string;
}

export interface BomShopSyncMaterialRow {
syncLogId?: number;
syncDateTime?: string;
bomId?: number;
finishedItemCode?: string;
m18HeaderCode?: string;
version?: string;
syncStatus?: string;
lineNo?: string;
materialName?: string;
udfProductM18Id?: number;
udfBaseUnit?: string;
udfQty?: number;
udfSupplierM18Id?: number;
udfPurchaseUnitM18Id?: number;
}

export interface BomShopSyncReportResponse {
summary?: BomShopSyncReportSummary;
syncRows?: BomShopSyncRow[];
materialRows?: BomShopSyncMaterialRow[];
}

const SHEET_SYNC = "BOM同步記錄";
const SHEET_MATERIALS = "BOM物料明細";

const NO_DATA_NOTE =
"(篩選範圍內無資料 / No records in the selected range)";

/** Column keys for sheet 1 — used for headers when there are no data rows. */
function emptySyncSheetRow(note: string = NO_DATA_NOTE): Record<string, unknown> {
return {
同步時間: note,
成品貨號: "",
成品名稱: "",
BOM路由編號: "",
"M18 BOM Code": "",
版本: "",
"M18 Record Id": "",
狀態: "",
失敗原因: "",
訊息: "",
"BOM Id": "",
"Sync Log Id": "",
};
}

/** Column keys for sheet 2 — used for headers when there are no data rows. */
function emptyMaterialSheetRow(note: string = NO_DATA_NOTE): Record<string, unknown> {
return {
同步時間: note,
成品貨號: "",
"M18 BOM Code": "",
版本: "",
狀態: "",
行號: "",
物料名稱: "",
"M18 Product Id": "",
單位: "",
用量: "",
"M18 Supplier Id": "",
"M18 Purchase Unit Id": "",
"Sync Log Id": "",
};
}

export async function fetchBomShopSyncReportData(
criteria: Record<string, string>,
): Promise<BomShopSyncReportResponse> {
const queryParams = new URLSearchParams(criteria).toString();
const url = `${NEXT_PUBLIC_API_URL}/report/bom-shop-sync-history?${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}`);

return (await response.json()) as BomShopSyncReportResponse;
}

function syncStatusLabel(status: string | undefined): string {
switch (status) {
case "SUCCESS":
return "成功";
case "SKIPPED_UNCHANGED":
return "略過(內容未變)";
case "FAILED":
return "失敗";
default:
return status ?? "";
}
}

function toSyncExcelRow(r: BomShopSyncRow): Record<string, unknown> {
const base = emptySyncSheetRow("");
return {
...base,
同步時間: r.syncDateTime ?? "",
成品貨號: r.finishedItemCode ?? "",
成品名稱: r.finishedItemName ?? "",
BOM路由編號: r.bomRoutingCode ?? "",
"M18 BOM Code": r.m18HeaderCode ?? "",
版本: r.version ?? "",
"M18 Record Id": r.m18RecordId ?? "",
狀態: syncStatusLabel(r.syncStatus),
失敗原因: r.failureReason ?? "",
訊息: r.message ?? "",
"BOM Id": r.bomId ?? "",
"Sync Log Id": r.syncLogId ?? "",
};
}

function toMaterialExcelRow(r: BomShopSyncMaterialRow): Record<string, unknown> {
const base = emptyMaterialSheetRow("");
return {
...base,
同步時間: r.syncDateTime ?? "",
成品貨號: r.finishedItemCode ?? "",
"M18 BOM Code": r.m18HeaderCode ?? "",
版本: r.version ?? "",
狀態: syncStatusLabel(r.syncStatus),
行號: r.lineNo ?? "",
物料名稱: r.materialName ?? "",
"M18 Product Id": r.udfProductM18Id ?? "",
單位: r.udfBaseUnit ?? "",
用量: r.udfQty ?? "",
"M18 Supplier Id": r.udfSupplierM18Id ?? "",
"M18 Purchase Unit Id": r.udfPurchaseUnitM18Id ?? "",
"Sync Log Id": r.syncLogId ?? "",
};
}

export async function generateBomShopSyncReportExcel(
criteria: Record<string, string>,
reportTitle: string = "M18 BOM Shop 同步記錄",
): Promise<void> {
const data = await fetchBomShopSyncReportData(criteria);
const syncRows =
(data.syncRows ?? []).length > 0
? (data.syncRows ?? []).map(toSyncExcelRow)
: [emptySyncSheetRow()];
const materialRows =
(data.materialRows ?? []).length > 0
? (data.materialRows ?? []).map(toMaterialExcelRow)
: [emptyMaterialSheetRow()];

const start = criteria.syncDateStart;
const end = criteria.syncDateEnd;
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 filename = `${reportTitle}_${datePart.replace(/[^\d\-_/]/g, "")}`;

exportMultiSheetToXlsx(
[
{ name: SHEET_SYNC, rows: syncRows },
{ name: SHEET_MATERIALS, rows: materialRows },
],
filename,
);
}

+ 238
- 0
src/app/(main)/report/grnReportApi.ts Näytä tiedosto

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

import { NEXT_PUBLIC_API_URL } from "@/config/api";
import { clientAuthFetch } from "@/app/utils/clientAuthFetch";
import {
exportChartToXlsx,
exportMultiSheetToXlsx,
} 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;
/** PO line unit price (purchase_order_line.up) */
unitPrice?: number;
/** unitPrice × acceptedQty */
lineAmount?: number;
/** PO currency code (currency.code) */
currencyCode?: string;
/** M18 AN document code from m18_goods_receipt_note_log.grn_code */
grnCode?: string;
/** M18 record id (m18_record_id) */
grnId?: number | string;
/** From purchase_order.m18CreatedUId; e.g. "2569 (legato)" */
poM18CreatorDisplay?: string;
[key: string]: unknown;
}

/** Sheet "已上架PO金額": totals grouped by receipt date + currency / PO (ADMIN-only data from API). */
export interface ListedPoAmounts {
currencyTotals: {
receiptDate?: string;
currencyCode?: string;
totalAmount?: number;
}[];
byPurchaseOrder: {
receiptDate?: string;
poCode?: string;
currencyCode?: string;
totalAmount?: number;
grnCodes?: string;
}[];
}

export interface GrnReportResponse {
rows: GrnReportRow[];
listedPoAmounts?: ListedPoAmounts;
}

/**
* 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<{ rows: GrnReportRow[]; listedPoAmounts?: ListedPoAmounts }> {
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[];
if (Array.isArray(data)) {
return { rows: data };
}
const body = data as GrnReportResponse;
return {
rows: body.rows ?? [],
listedPoAmounts: body.listedPoAmounts,
};
}

/** Coerce API JSON (number or numeric string) to a finite number. */
function coerceToFiniteNumber(value: unknown): number | null {
if (value === null || value === undefined) return null;
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const t = value.trim();
if (t === "") return null;
const n = Number(t);
return Number.isFinite(n) ? n : null;
}
return null;
}

/**
* Cell value for money columns: numeric when possible so Excel export can apply `#,##0.00` (see exportChartToXlsx).
*/
function moneyCellValue(v: unknown): number | string {
const n = coerceToFiniteNumber(v);
if (n === null) return "";
return n;
}

/** Thousands separator for quantities (up to 4 decimal places, trims trailing zeros). */
const formatQty = (n: number | undefined | null): string => {
if (n === undefined || n === null || Number.isNaN(Number(n))) return "";
return new Intl.NumberFormat("en-US", {
minimumFractionDigits: 0,
maximumFractionDigits: 4,
}).format(Number(n));
};

/** Excel column headers (bilingual) for GRN report */
function toExcelRow(
r: GrnReportRow,
includeFinancialColumns: boolean
): Record<string, string | number | undefined> {
const base: Record<string, string | number | undefined> = {
"PO No. / 訂單編號": r.poCode ?? "",
"Delivery Note No. / 送貨單編號": r.deliveryNoteNo ?? "",
"Receipt Date / 收貨日期": r.receiptDate ?? "",
"Item Code / 物料編號": r.itemCode ?? "",
"Item Name / 物料名稱": r.itemName ?? "",
"Qty / 數量": formatQty(
r.acceptedQty ?? r.receivedQty ?? undefined
),
"Demand Qty / 訂單數量": formatQty(r.demandQty),
"UOM / 單位": r.uom ?? r.purchaseUomDesc ?? r.stockUomDesc ?? "",
"Supplier Lot No. 供應商批次": r.productLotNo ?? "",
"Expiry Date / 到期日": r.expiryDate ?? "",
"Supplier Code / 供應商編號": r.supplierCode ?? "",
"Supplier / 供應商": r.supplier ?? "",
"入倉狀態": r.status ?? "",
};
if (includeFinancialColumns) {
base["Unit Price / 單價"] = moneyCellValue(r.unitPrice);
base["Currency / 貨幣"] = r.currencyCode ?? "";
base["Amount / 金額"] = moneyCellValue(r.lineAmount);
}
base["GRN Code / M18 入倉單號"] = r.grnCode ?? "";
base["GRN Id / M18 記錄編號"] = r.grnId ?? "";
base["PO建立者(M18) / PO creator (M18)"] = r.poM18CreatorDisplay ?? "";
return base;
}

const GRN_SHEET_DETAIL = "PO入倉記錄";
const GRN_SHEET_LISTED_PO = "已上架PO金額";

/** Rows for sheet "已上架PO金額" (ADMIN-only; do not add this sheet for other users). */
function buildListedPoAmountSheetRows(
listed: ListedPoAmounts | undefined
): Record<string, string | number | undefined>[] {
if (
!listed ||
(listed.currencyTotals.length === 0 &&
listed.byPurchaseOrder.length === 0)
) {
return [
{
"Note / 備註":
"(篩選範圍內無已完成之 PO 行) / No completed PO lines in the selected range",
},
];
}
const out: Record<string, string | number | undefined>[] = [];
for (const c of listed.currencyTotals) {
out.push({
"Category / 類別": "貨幣小計 / Currency total",
"Receipt Date / 收貨日期": c.receiptDate ?? "",
"PO No. / 訂單編號": "",
"Currency / 貨幣": c.currencyCode ?? "",
"Total Amount / 金額": moneyCellValue(c.totalAmount),
"GRN Code(s) / M18 入倉單號": "",
});
}
for (const p of listed.byPurchaseOrder) {
out.push({
"Category / 類別": "訂單 / PO",
"Receipt Date / 收貨日期": p.receiptDate ?? "",
"PO No. / 訂單編號": p.poCode ?? "",
"Currency / 貨幣": p.currencyCode ?? "",
"Total Amount / 金額": moneyCellValue(p.totalAmount),
"GRN Code(s) / M18 入倉單號": p.grnCodes ?? "",
});
}
return out;
}

/**
* Generate and download GRN report as Excel.
* Sheet "已上架PO金額" is included only when `includeFinancialColumns` is true (ADMIN).
*/
export async function generateGrnReportExcel(
criteria: Record<string, string>,
reportTitle: string = "PO 入倉記錄",
/** Only users with ADMIN authority should pass true (must match backend). */
includeFinancialColumns: boolean = false
): Promise<void> {
const { rows, listedPoAmounts } = await fetchGrnReportData(criteria);
const excelRows = rows.map((r) => toExcelRow(r, includeFinancialColumns));
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}`;

if (includeFinancialColumns) {
const sheet2 = buildListedPoAmountSheetRows(listedPoAmounts);
exportMultiSheetToXlsx(
[
{ name: GRN_SHEET_DETAIL, rows: excelRows as Record<string, unknown>[] },
{ name: GRN_SHEET_LISTED_PO, rows: sheet2 as Record<string, unknown>[] },
],
filename
);
} else {
exportChartToXlsx(excelRows as Record<string, unknown>[], filename, GRN_SHEET_DETAIL);
}
}

+ 26
- 0
src/app/(main)/report/layout.tsx Näytä tiedosto

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

export default async function ReportLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getServerSession(authOptions);
const abilities = session?.user?.abilities ?? [];
const canAccess =
abilities.includes(AUTH.REPORT_MGMT) || abilities.includes(AUTH.ADMIN);

if (!canAccess) {
redirect("/dashboard");
}

return (
<I18nProvider namespaces={["report", "navigation", "common"]}>
{children}
</I18nProvider>
);
}

+ 628
- 67
src/app/(main)/report/page.tsx Näytä tiedosto

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

import React, { useState, useMemo } from 'react';
import React, { useState, useMemo, useEffect } from 'react';
import { useSession } from "next-auth/react";
import { SessionWithTokens } from "@/config/authConfig";
import { AUTH } from "@/authorities";
import {
Box,
Card,
@@ -10,59 +13,335 @@ import {
TextField,
Button,
Grid,
Divider
Divider,
Chip,
Autocomplete,
Checkbox,
FormControlLabel,
} 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 ReportSelectionDashboard from './ReportSelectionDashboard';
import {
fetchSemiFGItemCodes,
fetchSemiFGItemCodesWithCategory
} from './semiFGProductionAnalysisApi';
import { generateGrnReportExcel } from './grnReportApi';
import { generateBomShopSyncReportExcel } from './bomShopSyncReportApi';
import {
FEATURE_USAGE,
FEATURE_USAGE_ACTION,
logFeatureUsage,
} from '@/lib/featureUsageLog';

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

export default function ReportPage() {
const { data: session } = useSession() as { data: SessionWithTokens | null };
const includeGrnFinancialColumns =
session?.abilities?.includes(AUTH.ADMIN) ?? false;

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 rep012RoundIds = useMemo(() => {
if (selectedReportId !== 'rep-012') return [] as string[];
return (criteria.stockTakeRoundId || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
}, [selectedReportId, criteria.stockTakeRoundId]);

const rep012MultiRound = rep012RoundIds.length > 1;

const currentReport = useMemo(() =>
REPORTS.find((r) => r.id === selectedReportId),
[selectedReportId]);

const handleReportChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setSelectedReportId(event.target.value);
setCriteria({}); // Clear criteria when switching reports
const handleSelectReport = (reportId: string) => {
if (reportId === selectedReportId) return;
setSelectedReportId(reportId);
setCriteria({});
};

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

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({});

// Default "All" (no filter) for stock take variance report conditions.
if (selectedReportId === 'rep-012') {
setCriteria({
store_id: 'All',
status: 'All',
type: 'All',
});
}
}, [selectedReportId]);

/** rep-012:多選輪次時狀態固定為已審核 */
useEffect(() => {
if (selectedReportId !== 'rep-012' || !rep012MultiRound) return;
if (criteria.status === 'completed') return;
setCriteria((prev) => ({ ...prev, status: 'completed' }));
}, [selectedReportId, rep012MultiRound, criteria.status]);

// React 18 Strict Mode (dev) mounts → unmounts → remounts, so effects with [] run twice.
// Dedupe PAGE_VIEW within a short window so 進入頁面次數 is +1 per real visit.
useEffect(() => {
if (typeof window === "undefined") return;
const w = window as Window & { __fpsmsReportPageViewLoggedAt?: number };
const now = Date.now();
if (w.__fpsmsReportPageViewLoggedAt != null && now - w.__fpsmsReportPageViewLoggedAt < 2000) {
return;
}
w.__fpsmsReportPageViewLoggedAt = now;
logFeatureUsage(FEATURE_USAGE.REPORT_MANAGEMENT, FEATURE_USAGE_ACTION.PAGE_VIEW);
}, []);

const validateRequiredFields = () => {
if (!currentReport) return true;

// 1. Mandatory Field Validation
if (currentReport.id === 'rep-012') {
if (rep012RoundIds.length === 0) {
alert('缺少必填條件:\n- 盤點輪次');
return false;
}
return true;
}

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

/** rep-012:單輪送 status;多輪送 stockTakeRoundId 清單且 status=completed */
const buildRep012QueryString = (): string => {
const p = new URLSearchParams();
p.set('stockTakeRoundId', rep012RoundIds.join(','));
const code = criteria.itemCode?.trim();
if (code) p.set('itemCode', code);
const store = criteria.store_id?.trim();
if (store && store !== 'All') p.set('store_id', store);
if (rep012MultiRound) {
p.set('status', 'completed');
} else {
const status = criteria.status?.trim();
if (status && status !== 'All') p.set('status', status);
}
const lotType = criteria.type?.trim();
if (lotType && lotType !== 'All') p.set('type', lotType);
return p.toString();
};

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 {
const token = localStorage.getItem("accessToken");
const queryParams = new URLSearchParams(criteria).toString();
if (currentReport.id === 'rep-014') {
await generateGrnReportExcel(
criteria,
currentReport.title,
includeGrnFinancialColumns
);
} else if (currentReport.id === 'rep-015') {
await generateBomShopSyncReportExcel(criteria, currentReport.title);
} else {
// Backend returns actual .xlsx bytes for this Excel endpoint.
const queryParams =
currentReport.id === 'rep-012'
? buildRep012QueryString()
: 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);
}
if (currentReport) {
logFeatureUsage(
FEATURE_USAGE.REPORT_MANAGEMENT,
FEATURE_USAGE_ACTION.DOWNLOAD,
`${currentReport.id}:excel`,
);
}
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 queryParams =
currentReport.id === 'rep-012'
? buildRep012QueryString()
: 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 +359,14 @@ export default function ReportPage() {
link.click();
link.remove();
window.URL.revokeObjectURL(downloadUrl);

logFeatureUsage(
FEATURE_USAGE.REPORT_MANAGEMENT,
FEATURE_USAGE_ACTION.DOWNLOAD,
`${currentReport.id}:pdf`,
);
setShowConfirmDialog(false);
} catch (error) {
console.error("Failed to generate report:", error);
alert("An error occurred while generating the report. Please try again.");
@@ -89,75 +376,349 @@ export default function ReportPage() {
};

return (
<Box sx={{ p: 4, maxWidth: 1000, margin: '0 auto' }}>
<Box sx={{ p: 4, maxWidth: 1280, 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"
value={selectedReportId}
onChange={handleReportChange}
helperText="Please select which report you want to generate"
>
{REPORTS.map((report) => (
<MenuItem key={report.id} value={report.id}>
{report.title}
</MenuItem>
))}
</TextField>
</CardContent>
</Card>

<ReportSelectionDashboard
selectedReportId={selectedReportId}
onSelectReport={handleSelectReport}
/>

{currentReport && (
<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 };

const disabledByCheckedCheckbox = currentReport.fields.some((f) => {
if (f.type !== 'checkbox' || criteria[f.name] !== 'true') return false;
return f.disablesFieldsWhenChecked?.includes(field.name) ?? false;
});
const disabledRep012Status =
currentReport.id === 'rep-012' &&
field.name === 'status' &&
rep012MultiRound;

if (field.type === 'checkbox') {
return (
<Grid item {...gridSize} key={field.name}>
<FormControlLabel
control={
<Checkbox
checked={criteria[field.name] === 'true'}
onChange={(e) =>
handleFieldChange(field.name, e.target.checked ? 'true' : '')
}
/>
}
label={field.label}
/>
</Grid>
);
}

// 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}
disabled={disabledByCheckedCheckbox || disabledRep012Status}
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
.map((v) => {
const opt = options.find((o) => o.value === v);
return opt?.label ?? String(v);
})
.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}
onExportSuccess={(format) => {
logFeatureUsage(
FEATURE_USAGE.REPORT_MANAGEMENT,
FEATURE_USAGE_ACTION.DOWNLOAD,
`${currentReport.id}:${format}`,
);
}}
/>
) : currentReport.id === 'rep-013' || currentReport.id === 'rep-009' || currentReport.id === 'rep-012' || currentReport.id === 'rep-004' || currentReport.id === 'rep-007' || currentReport.id === 'rep-008' || currentReport.id === 'rep-011' ? (
<>
<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.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.id === 'rep-010' ? (
<>
<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>


+ 38
- 0
src/app/(main)/report/reportCategories.ts Näytä tiedosto

@@ -0,0 +1,38 @@
export type ReportCategoryId = "inventory" | "inbound-outbound" | "production";

export interface ReportCategoryConfig {
id: ReportCategoryId;
title: string;
headerBg: string;
bodyBg: string;
accent: string;
reportIds: string[];
}

/** Display order and grouping for the report management dashboard. */
export const REPORT_CATEGORIES: ReportCategoryConfig[] = [
{
id: "inventory",
title: "庫存管理",
headerBg: "#b8e0b8",
bodyBg: "#eef8ee",
accent: "#2e7d32",
reportIds: ["rep-011", "rep-007", "rep-012", "rep-010"],
},
{
id: "inbound-outbound",
title: "出入倉作業",
headerBg: "#b3d4f0",
bodyBg: "#eef5fc",
accent: "#1565c0",
reportIds: ["rep-004", "rep-014", "rep-008", "rep-009", "rep-013"],
},
{
id: "production",
title: "生產與趨勢",
headerBg: "#f5d4a8",
bodyBg: "#fdf6ec",
accent: "#e65100",
reportIds: ["rep-006", "rep-005", "rep-015"],
},
];

+ 141
- 0
src/app/(main)/report/semiFGProductionAnalysisApi.ts Näytä tiedosto

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

+ 84
- 0
src/app/(main)/report/truckRoutingSummaryApi.ts Näytä tiedosto

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

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

export interface ReportOption {
label: string;
value: string;
}

export interface TruckRoutingSummaryPrecheck {
unpickedOrderCount: number;
hasUnpickedOrders: boolean;
}

export async function fetchTruckRoutingStoreOptions(): Promise<ReportOption[]> {
const response = await clientAuthFetch(
`${NEXT_PUBLIC_API_URL}/truck-routing-summary/store-options`,
{
method: "GET",
headers: { "Content-Type": "application/json" },
}
);

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

const data = await response.json();
if (!Array.isArray(data)) return [];
return data.map((item: any) => ({
label: item?.label ?? item?.value ?? "",
value: item?.value ?? "",
}));
}

export async function fetchTruckRoutingLaneOptions(storeId?: string): Promise<ReportOption[]> {
const qs = storeId ? `?storeId=${encodeURIComponent(storeId)}` : "";
const response = await clientAuthFetch(
`${NEXT_PUBLIC_API_URL}/truck-routing-summary/lane-options${qs}`,
{
method: "GET",
headers: { "Content-Type": "application/json" },
}
);

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

const data = await response.json();
if (!Array.isArray(data)) return [];
return data.map((item: any) => ({
label: item?.label ?? item?.value ?? "",
value: item?.value ?? "",
}));
}

export async function fetchTruckRoutingSummaryPrecheck(params: {
storeId: string;
truckLanceCode: string;
date: string;
}): Promise<TruckRoutingSummaryPrecheck> {
const qs = new URLSearchParams({
storeId: params.storeId,
truckLanceCode: params.truckLanceCode,
date: params.date,
}).toString();
const response = await clientAuthFetch(
`${NEXT_PUBLIC_API_URL}/truck-routing-summary/precheck?${qs}`,
{
method: "GET",
headers: { "Content-Type": "application/json" },
}
);
if (!response.ok) {
throw new Error(`Failed to precheck routing summary: ${response.status}`);
}
const data = await response.json();
return {
unpickedOrderCount: Number(data?.unpickedOrderCount ?? 0),
hasUnpickedOrders: Boolean(data?.hasUnpickedOrders),
};
}

+ 1
- 1
src/app/(main)/scheduling/detailed/edit/page.tsx Näytä tiedosto

@@ -45,7 +45,7 @@ const DetailScheduling: React.FC<Props> = async ({ searchParams }) => {
<Typography variant="h4" marginInlineEnd={2}>
{t("FG Production Schedule")}
</Typography>
<I18nProvider namespaces={["schedule", "common"]}>
<I18nProvider namespaces={["schedule","navigation","common"]}>
<Suspense fallback={<DetailedScheduleDetail.Loading />}>
<DetailedScheduleDetail type={type} id={parseInt(id)} />
</Suspense>


+ 1
- 1
src/app/(main)/scheduling/detailed/page.tsx Näytä tiedosto

@@ -31,7 +31,7 @@ const DetailScheduling: React.FC = async () => {
{t("Detail Scheduling")}
</Typography>
</Stack>
<I18nProvider namespaces={["schedule", "common"]}>
<I18nProvider namespaces={["schedule","navigation","common"]}>
<Suspense fallback={<DetailedSchedule.Loading />}>
<DetailedSchedule type={type} />
</Suspense>


+ 1
- 1
src/app/(main)/scheduling/rough/edit/page.tsx Näytä tiedosto

@@ -62,7 +62,7 @@ const roughSchedulingDetail: React.FC<Props> = async ({ searchParams }) => {
{t("Create product")}
</Button> */}
</Stack>
<I18nProvider namespaces={["schedule", "common"]}>
<I18nProvider namespaces={["schedule","navigation","common"]}>
<Suspense fallback={<RoughScheduleDetailView.Loading />}>
<RoughScheduleDetailView type={type} id={parseInt(id)} />
</Suspense>


+ 1
- 1
src/app/(main)/scheduling/rough/page.tsx Näytä tiedosto

@@ -47,7 +47,7 @@ const roughScheduling: React.FC = async () => {
{t("Test Rough Scheduling")}
</Button> */}
</Stack>
<I18nProvider namespaces={["schedule", "common"]}>
<I18nProvider namespaces={["schedule","navigation","common"]}>
<Suspense fallback={<RoughSchedule.Loading />}>
<RoughSchedule type={type} />
</Suspense>


+ 25
- 0
src/app/(main)/settings/bomWeighting/page.tsx Näytä tiedosto

@@ -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("bomWeighting");
const bomWeightingScores = await fetchBomWeightingScores();

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

export default BomWeightingScorePage;

+ 20
- 0
src/app/(main)/settings/clientMonitor/page.tsx Näytä tiedosto

@@ -0,0 +1,20 @@
import { I18nProvider } from "@/i18n";
import ClientMonitorPage from "@/components/ClientMonitor/ClientMonitorPage";
import { isMonitoringEnabled } from "@/config/monitoring";
import { Metadata } from "next";
import { redirect } from "next/navigation";

export const metadata: Metadata = {
title: "裝置連線監控",
};

export default function ClientMonitorRoutePage() {
if (!isMonitoringEnabled) {
redirect("/settings/user");
}
return (
<I18nProvider namespaces={["clientMonitor", "navigation", "common"]}>
<ClientMonitorPage />
</I18nProvider>
);
}

+ 21
- 0
src/app/(main)/settings/deliveryOrderFloor/page.tsx Näytä tiedosto

@@ -0,0 +1,21 @@
import DeliveryOrderFloorSettings from "@/components/DeliveryOrderFloorSettings/DeliveryOrderFloorSettings";
import { getServerI18n, I18nProvider } from "@/i18n";
import { Stack, Typography } from "@mui/material";
import { Metadata } from "next";

export const metadata: Metadata = {
title: "Delivery order floor",
};

export default async function DeliveryOrderFloorPage() {
const { t } = await getServerI18n("deliveryOrderFloor");

return (
<I18nProvider namespaces={["deliveryOrderFloor","navigation","common"]}>
<Stack spacing={2}>
<Typography variant="h4">{t("title")}</Typography>
<DeliveryOrderFloorSettings />
</Stack>
</I18nProvider>
);
}

+ 1
- 1
src/app/(main)/settings/equipment/EquipmentTabs.tsx Näytä tiedosto

@@ -13,7 +13,7 @@ type EquipmentTabsProps = {
const EquipmentTabs: React.FC<EquipmentTabsProps> = ({ onTabChange }) => {
const router = useRouter();
const searchParams = useSearchParams();
const { t } = useTranslation("common");
const { t } = useTranslation(["equipment", "common"]);
const tabFromUrl = searchParams.get("tab");
const initialTabIndex = tabFromUrl ? parseInt(tabFromUrl, 10) : 0;


+ 3
- 3
src/app/(main)/settings/equipment/MaintenanceEdit/page.tsx Näytä tiedosto

@@ -9,8 +9,8 @@ import UpdateMaintenanceForm from "@/components/UpdateMaintenance/UpdateMaintena
type Props = {} & SearchParams;

const MaintenanceEditPage: React.FC<Props> = async ({ searchParams }) => {
const type = "common";
const { t } = await getServerI18n(type);
const type = "equipment";
const { t } = await getServerI18n(type, "navigation", "common");
const id = isString(searchParams["id"])
? parseInt(searchParams["id"])
: undefined;
@@ -20,7 +20,7 @@ const MaintenanceEditPage: React.FC<Props> = async ({ searchParams }) => {
return (
<>
<Typography variant="h4">{t("Update Equipment Maintenance and Repair")}</Typography>
<I18nProvider namespaces={[type]}>
<I18nProvider namespaces={[type, "navigation", "common"]}>
<UpdateMaintenanceForm id={id} />
</I18nProvider>
</>


+ 2
- 2
src/app/(main)/settings/equipment/create/page.tsx Näytä tiedosto

@@ -9,11 +9,11 @@ type Props = {} & SearchParams;

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


+ 3
- 3
src/app/(main)/settings/equipment/edit/page.tsx Näytä tiedosto

@@ -9,8 +9,8 @@ import { notFound } from "next/navigation";
type Props = {} & SearchParams;

const productSetting: React.FC<Props> = async ({ searchParams }) => {
const type = "common";
const { t } = await getServerI18n(type);
const type = "equipment";
const { t } = await getServerI18n(type, "navigation", "common");
const id = isString(searchParams["id"])
? parseInt(searchParams["id"])
: undefined;
@@ -20,7 +20,7 @@ const productSetting: React.FC<Props> = async ({ searchParams }) => {
return (
<>
{/* <Typography variant="h4">{t("Create Material")}</Typography> */}
<I18nProvider namespaces={[type]}>
<I18nProvider namespaces={[type, "navigation", "common"]}>
<CreateEquipmentType id={id} />
</I18nProvider>
</>


+ 3
- 3
src/app/(main)/settings/equipment/page.tsx Näytä tiedosto

@@ -19,8 +19,8 @@ export const metadata: Metadata = {
};

const productSetting: React.FC = async () => {
const type = "common";
const { t } = await getServerI18n(type);
const type = "equipment";
const { t } = await getServerI18n(type, "navigation", "common");

return (
<>
@@ -35,7 +35,7 @@ const productSetting: React.FC = async () => {
</Typography>
</Stack>
<Suspense fallback={<EquipmentSearchLoading />}>
<I18nProvider namespaces={["common", "project"]}>
<I18nProvider namespaces={["equipment","navigation","common","project"]}>
<EquipmentSearchWrapper />
</I18nProvider>
</Suspense>


+ 2
- 2
src/app/(main)/settings/equipmentType/create/page.tsx Näytä tiedosto

@@ -9,11 +9,11 @@ type Props = {} & SearchParams;

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


+ 3
- 3
src/app/(main)/settings/equipmentType/edit/page.tsx Näytä tiedosto

@@ -9,8 +9,8 @@ import { notFound } from "next/navigation";
type Props = {} & SearchParams;

const productSetting: React.FC<Props> = async ({ searchParams }) => {
const type = "common";
const { t } = await getServerI18n(type);
const type = "equipment";
const { t } = await getServerI18n(type, "navigation", "common");
const id = isString(searchParams["id"])
? parseInt(searchParams["id"])
: undefined;
@@ -20,7 +20,7 @@ const productSetting: React.FC<Props> = async ({ searchParams }) => {
return (
<>
{/* <Typography variant="h4">{t("Create Material")}</Typography> */}
<I18nProvider namespaces={[type]}>
<I18nProvider namespaces={[type, "navigation", "common"]}>
<CreateEquipmentType id={id} />
</I18nProvider>
</>


+ 3
- 3
src/app/(main)/settings/equipmentType/page.tsx Näytä tiedosto

@@ -15,8 +15,8 @@ export const metadata: Metadata = {
};

const productSetting: React.FC = async () => {
const type = "common";
const { t } = await getServerI18n(type);
const type = "equipment";
const { t } = await getServerI18n(type, "navigation", "common");
const equipmentTypes = await fetchAllEquipmentTypes();
// preloadClaims();

@@ -41,7 +41,7 @@ const productSetting: React.FC = async () => {
</Button> */}
</Stack>
<Suspense fallback={<EquipmentTypeSearch.Loading />}>
<I18nProvider namespaces={["common", "project"]}>
<I18nProvider namespaces={["equipment","navigation","common","project"]}>
<EquipmentTypeSearch />
</I18nProvider>
</Suspense>


+ 52
- 0
src/app/(main)/settings/importBom/EquipmentTabs.tsx Näytä tiedosto

@@ -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 Näytä tiedosto

@@ -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 = "importBom";
const { t } = await getServerI18n(type, "navigation", "common");
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, "navigation", "common"]}>
<UpdateMaintenanceForm id={id} />
</I18nProvider>
</>
);
};
export default MaintenanceEditPage;

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

Ladataan…
Peruuta
Tallenna