Compare commits

...

552 커밋

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

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
파일 보기


+ 4
- 2
package.json 파일 보기

@@ -5,7 +5,7 @@
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "set NODE_OPTIONS=--inspect&& next start",
"start": "set NODE_OPTIONS=--inspect --max-old-space-size=6144&& next start",
"lint": "next lint",
"type-check": "tsc --noEmit"
},
@@ -65,7 +65,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 파일 보기


+ 267
- 0
scripts/update-chart-i18n.js 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

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

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

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

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

+ 31
- 0
src/app/(main)/chart/_components/DateRangeSelect.tsx 파일 보기

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

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

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

+ 161
- 0
src/app/(main)/chart/_components/EXCEL_EXPORT_STANDARD.md 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
파일 보기


+ 386
- 0
src/app/(main)/chart/joborder/page.tsx 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
파일 보기


+ 54
- 0
src/app/(main)/chart/purchase/exportPurchaseChartMaster.ts 파일 보기

@@ -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
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
파일 보기


+ 61
- 0
src/app/(main)/chart/useChartBoardRefreshPrefs.ts 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

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

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

+ 31
- 0
src/app/(main)/doworkbenchsearch/page.tsx 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
파일 보기


+ 1
- 1
src/app/(main)/putAway/page.tsx 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

@@ -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 파일 보기

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

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

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

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

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

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

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

export default EquipmentTabs;

+ 29
- 0
src/app/(main)/settings/importBom/MaintenanceEdit/page.tsx 파일 보기

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

type Props = {} & SearchParams;

const MaintenanceEditPage: React.FC<Props> = async ({ searchParams }) => {
const type = "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;

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.

불러오는 중...
취소
저장