Compare commits

...

412 Ревизии

Автор SHA1 Съобщение Дата
  kelvin.yau 379d9bf053 New PO Workbench, UI only. преди 1 седмица
  CANCERYS\kw093 6866832a18 putaway fix преди 2 седмици
  CANCERYS\kw093 86ff846ad7 Merge remote-tracking branch 'origin/MergeProblem1' into MergeProblem1 преди 2 седмици
  CANCERYS\kw093 f31675640b merage преди 2 седмици
  CANCERYS\kw093 646320a3cf merage jo actions преди 2 седмици
  [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 преди 3 седмици
  CANCERYS\kw093 d9c3f2c3bb update преди 3 седмици
  CANCERYS\kw093 e1cd48df21 update do ticket ui преди 3 седмици
  CANCERYS\kw093 fd68182fd2 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 преди 3 седмици
  CANCERYS\kw093 6639610f02 update преди 3 седмици
  Tommy\2Fi-Staff 8c724ede0b no message преди 3 седмици
  Tommy\2Fi-Staff dbce92cccd pick order order sequence преди 3 седмици
  CANCERYS\kw093 90fbaf3673 jo auto put away преди 3 седмици
  B.E.N.S.O.N f9dbfd9c3a Truck Routing Summary Update преди 3 седмици
  B.E.N.S.O.N 66e07c5b3e Truck Routing Summary Update преди 3 седмици
  kelvin.yau de25934531 Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/derek/FPSMS-frontend into MergeProblem1 преди 3 седмици
  kelvin.yau 1458db7e20 translate преди 3 седмици
  Tommy\2Fi-Staff 181140738d lotlabelprintmodal update преди 3 седмици
  CANCERYS\kw093 91f5ea88b2 update put away & jo qc/putawayed преди 3 седмици
  Tommy\2Fi-Staff c58898e8bd excels button преди 3 седмици
  [email protected] 2ae88f29e9 no message преди 3 седмици
  Tommy\2Fi-Staff cbc103bf47 Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/jason/FPSMS-frontend into MergeProblem1 преди 3 седмици
  Tommy\2Fi-Staff b005b1c2fb excel version for stocktakevaiance report, lotlabelprint modal show locatioln update преди 3 седмици
  CANCERYS\kw093 b64fe6f25f Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 преди 3 седмици
  CANCERYS\kw093 850bec67aa update преди 3 седмици
  Tommy\2Fi-Staff 1272409bbb lot label print modal for pick order преди 3 седмици
  CANCERYS\kw093 0389cbbc31 update finshed QC part преди 3 седмици
  CANCERYS\kw093 e1a0f56b80 update jo edit and jo compelte reocrd search преди 3 седмици
  CANCERYS\kw093 6e1c2e3d7c updated jo efficient преди 3 седмици
  CANCERYS\kw093 27f062341e update truck X part преди 3 седмици
  CANCERYS\kw093 672fdcd87d update do swtich lot V2 преди 3 седмици
  CANCERYS\kw093 270763a2ae do switch lot update V1 преди 3 седмици
  CANCERYS\kw093 e0d7404898 update jo edit form преди 3 седмици
  CANCERYS\kw093 36c7216fbd efficient improve V3 преди 3 седмици
  kelvin.yau 8164ea3dea new po Animation Fix преди 3 седмици
  kelvin.yau 9fb88afbd7 New PO Testing page + Testing data (cannot be used in prod yet) преди 3 седмици
  CANCERYS\kw093 f74760bb93 disbale edit button again преди 4 седмици
  CANCERYS\kw093 c4154ebc11 update преди 4 седмици
  B.E.N.S.O.N 632d0de6eb TruckRoutingSummary Update преди 4 седмици
  CANCERYS\kw093 dfab4524c4 update преди 4 седмици
  CANCERYS\kw093 4851a4d4c5 update преди 4 седмици
  CANCERYS\kw093 cdad533861 update преди 1 месец
  CANCERYS\kw093 8a262831b2 update преди 1 месец
  CANCERYS\kw093 00233d5353 update преди 1 месец
  B.E.N.S.O.N 9737d94e49 Truck Routing Summary List преди 1 месец
  CANCERYS\kw093 09917026c0 update преди 1 месец
  CANCERYS\kw093 ac423981bd update преди 1 месец
  CANCERYS\kw093 ad35404904 update преди 1 месец
  CANCERYS\kw093 9c9888a44f update преди 1 месец
  CANCERYS\kw093 b826baabe2 update преди 1 месец
  Tommy\2Fi-Staff 0db2ede83c Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/jason/FPSMS-frontend into MergeProblem1 преди 1 месец
  Tommy\2Fi-Staff f3a6822ca8 update преди 1 месец
  CANCERYS\kw093 82eedd7e80 update преди 1 месец
  PC-20260115JRSN\Administrator 790a8c8a60 added red spot for stock in po преди 1 месец
  CANCERYS\kw093 9aecdbbf88 udpate преди 1 месец
  CANCERYS\kw093 a4512e90a1 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 преди 1 месец
  CANCERYS\kw093 e79b060f32 update expiry lot handle in jo/do and show qty will submit and no partly compelte by fronetend преди 1 месец
  PC-20260115JRSN\Administrator 65329be227 added jo process and job order board as chart преди 1 месец
  CANCERYS\kw093 045f9a6bd5 update преди 1 месец
  DESKTOP-064TTA1\Fai LUK 519a2fb82c no message преди 1 месец
  DESKTOP-064TTA1\Fai LUK cf9fb4c527 no message преди 1 месец
  DESKTOP-064TTA1\Fai LUK df91d458ba no message преди 1 месец
  PC-20260115JRSN\Administrator 10d4e795a5 no message преди 1 месец
  PC-20260115JRSN\Administrator 991cfa72d0 no message преди 1 месец
  DESKTOP-064TTA1\Fai LUK b4cf6ab714 no message преди 1 месец
  CANCERYS\kw093 ebb5845324 update преди 1 месец
  kelvin.yau 6bea17fdd0 Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/derek/FPSMS-frontend into MergeProblem1 преди 1 месец
  kelvin.yau 691bce388f new PO testing преди 1 месец
  CANCERYS\kw093 e3df7b3975 update преди 1 месец
  CANCERYS\kw093 f1fe469ccb update преди 1 месец
  PC-20260115JRSN\Administrator 6d3583a938 no message преди 1 месец
  PC-20260115JRSN\Administrator 3df19f9a0b no message преди 1 месец
  CANCERYS\kw093 507742157e Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 преди 1 месец
  CANCERYS\kw093 d2854953a8 update преди 1 месец
  PC-20260115JRSN\Administrator 548548f453 adding onpack 2nd machine zip download, added DO syn test for single DO code преди 1 месец
  CANCERYS\kw093 bd92bf2492 update преди 1 месец
  CANCERYS\kw093 7dc1fbf323 update преди 1 месец
  CANCERYS\kw093 6720687726 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 преди 1 месец
  CANCERYS\kw093 b01f7eda9f update преди 1 месец
  PC-20260115JRSN\Administrator 4c9632ee7b no message преди 1 месец
  B.E.N.S.O.N 159cfbbc44 FGStockOutTraceabilityReport Excel Version преди 1 месец
  PC-20260115JRSN\Administrator 7415dbe4b6 added some purchase chart преди 1 месец
  kelvin.yau b537d3433a fix breadcrumb преди 1 месец
  kelvin.yau e808eff9cb JO tesing page + set JO creation page search result to default showing 100 results преди 1 месец
  CANCERYS\kw093 484d96203f update преди 1 месец
  CANCERYS\kw093 aae9839da9 update stock take record преди 1 месец
  CANCERYS\kw093 10dbc666f2 update преди 1 месец
  B.E.N.S.O.N 8ac38b7398 ItemQCFailReport Excel Version преди 1 месец
  Tommy\2Fi-Staff 804d7ea9d1 no message преди 1 месец
  CANCERYS\kw093 fae1803c21 update преди 1 месец
  B.E.N.S.O.N d9d175fb76 MaterialStockOutTraceabilityReport Excel Version преди 1 месец
  B.E.N.S.O.N 6250eb2a4a Merge remote-tracking branch 'origin/MergeProblem1' into MergeProblem1 преди 1 месец
  B.E.N.S.O.N 98f0919439 Update преди 1 месец
  CANCERYS\kw093 ab31d6a74c Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 преди 1 месец
  CANCERYS\kw093 9bcfe9c380 revert/ just complete do pick order преди 1 месец
  PC-20260115JRSN\Administrator 5b465b0abb Refining the PO Stock in report преди 1 месец
  CANCERYS\kw093 081ccb9f8f update job order search and cacel job order преди 1 месец
  CANCERYS\kw093 947f471ed8 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 преди 1 месец
  CANCERYS\kw093 eabbc39c57 update преди 1 месец
  CANCERYS\kw093 430ace8d76 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 преди 1 месец
  CANCERYS\kw093 87d32c728a update преди 1 месец
  PC-20260115JRSN\Administrator a358b79d8f change the PO with m18 uom and qty, included stock in PO, putaway process and GRN преди 1 месец
  CANCERYS\kw093 cef025fae8 update преди 1 месец
  PC-20260115JRSN\Administrator c9f05abfb0 added po number syn m18 преди 1 месец
  B.E.N.S.O.N 05eab73a5b StockItemConsumptionTrendReport Excel Version преди 1 месец
  CANCERYS\kw093 8b2ab939e8 update switch lot преди 1 месец
  Tommy\2Fi-Staff cc14a5e100 no message преди 1 месец
  Tommy\2Fi-Staff e6afcf40cc no message преди 1 месец
  B.E.N.S.O.N c9ce1e30af User Page Update преди 1 месец
  B.E.N.S.O.N c7c5727e36 SemiFGProductionAnalysisReport Excel Version преди 1 месец
  Tommy\2Fi-Staff 1f56e1b5bd update shop and truck преди 1 месец
  B.E.N.S.O.N e00f711845 Update преди 1 месец
  PC-20260115JRSN\Administrator 5c215ece1b no message преди 1 месец
  CANCERYS\kw093 8421e66ec4 update преди 1 месец
  CANCERYS\kw093 0947fd181d update dashboard, job order list преди 1 месец
  kelvin.yau 6fe4889b02 better UX in inventory search преди 1 месец
  kelvin.yau 1799088819 no message преди 1 месец
  CANCERYS\kw093 6bec9ce850 update преди 1 месец
  PC-20260115JRSN\Administrator 1343362dc5 no message преди 1 месец
  kelvin.yau 080aed9316 Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/derek/FPSMS-frontend into MergeProblem1 преди 1 месец
  kelvin.yau 93e61dddbc no message преди 1 месец
  kelvin.yau f7a8c882a0 no message преди 1 месец
  CANCERYS\kw093 f58875a3e9 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 преди 1 месец
  CANCERYS\kw093 5af4f5ac6e update преди 1 месец
  PC-20260115JRSN\Administrator 284aaaaf85 no message преди 1 месец
  PC-20260115JRSN\Administrator 58c44b2987 fixing it cannot build преди 1 месец
  CANCERYS\kw093 d65e3db136 update преди 1 месец
  kelvin.yau 9bd475e306 stock transfer ui fix преди 1 месец
  kelvin.yau a167e79a74 Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/derek/FPSMS-frontend into MergeProblem1 преди 1 месец
  kelvin.yau ebc4cdfdee search lot by scanning qr code преди 1 месец
  CANCERYS\kw093 a249363da4 updated преди 1 месец
  CANCERYS\kw093 88d1b60fc7 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 преди 1 месец
  CANCERYS\kw093 44d51c8390 update преди 1 месец
  PC-20260115JRSN\Administrator 67ee15b312 no message преди 1 месец
  CANCERYS\kw093 76ad78f126 update преди 1 месец
  CANCERYS\kw093 ad127b39ac update преди 1 месец
  CANCERYS\kw093 fc8b94c562 update преди 1 месец
  kelvin.yau de65686192 UPDATE OPEN INVENTORY FOR ITEMS WITH NO INVENTORY преди 1 месец
  CANCERYS\kw093 b7ccfe3574 update преди 1 месец
  B.E.N.S.O.N 56e5c937af Good Pick Issue Fixing преди 1 месец
  CANCERYS\kw093 25cfed96d6 update qR scan преди 1 месец
  B.E.N.S.O.N c5d79de697 Login Page Update преди 1 месец
  Tommy\2Fi-Staff d536dbb8d3 update variance report config преди 1 месец
  CANCERYS\kw093 1c737822c5 update преди 1 месец
  CANCERYS\kw093 d1423bdd29 update преди 1 месец
  PC-20260115JRSN\Administrator 7801b32fcd no message преди 1 месец
  Tommy\2Fi-Staff 84088c143d Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/jason/FPSMS-frontend into MergeProblem1 преди 1 месец
  Tommy\2Fi-Staff bde63fdd4d fix putaway преди 1 месец
  CANCERYS\kw093 bbfc821d44 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 преди 1 месец
  CANCERYS\kw093 77ad12967b jo edit преди 1 месец
  PC-20260115JRSN\Administrator 457e4f101f no message преди 1 месец
  CANCERYS\kw093 5e83e2c8e6 update преди 1 месец
  PC-20260115JRSN\Administrator c59949643e no message преди 1 месец
  PC-20260115JRSN\Administrator 49e11a72ee no message преди 1 месец
  kelvin.yau 3598941032 build bug fix преди 1 месец
  kelvin.yau 953cb0783e no message преди 1 месец
  kelvin.yau 5e4c8c46e7 no message преди 1 месец
  kelvin.yau 060de0d2f6 no message преди 1 месец
  kelvin.yau 84baa17e9f no message преди 1 месец
  kelvin.yau dc221be8b8 no message преди 1 месец
  kelvin.yau d91928082f fix frontend build error преди 1 месец
  TASTEOFASIA\MTMS f2a2337e1a no message преди 1 месец
  CANCERYS\kw093 7564ee01eb update stock take drop down преди 1 месец
  CANCERYS\kw093 92a0a894cc Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 преди 1 месец
  CANCERYS\kw093 842aa9ffec update преди 1 месец
  kelvin.yau 5a0b3a43d0 update default store location for FA and WIP преди 1 месец
  kelvin.yau f60c702e74 no message преди 1 месец
  kelvin.yau 062a268bc8 bug fix преди 1 месец
  kelvin.yau f82bb5e056 Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/derek/FPSMS-frontend into MergeProblem1 преди 1 месец
  kelvin.yau 3675c90342 price inqury преди 1 месец
  PC-20260115JRSN\Administrator da9f8b277e adding some charts to test преди 1 месец
  B.E.N.S.O.N e1bda42014 Dashboard UpDATE преди 1 месец
  CANCERYS\kw093 9b5d1306d9 stocktakeALL преди 1 месец
  CANCERYS\kw093 37f9eeed01 update stock take search преди 1 месец
  PC-20260115JRSN\Administrator 190d78c6df adding PS settings преди 1 месец
  CANCERYS\kw093 9b4db0dde5 update преди 1 месец
  CANCERYS\kw093 e4f0273a0e product process list and warehouse преди 1 месец
  kelvin.yau 4fa7bc2b8e translation issue преди 1 месец
  CANCERYS\kw093 4b264a82a8 update bom ui преди 1 месец
  CANCERYS\kw093 15592a176a Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 преди 1 месец
  CANCERYS\kw093 a494673402 update преди 1 месец
  kelvin.yau 086cc40c0a Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/derek/FPSMS-frontend into MergeProblem1 преди 1 месец
  kelvin.yau 806c8c1242 A4 printer routing and register, see backend преди 1 месец
  CANCERYS\kw093 081c76581c update stock in line lotNo and joborder show lotNo преди 1 месец
  Tommy\2Fi-Staff 86bf59e675 make putaway smaller преди 1 месец
  B.E.N.S.O.N 2b7ff5d2ea Warehouse Supporting Function Update преди 1 месец
  B.E.N.S.O.N 5dbbe07614 Warehouse Supporting Function Update преди 1 месец
  TASTEOFASIA\MTMS d31012af63 update the new server ip and setting in env-prod преди 1 месец
  PC-20260115JRSN\Administrator edd947c227 try fixing the pages преди 1 месец
  PC-20260115JRSN\Administrator 6d802eddf4 try fixing the page problem преди 1 месец
  PC-20260115JRSN\Administrator 10fca7bc19 try to fix the page problem преди 1 месец
  PC-20260115JRSN\Administrator 9e6cb8345e try to fix the redirect problem in server преди 1 месец
  CANCERYS\kw093 2548b7a007 update stock in line преди 1 месец
  [email protected] 9d376e4857 trying to build on server преди 1 месец
  [email protected] 4f04ddde6e fixing the code the make project failed to build преди 1 месец
  CANCERYS\kw093 d7a34cf064 updaate import bom преди 1 месец
  CANCERYS\kw093 1e346fa9b8 Merge remote-tracking branch 'origin/MergeProblem1' into MergeProblem1 преди 1 месец
  CANCERYS\kw093 bf2b7f1101 update преди 1 месец
  [email protected] d5fb8294ef adding bag printing page, copy from Bag1.py преди 1 месец
  Tommy\2Fi-Staff f9499d9a37 no message преди 1 месец
  B.E.N.S.O.N d5f19a7057 Bom Supporting Function преди 2 месеца
  CANCERYS\kw093 4f0df8f5f8 update преди 2 месеца
  CANCERYS\kw093 6dc9687949 Merge remote-tracking branch 'origin/MergeProblem1' into MergeProblem1 преди 2 месеца
  CANCERYS\kw093 dfbd808b3a update bom import ,epqc, преди 2 месеца
  CANCERYS\kw093 dff5000125 update преди 2 месеца
  CANCERYS\kw093 9ad4009bc3 update преди 2 месеца
  B.E.N.S.O.N 51e4f705c3 Bom Supporting Function преди 2 месеца
  kelvin.yau 88513e744b No longer refresh after QC преди 2 месеца
  B.E.N.S.O.N aa4f0fff29 Update преди 2 месеца
  [email protected] 435d041f5c no message преди 2 месеца
  [email protected] 0a24dc116f no message преди 2 месеца
  CANCERYS\kw093 42cb203514 update truck X преди 2 месеца
  [email protected] 9d00348946 For my testing use, use the cam instead of barcode scanner for putaway преди 2 месеца
  CANCERYS\kw093 c60f80fe1d update преди 2 месеца
  CANCERYS\kw093 fb271f9209 update преди 2 месеца
  CANCERYS\kw093 48a0fbb924 update преди 2 месеца
  kelvin.yau 703ac2ba72 dashboard fix (FG + equipment) преди 2 месеца
  kelvin.yau 86b2c12321 dashboard fix преди 2 месеца
  kelvin.yau 19b4ed534c dashboards formatting (keep same) преди 2 месеца
  Tommy\2Fi-Staff ad53e1a701 no message преди 2 месеца
  Tommy\2Fi-Staff e59d79797a update преди 2 месеца
  Tommy\2Fi-Staff d74f5d184b update преди 2 месеца
  Tommy\2Fi-Staff 656a222976 upDATE преди 2 месеца
  Tommy\2Fi-Staff f3c480b983 qcstockin ui update преди 2 месеца
  kelvin.yau c5c6d61af2 Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/derek/FPSMS-frontend into MergeProblem1 преди 2 месеца
  kelvin.yau f4a3c12d99 title updates преди 2 месеца
  CANCERYS\kw093 7cd54de584 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 преди 2 месеца
  CANCERYS\kw093 5e5fa63ce8 update преди 2 месеца
  Tommy\2Fi-Staff 8daf185e60 trucklane dashboard преди 2 месеца
  CANCERYS\kw093 66b8912ff0 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 преди 2 месеца
  CANCERYS\kw093 75f3e6a819 update преди 2 месеца
  B.E.N.S.O.N 329830e09b New Goods Receipt Status Dashboard преди 2 месеца
  B.E.N.S.O.N 35ee724b0f Merge remote-tracking branch 'origin/MergeProblem1' into MergeProblem1 преди 2 месеца
  B.E.N.S.O.N dc4767c312 New Goods Receipt Status Dashboard преди 2 месеца
  Tommy\2Fi-Staff f650492e27 reportconfig update преди 2 месеца
  Tommy\2Fi-Staff 0cf603a7e1 add handler filter преди 2 месеца
  CANCERYS\kw093 2b3752d64f update преди 2 месеца
  CANCERYS\kw093 09e8bdff0d update pucahseorder speed преди 2 месеца
  Tommy\2Fi-Staff 89b0effbf4 trucklane dashboard update преди 2 месеца
  CANCERYS\kw093 131893efa0 stock take input fix преди 2 месеца
  B.E.N.S.O.N c81aed0950 User QR-Code Update преди 2 месеца
  B.E.N.S.O.N 526058cbb9 update преди 2 месеца
  CANCERYS\kw093 33cf1752b4 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 преди 2 месеца
  CANCERYS\kw093 1ee123ddb5 auto stock in "%FA%" and stock record page fix преди 2 месеца
  B.E.N.S.O.N 9ead9d244e Bom Supporting Function преди 2 месеца
  B.E.N.S.O.N 5c243b376b Merge remote-tracking branch 'origin/MergeProblem1' into MergeProblem1 преди 2 месеца
  B.E.N.S.O.N 66061a5837 update преди 2 месеца
  CANCERYS\kw093 59b4a88735 update bag преди 2 месеца
  [email protected] 2da62e9bc7 no message преди 2 месеца
  CANCERYS\kw093 bcadb14423 fix stock reocrd преди 2 месеца
  CANCERYS\kw093 a4a4075087 update confirm преди 2 месеца
  CANCERYS\kw093 2040ef798e Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 преди 2 месеца
  CANCERYS\kw093 a40305f880 update stock take преди 2 месеца
  [email protected] 2de29a9a8c make access right with STOCK can do stock take преди 2 месеца
  kelvin.yau ae3fa7993c translation issue преди 2 месеца
  kelvin.yau 26abb13a6c Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/derek/FPSMS-frontend into MergeProblem1 преди 2 месеца
  kelvin.yau 72b92cc6e7 stock adj + stocktrf преди 2 месеца
  [email protected] b0e5aaa72a no message преди 2 месеца
  CANCERYS\kw093 7e831edcf3 update jo,po,i18n преди 2 месеца
  CANCERYS\kw093 42ee4a6d92 update преди 2 месеца
  [email protected] 3236f144cd fix the /ps overlap problem преди 2 месеца
  [email protected] f17ed17f87 no message преди 2 месеца
  CANCERYS\kw093 7c93b9f880 Merge remote-tracking branch 'origin/MergeProblem1' into MergeProblem1 преди 2 месеца
  CANCERYS\kw093 0d0a05ed55 update zh преди 2 месеца
  Tommy\2Fi-Staff a9833d424a translation & alignment преди 2 месеца
  CANCERYS\kw093 eaa9477faa update jobmatch преди 2 месеца
  [email protected] febf75eb38 it says it can control the popup keyboard size in tablet преди 2 месеца
  B.E.N.S.O.N eac95c343c update преди 2 месеца
  [email protected] 765491197f no message преди 2 месеца
  [email protected] f0ddd56381 changed the look and feel slightly преди 2 месеца
  PC-20260115JRSN\Administrator 3579a83ff7 no message преди 2 месеца
  PC-20260115JRSN\Administrator b0356b7a8a Fix the files that make project failed to compile преди 2 месеца
  B.E.N.S.O.N 0eb0936e45 Update преди 2 месеца
  CANCERYS\kw093 1544f3f653 Merge remote-tracking branch 'origin/MergeProblem1' into MergeProblem1 преди 2 месеца
  CANCERYS\kw093 eb9714a79b update преди 2 месеца
  B.E.N.S.O.N d726d933b5 Report Page Update преди 2 месеца
  Tommy\2Fi-Staff 1059b8770a update преди 2 месеца
  CANCERYS\kw093 e8ef71601f update преди 2 месеца
  CANCERYS\kw093 ca8b3ea050 update преди 2 месеца
  CANCERYS\kw093 e5feedc2a7 update преди 2 месеца
  CANCERYS\kw093 263d12e248 update job pick dashboard преди 2 месеца
  CANCERYS\kw093 d56cd6e69f update преди 2 месеца
  Tommy\2Fi-Staff b320307a51 update преди 2 месеца
  Tommy\2Fi-Staff 3303de63d7 update search sorting преди 2 месеца
  Tommy\2Fi-Staff 4446c8503f Update StockBalanceReport & StockInTracabilityReport преди 2 месеца
  B.E.N.S.O.N 8b3f8fc6e9 Report Update преди 2 месеца
  Tommy\2Fi-Staff 6479034e62 TruckScheduleDashboard & StockInTraceability report update преди 2 месеца
  B.E.N.S.O.N 6d9ec7b372 Report Update преди 2 месеца
  CANCERYS\kw093 cb4c0aa11f Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 преди 2 месеца
  CANCERYS\kw093 f408aba874 update преди 2 месеца
  kelvin.yau 754ef92046 translation преди 2 месеца
  CANCERYS\kw093 316d2fcdb1 update преди 2 месеца
  B.E.N.S.O.N e7c273ba0e Stock Item Consumption Trend Report преди 2 месеца
  CANCERYS\kw093 a71f0cc9a9 Merge remote-tracking branch 'origin/MergeProblem1' into MergeProblem1 преди 2 месеца
  CANCERYS\kw093 c06ee2e543 update преди 2 месеца
  kelvin.yau 28fe834ab0 enson update преди 2 месеца
  B.E.N.S.O.N 8987046f00 Dashboard: Goods Receipt Status Update преди 3 месеца
  B.E.N.S.O.N 329ccc22bd FG/SemiFG Production Analysis Report Update преди 3 месеца
  CANCERYS\kw093 bdde9644f0 Merge remote-tracking branch 'origin/MergeProblem1' into MergeProblem1 преди 3 месеца
  CANCERYS\kw093 5e6a440aae update dashBoard преди 3 месеца
  kelvin.yau 626a13ee60 Stock TRF UI update преди 3 месеца
  B.E.N.S.O.N 1600995bc1 FG/SemiFG Production Analysis Report Update преди 3 месеца
  CANCERYS\kw093 4f4a5baf75 update преди 3 месеца
  B.E.N.S.O.N a3c07650f8 FG/SemiFG Production Analysis Report преди 3 месеца
  CANCERYS\kw093 757ccc5cbd update select unit преди 3 месеца
  CANCERYS\kw093 a0675af6e0 upate select unit преди 3 месеца
  CANCERYS\kw093 b006a1115c update преди 3 месеца
  CANCERYS\kw093 e3f2b06561 update pick record user and putaway default warehouse преди 3 месеца
  CANCERYS\kw093 3501863943 update преди 3 месеца
  CANCERYS\kw093 8cbbdf5714 update преди 3 месеца
  [email protected] bdf7d52cd9 no message преди 3 месеца
  [email protected] fc398b038b no message преди 3 месеца
  [email protected] f747984479 make some chinese looks better преди 3 месеца
  CANCERYS\kw093 30823cee8e update scan lot преди 3 месеца
  CANCERYS\kw093 26302151c3 update qc putaway преди 3 месеца
  Tommy\2Fi-Staff 53cc1692ad fix fg goods status dasboard bug преди 3 месеца
  CANCERYS\kw093 878eaedfb6 update new stokc issue handle преди 3 месеца
  [email protected] b541872d24 no message преди 3 месеца
  CANCERYS\kw093 4fc7e87375 update some jo qr преди 3 месеца
  CANCERYS\kw093 549481e71a benson want remove / преди 3 месеца
  CANCERYS\kw093 4b1ed59261 dashboard преди 3 месеца
  CANCERYS\kw093 468e907db9 update преди 3 месеца
  CANCERYS\kw093 55d9e24f83 update qr code scan преди 3 месеца
  CANCERYS\kw093 c45802fb76 test преди 3 месеца
  CANCERYS\kw093 667cc5f184 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 преди 3 месеца
  CANCERYS\kw093 0aedd3b83d update преди 3 месеца
  CANCERYS\kw093 29bdcf6c1a update do pick confirm преди 3 месеца
  CANCERYS\kw093 9e9c8d073c update преди 3 месеца
  CANCERYS\kw093 f807fcee82 update преди 3 месеца
  CANCERYS\kw093 5473ff820d update bar преди 3 месеца
  B.E.N.S.O.N 927485e8d3 Dashboard Page Update преди 3 месеца
  B.E.N.S.O.N feb162ae60 Dashboard: Goods Receipt Status Update преди 3 месеца
  B.E.N.S.O.N b58947b1e5 Dashboard: Goods Receipt Status преди 3 месеца
  CANCERYS\kw093 bb5f3d2584 update do issue form преди 3 месеца
  CANCERYS\kw093 d04e2eeadc update преди 3 месеца
  CANCERYS\kw093 8576172e8e fix scan lot and scan not match lt and new issue handle преди 3 месеца
  CANCERYS\kw093 be2fdb6a3b update преди 3 месеца
  CANCERYS\kw093 3fa46072fd Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 преди 3 месеца
  CANCERYS\kw093 7cd450ef1b update printer select преди 3 месеца
  PC-20260115JRSN\Administrator 3930cd7f39 fixing the merged i18 master syn request преди 3 месеца
  CANCERYS\kw093 c02a6956c4 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 преди 3 месеца
  CANCERYS\kw093 a32e2b30bc printer преди 3 месеца
  Tommy\2Fi-Staff e317d18821 Stock In Traceability Report преди 3 месеца
  B.E.N.S.O.N 09d269f2b7 Update: Printer Handle преди 3 месеца
  B.E.N.S.O.N 321927854e Supporting function: Printer Handle преди 3 месеца
  CANCERYS\kw093 3c014abbff update approve can 0 преди 3 месеца
  CANCERYS\kw093 f903dae3c1 update skip button преди 3 месеца
  CANCERYS\kw093 483577ed0d update do search преди 3 месеца
  B.E.N.S.O.N d09ee3a962 Update преди 3 месеца
  B.E.N.S.O.N e62830e1e2 Merge remote-tracking branch 'origin/MergeProblem1' into MergeProblem1 преди 3 месеца
  B.E.N.S.O.N 4702c93a93 path преди 3 месеца
  kelvin.yau 88d1354944 fix преди 3 месеца
  kelvin.yau de2f012c24 stock transfer ui преди 3 месеца
  Tommy\2Fi-Staff cc68dfbb65 update item преди 3 месеца
  [email protected] 363306c98e fixing the ps export path преди 3 месеца
  CANCERYS\kw093 bc5d88699c update page control преди 3 месеца
  CANCERYS\kw093 b24ae5dfea stockissue преди 3 месеца
  CANCERYS\kw093 d7e139dd2c i18n преди 3 месеца
  [email protected] 7ce84920e2 fixing the GET type преди 3 месеца
  [email protected] 30eb8517d1 refining the data syn преди 3 месеца
  Tommy\2Fi-Staff 4cb751740c update shop and truck lazy load преди 3 месеца
  Tommy\2Fi-Staff 289e59d2b5 update missing item, update FG pick status dashboard преди 3 месеца
  [email protected] c48d070a77 refining the m18 import testing params преди 3 месеца
  CANCERYS\kw093 a0febe7794 update qcitem combine page преди 3 месеца
  CANCERYS\kw093 d240e23bab Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 преди 3 месеца
  CANCERYS\kw093 8f9e94530e update path преди 3 месеца
  PC-20260115JRSN\Administrator 063faba2e7 adding printer testing for HANS преди 3 месеца
  B.E.N.S.O.N d92242ea2c Dashboard: Goods Receipt Status UI преди 3 месеца
  Tommy\2Fi-Staff d50aebb674 Dashboard ui преди 3 месеца
  B.E.N.S.O.N 1d921e105d Dashboard: Goods Receipt Status UI преди 3 месеца
  Tommy\2Fi-Staff 0008e1471f Missing Item supporting function &report преди 3 месеца
  CANCERYS\kw093 770d569f9b productprocess преди 3 месеца
  CANCERYS\kw093 6aefd923c5 updatestock issue преди 3 месеца
  CANCERYS\kw093 a661b1dfc2 update putasway show преди 3 месеца
  CANCERYS\kw093 1dbe9c67c1 upate i18n преди 3 месеца
  CANCERYS\kw093 8b12ae623b Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 преди 3 месеца
  CANCERYS\kw093 1f07b8ea5a update stockissue api преди 3 месеца
  kelvin.yau 2ffa66c4a3 updated inventorylotline table преди 3 месеца
  kelvin.yau 9f635df2eb Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/derek/FPSMS-frontend into MergeProblem1 преди 3 месеца
  kelvin.yau e76073f36e test преди 3 месеца
  [email protected] 44d6b8f823 no message преди 3 месеца
променени са 100 файла, в които са добавени 14137 реда и са изтрити 927 реда
  1. +91
    -0
      .cursor/rules.md
  2. +3
    -3
      .env.production
  3. +952
    -136
      package-lock.json
  4. +4
    -2
      package.json
  5. +72
    -0
      src/app/(main)/MainContentArea.tsx
  6. +40
    -0
      src/app/(main)/MainLayoutBody.tsx
  7. +47
    -0
      src/app/(main)/axios/AxiosProvider.tsx
  8. +23
    -0
      src/app/(main)/bagPrint/page.tsx
  9. +51
    -0
      src/app/(main)/chart/_components/ChartCard.tsx
  10. +31
    -0
      src/app/(main)/chart/_components/DateRangeSelect.tsx
  11. +161
    -0
      src/app/(main)/chart/_components/EXCEL_EXPORT_STANDARD.md
  12. +12
    -0
      src/app/(main)/chart/_components/constants.ts
  13. +182
    -0
      src/app/(main)/chart/_components/exportChartToXlsx.ts
  14. +88
    -0
      src/app/(main)/chart/chartBoardRefreshPrefs.ts
  15. +391
    -0
      src/app/(main)/chart/delivery/page.tsx
  16. +535
    -0
      src/app/(main)/chart/equipment/board/page.tsx
  17. +309
    -0
      src/app/(main)/chart/forecast/page.tsx
  18. +1047
    -0
      src/app/(main)/chart/joborder/board/page.tsx
  19. +386
    -0
      src/app/(main)/chart/joborder/page.tsx
  20. +24
    -0
      src/app/(main)/chart/layout.tsx
  21. +5
    -0
      src/app/(main)/chart/page.tsx
  22. +1309
    -0
      src/app/(main)/chart/process/board/page.tsx
  23. +54
    -0
      src/app/(main)/chart/purchase/exportPurchaseChartMaster.ts
  24. +1129
    -0
      src/app/(main)/chart/purchase/page.tsx
  25. +61
    -0
      src/app/(main)/chart/useChartBoardRefreshPrefs.ts
  26. +360
    -0
      src/app/(main)/chart/warehouse/page.tsx
  27. +1
    -1
      src/app/(main)/dashboard/page.tsx
  28. +20
    -22
      src/app/(main)/do/edit/page.tsx
  29. +2
    -8
      src/app/(main)/do/page.tsx
  30. +2
    -2
      src/app/(main)/finishedGood/detail/page.tsx
  31. +30
    -0
      src/app/(main)/finishedGood/management/page.tsx
  32. +1
    -1
      src/app/(main)/finishedGood/page.tsx
  33. +7
    -0
      src/app/(main)/isPoWorkbenchRoute.ts
  34. +34
    -35
      src/app/(main)/jo/edit/page.tsx
  35. +18
    -27
      src/app/(main)/jo/page.tsx
  36. +21
    -0
      src/app/(main)/jo/testing/page.tsx
  37. +31
    -30
      src/app/(main)/jodetail/edit/page.tsx
  38. +21
    -30
      src/app/(main)/jodetail/page.tsx
  39. +23
    -0
      src/app/(main)/laserPrint/page.tsx
  40. +16
    -26
      src/app/(main)/layout.tsx
  41. +22
    -0
      src/app/(main)/m18Syn/layout.tsx
  42. +239
    -0
      src/app/(main)/m18Syn/page.tsx
  43. +1
    -1
      src/app/(main)/pickOrder/page.tsx
  44. +20
    -0
      src/app/(main)/po/workbench/PoWorkbenchPageClient.tsx
  45. +32
    -0
      src/app/(main)/po/workbench/layout.tsx
  46. +25
    -0
      src/app/(main)/po/workbench/page.tsx
  47. +5
    -5
      src/app/(main)/productionProcess/page.tsx
  48. +1084
    -221
      src/app/(main)/ps/page.tsx
  49. +37
    -0
      src/app/(main)/putAwayCam/page.tsx
  50. +70
    -0
      src/app/(main)/report/GRN_REPORT_BACKEND_SPEC.md
  51. +209
    -0
      src/app/(main)/report/SemiFGProductionAnalysisReport.tsx
  52. +238
    -0
      src/app/(main)/report/grnReportApi.ts
  53. +518
    -42
      src/app/(main)/report/page.tsx
  54. +141
    -0
      src/app/(main)/report/semiFGProductionAnalysisApi.ts
  55. +84
    -0
      src/app/(main)/report/truckRoutingSummaryApi.ts
  56. +25
    -0
      src/app/(main)/settings/bomWeighting/page.tsx
  57. +52
    -0
      src/app/(main)/settings/importBom/EquipmentTabs.tsx
  58. +29
    -0
      src/app/(main)/settings/importBom/MaintenanceEdit/page.tsx
  59. +22
    -0
      src/app/(main)/settings/importBom/create/page.tsx
  60. +29
    -0
      src/app/(main)/settings/importBom/edit/page.tsx
  61. +29
    -0
      src/app/(main)/settings/importBom/page.tsx
  62. +27
    -0
      src/app/(main)/settings/itemPrice/page.tsx
  63. +22
    -0
      src/app/(main)/settings/printer/create/page.tsx
  64. +38
    -0
      src/app/(main)/settings/printer/edit/page.tsx
  65. +47
    -0
      src/app/(main)/settings/printer/page.tsx
  66. +19
    -0
      src/app/(main)/settings/qcItem copy/create/not-found.tsx
  67. +26
    -0
      src/app/(main)/settings/qcItem copy/create/page.tsx
  68. +19
    -0
      src/app/(main)/settings/qcItem copy/edit/not-found.tsx
  69. +53
    -0
      src/app/(main)/settings/qcItem copy/edit/page.tsx
  70. +48
    -0
      src/app/(main)/settings/qcItem copy/page.tsx
  71. +72
    -0
      src/app/(main)/settings/qcItemAll/page.tsx
  72. +10
    -10
      src/app/(main)/settings/warehouse/page.tsx
  73. +3
    -3
      src/app/(main)/stockIssue/page.tsx
  74. +2
    -2
      src/app/(main)/stockOutIssueRecord/detail/page.tsx
  75. +1
    -1
      src/app/(main)/stockOutIssueRecord/page.tsx
  76. +1
    -1
      src/app/(main)/stocktakemanagement/page.tsx
  77. +550
    -250
      src/app/(main)/testing/page.tsx
  78. +24
    -1
      src/app/api/bag/action.ts
  79. +168
    -0
      src/app/api/bagPrint/actions.ts
  80. +158
    -0
      src/app/api/bom/client.ts
  81. +138
    -10
      src/app/api/bom/index.ts
  82. +16
    -0
      src/app/api/bom/recalculateClient.ts
  83. +975
    -0
      src/app/api/chart/client.ts
  84. +24
    -0
      src/app/api/dashboard/actions.ts
  85. +17
    -0
      src/app/api/dashboard/client.ts
  86. +225
    -13
      src/app/api/do/actions.tsx
  87. +2
    -2
      src/app/api/do/client.ts
  88. +2
    -0
      src/app/api/escalation/index.ts
  89. +31
    -0
      src/app/api/inventory/actions.ts
  90. +2
    -0
      src/app/api/inventory/index.ts
  91. +352
    -26
      src/app/api/jo/actions.ts
  92. +2
    -0
      src/app/api/jo/index.ts
  93. +204
    -0
      src/app/api/laserPrint/actions.ts
  94. +22
    -3
      src/app/api/pdf/actions.ts
  95. +252
    -8
      src/app/api/pickOrder/actions.ts
  96. +16
    -1
      src/app/api/po/actions.ts
  97. +1
    -1
      src/app/api/po/index.ts
  98. +3
    -3
      src/app/api/qc/index.ts
  99. +30
    -0
      src/app/api/settings/bomWeighting/actions.ts
  100. +30
    -0
      src/app/api/settings/bomWeighting/client.ts

+ 91
- 0
.cursor/rules.md Целия файл

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

## UI Standard (apply to all pages)

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

### Stack & layout

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

### Theme & colors

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

### Page structure (every page)

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

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

### Search criteria

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

### Forms & inputs

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

### Buttons

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

### Tables & grids

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

### Cards & surfaces

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

### Menu bar & sidebar

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

### Reference implementations

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

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

### Checklist for new pages

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

+ 3
- 3
.env.production Целия файл

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

+ 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",


+ 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 { isPoWorkbenchRoute } from "@/app/(main)/isPoWorkbenchRoute";
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 = isPoWorkbenchRoute(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 { isPoWorkbenchRoute } from "@/app/(main)/isPoWorkbenchRoute";
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 = isPoWorkbenchRoute(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) {


+ 23
- 0
src/app/(main)/bagPrint/page.tsx Целия файл

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

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

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

export default BagPrintPage;

+ 51
- 0
src/app/(main)/chart/_components/ChartCard.tsx Целия файл

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

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

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

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

+ 31
- 0
src/app/(main)/chart/_components/DateRangeSelect.tsx Целия файл

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

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

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

+ 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 = 30;

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

+ 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 */
}
}

+ 391
- 0
src/app/(main)/chart/delivery/page.tsx Целия файл

@@ -0,0 +1,391 @@
"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 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 = "發貨與配送";

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

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

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

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

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

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

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

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

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

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

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

+ 24
- 0
src/app/(main)/chart/layout.tsx Целия файл

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

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

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

+ 5
- 0
src/app/(main)/chart/page.tsx Целия файл

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

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

+ 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", "common", "purchaseOrder"]}>
<Suspense fallback={<DashboardPage.Loading />}>
<DashboardPage searchParams={searchParams} />
</Suspense>


+ 20
- 22
src/app/(main)/do/edit/page.tsx Целия файл

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

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

type Props = SearchParams;

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

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

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

export default DoEdit;

+ 2
- 8
src/app/(main)/do/page.tsx Целия файл

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

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

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


+ 2
- 2
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";
@@ -14,7 +14,7 @@ type Props = {} & SearchParams;
const PickOrder: React.FC<Props> = async ({ searchParams }) => {
const { t } = await getServerI18n("pickOrder");

PreloadPickOrder();

return (
<>


+ 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={["common"]}>
<Suspense fallback={<FinishedGoodManagement.Loading />}>
<FinishedGoodManagement />
</Suspense>
</I18nProvider>
);
};

export default Page;


+ 1
- 1
src/app/(main)/finishedGood/page.tsx Целия файл

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

PreloadPickOrder();
//PreloadPickOrder();

return (
<>


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

export default JoEdit;

+ 18
- 27
src/app/(main)/jo/page.tsx Целия файл

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

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

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

preloadBomCombo()
preloadBomCombo();

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

export default jo;
export default Jo;

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

+ 31
- 30
src/app/(main)/jodetail/edit/page.tsx Целия файл

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

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

type Props = SearchParams;

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

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

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

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

export default JoEdit;
export default JodetailEdit;

+ 21
- 30
src/app/(main)/jodetail/page.tsx Целия файл

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

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

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

preloadBomCombo()
preloadBomCombo();

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

export default jo;
export default Jodetail;

+ 23
- 0
src/app/(main)/laserPrint/page.tsx Целия файл

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

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

const LaserPrintPage: React.FC = () => {
return (
<>
<Stack direction="row" justifyContent="space-between" flexWrap="wrap" rowGap={2}>
<Typography variant="h4" marginInlineEnd={2}>
檸檬機(激光機)
</Typography>
</Stack>
<LaserPrintSearch />
</>
);
};

export default LaserPrintPage;

+ 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={["common"]}>
<MainContentArea>{children}</MainContentArea>
</I18nProvider>
}
/>
</QrCodeScannerProvider>
</AxiosProvider>
{/* </CameraProvider> */}
</UploadProvider>
</SessionProviderWrapper>


+ 22
- 0
src/app/(main)/m18Syn/layout.tsx Целия файл

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

export const metadata: Metadata = {
title: "M18 Sync",
};

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

+ 239
- 0
src/app/(main)/m18Syn/page.tsx Целия файл

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

import React, { 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);

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

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

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

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

const handleSyncM18DoByCode = async () => {
if (!m18DoCode.trim()) {
alert("Please enter DO / shop PO code.");
return;
}
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(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);
}
};

const handleSyncM18ProductByCode = async () => {
if (!m18ProductCode.trim()) {
alert("Please enter M18 item / product code.");
return;
}
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(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);
}
};

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. PO" id="m18syn-tab-0" />
<Tab label="2. DO" id="m18syn-tab-1" />
<Tab label="3. Product" id="m18syn-tab-2" />
</Tabs>

<TabPanel value={tabValue} index={0}>
<Section title="M18 Purchase Order — 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 Delivery Order — 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 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/page.tsx Целия файл

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

PreloadPickOrder();
//PreloadPickOrder();

return (
<>


+ 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", "purchaseOrder", "common"]}>
<PoWorkbenchPageClient />
</I18nProvider>
</Box>
);
}

+ 5
- 5
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";

@@ -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={["common", "production","purchaseOrder","jo","dashboard"]}>
<Suspense fallback={<ProductionProcessLoading />}>
<ProductionProcessPage printerCombo={printerCombo} />
</Suspense>
</I18nProvider>
</>
);


+ 1084
- 221
src/app/(main)/ps/page.tsx
Файловите разлики са ограничени, защото са твърде много
Целия файл


+ 37
- 0
src/app/(main)/putAwayCam/page.tsx Целия файл

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

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

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

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

export default PutAwayCamPage;


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

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

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

+ 518
- 42
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,17 +13,42 @@ import {
TextField,
Button,
Grid,
Divider
Divider,
Chip,
Autocomplete
} from '@mui/material';
import PrintIcon from '@mui/icons-material/Print';
import DownloadIcon from '@mui/icons-material/Download';
import { REPORTS, ReportDefinition } from '@/config/reportConfig';
import { getSession } from "next-auth/react";
import { NEXT_PUBLIC_API_URL } from '@/config/api';
import { clientAuthFetch } from '@/app/utils/clientAuthFetch';
import SemiFGProductionAnalysisReport from './SemiFGProductionAnalysisReport';
import {
fetchSemiFGItemCodes,
fetchSemiFGItemCodesWithCategory
} from './semiFGProductionAnalysisApi';
import { generateGrnReportExcel } from './grnReportApi';
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 currentReport = useMemo(() =>
REPORTS.find((r) => r.id === selectedReportId),
@@ -31,38 +59,220 @@ export default function ReportPage() {
setCriteria({}); // Clear criteria when switching reports
};

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

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

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

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

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

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

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

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

return true;
};

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

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

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

await executePrint();
};

const handleExcelPrint = async () => {
if (!currentReport) return;
if (!validateRequiredFields()) return;
await executeExcelReport();
};

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

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

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

const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = downloadUrl;

const contentDisposition = response.headers.get('Content-Disposition');
let fileName = `${currentReport.title}.xlsx`;
if (contentDisposition?.includes('filename=')) {
fileName = contentDisposition.split('filename=')[1].split(';')[0].replace(/"/g, '');
}

link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(downloadUrl);
}
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 token = localStorage.getItem("accessToken");
const queryParams = new URLSearchParams(criteria).toString();
const url = `${currentReport.apiEndpoint}?${queryParams}`;
const response = await fetch(url, {
const response = await clientAuthFetch(url, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Accept': 'application/pdf',
},
headers: { 'Accept': 'application/pdf' },
});

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

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

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

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

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

<Box sx={{ mt: 4, display: 'flex', justifyContent: 'flex-end' }}>
<Button
variant="contained"
size="large"
startIcon={<PrintIcon />}
onClick={handlePrint}
disabled={loading}
sx={{ px: 4 }}
>
{loading ? "Generating..." : "Print Report"}
</Button>
<Box sx={{ mt: 4, display: 'flex', gap: 2, justifyContent: 'flex-end' }}>
{currentReport.id === 'rep-005' ? (
<SemiFGProductionAnalysisReport
criteria={criteria}
requiredFieldLabels={currentReport.fields.filter(f => f.required && !criteria[f.name]).map(f => f.label)}
loading={loading}
setLoading={setLoading}
reportTitle={currentReport.title}
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>


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

+ 25
- 0
src/app/(main)/settings/bomWeighting/page.tsx Целия файл

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

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

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

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

export default BomWeightingScorePage;

+ 52
- 0
src/app/(main)/settings/importBom/EquipmentTabs.tsx Целия файл

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

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

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

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

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

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

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

export default EquipmentTabs;

+ 29
- 0
src/app/(main)/settings/importBom/MaintenanceEdit/page.tsx Целия файл

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

type Props = {} & SearchParams;

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

+ 22
- 0
src/app/(main)/settings/importBom/create/page.tsx Целия файл

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

type Props = {} & SearchParams;

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

+ 29
- 0
src/app/(main)/settings/importBom/edit/page.tsx Целия файл

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

type Props = {} & SearchParams;

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

+ 29
- 0
src/app/(main)/settings/importBom/page.tsx Целия файл

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

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

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

+ 27
- 0
src/app/(main)/settings/itemPrice/page.tsx Целия файл

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

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

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

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

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

export default ItemPriceSetting;

+ 22
- 0
src/app/(main)/settings/printer/create/page.tsx Целия файл

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

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

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

export default CreatePrinterPage;


+ 38
- 0
src/app/(main)/settings/printer/edit/page.tsx Целия файл

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

type Props = {} & SearchParams;

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

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

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

export default EditPrinterPage;

+ 47
- 0
src/app/(main)/settings/printer/page.tsx Целия файл

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

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

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

export default Printer;

+ 19
- 0
src/app/(main)/settings/qcItem copy/create/not-found.tsx Целия файл

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

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

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

+ 26
- 0
src/app/(main)/settings/qcItem copy/create/page.tsx Целия файл

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

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

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

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

export default qcItem;

+ 19
- 0
src/app/(main)/settings/qcItem copy/edit/not-found.tsx Целия файл

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

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

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

+ 53
- 0
src/app/(main)/settings/qcItem copy/edit/page.tsx Целия файл

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

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

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

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

const id = searchParams["id"];

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

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

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

export default qcItem;

+ 48
- 0
src/app/(main)/settings/qcItem copy/page.tsx Целия файл

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

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

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

preloadQcItem();

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

export default qcItem;

+ 72
- 0
src/app/(main)/settings/qcItemAll/page.tsx Целия файл

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

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

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

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

export default qcItemAll;



























+ 10
- 10
src/app/(main)/settings/warehouse/page.tsx Целия файл

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

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

+ 3
- 3
src/app/(main)/stockIssue/page.tsx Целия файл

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

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

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

PreloadList();

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


+ 2
- 2
src/app/(main)/stockOutIssueRecord/detail/page.tsx Целия файл

@@ -1,4 +1,4 @@
import { PreloadPickOrder } from "@/app/api/pickOrder";
import { SearchParams } from "@/app/utils/fetchUtil";
import PickOrderDetail from "@/components/PickOrderDetail";
import { getServerI18n, I18nProvider } from "@/i18n";
@@ -14,7 +14,7 @@ type Props = {} & SearchParams;
const PickOrder: React.FC<Props> = async ({ searchParams }) => {
const { t } = await getServerI18n("pickOrder");

PreloadPickOrder();

return (
<>


+ 1
- 1
src/app/(main)/stockOutIssueRecord/page.tsx Целия файл

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

PreloadPickOrder();
// PreloadPickOrder();

return (
<>


+ 1
- 1
src/app/(main)/stocktakemanagement/page.tsx Целия файл

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


+ 550
- 250
src/app/(main)/testing/page.tsx Целия файл

@@ -1,306 +1,606 @@
"use client";

import React, { useState } from "react";
import {
Box, Grid, Paper, Typography, Button, Dialog, DialogTitle,
DialogContent, DialogActions, TextField, Stack, Table,
TableBody, TableCell, TableContainer, TableHead, TableRow
import React, { useEffect, useMemo, useState } from "react";
import {
Alert,
Box,
Button,
CircularProgress,
Paper,
Stack,
Tab,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Tabs,
TextField,
Typography,
} from "@mui/material";
import { FileDownload, Print, SettingsEthernet, Lan, Router } from "@mui/icons-material";
import { FileDownload } from "@mui/icons-material";
import dayjs from "dayjs";
import { formatHongKongDateTime } from "@/utils/formatHongKongDateTime";
import { NEXT_PUBLIC_API_URL } from "@/config/api";
import { clientAuthFetch } from "@/app/utils/clientAuthFetch";
import LotLabelPrintModal from "@/components/InventorySearch/LotLabelPrintModal";
import {
buildOnPackJobOrdersPayload,
downloadOnPackTextQrZip,
fetchJobOrders,
pushOnPackTextQrZipToNgpcl,
type JobOrderListItem,
} from "@/app/api/bagPrint/actions";
import {
fetchLaserBag2Settings,
runLaserBag2AutoSend,
type LaserBag2AutoSendReport,
type LaserLastReceiveSuccess,
} from "@/app/api/laserPrint/actions";
import * as XLSX from "xlsx";

interface TabPanelProps {
children?: React.ReactNode;
index: number;
value: number;
}

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

export default function TestingPage() {
// --- 1. TSC Section States ---
const [tscConfig, setTscConfig] = useState({ ip: '192.168.1.100', port: '9100' });
const [tscItems, setTscItems] = useState([
{ id: 1, itemCode: 'FG-001', itemName: 'Yellow Curry Sauce', lotNo: 'LOT-TSC-01', expiryDate: '2025-12-01' },
{ id: 2, itemCode: 'FG-002', itemName: 'Red Curry Paste', lotNo: 'LOT-TSC-02', expiryDate: '2025-12-05' },
]);
const [tabValue, setTabValue] = useState(0);
const [lotLabelModalOpen, setLotLabelModalOpen] = useState(false);

// --- 2. DataFlex Section States ---
const [dfConfig, setDfConfig] = useState({ ip: '192.168.1.101', port: '9100' });
const [dfItems, setDfItems] = useState([
{ id: 1, itemCode: 'DF-101', itemName: 'Instant Noodle A', lotNo: 'LOT-DF-01', expiryDate: '2026-01-10' },
{ id: 2, itemCode: 'DF-102', itemName: 'Instant Noodle B', lotNo: 'LOT-DF-02', expiryDate: '2026-01-15' },
]);
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
setTabValue(newValue);
};

// --- 3. OnPack Section States ---
const [isPrinterModalOpen, setIsPrinterModalOpen] = useState(false);
const [printerFormData, setPrinterFormData] = useState({
itemCode: '',
lotNo: '',
expiryDate: dayjs().format('YYYY-MM-DD'),
productName: ''
});
// --- 1. GRN Preview (M18) ---
const [grnPreviewReceiptDate, setGrnPreviewReceiptDate] =
useState("2026-03-16");
// --- 2. OnPack NGPCL (same job-order → ZIP logic as /bagPrint) ---
const [onpackPlanDate, setOnpackPlanDate] = useState(() =>
dayjs().format("YYYY-MM-DD"),
);
const [onpackJobOrders, setOnpackJobOrders] = useState<JobOrderListItem[]>(
[],
);
const [onpackLoading, setOnpackLoading] = useState(false);
const [onpackLoadError, setOnpackLoadError] = useState<string | null>(null);
const [onpackLemonDownloading, setOnpackLemonDownloading] = useState(false);
const [onpackPushLoading, setOnpackPushLoading] = useState(false);
const [onpackPushResult, setOnpackPushResult] = useState<string | null>(null);
// --- 3. Laser Bag2 auto-send (same as /laserPrint + DB LASER_PRINT.*) ---
const [laserAutoPlanDate, setLaserAutoPlanDate] = useState(() =>
dayjs().format("YYYY-MM-DD"),
);
const [laserAutoLimit, setLaserAutoLimit] = useState("1");
const [laserAutoLoading, setLaserAutoLoading] = useState(false);
const [laserAutoReport, setLaserAutoReport] =
useState<LaserBag2AutoSendReport | null>(null);
const [laserAutoError, setLaserAutoError] = useState<string | null>(null);
const [laserLastReceive, setLaserLastReceive] =
useState<LaserLastReceiveSuccess | null>(null);

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

// Generic handler for inline table edits
const handleItemChange = (setter: any, id: number, field: string, value: string) => {
setter((prev: any[]) => prev.map(item =>
item.id === id ? { ...item, [field]: value } : item
));
};
useEffect(() => {
if (tabValue !== 1) return;
let cancelled = false;
(async () => {
setOnpackLoading(true);
setOnpackLoadError(null);
try {
const data = await fetchJobOrders(onpackPlanDate);
if (!cancelled) setOnpackJobOrders(data);
} catch (e) {
if (!cancelled) {
setOnpackLoadError(
e instanceof Error ? e.message : "Failed to load job orders",
);
setOnpackJobOrders([]);
}
} finally {
if (!cancelled) setOnpackLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [tabValue, onpackPlanDate]);

// --- API CALLS ---
useEffect(() => {
if (tabValue !== 2) return;
let cancelled = false;
(async () => {
try {
const s = await fetchLaserBag2Settings();
if (!cancelled) setLaserLastReceive(s.lastReceiveSuccess ?? null);
} catch {
if (!cancelled) setLaserLastReceive(null);
}
})();
return () => {
cancelled = true;
};
}, [tabValue]);

// TSC Print (Section 1)
const handleTscPrint = async (row: any) => {
const token = localStorage.getItem("accessToken");
const payload = { ...row, printerIp: tscConfig.ip, printerPort: tscConfig.port };
const handleDownloadGrnPreviewXlsx = async () => {
try {
const response = await fetch(`${NEXT_PUBLIC_API_URL}/plastic/print-tsc`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (response.ok) alert(`TSC Print Command Sent for ${row.itemCode}!`);
else alert("TSC Print Failed");
} catch (e) { console.error("TSC Error:", e); }
};
const response = await clientAuthFetch(
`${NEXT_PUBLIC_API_URL}/report/grn-preview-m18?receiptDate=${encodeURIComponent(
grnPreviewReceiptDate,
)}`,
{ method: "GET" },
);
if (response.status === 401 || response.status === 403) return;
if (!response.ok) throw new Error(`Download failed: ${response.status}`);

// DataFlex Print (Section 2)
const handleDfPrint = async (row: any) => {
const token = localStorage.getItem("accessToken");
const payload = { ...row, printerIp: dfConfig.ip, printerPort: dfConfig.port };
try {
const response = await fetch(`${NEXT_PUBLIC_API_URL}/plastic/print-dataflex`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (response.ok) alert(`DataFlex Print Command Sent for ${row.itemCode}!`);
else alert("DataFlex Print Failed");
} catch (e) { console.error("DataFlex Error:", e); }
};
const data = await response.json();
const rows = Array.isArray(data?.rows) ? data.rows : [];

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

if (!response.ok) throw new Error('Download failed');
const xlsxArrayBuffer = XLSX.write(wb, {
bookType: "xlsx",
type: "array",
});
const blob = new Blob([xlsxArrayBuffer], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});

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

const handleLaserPrint = async (row: any) => {
const token = localStorage.getItem("accessToken");
const payload = { ...row, printerIp: laserConfig.ip, printerPort: laserConfig.port };
const downloadBlob = (blob: Blob, filename: string) => {
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", filename);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
};

const handleOnpackDownloadLemonZip = async () => {
if (onpackPayload.length === 0) {
alert(
"No job orders with item code for this plan date (same rule as Bag Print).",
);
return;
}
setOnpackLemonDownloading(true);
try {
const response = await fetch(`${NEXT_PUBLIC_API_URL}/plastic/print-laser`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (response.ok) alert(`Laser Command Sent: ${row.templateId}`);
} catch (e) { console.error(e); }
const blob = await downloadOnPackTextQrZip({ jobOrders: onpackPayload });
downloadBlob(blob, `onpack2023_lemon_qr_${onpackPlanDate}.zip`);
} catch (e) {
console.error("Lemon OnPack ZIP download error:", e);
alert(e instanceof Error ? e.message : "Lemon OnPack ZIP failed");
} finally {
setOnpackLemonDownloading(false);
}
};

const handleLaserPreview = async (row: any) => {
const token = localStorage.getItem("accessToken");
const payload = { ...row, printerIp: laserConfig.ip, printerPort: parseInt(laserConfig.port) };
const handleLaserBag2AutoSend = async () => {
setLaserAutoLoading(true);
setLaserAutoError(null);
setLaserAutoReport(null);
try {
// We'll create this endpoint in the backend next
const response = await fetch(`${NEXT_PUBLIC_API_URL}/plastic/preview-laser`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
const lim = parseInt(laserAutoLimit.trim(), 10);
const report = await runLaserBag2AutoSend({
planStart: laserAutoPlanDate,
limitPerRun: Number.isFinite(lim) ? lim : 1,
});
if (response.ok) alert("Red light preview active!");
} catch (e) { console.error("Preview Error:", e); }
setLaserAutoReport(report);
try {
const s = await fetchLaserBag2Settings();
setLaserLastReceive(s.lastReceiveSuccess ?? null);
} catch {
/* ignore */
}
} catch (e) {
setLaserAutoError(e instanceof Error ? e.message : String(e));
} finally {
setLaserAutoLoading(false);
}
};

const handleOnpackPushNgpcl = async () => {
if (onpackPayload.length === 0) {
alert("No job orders with item code for this plan date.");
return;
}
setOnpackPushLoading(true);
setOnpackPushResult(null);
try {
const r = await pushOnPackTextQrZipToNgpcl({ jobOrders: onpackPayload });
setOnpackPushResult(
`${r.pushed ? "Pushed" : "Not pushed"}: ${r.message}`,
);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
setOnpackPushResult(`Error: ${msg}`);
alert(msg);
} finally {
setOnpackPushLoading(false);
}
};

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

return (
<Box sx={{ p: 4 }}>
<Typography variant="h4" sx={{ mb: 4, fontWeight: 'bold' }}>Printer Testing Dashboard</Typography>
<Grid container spacing={3}>
{/* 1. TSC Section */}
<Section title="1. TSC">
<Stack direction="row" spacing={2} sx={{ mb: 2 }}>
<TextField size="small" label="Printer IP" value={tscConfig.ip} onChange={e => setTscConfig({...tscConfig, ip: e.target.value})} />
<TextField size="small" label="Port" value={tscConfig.port} onChange={e => setTscConfig({...tscConfig, port: e.target.value})} />
<SettingsEthernet color="action" />
<Typography variant="h4" sx={{ mb: 4, fontWeight: "bold" }}>
Testing
</Typography>

<Tabs
value={tabValue}
onChange={handleTabChange}
aria-label="testing sections tabs"
centered
variant="fullWidth"
>
<Tab label="1. GRN Preview" />
<Tab label="2. OnPack NGPCL" />
<Tab label="3. Laser Bag2 自動送" />
<Tab label="4. 批號標籤列印" />
</Tabs>

<TabPanel value={tabValue} index={0}>
<Section title="1. GRN Preview (M18)">
<Stack
direction="row"
spacing={2}
sx={{ mb: 2, alignItems: "center" }}
>
<TextField
size="small"
label="Receipt Date"
type="date"
value={grnPreviewReceiptDate}
onChange={(e) => setGrnPreviewReceiptDate(e.target.value)}
InputLabelProps={{ shrink: true }}
/>
<Button
variant="contained"
color="success"
size="medium"
startIcon={<FileDownload />}
onClick={handleDownloadGrnPreviewXlsx}
>
Download GRN Preview XLSX
</Button>
</Stack>
<TableContainer component={Paper} variant="outlined" sx={{ maxHeight: 300 }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell>Code</TableCell>
<TableCell>Name</TableCell>
<TableCell>Lot</TableCell>
<TableCell>Expiry</TableCell>
<TableCell align="center">Action</TableCell>
</TableRow>
</TableHead>
<TableBody>
{tscItems.map(row => (
<TableRow key={row.id}>
<TableCell><TextField variant="standard" value={row.itemCode} onChange={e => handleItemChange(setTscItems, row.id, 'itemCode', e.target.value)} /></TableCell>
<TableCell><TextField variant="standard" value={row.itemName} onChange={e => handleItemChange(setTscItems, row.id, 'itemName', e.target.value)} /></TableCell>
<TableCell><TextField variant="standard" value={row.lotNo} onChange={e => handleItemChange(setTscItems, row.id, 'lotNo', e.target.value)} /></TableCell>
<TableCell><TextField variant="standard" type="date" value={row.expiryDate} onChange={e => handleItemChange(setTscItems, row.id, 'expiryDate', e.target.value)} /></TableCell>
<TableCell align="center"><Button variant="contained" size="small" startIcon={<Print />} onClick={() => handleTscPrint(row)}>Print</Button></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Typography variant="body2" color="textSecondary">
Backend endpoint:{" "}
<code>/report/grn-preview-m18?receiptDate=YYYY-MM-DD</code>
</Typography>
</Section>
</TabPanel>

<TabPanel value={tabValue} index={1}>
<Section title="2. OnPack NGPCL (same logic as /bagPrint)">
<Alert severity="info" sx={{ mb: 2 }}>
Uses <strong>GET /py/job-orders?planStart=</strong> for the day,
then the same <code>jobOrders</code> payload as{" "}
<strong>Bag Print → 下載 OnPack2023檸檬機</strong>. The ZIP contains
loose <code>.job</code> / <code>.image</code> / BMPs — extract
before sending to NGE; the ZIP itself is only a transport bundle.
</Alert>
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
Distinct item codes in the list produce one label set each (backend
groups by code). Configure <code>ngpcl.push-url</code> on the server
to POST the same lemon ZIP bytes to your NGPCL HTTP gateway;
otherwise use download only.
</Typography>

{/* 2. DataFlex Section */}
<Section title="2. DataFlex">
<Stack direction="row" spacing={2} sx={{ mb: 2 }}>
<TextField size="small" label="Printer IP" value={dfConfig.ip} onChange={e => setDfConfig({...dfConfig, ip: e.target.value})} />
<TextField size="small" label="Port" value={dfConfig.port} onChange={e => setDfConfig({...dfConfig, port: e.target.value})} />
<Lan color="action" />
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
sx={{ mb: 2, alignItems: "center", flexWrap: "wrap" }}
>
<TextField
size="small"
label="Plan date (planStart)"
type="date"
value={onpackPlanDate}
onChange={(e) => setOnpackPlanDate(e.target.value)}
InputLabelProps={{ shrink: true }}
/>
<Typography variant="body2" color="textSecondary">
{onpackLoading ? (
<>
<CircularProgress
size={16}
sx={{ mr: 1, verticalAlign: "middle" }}
/>
Loading job orders…
</>
) : (
`${onpackJobOrders.length} job order(s), ${onpackPayload.length} row(s) with item code → ZIP`
)}
</Typography>
</Stack>
<TableContainer component={Paper} variant="outlined" sx={{ maxHeight: 300 }}>
<Table size="small" stickyHeader>
<TableHead>
{onpackLoadError ? (
<Alert severity="error" sx={{ mb: 2 }}>
{onpackLoadError}
</Alert>
) : null}

<Table size="small" sx={{ mb: 2, maxWidth: 900 }}>
<TableHead>
<TableRow>
<TableCell>JO id</TableCell>
<TableCell>Code</TableCell>
<TableCell>Item code</TableCell>
<TableCell>Lot</TableCell>
</TableRow>
</TableHead>
<TableBody>
{onpackJobOrders.length === 0 && !onpackLoading ? (
<TableRow>
<TableCell>Code</TableCell>
<TableCell>Name</TableCell>
<TableCell>Lot</TableCell>
<TableCell>Expiry</TableCell>
<TableCell align="center">Action</TableCell>
<TableCell colSpan={4}>
<Typography variant="body2" color="textSecondary">
No rows for this date (or still loading).
</Typography>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{dfItems.map(row => (
<TableRow key={row.id}>
<TableCell><TextField variant="standard" value={row.itemCode} onChange={e => handleItemChange(setDfItems, row.id, 'itemCode', e.target.value)} /></TableCell>
<TableCell><TextField variant="standard" value={row.itemName} onChange={e => handleItemChange(setDfItems, row.id, 'itemName', e.target.value)} /></TableCell>
<TableCell><TextField variant="standard" value={row.lotNo} onChange={e => handleItemChange(setDfItems, row.id, 'lotNo', e.target.value)} /></TableCell>
<TableCell><TextField variant="standard" type="date" value={row.expiryDate} onChange={e => handleItemChange(setDfItems, row.id, 'expiryDate', e.target.value)} /></TableCell>
<TableCell align="center"><Button variant="contained" color="secondary" size="small" startIcon={<Print />} onClick={() => handleDfPrint(row)}>Print</Button></TableCell>
) : (
onpackJobOrders.map((jo) => (
<TableRow key={jo.id}>
<TableCell>{jo.id}</TableCell>
<TableCell>{jo.code ?? "—"}</TableCell>
<TableCell>{jo.itemCode ?? "—"}</TableCell>
<TableCell>{jo.lotNo ?? "—"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Section>
))
)}
</TableBody>
</Table>

{/* 3. OnPack Section */}
<Section title="3. OnPack">
<Box sx={{ m: 'auto', textAlign: 'center' }}>
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
Calls /plastic/get-printer6 to generate CoLOS .job bundle.
</Typography>
<Button variant="contained" color="success" size="large" startIcon={<FileDownload />} onClick={() => setIsPrinterModalOpen(true)}>
Generate CoLOS Files
<TextField
fullWidth
multiline
minRows={3}
label="Resolved POST body (download-onpack-qr-text / NGPCL push)"
value={JSON.stringify({ jobOrders: onpackPayload }, null, 2)}
InputProps={{ readOnly: true }}
sx={{ mb: 2, fontFamily: "monospace" }}
/>

<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
sx={{ mb: 2, flexWrap: "wrap" }}
>
<Button
variant="contained"
color="success"
onClick={handleOnpackDownloadLemonZip}
disabled={onpackLemonDownloading || onpackLoading}
>
{onpackLemonDownloading
? "Downloading…"
: "Download lemon OnPack ZIP"}
</Button>
<Button
variant="outlined"
onClick={handleOnpackPushNgpcl}
disabled={onpackPushLoading || onpackLoading}
>
{onpackPushLoading
? "Pushing…"
: "Push to NGPCL (server → ngpcl.push-url)"}
</Button>
</Box>
</Stack>
{onpackPushResult ? (
<TextField
fullWidth
multiline
minRows={2}
label="Last NGPCL push result"
value={onpackPushResult}
InputProps={{ readOnly: true }}
/>
) : null}
<Typography variant="body2" color="textSecondary" sx={{ mt: 1 }}>
<code>POST /plastic/download-onpack-qr-text</code> ·{" "}
<code>POST /plastic/ngpcl/push-onpack-qr-text</code> (same body)
</Typography>
</Section>
</TabPanel>

{/* 4. Laser Section (HANS600S-M) */}
<Section title="4. Laser">
<Stack direction="row" spacing={2} sx={{ mb: 2 }}>
<TextField size="small" label="Laser IP" value={laserConfig.ip} onChange={e => setLaserConfig({...laserConfig, ip: e.target.value})} />
<TextField size="small" label="Port" value={laserConfig.port} onChange={e => setLaserConfig({...laserConfig, port: e.target.value})} />
<Router color="action" />
<TabPanel value={tabValue} index={2}>
<Section title="3. Laser Bag2 自動送(與 /laserPrint 相同邏輯)">
{laserLastReceive ? (
<Alert severity="info" sx={{ mb: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
上次印表機已確認(receive)的工單(資料庫)
</Typography>
<Typography variant="body2" sx={{ mt: 0.5 }}>
工單號:{laserLastReceive.jobOrderNo ?? "—"} Lot:
{laserLastReceive.lotNo ?? "—"}
</Typography>
<Typography
variant="body2"
sx={{ mt: 0.5, fontFamily: "monospace" }}
>
JSON:{" "}
{laserLastReceive.itemId != null &&
laserLastReceive.stockInLineId != null
? JSON.stringify({
itemId: laserLastReceive.itemId,
stockInLineId: laserLastReceive.stockInLineId,
})
: "—"}
</Typography>
<Typography
variant="caption"
color="textSecondary"
display="block"
sx={{ mt: 0.5 }}
>
{formatHongKongDateTime(laserLastReceive.sentAt)} {laserLastReceive.source ?? ""}
</Typography>
</Alert>
) : null}
<Alert severity="warning" sx={{ mb: 2 }}>
依資料庫 <strong>LASER_PRINT.host</strong>、
<strong>LASER_PRINT.port</strong>、
<strong>LASER_PRINT.itemCodes</strong> 查當日包裝工單並送
TCP(每筆工單預設 3 次、間隔 3 秒,與前端點列相同)。
排程預設關閉;啟用請設{" "}
<code>laser.bag2.auto-send.enabled=true</code>(後端
application.yml)。
</Alert>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
sx={{ mb: 2, alignItems: "center", flexWrap: "wrap" }}
>
<TextField
size="small"
label="Plan date (planStart)"
type="date"
value={laserAutoPlanDate}
onChange={(e) => setLaserAutoPlanDate(e.target.value)}
InputLabelProps={{ shrink: true }}
/>
<TextField
size="small"
label="limitPerRun(目前固定只送第一筆)"
value={laserAutoLimit}
onChange={(e) => setLaserAutoLimit(e.target.value)}
sx={{ width: 200 }}
helperText="目前後端會限制為第一筆;此欄位保留給未來調整"
/>
<Button
variant="contained"
color="primary"
onClick={() => void handleLaserBag2AutoSend()}
disabled={laserAutoLoading}
>
{laserAutoLoading
? "送出中…"
: "執行 POST /plastic/laser-bag2-auto-send"}
</Button>
</Stack>
<TableContainer component={Paper} variant="outlined" sx={{ maxHeight: 300 }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell>Template</TableCell>
<TableCell>Lot</TableCell>
<TableCell>Exp</TableCell>
<TableCell>Pwr%</TableCell>
<TableCell align="center">Action</TableCell>
</TableRow>
</TableHead>
<TableBody>
{laserItems.map(row => (
<TableRow key={row.id}>
<TableCell><TextField variant="standard" value={row.templateId} onChange={e => handleItemChange(setLaserItems, row.id, 'templateId', e.target.value)} /></TableCell>
<TableCell><TextField variant="standard" value={row.lotNo} onChange={e => handleItemChange(setLaserItems, row.id, 'lotNo', e.target.value)} /></TableCell>
<TableCell><TextField variant="standard" type="date" value={row.expiryDate} onChange={e => handleItemChange(setLaserItems, row.id, 'expiryDate', e.target.value)} /></TableCell>
<TableCell><TextField variant="standard" value={row.power} sx={{ width: 40 }} onChange={e => handleItemChange(setLaserItems, row.id, 'power', e.target.value)} /></TableCell>
<TableCell align="center">
<Stack direction="row" spacing={1} justifyContent="center">
<Button
variant="outlined"
color="info"
size="small"
onClick={() => handleLaserPreview(row)}
>
Preview
</Button>
<Button
variant="contained"
color="warning"
size="small"
startIcon={<Print />}
onClick={() => handleLaserPrint(row)}
>
Mark
</Button>
</Stack>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Typography variant="caption" sx={{ mt: 2, display: 'block', color: 'text.secondary' }}>
Note: HANS Laser requires pre-saved templates on the controller.
{laserAutoError ? (
<Alert severity="error" sx={{ mb: 2 }}>
{laserAutoError}
</Alert>
) : null}
{laserAutoReport ? (
<TextField
fullWidth
multiline
minRows={8}
label="回應(LaserBag2AutoSendReport)"
value={JSON.stringify(laserAutoReport, null, 2)}
InputProps={{ readOnly: true }}
sx={{ fontFamily: "monospace" }}
/>
) : null}
<Typography variant="body2" color="textSecondary" sx={{ mt: 1 }}>
<code>
POST
/api/plastic/laser-bag2-auto-send?planStart=YYYY-MM-DD&amp;limitPerRun=N
</code>
</Typography>
</Section>
</Grid>
</TabPanel>

{/* Dialog for OnPack */}
<Dialog open={isPrinterModalOpen} onClose={() => setIsPrinterModalOpen(false)} fullWidth maxWidth="sm">
<DialogTitle sx={{ bgcolor: 'success.main', color: 'white' }}>OnPack Printer Job Details</DialogTitle>
<DialogContent sx={{ mt: 2 }}>
<Stack spacing={3}>
<TextField label="Item Code" fullWidth value={printerFormData.itemCode} onChange={(e) => setPrinterFormData({ ...printerFormData, itemCode: e.target.value })} />
<TextField label="Lot Number" fullWidth value={printerFormData.lotNo} onChange={(e) => setPrinterFormData({ ...printerFormData, lotNo: e.target.value })} />
<TextField label="Product Name" fullWidth value={printerFormData.productName} onChange={(e) => setPrinterFormData({ ...printerFormData, productName: e.target.value })} />
<TextField label="Expiry Date" type="date" fullWidth InputLabelProps={{ shrink: true }} value={printerFormData.expiryDate} onChange={(e) => setPrinterFormData({ ...printerFormData, expiryDate: e.target.value })} />
<TabPanel value={tabValue} index={3}>
<Section title="4. 批號標籤列印(掃碼 → 查同品批號 → 選印表機 → 列印)">
<Alert severity="info" sx={{ mb: 2 }}>
此工具會呼叫後端 <code>/inventoryLotLine/analyze-qr-code</code>{" "}
找同品可用批號,再用 <code>/inventoryLotLine/print-label</code>(需
printerId)送出列印。
</Alert>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<Button
variant="contained"
onClick={() => setLotLabelModalOpen(true)}
>
開啟列印視窗
</Button>
<Typography
variant="body2"
color="text.secondary"
sx={{ alignSelf: "center" }}
>
掃碼格式:<code>{'{"itemId":16431,"stockInLineId":10381'}</code>
</Typography>
</Stack>
</DialogContent>
<DialogActions sx={{ p: 3 }}>
<Button onClick={() => setIsPrinterModalOpen(false)} variant="outlined" color="inherit">Cancel</Button>
<Button variant="contained" color="success" onClick={handleDownloadPrintJob}>Generate & Download</Button>
</DialogActions>
</Dialog>
<LotLabelPrintModal
open={lotLabelModalOpen}
onClose={() => setLotLabelModalOpen(false)}
/>
</Section>
</TabPanel>
</Box>
);
}
}

+ 24
- 1
src/app/api/bag/action.ts Целия файл

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

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

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

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

+ 168
- 0
src/app/api/bagPrint/actions.ts Целия файл

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

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

export interface JobOrderListItem {
id: number;
code: string | null;
planStart: string | null;
itemCode: string | null;
itemName: string | null;
reqQty: number | null;
stockInLineId: number | null;
itemId: number | null;
lotNo: string | null;
/** 打袋機 DataFlex cumulative printed qty */
bagPrintedQty?: number;
/** 標簽機 cumulative printed qty */
labelPrintedQty?: number;
/** 激光機 cumulative printed qty */
laserPrintedQty?: number;
}

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

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

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

/** Same mapping as Bag Print download buttons: one entry per row with a non-empty item code. */
export function buildOnPackJobOrdersPayload(jobOrders: JobOrderListItem[]): {
jobOrderId: number;
itemCode: string;
}[] {
return jobOrders
.map((jobOrder) => ({
jobOrderId: jobOrder.id,
itemCode: jobOrder.itemCode?.trim() || "",
}))
.filter((jobOrder) => jobOrder.itemCode.length > 0);
}

export interface NgpclPushResponse {
pushed: boolean;
message: string;
}

/**
* POST the same lemon OnPack ZIP bytes as download-onpack-qr-text to the server-configured NGPCL HTTP endpoint (ngpcl.push-url).
* When the URL is not configured, response has pushed=false — use download ZIP instead.
*/
export async function pushOnPackTextQrZipToNgpcl(request: OnPackQrDownloadRequest): Promise<NgpclPushResponse> {
const url = `${NEXT_PUBLIC_API_URL}/plastic/ngpcl/push-onpack-qr-text`;
const res = await clientAuthFetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
});
if (res.status === 401 || res.status === 403) {
return { pushed: false, message: "Session expired or unauthorized." };
}
const data = (await res.json()) as NgpclPushResponse;
if (!res.ok) {
throw new Error(data.message || `HTTP ${res.status}`);
}
return data;
}

/** Readable message when ZIP download returns non-OK (plain text, JSON error body, or generic). */
async function zipDownloadError(res: Response): Promise<Error> {
const text = await res.text();
const ct = res.headers.get("content-type") ?? "";
if (ct.includes("application/json")) {
try {
const j = JSON.parse(text) as { message?: string; error?: string };
if (typeof j.message === "string" && j.message.length > 0) {
return new Error(j.message);
}
if (typeof j.error === "string" && j.error.length > 0) {
return new Error(j.error);
}
} catch {
/* ignore parse */
}
}
if (text && text.length > 0 && text.length < 800 && !text.trim().startsWith("{")) {
return new Error(text);
}
return new Error(`下載失敗(HTTP ${res.status})。請查看後端日誌或確認資料庫已執行 Liquibase 更新。`);
}

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

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

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

return data;
}

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

if (!res.ok) {
throw await zipDownloadError(res);
}

return res.blob();
}

/** OnPack2023 檸檬機 — text QR template (`onpack2030_2`), no separate .bmp */
export async function downloadOnPackTextQrZip(
request: OnPackQrDownloadRequest,
): Promise<Blob> {
const url = `${NEXT_PUBLIC_API_URL}/plastic/download-onpack-qr-text`;
const res = await clientAuthFetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
});

if (!res.ok) {
throw await zipDownloadError(res);
}

return res.blob();
}

+ 158
- 0
src/app/api/bom/client.ts Целия файл

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

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

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

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

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

export async function fetchBomComboClient(): Promise<BomCombo[]> {
const response = await axiosInstance.get<BomCombo[]>(
`${NEXT_PUBLIC_API_URL}/bom/combo`
);
return response.data;
}
export async function fetchBomDetailClient(id: number): Promise<BomDetailResponse> {
const response = await axiosInstance.get<BomDetailResponse>(
`${NEXT_PUBLIC_API_URL}/bom/${id}/detail`
);
return response.data;
}

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

/** Master `equipment` rows for BOM process editor (description/name → code). */
export type EquipmentMasterRow = {
code: string;
name: string;
description: string;
};

/** Master `process` rows for BOM process editor (dropdown by code). */
export type ProcessMasterRow = {
code: string;
name: string;
};

export async function fetchAllEquipmentsMasterClient(): Promise<
EquipmentMasterRow[]
> {
const response = await axiosInstance.get<unknown[]>(
`${NEXT_PUBLIC_API_URL}/Equipment`,
);
const rows = Array.isArray(response.data) ? response.data : [];
return rows.map((r: any) => ({
code: String(r?.code ?? "").trim(),
name: String(r?.name ?? "").trim(),
description: String(r?.description ?? "").trim(),
}));
}

export async function fetchAllProcessesMasterClient(): Promise<
ProcessMasterRow[]
> {
const response = await axiosInstance.get<unknown[]>(
`${NEXT_PUBLIC_API_URL}/Process`,
);
const rows = Array.isArray(response.data) ? response.data : [];
return rows.map((r: any) => ({
code: String(r?.code ?? "").trim(),
name: String(r?.name ?? "").trim(),
}));
}

+ 138
- 10
src/app/api/bom/index.ts Целия файл

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

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

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

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

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

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

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



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

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

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

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

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

export interface EditBomRequest {
// basic fields
description?: string;
outputQty?: number;
outputQtyUom?: string;
yield?: number;

// baseScore inputs (server will recalculate)
isDark?: number;
isFloat?: number;
isDense?: number;
scrapRate?: number;
allergicSubstances?: number;
timeSequence?: number;
complexity?: number;
isDrink?: boolean;

materials?: EditBomMaterialRequest[];
processes?: EditBomProcessRequest[];
}

export interface EditBomMaterialRequest {
id?: number;
// At least one of itemId/itemCode should be present.
itemId?: number;
itemCode?: string;
qty: number;
isConsumable?: boolean;
}

export interface EditBomProcessRequest {
id?: number;
seqNo?: number;
processId?: number;
processCode?: string;
equipmentId?: number;
equipmentCode?: string;
newEquipment?: {
code: string;
name: string;
description?: string;
equipmentTypeId?: number;
};
description?: string;
durationInMinute?: number;
prepTimeInMinute?: number;
postProdTimeInMinute?: number;
}

+ 16
- 0
src/app/api/bom/recalculateClient.ts Целия файл

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

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

export interface BomScoreRecalcResponse {
updatedCount: number;
}

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


+ 975
- 0
src/app/api/chart/client.ts Целия файл

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

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

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

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

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

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

/** Multi-select filters for purchase charts (repeated `supplierId` / `itemCode` / `purchaseOrderNo` query params). */
export type PurchaseOrderChartFilters = {
supplierIds?: number[];
itemCodes?: string[];
purchaseOrderNos?: string[];
/** Single supplier code (drill when row has no supplier id); not used with `supplierIds`. */
supplierCode?: string;
};

function appendPurchaseOrderListParams(p: URLSearchParams, filters?: PurchaseOrderChartFilters) {
(filters?.supplierIds ?? []).forEach((id) => {
if (Number.isFinite(id) && id > 0) p.append("supplierId", String(id));
});
(filters?.itemCodes ?? []).forEach((c) => {
const t = String(c).trim();
if (t) p.append("itemCode", t);
});
(filters?.purchaseOrderNos ?? []).forEach((n) => {
const t = String(n).trim();
if (t) p.append("purchaseOrderNo", t);
});
const sc = filters?.supplierCode?.trim();
if (sc) p.set("supplierCode", sc);
}

export interface PoFilterSupplierOption {
supplierId: number;
code: string;
name: string;
}

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

export interface PoFilterPoNoOption {
poNo: string;
}

export interface PurchaseOrderFilterOptions {
suppliers: PoFilterSupplierOption[];
items: PoFilterItemOption[];
poNos: PoFilterPoNoOption[];
}

export interface PurchaseOrderEstimatedArrivalRow {
bucket: string;
count: number;
}

export interface PurchaseOrderDetailByStatusRow {
purchaseOrderId: number;
purchaseOrderNo: string;
status: string;
orderDate: string;
estimatedArrivalDate: string;
/** Shop / supplier FK; use for grouping when code is blank */
supplierId: number | null;
supplierCode: string;
supplierName: string;
itemCount: number;
totalQty: number;
}

export interface PurchaseOrderItemRow {
purchaseOrderLineId: number;
itemCode: string;
itemName: string;
orderedQty: number;
uom: string;
receivedQty: number;
pendingQty: number;
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

export interface JobOrderBoardRow {
jobOrderId: number;
code: string;
status: string;
planStart: string;
actualStart: string;
planEnd: string;
actualEnd: string;
materialPendingCount: number;
materialPickedCount: number;
processTotalCount: number;
processCompletedCount: number;
currentProcessCode: string;
currentProcessName: string;
currentProcessStartTime: string;
/** FG/WIP job stock-in: sum acceptedQty on all linked lines */
stockInAcceptedQtyTotal: number;
/** Lines QC-passed, waiting putaway (receiving / received) */
fgReadyToStockInCount: number;
fgReadyToStockInQty: number;
fgInQcLineCount: number;
fgInQcQty: number;
fgStockedQty: number;
/** Same sources as /jo/edit 工藝流程 summary (product process + lines) */
itemCode: string;
itemName: string;
jobTypeName: string;
reqQty: number;
outputQtyUom: string;
productionDate: string;
/** Sum of line processingTime (matches ProcessSummaryHeader 預計所需時間) */
planProcessingMinsTotal: number;
/** Sum of setup + changeover minutes on all lines */
planSetupChangeoverMinsTotal: number;
productProcessStart: string;
/** Σ line durations in decimal minutes (seconds÷60); sub-minute shown; Pass w/o endTime uses planned processing min */
actualLineMinsTotal: number;
}

function numField(v: unknown): number {
if (v == null || v === "") return 0;
const n = Number(v);
return Number.isFinite(n) ? n : 0;
}

function mapJobOrderBoardRow(r: Record<string, unknown>): JobOrderBoardRow {
const id = r.jobOrderId ?? r.joborderid;
return {
jobOrderId: Number(id ?? 0),
code: String(r.code ?? ""),
status: String(r.status ?? ""),
planStart: String(r.planStart ?? r.planstart ?? ""),
actualStart: String(r.actualStart ?? r.actualstart ?? ""),
planEnd: String(r.planEnd ?? r.planend ?? ""),
actualEnd: String(r.actualEnd ?? r.actualend ?? ""),
materialPendingCount: Number(r.materialPendingCount ?? r.materialpendingcount ?? 0),
materialPickedCount: Number(r.materialPickedCount ?? r.materialpickedcount ?? 0),
processTotalCount: Number(r.processTotalCount ?? r.processtotalcount ?? 0),
processCompletedCount: Number(r.processCompletedCount ?? r.processcompletedcount ?? 0),
currentProcessCode: String(r.currentProcessCode ?? r.currentprocesscode ?? ""),
currentProcessName: String(r.currentProcessName ?? r.currentprocessname ?? ""),
currentProcessStartTime: String(r.currentProcessStartTime ?? r.currentprocessstarttime ?? ""),
stockInAcceptedQtyTotal: Number(r.stockInAcceptedQtyTotal ?? r.stockinacceptedqtytotal ?? 0),
fgReadyToStockInCount: Number(r.fgReadyToStockInCount ?? r.fgreadytostockincount ?? 0),
fgReadyToStockInQty: Number(r.fgReadyToStockInQty ?? r.fgreadytostockinqty ?? 0),
fgInQcLineCount: Number(r.fgInQcLineCount ?? r.fginqclinecount ?? 0),
fgInQcQty: Number(r.fgInQcQty ?? r.fginqcqty ?? 0),
fgStockedQty: Number(r.fgStockedQty ?? r.fgstockedqty ?? 0),
itemCode: String(r.itemCode ?? r.itemcode ?? ""),
itemName: String(r.itemName ?? r.itemname ?? ""),
jobTypeName: String(r.jobTypeName ?? r.jobtypename ?? ""),
reqQty: numField(r.reqQty ?? r.reqqty),
outputQtyUom: String(r.outputQtyUom ?? r.outputqtyuom ?? ""),
productionDate: String(r.productionDate ?? r.productiondate ?? ""),
planProcessingMinsTotal: numField(r.planProcessingMinsTotal ?? r.planprocessingminstotal),
planSetupChangeoverMinsTotal: numField(r.planSetupChangeoverMinsTotal ?? r.plansetupchangeoverminstotal),
productProcessStart: String(r.productProcessStart ?? r.productprocessstart ?? ""),
actualLineMinsTotal: numField(r.actualLineMinsTotal ?? r.actuallineminstotal),
};
}

/** Per-job board rows. With [incompleteOnly], excludes status completed (backend LOWER(status) <> 'completed'). */
export async function fetchJobOrderBoard(
targetDate?: string,
opts?: { incompleteOnly?: boolean },
): Promise<JobOrderBoardRow[]> {
const params: Record<string, string | number | undefined> = {};
if (targetDate) params.targetDate = targetDate;
if (opts?.incompleteOnly) params.incompleteOnly = "true";
const q = buildParams(params);
const res = await clientAuthFetch(q ? `${BASE}/job-order-board?${q}` : `${BASE}/job-order-board`);
if (!res.ok) throw new Error("Failed to fetch job order board");
const data = await res.json();
return ((Array.isArray(data) ? data : []) as Record<string, unknown>[]).map(mapJobOrderBoardRow);
}

export interface ProcessBoardRow {
jopId: number;
jobOrderId: number;
jobOrderCode: string;
jobOrderStatus: string;
processId: number;
processCode: string;
processName: string;
seqNo: number;
rowStatus: string;
jobPlanStart: string;
startTime: string;
endTime: string;
/** Derived: pending | in_progress | completed */
boardStatus: string;
/** 工藝流程步驟名稱(productprocessline.name;多筆以 | 分隔);無明細時為主檔工序名。 */
lineStepName: string;
/** 描述 */
lineDescription: string;
/** 設備類型-設備名稱-編號(與工單工藝流程一致) */
lineEquipmentLabel: string;
/** 操作員/員工顯示名 */
lineOperatorInfo: string;
itemCode: string;
itemName: string;
jobTypeName: string;
reqQty: number;
outputQtyUom: string;
productionDate: string;
planProcessingMinsTotal: number;
planSetupChangeoverMinsTotal: number;
productProcessStart: string;
actualLineMinsTotal: number;
/** This BOM step: sum(processing+setup+changeover) on matching lines */
stepPlanMins: number;
/** This BOM step: Σ line durations in decimal minutes (seconds÷60); Pass/Completed without endTime uses planned processing min as fallback */
stepActualMins: number;
}

function mapProcessBoardRow(r: Record<string, unknown>): ProcessBoardRow {
return {
jopId: Number(r.jopId ?? r.jopid ?? 0),
jobOrderId: Number(r.jobOrderId ?? r.joborderid ?? 0),
jobOrderCode: String(r.jobOrderCode ?? r.jobordercode ?? ""),
jobOrderStatus: String(r.jobOrderStatus ?? r.joborderstatus ?? ""),
processId: Number(r.processId ?? r.processid ?? 0),
processCode: String(r.processCode ?? r.processcode ?? ""),
processName: String(r.processName ?? r.processname ?? ""),
seqNo: Number(r.seqNo ?? r.seqno ?? 0),
rowStatus: String(r.rowStatus ?? r.rowstatus ?? ""),
jobPlanStart: String(r.jobPlanStart ?? r.jobplanstart ?? ""),
startTime: String(r.startTime ?? r.starttime ?? ""),
endTime: String(r.endTime ?? r.endtime ?? ""),
boardStatus: String(r.boardStatus ?? r.boardstatus ?? "pending").toLowerCase(),
lineStepName: String(r.lineStepName ?? r.linestepname ?? r.line_step_name ?? ""),
lineDescription: String(r.lineDescription ?? r.linedescription ?? r.line_description ?? ""),
lineEquipmentLabel: String(r.lineEquipmentLabel ?? r.lineequipmentlabel ?? r.line_equipment_label ?? ""),
lineOperatorInfo: String(r.lineOperatorInfo ?? r.lineoperatorinfo ?? r.line_operator_info ?? ""),
itemCode: String(r.itemCode ?? r.itemcode ?? ""),
itemName: String(r.itemName ?? r.itemname ?? ""),
jobTypeName: String(r.jobTypeName ?? r.jobtypename ?? ""),
reqQty: numField(r.reqQty ?? r.reqqty),
outputQtyUom: String(r.outputQtyUom ?? r.outputqtyuom ?? ""),
productionDate: String(r.productionDate ?? r.productiondate ?? ""),
planProcessingMinsTotal: numField(r.planProcessingMinsTotal ?? r.planprocessingminstotal),
planSetupChangeoverMinsTotal: numField(r.planSetupChangeoverMinsTotal ?? r.plansetupchangeoverminstotal),
productProcessStart: String(r.productProcessStart ?? r.productprocessstart ?? ""),
actualLineMinsTotal: numField(r.actualLineMinsTotal ?? r.actuallineminstotal),
stepPlanMins: numField(r.stepPlanMins ?? r.stepplanmins),
stepActualMins: numField(r.stepActualMins ?? r.stepactualmins),
};
}

/** Per job_order_process line; same filters as job-order board. */
export async function fetchProcessBoard(
targetDate?: string,
opts?: { incompleteOnly?: boolean },
): Promise<ProcessBoardRow[]> {
const params: Record<string, string | number | undefined> = {};
if (targetDate) params.targetDate = targetDate;
if (opts?.incompleteOnly) params.incompleteOnly = "true";
const q = buildParams(params);
const res = await clientAuthFetch(q ? `${BASE}/process-board?${q}` : `${BASE}/process-board`);
if (!res.ok) throw new Error("Failed to fetch process board");
const data = await res.json();
return ((Array.isArray(data) ? data : []) as Record<string, unknown>[]).map(mapProcessBoardRow);
}

export interface EquipmentUsageBoardRow {
jopdId: number;
equipmentId: number;
equipmentCode: string;
equipmentName: string;
jobOrderId: number;
jobOrderCode: string;
jobPlanStart: string;
processCode: string;
processName: string;
operatingStart: string;
operatingEnd: string;
/** Estimated usage minutes (start–end diff, or 產線 processingTime when Pass/Completed without end). */
usageMinutes: number;
workingNow: number;
operatorUsername: string;
operatorName: string;
}

function mapEquipmentUsageBoardRow(r: Record<string, unknown>): EquipmentUsageBoardRow {
return {
jopdId: Number(r.jopdId ?? r.jopdid ?? 0),
equipmentId: Number(r.equipmentId ?? r.equipmentid ?? 0),
equipmentCode: String(r.equipmentCode ?? r.equipmentcode ?? ""),
equipmentName: String(r.equipmentName ?? r.equipmentname ?? ""),
jobOrderId: Number(r.jobOrderId ?? r.joborderid ?? 0),
jobOrderCode: String(r.jobOrderCode ?? r.jobordercode ?? ""),
jobPlanStart: String(r.jobPlanStart ?? r.jobplanstart ?? ""),
processCode: String(r.processCode ?? r.processcode ?? ""),
processName: String(r.processName ?? r.processname ?? ""),
operatingStart: String(r.operatingStart ?? r.operatingstart ?? ""),
operatingEnd: String(r.operatingEnd ?? r.operatingend ?? ""),
usageMinutes: Number(r.usageMinutes ?? r.usageminutes ?? 0),
workingNow: Number(r.workingNow ?? r.workingnow ?? 0),
operatorUsername: String(r.operatorUsername ?? r.operatorusername ?? ""),
operatorName: String(r.operatorName ?? r.operatorname ?? ""),
};
}

/** Day = COALESCE(line/jopd times, jop.endTime, planStart). Includes productprocessline (工藝流程) and job_order_process_detail. Omit targetDate = server today. */
export async function fetchEquipmentUsageBoard(targetDate?: string): Promise<EquipmentUsageBoardRow[]> {
const q = buildParams({ targetDate: targetDate ?? "" });
const res = await clientAuthFetch(q ? `${BASE}/equipment-usage-board?${q}` : `${BASE}/equipment-usage-board`);
if (!res.ok) throw new Error("Failed to fetch equipment usage board");
const data = await res.json();
return ((Array.isArray(data) ? data : []) as Record<string, unknown>[]).map(mapEquipmentUsageBoardRow);
}

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

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

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

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

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

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

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

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

export async function fetchPurchaseOrderFilterOptions(
targetDate?: string
): Promise<PurchaseOrderFilterOptions> {
const p = new URLSearchParams();
if (targetDate) p.set("targetDate", targetDate);
const q = p.toString();
const res = await clientAuthFetch(
q ? `${BASE}/purchase-order-filter-options?${q}` : `${BASE}/purchase-order-filter-options`
);
if (!res.ok) throw new Error("Failed to fetch purchase order filter options");
const data = await res.json();
const row = (data ?? {}) as Record<string, unknown>;
const suppliers = (Array.isArray(row.suppliers) ? row.suppliers : []) as Record<string, unknown>[];
const items = (Array.isArray(row.items) ? row.items : []) as Record<string, unknown>[];
const poNos = (Array.isArray(row.poNos) ? row.poNos : []) as Record<string, unknown>[];
return {
suppliers: suppliers.map((r) => ({
supplierId: Number(r.supplierId ?? r.supplierid ?? 0),
code: String(r.code ?? ""),
name: String(r.name ?? ""),
})),
items: items.map((r) => ({
itemCode: String(r.itemCode ?? r.itemcode ?? ""),
itemName: String(r.itemName ?? r.itemname ?? ""),
})),
poNos: poNos.map((r) => ({
poNo: String(r.poNo ?? r.pono ?? ""),
})),
};
}

export async function fetchPurchaseOrderEstimatedArrivalSummary(
targetDate?: string,
filters?: PurchaseOrderChartFilters
): Promise<PurchaseOrderEstimatedArrivalRow[]> {
const p = new URLSearchParams();
if (targetDate) p.set("targetDate", targetDate);
appendPurchaseOrderListParams(p, filters);
const q = p.toString();
const res = await clientAuthFetch(
q
? `${BASE}/purchase-order-estimated-arrival-summary?${q}`
: `${BASE}/purchase-order-estimated-arrival-summary`
);
if (!res.ok) throw new Error("Failed to fetch estimated arrival summary");
const data = await res.json();
return ((Array.isArray(data) ? data : []) as Record<string, unknown>[]).map((r: Record<string, unknown>) => ({
bucket: String(r.bucket ?? ""),
count: Number(r.count ?? 0),
}));
}

export interface EstimatedArrivalBreakdownSupplierRow {
supplierId: number | null;
supplierCode: string;
supplierName: string;
poCount: number;
}

export interface EstimatedArrivalBreakdownItemRow {
itemCode: string;
itemName: string;
poCount: number;
totalQty: number;
}

export interface EstimatedArrivalBreakdownPoRow {
purchaseOrderId: number;
purchaseOrderNo: string;
status: string;
orderDate: string;
supplierId: number | null;
supplierCode: string;
supplierName: string;
}

export interface PurchaseOrderEstimatedArrivalBreakdown {
suppliers: EstimatedArrivalBreakdownSupplierRow[];
items: EstimatedArrivalBreakdownItemRow[];
purchaseOrders: EstimatedArrivalBreakdownPoRow[];
}

/** Related suppliers / items / POs for one 預計送貨 bucket (same bar filters as the donut). */
export async function fetchPurchaseOrderEstimatedArrivalBreakdown(
targetDate: string,
estimatedArrivalBucket: string,
filters?: PurchaseOrderChartFilters
): Promise<PurchaseOrderEstimatedArrivalBreakdown> {
const p = new URLSearchParams();
p.set("targetDate", targetDate);
p.set("estimatedArrivalBucket", estimatedArrivalBucket.trim().toLowerCase());
appendPurchaseOrderListParams(p, filters);
const res = await clientAuthFetch(`${BASE}/purchase-order-estimated-arrival-breakdown?${p.toString()}`);
if (!res.ok) throw new Error("Failed to fetch estimated arrival breakdown");
const data = await res.json();
const row = (data ?? {}) as Record<string, unknown>;
const suppliers = (Array.isArray(row.suppliers) ? row.suppliers : []) as Record<string, unknown>[];
const items = (Array.isArray(row.items) ? row.items : []) as Record<string, unknown>[];
const purchaseOrders = (Array.isArray(row.purchaseOrders) ? row.purchaseOrders : []) as Record<string, unknown>[];
return {
suppliers: suppliers.map((r) => ({
supplierId: (() => {
const v = r.supplierId ?? r.supplierid;
if (v == null || v === "") return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
})(),
supplierCode: String(r.supplierCode ?? r.suppliercode ?? ""),
supplierName: String(r.supplierName ?? r.suppliername ?? ""),
poCount: Number(r.poCount ?? r.pocount ?? 0),
})),
items: items.map((r) => ({
itemCode: String(r.itemCode ?? r.itemcode ?? ""),
itemName: String(r.itemName ?? r.itemname ?? ""),
poCount: Number(r.poCount ?? r.pocount ?? 0),
totalQty: Number(r.totalQty ?? r.totalqty ?? 0),
})),
purchaseOrders: purchaseOrders.map((r) => ({
purchaseOrderId: Number(r.purchaseOrderId ?? r.purchaseorderid ?? 0),
purchaseOrderNo: String(r.purchaseOrderNo ?? r.purchaseorderno ?? ""),
status: String(r.status ?? ""),
orderDate: String(r.orderDate ?? r.orderdate ?? ""),
supplierId: (() => {
const v = r.supplierId ?? r.supplierid;
if (v == null || v === "") return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
})(),
supplierCode: String(r.supplierCode ?? r.suppliercode ?? ""),
supplierName: String(r.supplierName ?? r.suppliername ?? ""),
})),
};
}

export type PurchaseOrderDrillQuery = PurchaseOrderChartFilters & {
/** order = PO order date; complete = PO complete date (for received/completed on a day) */
dateFilter?: "order" | "complete";
/** delivered | not_delivered | cancelled | other — same as 預計送貨 donut buckets */
estimatedArrivalBucket?: string;
};

export async function fetchPurchaseOrderDetailsByStatus(
status: string,
targetDate?: string,
opts?: PurchaseOrderDrillQuery
): Promise<PurchaseOrderDetailByStatusRow[]> {
const p = new URLSearchParams();
p.set("status", status.trim().toLowerCase());
if (targetDate) p.set("targetDate", targetDate);
if (opts?.dateFilter) p.set("dateFilter", opts.dateFilter);
if (opts?.estimatedArrivalBucket?.trim()) {
p.set("estimatedArrivalBucket", opts.estimatedArrivalBucket.trim().toLowerCase());
}
appendPurchaseOrderListParams(p, opts);
const q = p.toString();
const res = await clientAuthFetch(`${BASE}/purchase-order-details-by-status?${q}`);
if (!res.ok) throw new Error("Failed to fetch purchase order details by status");
const data = await res.json();
return ((Array.isArray(data) ? data : []) as Record<string, unknown>[]).map((r: Record<string, unknown>) => ({
purchaseOrderId: Number(r.purchaseOrderId ?? 0),
purchaseOrderNo: String(r.purchaseOrderNo ?? ""),
status: String(r.status ?? ""),
orderDate: String(r.orderDate ?? ""),
estimatedArrivalDate: String(r.estimatedArrivalDate ?? ""),
supplierId: (() => {
const v = r.supplierId;
if (v == null || v === "") return null;
const n = Number(v);
return Number.isFinite(n) && n > 0 ? n : null;
})(),
supplierCode: String(r.supplierCode ?? ""),
supplierName: String(r.supplierName ?? ""),
itemCount: Number(r.itemCount ?? 0),
totalQty: Number(r.totalQty ?? 0),
}));
}

export async function fetchPurchaseOrderItems(
purchaseOrderId: number
): Promise<PurchaseOrderItemRow[]> {
const q = buildParams({ purchaseOrderId });
const res = await clientAuthFetch(`${BASE}/purchase-order-items?${q}`);
if (!res.ok) throw new Error("Failed to fetch purchase order items");
const data = await res.json();
return ((Array.isArray(data) ? data : []) as Record<string, unknown>[]).map((r: Record<string, unknown>) => ({
purchaseOrderLineId: Number(r.purchaseOrderLineId ?? 0),
itemCode: String(r.itemCode ?? ""),
itemName: String(r.itemName ?? ""),
orderedQty: Number(r.orderedQty ?? 0),
uom: String(r.uom ?? ""),
receivedQty: Number(r.receivedQty ?? 0),
pendingQty: Number(r.pendingQty ?? 0),
}));
}

export async function fetchPurchaseOrderItemsByStatus(
status: string,
targetDate?: string,
opts?: PurchaseOrderDrillQuery
): Promise<PurchaseOrderItemRow[]> {
const p = new URLSearchParams();
p.set("status", status.trim().toLowerCase());
if (targetDate) p.set("targetDate", targetDate);
if (opts?.dateFilter) p.set("dateFilter", opts.dateFilter);
if (opts?.estimatedArrivalBucket?.trim()) {
p.set("estimatedArrivalBucket", opts.estimatedArrivalBucket.trim().toLowerCase());
}
appendPurchaseOrderListParams(p, opts);
const q = p.toString();
const res = await clientAuthFetch(`${BASE}/purchase-order-items-by-status?${q}`);
if (!res.ok) throw new Error("Failed to fetch purchase order items by status");
const data = await res.json();
return ((Array.isArray(data) ? data : []) as Record<string, unknown>[]).map((r: Record<string, unknown>) => ({
purchaseOrderLineId: 0,
itemCode: String(r.itemCode ?? ""),
itemName: String(r.itemName ?? ""),
orderedQty: Number(r.orderedQty ?? 0),
uom: String(r.uom ?? ""),
receivedQty: Number(r.receivedQty ?? 0),
pendingQty: Number(r.pendingQty ?? 0),
}));
}

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

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

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

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

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

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

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

+ 24
- 0
src/app/api/dashboard/actions.ts Целия файл

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

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

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

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

+ 17
- 0
src/app/api/dashboard/client.ts Целия файл

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

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

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

export type { GoodsReceiptStatusRow };

export default fetchGoodsReceiptStatusClient;


+ 225
- 13
src/app/api/do/actions.tsx Целия файл

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

}
export interface DoSearchLiteResponse {
records: DoSearchAll[];
total: number;
}
export interface ReleaseDoRequest {
id: number;
}
@@ -96,10 +100,20 @@ export interface PrintDNLabelsRequest{
numOfCarton: number;
}

export interface PrintDNLabelsReprintRequest{
doPickOrderId: number;
printerId: number;
printQty: number;
fromCarton: number;
toCarton: number;
totalCartonsOnShipment: number;
}

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

export interface BatchReleaseRequest {
ids: number[];
}
@@ -130,6 +144,8 @@ export interface getTicketReleaseTable {
requiredDeliveryDate: string | null;
handlerName: string | null;
numberOfFGItems: number;
/** 進行中 do_pick_order 為 true,才可呼叫 force-complete / revert-assignment(id 為 do_pick_order 主鍵) */
isActiveDoPickOrder?: boolean;
}

export interface TruckScheduleDashboardItem {
@@ -197,9 +213,12 @@ export const fetchTicketReleaseTable = cache(async (startDate: string, endDate:
);
});

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

export const fetchDoSearch = cache(async (code: string, shopName: string, status: string, orderStartDate: string, orderEndDate: string, estArrStartDate: string, estArrEndDate: string)=>{
console.log(`${BASE_API_URL}/do/search-DO/${code}&${shopName}&${status}&${orderStartDate}&${orderEndDate}&${estArrStartDate}&${estArrEndDate}`);
return serverFetchJson<DoSearchAll[]>(`${BASE_API_URL}/do/search-DO/${code}&${shopName}&${status}&${orderStartDate}&${orderEndDate}&${estArrStartDate}&${estArrEndDate}`,{
method: "GET",
next: { tags: ["doSearch"] }
/** 車線搜尋為「車線-X」時改走後端專用 API(只含推算車線為 null/空白之送貨單) */
function isTruckLaneXSearch(truckLanceCode?: string): boolean {
const t = truckLanceCode?.trim().toLowerCase() ?? "";
return t === "車線-x";
}

export async function fetchDoSearch(
code: string,
shopName: string,
status: string,
orderStartDate: string,
orderEndDate: string,
estArrStartDate: string,
estArrEndDate: string,
pageNum?: number,
pageSize?: number,
truckLanceCode?: string
): Promise<DoSearchLiteResponse> {
// 构建请求体
const requestBody: any = {
code: code || null,
shopName: shopName || null,
status: status || null,
estimatedArrivalDate: estArrStartDate || null, // 使用单个日期字段
truckLanceCode: truckLanceCode || null,
pageNum: pageNum || 1,
pageSize: pageSize || 10,
};

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

const useUnassignedTruck = isTruckLaneXSearch(truckLanceCode);
if (useUnassignedTruck) {
delete requestBody.truckLanceCode;
}

const url = useUnassignedTruck
? `${BASE_API_URL}/do/search-do-lite-unassigned-truck`
: `${BASE_API_URL}/do/search-do-lite`;

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

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

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

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

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

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

export async function printDNLabelsReprint(request: PrintDNLabelsReprintRequest){
const params = new URLSearchParams();
params.append('doPickOrderId', request.doPickOrderId.toString());
params.append('printerId', request.printerId.toString());
if (request.printQty !== null && request.printQty !== undefined) {
params.append('printQty', request.printQty.toString());
}
params.append('fromCarton', request.fromCarton.toString());
params.append('toCarton', request.toCarton.toString());
params.append('totalCartonsOnShipment', request.totalCartonsOnShipment.toString());

await serverFetchWithNoContent(`${BASE_API_URL}/do/print-DNLabels-reprint?${params.toString()}`,{
method: "GET"
});

return { success: true, message: "Print job sent successfully (reprint labels)"} as PrintDeliveryNoteResponse
}
/*
export interface PrintWorkbenchDeliveryNoteRequest{
deliveryOrderPickOrderId: number;
printerId: number;
printQty: number;
numOfCarton: number;
isDraft: boolean;
}

export interface PrintWorkbenchDNLabelsRequest{
deliveryOrderPickOrderId: number;
printerId: number;
printQty: number;
numOfCarton: number;
}
export async function printDNWorkbench(request: PrintWorkbenchDeliveryNoteRequest){
const params = new URLSearchParams();
params.append("doPickOrderId", request.deliveryOrderPickOrderId.toString());
params.append("printerId", request.printerId.toString());
if (request.printQty !== null && request.printQty !== undefined) {
params.append("printQty", request.printQty.toString());
}
params.append("numOfCarton", request.numOfCarton.toString());
params.append("isDraft", request.isDraft.toString());

try {
const response = await serverFetch(`${BASE_API_URL}/do/workbench/print-DN?${params.toString()}`, {
method: "GET",
});
if (response.ok) {
return { success: true, message: "Print job sent successfully (workbench DN)" } as PrintDeliveryNoteResponse;
}
const errorText = await response.text();
console.error("Workbench print DN error:", errorText);
return {
success: false,
message: "No workbench data found for this ticket.",
} as PrintDeliveryNoteResponse;
} catch (error) {
console.error("Error in printDNWorkbench:", error);
return {
success: false,
message: "No workbench data found for this ticket.",
} as PrintDeliveryNoteResponse;
}
}

export async function printDNLabelsWorkbench(request: PrintWorkbenchDNLabelsRequest){
const params = new URLSearchParams();
params.append("doPickOrderId", request.deliveryOrderPickOrderId.toString());
params.append("printerId", request.printerId.toString());
if (request.printQty !== null && request.printQty !== undefined) {
params.append("printQty", request.printQty.toString());
}
params.append("numOfCarton", request.numOfCarton.toString());

await serverFetchWithNoContent(`${BASE_API_URL}/do/workbench/print-DNLabels?${params.toString()}`,{
method: "GET"
});

return { success: true, message: "Print job sent successfully (workbench labels)"} as PrintDeliveryNoteResponse
}
*/
export interface Check4FTruckBatchResponse {
hasProblem: boolean;
problems: ProblemDoDto[];
@@ -368,4 +540,44 @@ export const check4FTrucksBatch = cache(async (doIds: number[]) => {
});
});

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

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

const useUnassignedTruck = isTruckLaneXSearch(truckLanceCode);
if (useUnassignedTruck) {
delete requestBody.truckLanceCode;
}

const url = useUnassignedTruck
? `${BASE_API_URL}/do/search-do-lite-unassigned-truck`
: `${BASE_API_URL}/do/search-do-lite`;

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

return data.records;
}

+ 2
- 2
src/app/api/do/client.ts Целия файл

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

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

export type { TruckScheduleDashboardItem };


+ 2
- 0
src/app/api/escalation/index.ts Целия файл

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


+ 31
- 0
src/app/api/inventory/actions.ts Целия файл

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

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

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

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

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

+ 2
- 0
src/app/api/inventory/index.ts Целия файл

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

export interface InventoryLotLineResult {


+ 352
- 26
src/app/api/jo/actions.ts Целия файл

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

export interface productProcessLineQtyRequest {
@@ -132,6 +133,7 @@ export interface PrintPickRecordRequest{
pickOrderId: number;
printerId: number;
printQty: number;
floor?: "2F" | "3F" | "4F" | "ALL";
}

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

export interface ProductProcessWithLinesResponse {
@@ -343,16 +346,27 @@ export interface AllJoborderProductProcessInfoResponse {
pickOrderStatus: string;
itemCode: string;
itemName: string;
lotNo: string;
requiredQty: number;
jobOrderId: number;
timeNeedToComplete: number;
uom: string;
isDrink?: boolean | null;
stockInLineId: number;
/** Stock-in-line current status (e.g. receiving/received/partially_completed/completed/rejected). */
stockInLineStatus?: string | null;
jobOrderCode: string;
productProcessLineCount: number;
FinishedProductProcessLineCount: number;
lines: ProductProcessInfoResponse[];
}

export interface JobOrderProductProcessPageResponse {
content: AllJoborderProductProcessInfoResponse[];
totalJobOrders: number;
page: number;
size: number;
}
export interface ProductProcessInfoResponse {
id: number;
operatorId?: number;
@@ -454,18 +468,29 @@ export interface JobOrderProcessLineDetailResponse {
}
export interface JobOrderLineInfo {
id: number,
jobOrderId: number,
jobOrderCode: string,
itemId: number,
itemCode: string,
itemName: string,
type: string,

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

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

reqUom: string,
reqBaseUom: string,

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

}
export interface ProductProcessLineInfoResponse {
@@ -496,6 +521,11 @@ export interface ProductProcessLineInfoResponse {
startTime: string,
endTime: string
}
export interface FloorPickCount {
floor: string;
finishedCount: number;
totalCount: number;
}
export interface AllJoPickOrderResponse {
id: number;
pickOrderId: number | null;
@@ -506,11 +536,15 @@ export interface AllJoPickOrderResponse {
jobOrderType: string | null;
itemId: number;
itemName: string;
lotNo: string | null;
reqQty: number;
uomId: number;
uomName: string;
jobOrderStatus: string;
finishedPickOLineCount: number;
floorPickCounts: FloorPickCount[];
noLotPickCount?: FloorPickCount | null;
suggestedFailCount?: number;
}
export interface UpdateJoPickOrderHandledByRequest {
pickOrderId: number;
@@ -553,11 +587,27 @@ export interface PickOrderLineWithLotsResponse {
itemCode: string | null;
itemName: string | null;
requiredQty: number | null;
totalAvailableQty?: number | null;
uomCode: string | null;
uomDesc: string | null;
status: string | null;
handler: string | null;
lots: LotDetailResponse[];
stockouts?: StockOutLineDetailResponse[];
}

export interface StockOutLineDetailResponse {
id: number | null;
status: string | null;
qty: number | null;
lotId: number | null;
lotNo: string | null;
location: string | null;
availableQty: number | null;
noLot: boolean;
/** Workbench API: matched suggest_pick_lot qty for this SOL lot line */
// suggestedPickQty?: number | null;
//suggestedPickLotId?: number | null;
}

export interface LotDetailResponse {
@@ -575,6 +625,7 @@ export interface LotDetailResponse {
pickOrderConsoCode: string | null;
pickOrderLineId: number | null;
stockOutLineId: number | null;
stockInLineId: number | null;
suggestedPickLotId: number | null;
stockOutLineQty: number | null;
stockOutLineStatus: string | null;
@@ -628,6 +679,16 @@ export const deleteJobOrder=cache(async (jobOrderId: number) => {
}
);
});

export const setJobOrderHidden = cache(async (jobOrderId: number, hidden: boolean) => {
const response = await serverFetchJson<any>(`${BASE_API_URL}/jo/set-hidden`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: jobOrderId, hidden }),
});
revalidateTag("jos");
return response;
});
export const fetchAllJobTypes = cache(async () => {
return serverFetchJson<JobTypeResponse[]>(
`${BASE_API_URL}/jo/jobTypes`,
@@ -655,14 +716,34 @@ export const fetchJobOrderLotsHierarchicalByPickOrderId = cache(async (pickOrder
},
);
});
export const fetchAllJoPickOrders = cache(async () => {

/** JO Workbench: in−out available (matches scan-pick); stockouts include suggestedPickQty / suggestedPickLotId when SPL matches SOL lot line */
/*
export const fetchJobOrderLotsHierarchicalByPickOrderIdWorkbench = cache(
async (pickOrderId: number) => {
return serverFetchJson<JobOrderLotsHierarchicalResponse>(
`${BASE_API_URL}/jo/all-lots-hierarchical-by-pick-order-workbench/${pickOrderId}`,
{
method: "GET",
next: { tags: ["jo-hierarchical-workbench"] },
},
);
},
);
*/
// NOTE: Do NOT wrap in `cache()` because the list needs to reflect just-completed lines
// immediately when navigating back from JobPickExecution.
export const fetchAllJoPickOrders = async (type?: string | null, floor?: string | null) => {
const params = new URLSearchParams();
if (type) params.set("type", type);
if (floor) params.set("floor", floor);
const query = params.toString() ? `?${params.toString()}` : "";
return serverFetchJson<AllJoPickOrderResponse[]>(
`${BASE_API_URL}/jo/AllJoPickOrder`,
{
method: "GET",
}
`${BASE_API_URL}/jo/AllJoPickOrder${query}`,
// Force re-fetch. This page reflects real-time pick completion state.
{ method: "GET", cache: "no-store" }
);
});
};
export const fetchProductProcessLineDetail = cache(async (lineId: number) => {
return serverFetchJson<JobOrderProcessLineDetailResponse>(
`${BASE_API_URL}/product-process/Demo/ProcessLine/detail/${lineId}`,
@@ -715,9 +796,13 @@ export const newUpdateProductProcessLineQrscan = cache(async (request: NewProduc
}
);
});
export const fetchAllJoborderProductProcessInfo = cache(async () => {
export const fetchAllJoborderProductProcessInfo = cache(async (type?: string | null) => {
const query = type
? `?type=${encodeURIComponent(type)}`
: "";

return serverFetchJson<AllJoborderProductProcessInfoResponse[]>(
`${BASE_API_URL}/product-process/Demo/Process/all`,
`${BASE_API_URL}/product-process/Demo/Process/all${query}`,
{
method: "GET",
next: { tags: ["productProcess"] },
@@ -725,6 +810,60 @@ export const fetchAllJoborderProductProcessInfo = cache(async () => {
);
});

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

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

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

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

/*
export const updateProductProcessLineQty = async (request: UpdateProductProcessLineQtyRequest) => {
return serverFetchJson<UpdateProductProcessLineQtyResponse>(
@@ -873,7 +1012,7 @@ export const updateSecondQrScanStatus = cache(async (pickOrderId: number, itemId
export const submitSecondScanQuantity = cache(async (
pickOrderId: number,
itemId: number,
data: { qty: number; isMissing?: boolean; isBad?: boolean; reason?: string }
data: { qty: number; isMissing?: boolean; isBad?: boolean; reason?: string; userId?: number }
) => {
return serverFetchJson<any>(
`${BASE_API_URL}/jo/second-scan-submit/${pickOrderId}/${itemId}`,
@@ -934,6 +1073,7 @@ export const fetchCompletedJobOrderPickOrders = cache(async (userId: number) =>
},
);
});
/*
// 获取已完成的 Job Order pick orders
export const fetchCompletedJobOrderPickOrdersrecords = cache(async () => {
return serverFetchJson<any>(
@@ -944,6 +1084,43 @@ export const fetchCompletedJobOrderPickOrdersrecords = cache(async () => {
},
);
});
*/
export const fetchCompletedJobOrderPickOrdersrecords = async (completedDate?: string | null) => {
const q =
completedDate && String(completedDate).trim() !== ""
? `?date=${encodeURIComponent(String(completedDate).trim())}`
: "";
return serverFetchJson<any>(`${BASE_API_URL}/jo/completed-job-order-pick-orders-only${q}`, {
method: "GET",
cache: "no-store",
});
};
export const fetchJobOrderPickOrdersrecords = async (
date?: string | null,
status?: string | null,
) => {
const params = new URLSearchParams();

if (date && String(date).trim() !== "") {
params.set("date", String(date).trim());
}
if (status && String(status).trim() !== "" && String(status) !== "All") {
params.set("status", String(status).trim());
}

const q = params.toString() ? `?${params.toString()}` : "";
return serverFetchJson<any>(`${BASE_API_URL}/jo/job-order-pick-orders${q}`, {
method: "GET",
cache: "no-store",
});
};

export const fetchJobOrderPickOrderLotDetailsForPick = cache(async (pickOrderId: number) => {
return serverFetchJson<any[]>(`${BASE_API_URL}/jo/job-order-pick-order-lot-details/${pickOrderId}`, {
method: "GET",
headers: { "Content-Type": "application/json" }
})
})
export const fetchJoForPrintQrCode = cache(async (date: string) => {
return serverFetchJson<JobOrderListForPrintQrCodeResponse[]>(
`${BASE_API_URL}/jo/joForPrintQrCode/${date}`,
@@ -1016,11 +1193,59 @@ export const fetchJos = cache(async (data?: SearchJoResultRequest) => {
}
}
)
console.log("fetchJos response:", response)
// console.log("fetchJos response:", response)
return response
})

export interface PostPickOrderResponse<T = null> {
id: number | null;
name: string;
code: string;
type?: string;
message: string | null;
errorPosition: string
entity?: T | T[];
consoCode?: string;
}
export interface PickExecutionIssueData {
type: string;
pickOrderId: number;
pickOrderCode: string;
pickOrderCreateDate: string;
pickExecutionDate: string;
pickOrderLineId: number;
itemId: number;
itemCode: string;
itemDescription: string;
lotId: number|null;
lotNo: string|null;
storeLocation: string;
requiredQty: number;
actualPickQty: number;
missQty: number;
badItemQty: number;
badPackageQty?: number;
/** Optional: frontend-only reference to stock_out_line.id for the picked lot. */
stockOutLineId?: number;
issueRemark: string;
pickerName: string;
handledBy?: number;
badReason?: string;
reason?: string;
}
/** 无 miss/bad/bad package:仅后端 hold + SOL checked,不写 pick_execution_issue(避免 DUPLICATE)。 */
export const applyPickExecutionHoldAndChecked = async (data: PickExecutionIssueData) => {
const result = await serverFetchJson<PostPickOrderResponse>(
`${BASE_API_URL}/pickExecution/applyHoldAndChecked`,
{
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
},
);
revalidateTag("pickorder");
return result;
};
export const updateJo = cache(async (data: UpdateJoRequest) => {
return serverFetchJson<SaveJoResponse>(`${BASE_API_URL}/jo/update`,
{
@@ -1094,6 +1319,9 @@ export async function PrintPickRecord(request: PrintPickRecordRequest){
if (request.printQty !== null && request.printQty !== undefined) {
params.append('printQty', request.printQty.toString());
}
if (request.floor) {
params.append('floor', request.floor);
}

//const response = await serverFetchWithNoContent(`${BASE_API_URL}/jo/print-PickRecord?${params.toString()}`,{
const response = await serverFetchWithNoContent(`${BASE_API_URL}/jo/print-PickRecord?${params.toString()}`,{
@@ -1188,18 +1416,26 @@ export interface MaterialPickStatusItem {
pickStatus: string | null;
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

};
;

+ 2
- 0
src/app/api/jo/index.ts Целия файл

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

export interface Machine {


+ 204
- 0
src/app/api/laserPrint/actions.ts Целия файл

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

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

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

export interface LaserLastReceiveSuccess {
jobOrderId?: number | null;
jobOrderNo?: string | null;
lotNo?: string | null;
itemId?: number | null;
stockInLineId?: number | null;
printerAck?: string | null;
sentAt?: string | null;
source?: string | null;
}

export interface LaserBag2Settings {
host: string;
port: number;
/** Comma-separated item codes; empty string = show all packaging job orders */
itemCodes: string;
/** Last job where the laser returned a receive ack (from DB settings). */
lastReceiveSuccess?: LaserLastReceiveSuccess | null;
}

export interface LaserBag2SendRequest {
itemId: number | null;
stockInLineId: number | null;
itemCode: string | null;
itemName: string | null;
printerIp?: string;
printerPort?: number;
jobOrderId?: number | null;
jobOrderNo?: string | null;
lotNo?: string | null;
source?: string | null;
}

export interface LaserBag2SendResponse {
success: boolean;
message: string;
payloadSent?: string | null;
/** Raw TCP reply from the laser plugin (often `receive;;`). */
printerAck?: string | null;
/** True when the peer reply contained `receive` and not `invalid`. */
receiveAcknowledged?: boolean;
}

/**
* Uses server LASER_PRINT.itemCodes filter. Calls public GET /py/laser-job-orders (same as Python Bag2 /py/job-orders),
* so it works without relying on authenticated /plastic routes.
*/
export async function fetchLaserJobOrders(planStart: string): Promise<JobOrderListItem[]> {
const base = (NEXT_PUBLIC_API_URL ?? "").replace(/\/$/, "");
if (!base) {
throw new Error("NEXT_PUBLIC_API_URL is not set; cannot reach API.");
}
const url = `${base}/py/laser-job-orders?planStart=${encodeURIComponent(planStart)}`;
let res: Response;
try {
res = await clientAuthFetch(url, { method: "GET" });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
throw new Error(
`無法連線 API(${url}):${msg}。請確認後端已啟動且 NEXT_PUBLIC_API_URL 指向正確(例如 http://localhost:8090/api)。`,
);
}
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(
`載入工單失敗(${res.status})${body ? `:${body.slice(0, 200)}` : ""}`,
);
}
return res.json() as Promise<JobOrderListItem[]>;
}

export async function fetchLaserBag2Settings(): Promise<LaserBag2Settings> {
const base = (NEXT_PUBLIC_API_URL ?? "").replace(/\/$/, "");
if (!base) {
throw new Error("NEXT_PUBLIC_API_URL is not set.");
}
const url = `${base}/plastic/laser-bag2-settings`;
let res: Response;
try {
res = await clientAuthFetch(url, { method: "GET" });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
throw new Error(`無法連線至 ${url}:${msg}`);
}
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`載入設定失敗(${res.status})${body ? body.slice(0, 200) : ""}`);
}
return res.json() as Promise<LaserBag2Settings>;
}

export async function sendLaserBag2Job(body: LaserBag2SendRequest): Promise<LaserBag2SendResponse> {
const url = `${NEXT_PUBLIC_API_URL}/plastic/print-laser-bag2`;
const res = await clientAuthFetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = (await res.json()) as LaserBag2SendResponse;
if (!res.ok) {
return data;
}
return data;
}

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

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

export async function checkPrinterStatus(request: PrinterStatusRequest): Promise<PrinterStatusResponse> {
const base = (NEXT_PUBLIC_API_URL ?? "").replace(/\/$/, "");
if (!base) {
throw new Error("NEXT_PUBLIC_API_URL is not set.");
}
const url = `${base}/plastic/check-printer`;
const res = await clientAuthFetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
});
const data = (await res.json()) as PrinterStatusResponse;
return data;
}

export interface LaserBag2JobSendResult {
jobOrderId: number;
itemCode: string | null;
success: boolean;
message: string;
printerAck?: string | null;
receiveAcknowledged?: boolean;
}

export interface LaserBag2AutoSendReport {
planStart: string;
jobOrdersFound: number;
jobOrdersProcessed: number;
results: LaserBag2JobSendResult[];
}

/**
* Same workflow as /laserPrint row click: list job orders (LASER_PRINT.itemCodes) for planStart,
* then TCP send using DB LASER_PRINT.host / port (server currently sends first matching job only).
*/
export async function runLaserBag2AutoSend(params?: {
planStart?: string;
limitPerRun?: number;
}): Promise<LaserBag2AutoSendReport> {
const base = (NEXT_PUBLIC_API_URL ?? "").replace(/\/$/, "");
if (!base) {
throw new Error("NEXT_PUBLIC_API_URL is not set.");
}
const sp = new URLSearchParams();
if (params?.planStart) sp.set("planStart", params.planStart);
if (params?.limitPerRun != null) sp.set("limitPerRun", String(params.limitPerRun));
const q = sp.toString();
const url = `${base}/plastic/laser-bag2-auto-send${q ? `?${q}` : ""}`;
const res = await clientAuthFetch(url, { method: "POST" });
if (!res.ok) {
const t = await res.text().catch(() => "");
throw new Error(t || `HTTP ${res.status}`);
}
return res.json() as Promise<LaserBag2AutoSendReport>;
}

export async function patchSetting(name: string, value: string): Promise<void> {
const url = `${NEXT_PUBLIC_API_URL}/settings/${encodeURIComponent(name)}`;
const res = await clientAuthFetch(url, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ value }),
});
if (!res.ok) {
const t = await res.text().catch(() => "");
throw new Error(t || `Failed to save setting: ${res.status}`);
}
}

+ 22
- 3
src/app/api/pdf/actions.ts Целия файл

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

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

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

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

return reportBlob;
}

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

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

+ 252
- 8
src/app/api/pickOrder/actions.ts Целия файл

@@ -207,9 +207,14 @@ export interface PickExecutionIssueData {
actualPickQty: number;
missQty: number;
badItemQty: number;
badPackageQty?: number;
/** Optional: frontend-only reference to stock_out_line.id for the picked lot. */
stockOutLineId?: number;
issueRemark: string;
pickerName: string;
handledBy?: number;
badReason?: string;
reason?: string;
}
export type AutoAssignReleaseResponse = {
id: number | null;
@@ -365,6 +370,8 @@ export interface CompletedDoPickOrderResponse {
completedDate: string;
fgPickOrders: FGPickOrderResponse[];
deliveryNoteCode: number;
/** Legacy: do_pick_order_record.handler_name; workbench: delivery_order_pick_order.handlerName */
handlerName?: string | null;
}

// 新增:搜索参数接口
@@ -372,6 +379,8 @@ export interface CompletedDoPickOrderSearchParams {
targetDate?: string;
shopName?: string;
deliveryNoteCode?: string;
/** 卡車/車道(後端 truckLanceCode 模糊匹配) */
truckLanceCode?: string;
}
export interface PickExecutionIssue {
id: number;
@@ -440,6 +449,7 @@ export interface UpdatePickExecutionIssueRequest {
export interface StoreLaneSummary {
storeId: string;
rows: LaneRow[];
defaultTruckCount: number | null;
}

export interface LaneRow {
@@ -449,8 +459,10 @@ export interface LaneRow {

export interface LaneBtn {
truckLanceCode: string;
loadingSequence?: number | null;
unassigned: number;
total: number;
handlerName: string;
}

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

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

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

// 按车道分配订单
@@ -591,6 +640,7 @@ export async function assignByLane(
storeId: string,
truckLanceCode: string,
truckDepartureTime?: string,
loadingSequence?: number | null,
requiredDate?: string
): Promise<any> {
const response = await serverFetchJson(
@@ -605,6 +655,7 @@ export async function assignByLane(
storeId,
truckLanceCode,
truckDepartureTime,
loadingSequence,
requiredDate,
}),
}
@@ -627,7 +678,10 @@ export const fetchCompletedDoPickOrders = async (
if (searchParams?.targetDate) {
params.append('targetDate', searchParams.targetDate);
}
if (searchParams?.truckLanceCode) {
params.append("truckLanceCode", searchParams.truckLanceCode);
}

const queryString = params.toString();
const url = `${BASE_API_URL}/pickOrder/completed-do-pick-orders/${userId}${queryString ? `?${queryString}` : ''}`;
@@ -637,6 +691,88 @@ export const fetchCompletedDoPickOrders = async (
return response;
};


/** DO workbench: completed tickets from `delivery_order_pick_order.ticketStatus = completed`. **/
/*
export const fetchCompletedDoPickOrdersWorkbench = async (
userId: number,
searchParams?: CompletedDoPickOrderSearchParams,
): Promise<CompletedDoPickOrderResponse[]> => {
const params = new URLSearchParams();

if (searchParams?.deliveryNoteCode) {
params.append("deliveryNoteCode", searchParams.deliveryNoteCode);
}
if (searchParams?.shopName) {
params.append("shopName", searchParams.shopName);
}
if (searchParams?.targetDate) {
params.append("targetDate", searchParams.targetDate);
}
if (searchParams?.truckLanceCode) {
params.append("truckLanceCode", searchParams.truckLanceCode);
}

const queryString = params.toString();
const url = `${BASE_API_URL}/pickOrder/completed-do-pick-orders-workbench/${userId}${
queryString ? `?${queryString}` : ""
}`;

return serverFetchJson<CompletedDoPickOrderResponse[]>(url, {
method: "GET",
});
};
*/
/** 全部已完成 DO 提貨記錄(不限經手人),需後端 `/completed-do-pick-orders-all` */
export const fetchCompletedDoPickOrdersAll = async (
searchParams?: CompletedDoPickOrderSearchParams
): Promise<CompletedDoPickOrderResponse[]> => {
const params = new URLSearchParams();

if (searchParams?.deliveryNoteCode) {
params.append("deliveryNoteCode", searchParams.deliveryNoteCode);
}
if (searchParams?.shopName) {
params.append("shopName", searchParams.shopName);
}
if (searchParams?.targetDate) {
params.append("targetDate", searchParams.targetDate);
}
if (searchParams?.truckLanceCode) {
params.append("truckLanceCode", searchParams.truckLanceCode);
}

const queryString = params.toString();
const url = `${BASE_API_URL}/pickOrder/completed-do-pick-orders-all${queryString ? `?${queryString}` : ""}`;

const response = await serverFetchJson<CompletedDoPickOrderResponse[]>(url, {
method: "GET",
});

return response;
};

/** 強制完成進行中的 do_pick_order(僅改狀態並歸檔,不調整揀貨數量) */
export const forceCompleteDoPickOrder = async (
doPickOrderId: number,
): Promise<PostPickOrderResponse> => {
return serverFetchJson<PostPickOrderResponse>(
`${BASE_API_URL}/doPickOrder/force-complete/${doPickOrderId}`,
{ method: "POST", headers: { "Content-Type": "application/json" } },
);
};

/** 撤銷使用者領取,可再次分配 */
export const revertDoPickOrderAssignment = async (
doPickOrderId: number,
): Promise<PostPickOrderResponse> => {
return serverFetchJson<PostPickOrderResponse>(
`${BASE_API_URL}/doPickOrder/revert-assignment/${doPickOrderId}`,
{ method: "POST", headers: { "Content-Type": "application/json" } },
);
};

export const updatePickOrderHideStatus = async (pickOrderId: number, hide: boolean) => {
const response = await serverFetchJson<UpdateDoPickOrderHideStatusRequest>(
`${BASE_API_URL}/pickOrder/update-hide-status/${pickOrderId}?hide=${hide}`,
@@ -667,6 +803,22 @@ export const fetchFGPickOrdersByUserId = async (userId: number) => {
);
return response;
};

/** DO workbench: FG headers from `delivery_order_pick_order`, not `do_pick_order_line`. */

/*
export const fetchFGPickOrdersByUserIdWorkbench = async (userId: number) => {
return serverFetchJson<FGPickOrderResponse[]>(
`${BASE_API_URL}/pickOrder/fg-pick-orders-workbench/${userId}`,
{
method: "GET",
// Must be fresh: determines whether shell shows Floor/Lane panel or Detail.
cache: "no-store",
next: { revalidate: 0 },
},
);
};
*/
export const updateSuggestedLotLineId = async (suggestedPickLotId: number, newLotLineId: number) => {
const response = await serverFetchJson<PostPickOrderResponse<UpdateSuggestedLotLineIdRequest>>(
`${BASE_API_URL}/suggestedPickLot/update-suggested-lot/${suggestedPickLotId}`,
@@ -726,6 +878,8 @@ export const recordPickExecutionIssue = async (data: PickExecutionIssueData) =>
revalidateTag("pickorder");
return result;
};


export const resuggestPickOrder = async (pickOrderId: number) => {
console.log("Resuggesting pick order:", pickOrderId);
const result = await serverFetchJson<PostPickOrderResponse>(
@@ -964,6 +1118,7 @@ export interface LotSubstitutionConfirmRequest {
stockOutLineId: number;
originalSuggestedPickLotId: number;
newInventoryLotNo: string;
newStockInLineId: number;
}
export const confirmLotSubstitution = async (data: LotSubstitutionConfirmRequest) => {
const response = await serverFetchJson<PostPickOrderResponse>(
@@ -1035,6 +1190,28 @@ export const fetchAllPickOrderLotsHierarchical = cache(async (userId: number): P
};
}
});

/** DO workbench: hierarchical lots where header is `delivery_order_pick_order`. */
/*
export const fetchAllPickOrderLotsHierarchicalWorkbench = cache(async (userId: number): Promise<any> => {
try {
const data = await serverFetchJson<any>(
`${BASE_API_URL}/pickOrder/all-lots-hierarchical-workbench/${userId}`,
{
method: "GET",
next: { tags: ["pickorder"] },
},
);
return data;
} catch (error) {
console.error("❌ Error fetching workbench hierarchical lot details:", error);
return {
pickOrder: null,
pickOrderLines: [],
};
}
});
*/
export const fetchLotDetailsByDoPickOrderRecordId = async (doPickOrderRecordId: number): Promise<{
fgInfo: any;
pickOrders: any[];
@@ -1350,4 +1527,71 @@ export const fetchReleasedDoPickOrders = async (): Promise<ReleasedDoPickOrderRe
},
);
return response;
};
// 新增:Released Do Pick Order 列表項目(對應後端 ReleasedDoPickOrderListItem)
export interface ReleasedDoPickOrderListItem {
id: number;
requiredDeliveryDate: string | null;
shopCode: string | null;
shopName: string | null;
storeId: string | null;
truckLanceCode: string | null;
truckDepartureTime: string | null;
deliveryOrderCodes: string[];
}

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

+ 16
- 1
src/app/api/po/actions.ts Целия файл

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

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

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


+ 1
- 1
src/app/api/po/index.ts Целия файл

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


+ 3
- 3
src/app/api/qc/index.ts Целия файл

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


+ 30
- 0
src/app/api/settings/bomWeighting/actions.ts Целия файл

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

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

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

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

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

return response;
};

+ 30
- 0
src/app/api/settings/bomWeighting/client.ts Целия файл

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

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

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

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

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

Някои файлове не бяха показани, защото твърде много файлове са промени

Зареждане…
Отказ
Запис