Porovnat revize

...

428 Revize

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

+ 91
- 0
.cursor/rules.md Zobrazit soubor

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

@@ -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
Diff nebyl zobrazen, protože je příliš veliký
Zobrazit soubor


+ 4
- 2
package.json Zobrazit soubor

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


+ 62
- 0
src/app/(main)/MainContentArea.tsx Zobrazit soubor

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

import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import { usePathname } from "next/navigation";
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.
*/
const WORKBENCH_MAIN =
"bg-slate-50 dark:bg-slate-900 p-0 overflow-hidden h-[calc(100dvh-56px)] max-h-[calc(100dvh-56px)] sm:h-[calc(100dvh-64px)] sm:max-h-[calc(100dvh-64px)]";
const MAIN_PADDING = "p-4 sm:p-4 md:p-6 lg:p-8";

/** Returns true when `pathname` is `/po/workbench` or a nested path under it. */
function isPoWorkbenchRoute(pathname: string | null): boolean {
if (!pathname) return false;
return pathname === "/po/workbench" || pathname.startsWith("/po/workbench/");
}

/**
* 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 },
}}
className={
fullBleedWorkbench
? WORKBENCH_MAIN
: `${MAIN_SURFACE} ${MAIN_PADDING}`
}
>
<Stack
spacing={fullBleedWorkbench ? 0 : 2}
sx={
fullBleedWorkbench
? { height: "100%", minHeight: 0, overflow: "hidden" }
: undefined
}
>
{children}
</Stack>
</Box>
);
}

+ 47
- 0
src/app/(main)/axios/AxiosProvider.tsx Zobrazit soubor

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

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

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

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

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

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

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

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

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

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

@@ -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
Diff nebyl zobrazen, protože je příliš veliký
Zobrazit soubor


+ 386
- 0
src/app/(main)/chart/joborder/page.tsx Zobrazit soubor

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

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

@@ -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
Diff nebyl zobrazen, protože je příliš veliký
Zobrazit soubor


+ 54
- 0
src/app/(main)/chart/purchase/exportPurchaseChartMaster.ts Zobrazit soubor

@@ -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
Diff nebyl zobrazen, protože je příliš veliký
Zobrazit soubor


+ 61
- 0
src/app/(main)/chart/useChartBoardRefreshPrefs.ts Zobrazit soubor

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

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

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


+ 36
- 0
src/app/(main)/do copy 2/edit/page.tsx Zobrazit soubor

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

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

type Props = SearchParams;

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

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

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

export default DoEdit;

+ 35
- 0
src/app/(main)/do copy 2/page.tsx Zobrazit soubor

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

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

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

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

export default Page;

+ 36
- 0
src/app/(main)/do copy/edit/page.tsx Zobrazit soubor

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

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

type Props = SearchParams;

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

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

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

export default DoEdit;

+ 29
- 0
src/app/(main)/do copy/page.tsx Zobrazit soubor

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

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

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

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

export default DeliveryOrder;

+ 20
- 22
src/app/(main)/do/edit/page.tsx Zobrazit soubor

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

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


+ 46
- 0
src/app/(main)/doworkbench/edit/page.tsx Zobrazit soubor

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

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

type Props = SearchParams;

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

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

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

export default Page;

+ 25
- 0
src/app/(main)/doworkbench/page.tsx Zobrazit soubor

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

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

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

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

export default DoWorkbenchPage;

+ 6
- 0
src/app/(main)/doworkbench/pick/page.tsx Zobrazit soubor

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

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

+ 31
- 0
src/app/(main)/doworkbenchsearch/page.tsx Zobrazit soubor

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

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

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

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

export default DoWorkbenchSearchPage;

+ 2
- 2
src/app/(main)/finishedGood/detail/page.tsx Zobrazit soubor

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

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

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

PreloadPickOrder();
//PreloadPickOrder();

return (
<>


+ 34
- 35
src/app/(main)/jo/edit/page.tsx Zobrazit soubor

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

+ 41
- 30
src/app/(main)/jo/page.tsx Zobrazit soubor

@@ -1,38 +1,49 @@
import { preloadBomCombo } from "@/app/api/bom";
import JoSearch from "@/components/JoSearch";
import { fetchBomCombo } from "@/app/api/bom";
import { fetchPrinterCombo } from "@/app/api/settings/printer";
import { fetchAllJobTypes, type SearchJoResultRequest } from "@/app/api/jo/actions";
import GeneralLoading from "@/components/General/GeneralLoading";
import PageTitleBar from "@/components/PageTitleBar";
import JoWorkbenchSearch from "@/components/JoWorkbench/JoWorkbenchSearch";
import { I18nProvider, getServerI18n } from "@/i18n";
import { Stack, Typography } from "@mui/material";
import { Metadata } from "next";
import React, { Suspense } from "react";

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

const jo: React.FC = async () => {
const { t } = await getServerI18n("jo");
const Jo: React.FC = async () => {
const { t } = await getServerI18n("jo");
const today = new Date();
const todayStr = today.toISOString().split("T")[0];
const defaultInputs: SearchJoResultRequest = {
code: "",
itemName: "",
planStart: `${todayStr}T00:00`,
planStartTo: `${todayStr}T23:59:59`,
joSearchStatus: "all",
};
const [bomCombo, printerCombo, jobTypes] = await Promise.all([
fetchBomCombo(),
fetchPrinterCombo(),
fetchAllJobTypes(),
]);

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

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

export default jo;
export default Jo;

+ 21
- 0
src/app/(main)/jo/testing/page.tsx Zobrazit soubor

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

+ 27
- 0
src/app/(main)/jo/workbench/page.tsx Zobrazit soubor

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

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

const JoWorkbenchPage = async () => {
const { t } = await getServerI18n("jo");

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

export default JoWorkbenchPage;

+ 31
- 30
src/app/(main)/jodetail/edit/page.tsx Zobrazit soubor

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

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

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

+ 5
- 19
src/app/(main)/layout.tsx Zobrazit soubor

@@ -2,10 +2,7 @@ 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 { AxiosProvider } from "@/app/(main)/axios/AxiosProvider";
import { SetupAxiosInterceptors } from "@/app/(main)/axios/axiosInstance";
import { CameraProvider } from "@/components/Cameras/CameraProvider";
@@ -13,7 +10,7 @@ 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,
}: {
@@ -45,20 +42,9 @@ export default async function MainLayout({
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>
<I18nProvider namespaces={["common"]}>
<MainContentArea>{children}</MainContentArea>
</I18nProvider>
</>
</QrCodeScannerProvider>
</AxiosProvider>


+ 22
- 0
src/app/(main)/m18Syn/layout.tsx Zobrazit soubor

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

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

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

PreloadPickOrder();
//PreloadPickOrder();

return (
<>


+ 27
- 0
src/app/(main)/po/workbench/layout.tsx Zobrazit soubor

@@ -0,0 +1,27 @@
"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.
*/
export default function PoWorkbenchLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<Box
sx={{
boxSizing: "border-box",
height: "100%",
minHeight: 0,
overflow: "hidden",
}}
>
{children}
</Box>
);
}

+ 16
- 0
src/app/(main)/po/workbench/page.tsx Zobrazit soubor

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

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

/**
* Purchase Order Workbench page (`/po/workbench`).
* Development-oriented route: not listed in primary navigation; layout is provided by the segment and `MainContentArea`.
*/
export default function PoWorkbenchPage() {
return (
<Box sx={{ height: "100%", minHeight: 0, overflow: "hidden" }}>
<PoWorkbenchShell />
</Box>
);
}

+ 5
- 5
src/app/(main)/productionProcess/page.tsx Zobrazit soubor

@@ -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
Diff nebyl zobrazen, protože je příliš veliký
Zobrazit soubor


+ 37
- 0
src/app/(main)/putAwayCam/page.tsx Zobrazit soubor

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


+ 313
- 13
src/app/api/do/actions.tsx Zobrazit soubor

@@ -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,37 @@ 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 WorkbenchTicketReleaseTable {
deliveryOrderPickOrderId: number;
storeId: string | null;
ticketNo: string | null;
loadingSequence: number | null;
ticketStatus: string | null;
truckDepartureTime: string | null;
handledBy: number | null;
ticketReleaseTime: string | null;
ticketCompleteDateTime: string | null;
truckLanceCode: string | null;
shopCode: string | null;
shopName: string | null;
requiredDeliveryDate: string | null;
handlerName: string | null;
numberOfFGItems: number;
isActiveWorkbenchTicket?: boolean;
}

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

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

export const fetchTruckScheduleDashboard = cache(async () => {
export const fetchWorkbenchTicketReleaseTable = cache(async (startDate: string, endDate: string)=> {
return await serverFetchJson<WorkbenchTicketReleaseTable[]>(
`${BASE_API_URL}/doPickOrder/workbench/ticket-release-table/${startDate}&${endDate}`,
{
method: "GET",
}
);
});

export async function forceCompleteWorkbenchTicket(
deliveryOrderPickOrderId: number,
): Promise<WorkbenchTicketOpResponse> {
return await serverFetchJson<WorkbenchTicketOpResponse>(
`${BASE_API_URL}/doPickOrder/workbench/force-complete/${deliveryOrderPickOrderId}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
},
);
}

export async function revertWorkbenchTicketAssignment(
deliveryOrderPickOrderId: number,
): Promise<WorkbenchTicketOpResponse> {
return await serverFetchJson<WorkbenchTicketOpResponse>(
`${BASE_API_URL}/doPickOrder/workbench/revert-assignment/${deliveryOrderPickOrderId}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
},
);
}

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 +364,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 +495,113 @@ 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 interface PrintWorkbenchDNLabelsReprintRequest{
deliveryOrderPickOrderId: number;
printerId: number;
printQty: number;
fromCarton: number;
toCarton: number;
totalCartonsOnShipment: 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}/doPickOrder/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}/doPickOrder/workbench/print-DNLabels?${params.toString()}`,{
method: "GET"
});

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

export async function printDNLabelsReprintWorkbench(request: PrintWorkbenchDNLabelsReprintRequest){
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("fromCarton", request.fromCarton.toString());
params.append("toCarton", request.toCarton.toString());
params.append("totalCartonsOnShipment", request.totalCartonsOnShipment.toString());

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

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

export interface Check4FTruckBatchResponse {
hasProblem: boolean;
problems: ProblemDoDto[];
@@ -368,4 +628,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 Zobrazit soubor

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


+ 372
- 0
src/app/api/doworkbench/actions.ts Zobrazit soubor

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

import { revalidateTag } from "next/cache";
import { BASE_API_URL } from "@/config/api";
import { serverFetchJson } from "@/app/utils/fetchUtil";
import type {
PostPickOrderResponse,
ReleasedDoPickOrderListItem,
StoreLaneSummary,
} from "@/app/api/pickOrder/actions";
import dayjs from "dayjs";

/** Aligns with backend MessageResponse for workbench endpoints */
export type WorkbenchMessageResponse = {
id?: number | null;
code?: string | null;
name?: string | null;
type?: string | null;
message?: string | null;
errorPosition?: string | null;
entity?: unknown;
};

export async function startWorkbenchBatchReleaseAsync(data: {
ids: number[];
userId: number;
}): Promise<WorkbenchMessageResponse> {
const { ids, userId } = data;
return serverFetchJson<WorkbenchMessageResponse>(
`${BASE_API_URL}/doPickOrder/workbench/batch-release/async?userId=${userId}`,
{
method: "POST",
body: JSON.stringify(ids),
headers: { "Content-Type": "application/json" },
}
);
}

/** V2: no SPL/stock out at batch release; created when assigning delivery_order_pick_order. */
export async function startWorkbenchBatchReleaseAsyncV2(data: {
ids: number[];
userId: number;
}): Promise<WorkbenchMessageResponse> {
const { ids, userId } = data;
return serverFetchJson<WorkbenchMessageResponse>(
`${BASE_API_URL}/doPickOrder/workbench/batch-release/async-v2?userId=${userId}`,
{
method: "POST",
body: JSON.stringify(ids),
headers: { "Content-Type": "application/json" },
}
);
}

export async function workbenchBatchReleaseSyncV2(data: {
ids: number[];
userId: number;
}): Promise<WorkbenchMessageResponse> {
const { ids, userId } = data;
return serverFetchJson<WorkbenchMessageResponse>(
`${BASE_API_URL}/doPickOrder/workbench/batch-release/sync-v1?userId=${userId}`,
{
method: "POST",
body: JSON.stringify(ids),
headers: { "Content-Type": "application/json" },
}
);
}

export async function getWorkbenchBatchReleaseProgress(
jobId: string
): Promise<WorkbenchMessageResponse> {
return serverFetchJson<WorkbenchMessageResponse>(
`${BASE_API_URL}/doPickOrder/workbench/batch-release/progress/${encodeURIComponent(jobId)}`,
{ method: "GET" }
);
}

export type WorkbenchScanPickBody = {
stockOutLineId: number;
lotNo: string;
/** From QR: ties to a single `inventory_lot` when lotNo is reused across stock-ins */
stockInLineId?: number | null;
/**
* When set (e.g. label-print modal row), backend resolves this exact inventory lot line
* instead of stockInLineId / newest-by-lotNo.
*/
inventoryLotLineId?: number | null;
/** Optional store scope (e.g. 2/F). When set, split re-suggestions must stay within the same store. */
storeId?: string | null;
/** Optional: exclude these warehouse codes from resuggest logic */
excludeWarehouseCodes?: string[] | null;
/** Optional decimal string or number serialized by JSON */
qty?: number | string | null;
userId: number;
};

function serializeWorkbenchQty(
qty: number | string | null | undefined
): number | undefined {
if (qty === null || qty === undefined || qty === "") return undefined;
const n = typeof qty === "string" ? Number(qty) : qty;
if (typeof n !== "number" || Number.isNaN(n) || !Number.isFinite(n)) return undefined;
// 0 is a valid explicit workbench short submit (must be sent, not omitted)
return n;
}

/**
* DO workbench scan-pick. Omit `qty` for full remaining on this SOL chunk (backend may split if lot runs out).
* Pass `qty` less than remaining for short submit (POL/SOL completed without `partially_completed` on POL).
* Pass `qty` greater than remaining to overscan: backend posts up to lot availability, then rebuild/ensure SOL.
*/
export async function workbenchScanPick(
body: WorkbenchScanPickBody
): Promise<WorkbenchMessageResponse> {
const qty = serializeWorkbenchQty(body.qty);
const sil = body.stockInLineId;
const stockInLineId =
typeof sil === "number" && Number.isFinite(sil) && sil > 0 ? sil : undefined;
const storeId =
typeof body.storeId === "string" && body.storeId.trim() !== ""
? body.storeId.trim()
: undefined;
const excludeWarehouseCodes =
Array.isArray(body.excludeWarehouseCodes) && body.excludeWarehouseCodes.length > 0
? body.excludeWarehouseCodes
.map((c) => (typeof c === "string" ? c.trim() : ""))
.filter((c) => c !== "")
: undefined;
const ill = body.inventoryLotLineId;
const inventoryLotLineId =
typeof ill === "number" && Number.isFinite(ill) && ill > 0 ? ill : undefined;
return serverFetchJson<WorkbenchMessageResponse>(
`${BASE_API_URL}/doPickOrder/workbench/scan-pick`,
{
method: "POST",
body: JSON.stringify({
stockOutLineId: body.stockOutLineId,
lotNo: body.lotNo,
...(stockInLineId !== undefined ? { stockInLineId } : {}),
...(inventoryLotLineId !== undefined ? { inventoryLotLineId } : {}),
...(storeId !== undefined ? { storeId } : {}),
...(excludeWarehouseCodes !== undefined ? { excludeWarehouseCodes } : {}),
...(qty !== undefined ? { qty } : {}),
userId: body.userId,
}),
headers: { "Content-Type": "application/json" },
}
);
}

export type WorkbenchBatchScanPickBody = {
lines: WorkbenchScanPickBody[];
};

/**
* DO workbench batch scan-pick.
* Intended for batch-submit style flows where we close multiple SOLs (commonly qty=0 for noLot/expired/unavailable).
*/
export async function workbenchBatchScanPick(
body: WorkbenchBatchScanPickBody,
): Promise<WorkbenchMessageResponse> {
const lines = Array.isArray(body.lines) ? body.lines : [];
return serverFetchJson<WorkbenchMessageResponse>(
`${BASE_API_URL}/doPickOrder/workbench/scan-pick/batch`,
{
method: "POST",
body: JSON.stringify({
lines: lines.map((l) => {
const qty = serializeWorkbenchQty(l.qty);
const sil = l.stockInLineId;
const stockInLineId =
typeof sil === "number" && Number.isFinite(sil) && sil > 0 ? sil : undefined;
const storeId =
typeof l.storeId === "string" && l.storeId.trim() !== "" ? l.storeId.trim() : undefined;
const excludeWarehouseCodes =
Array.isArray(l.excludeWarehouseCodes) && l.excludeWarehouseCodes.length > 0
? l.excludeWarehouseCodes
.map((c) => (typeof c === "string" ? c.trim() : ""))
.filter((c) => c !== "")
: undefined;
const ill = l.inventoryLotLineId;
const inventoryLotLineId =
typeof ill === "number" && Number.isFinite(ill) && ill > 0 ? ill : undefined;
return {
stockOutLineId: l.stockOutLineId,
lotNo: l.lotNo ?? "",
...(stockInLineId !== undefined ? { stockInLineId } : {}),
...(inventoryLotLineId !== undefined ? { inventoryLotLineId } : {}),
...(storeId !== undefined ? { storeId } : {}),
...(excludeWarehouseCodes !== undefined ? { excludeWarehouseCodes } : {}),
...(qty !== undefined ? { qty } : {}),
userId: l.userId,
};
}),
}),
headers: { "Content-Type": "application/json" },
},
);
}

/** Store lane grid backed by `delivery_order_pick_order` + `pick_order.deliveryOrderPickOrderId`. */
export async function fetchWorkbenchStoreLaneSummary(
storeId: string,
requiredDate?: string,
releaseType?: string
): Promise<StoreLaneSummary> {
const dateToUse = requiredDate || dayjs().format("YYYY-MM-DD");
const rt = releaseType || "all";
const url = `${BASE_API_URL}/doPickOrder/workbench/summary-by-store?storeId=${encodeURIComponent(storeId)}&requiredDate=${encodeURIComponent(dateToUse)}&releaseType=${encodeURIComponent(rt)}`;
return serverFetchJson<StoreLaneSummary>(url, {
method: "GET",
cache: "no-store",
next: { revalidate: 0 },
});
}

/** Past-date `delivery_order_pick_order` tickets (same shape as `/doPickOrder/released`). */
export async function fetchWorkbenchReleasedDoPickOrdersForSelection(
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/workbench/released${query ? `?${query}` : ""}`;
const response = await serverFetchJson<ReleasedDoPickOrderListItem[]>(url, { method: "GET" });
return response ?? [];
}

export async function fetchWorkbenchReleasedDoPickOrdersForSelectionToday(
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/workbench/released-today${query ? `?${query}` : ""}`;
const response = await serverFetchJson<ReleasedDoPickOrderListItem[]>(url, { method: "GET" });
return response ?? [];
}

/** Same body as `/doPickOrder/assign-by-lane` but resolves `delivery_order_pick_order`. */
export async function assignWorkbenchByLane(data: {
userId: number;
storeId: string;
truckLanceCode: string;
truckDepartureTime?: string;
requiredDate?: string;
}): Promise<PostPickOrderResponse> {
const res = await serverFetchJson<PostPickOrderResponse>(
`${BASE_API_URL}/doPickOrder/workbench/assign-by-lane`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}
);
revalidateTag("pickorder");
return res;
}

/** Assign V1 (legacy): old FG-style, no atomic conflict guard. */
export async function assignWorkbenchByLaneV1(data: {
userId: number;
storeId: string;
truckLanceCode: string;
truckDepartureTime?: string;
requiredDate?: string;
}): Promise<PostPickOrderResponse> {
const res = await serverFetchJson<PostPickOrderResponse>(
`${BASE_API_URL}/doPickOrder/workbench/assign-by-lane-v1`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}
);
revalidateTag("pickorder");
return res;
}

export async function assignByDeliveryOrderPickOrderId(
userId: number,
deliveryOrderPickOrderId: number
): Promise<PostPickOrderResponse> {
const res = await serverFetchJson<PostPickOrderResponse>(
`${BASE_API_URL}/doPickOrder/workbench/assign-by-delivery-order-pick-order-id`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, deliveryOrderPickOrderId }),
}
);
revalidateTag("pickorder");
return res;
}

/** Assign V1 (legacy): old FG-style, no atomic conflict guard. */
export async function assignByDeliveryOrderPickOrderIdV1(
userId: number,
deliveryOrderPickOrderId: number
): Promise<PostPickOrderResponse> {
const res = await serverFetchJson<PostPickOrderResponse>(
`${BASE_API_URL}/doPickOrder/workbench/assign-by-delivery-order-pick-order-id-v1`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, deliveryOrderPickOrderId }),
}
);
revalidateTag("pickorder");
return res;
}

export async function fetchWorkbenchCompletedLotDetails(
deliveryOrderPickOrderId: number,
): Promise<any> {
return serverFetchJson<any>(
`${BASE_API_URL}/doPickOrder/workbench/completed-lot-details/${deliveryOrderPickOrderId}`,
{ method: "GET" },
);
}
export type WorkbenchScanPayload = {
itemId: number;
stockInLineId: number;
};
export async function fetchWorkbenchPrinters() {
return serverFetchJson<any[]>(`${BASE_API_URL}/printers`, {
method: "GET",
cache: "no-store",
});
}
export async function analyzeWorkbenchQrCode(payload: WorkbenchScanPayload) {
return serverFetchJson<any>(`${BASE_API_URL}/inventoryLotLine/workbench/analyze-qr-code`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
cache: "no-store",
});
}

export async function fetchWorkbenchAvailableLotsByItem(itemId: number) {
return serverFetchJson<any>(
`${BASE_API_URL}/inventoryLotLine/workbench/available-lots-by-item/${itemId}`,
{
method: "GET",
cache: "no-store",
},
);
}

export async function printWorkbenchLotLabel(params: {
inventoryLotLineId: number;
printerId: number;
printQty: number;
}) {
const searchParams = new URLSearchParams();
searchParams.set("inventoryLotLineId", String(params.inventoryLotLineId));
searchParams.set("printerId", String(params.printerId));
searchParams.set("printQty", String(params.printQty));
return serverFetchJson<WorkbenchMessageResponse>(
`${BASE_API_URL}/inventoryLotLine/workbench/print-label?${searchParams.toString()}`,
{ method: "GET", cache: "no-store" },
);
}

+ 5
- 0
src/app/api/doworkbench/client.ts Zobrazit soubor

@@ -0,0 +1,5 @@
/** Server actions live in ./actions — import them directly in client components. */
export type {
WorkbenchMessageResponse,
WorkbenchScanPickBody,
} from "./actions";

+ 9
- 0
src/app/api/doworkbench/index.tsx Zobrazit soubor

@@ -0,0 +1,9 @@
export {
startWorkbenchBatchReleaseAsync,
startWorkbenchBatchReleaseAsyncV2,
workbenchBatchReleaseSyncV2,
getWorkbenchBatchReleaseProgress,
workbenchScanPick,
type WorkbenchMessageResponse,
type WorkbenchScanPickBody,
} from "./actions";

+ 74
- 0
src/app/api/doworkbench/truckRoutingSummaryWorkbenchApi.ts Zobrazit soubor

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

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

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

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

export async function fetchWorkbenchTruckRoutingStoreOptions(): Promise<WorkbenchReportOption[]> {
const response = await clientAuthFetch(
`${NEXT_PUBLIC_API_URL}/doPickOrder/workbench/truck-routing-summary/store-options`,
{
method: "GET",
headers: { "Content-Type": "application/json" },
}
);
if (!response.ok) throw new Error(`Failed to fetch workbench 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 fetchWorkbenchTruckRoutingLaneOptions(storeId?: string): Promise<WorkbenchReportOption[]> {
const qs = storeId ? `?storeId=${encodeURIComponent(storeId)}` : "";
const response = await clientAuthFetch(
`${NEXT_PUBLIC_API_URL}/doPickOrder/workbench/truck-routing-summary/lane-options${qs}`,
{
method: "GET",
headers: { "Content-Type": "application/json" },
}
);
if (!response.ok) throw new Error(`Failed to fetch workbench 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 fetchWorkbenchTruckRoutingSummaryPrecheck(params: {
storeId: string;
truckLanceCode: string;
date: string;
}): Promise<WorkbenchTruckRoutingSummaryPrecheck> {
const qs = new URLSearchParams({
storeId: params.storeId,
truckLanceCode: params.truckLanceCode,
date: params.date,
}).toString();
const response = await clientAuthFetch(
`${NEXT_PUBLIC_API_URL}/doPickOrder/workbench/truck-routing-summary/precheck?${qs}`,
{
method: "GET",
headers: { "Content-Type": "application/json" },
}
);
if (!response.ok) throw new Error(`Failed to precheck workbench routing summary: ${response.status}`);
const data = await response.json();
return {
unpickedOrderCount: Number(data?.unpickedOrderCount ?? 0),
hasUnpickedOrders: Boolean(data?.hasUnpickedOrders),
};
}

+ 14
- 0
src/app/api/doworkbench/workbenchScanPickUtils.ts Zobrazit soubor

@@ -0,0 +1,14 @@
/**
* Pure helpers for workbench scan-pick (not server actions — keep out of `actions.ts` with "use server").
*/

/**
* When true, the server created/reshaped lines (e.g. split pick); UI should reload hierarchical workbench data.
* Normal scans only need to patch the row from `entity`.
*/
export function workbenchScanPickResponseNeedsFullRefresh(res: {
message?: string | null;
}): boolean {
const m = (res.message ?? "").toLowerCase();
return m.includes("next stock-out line") || m.includes("remaining quantity allocated");
}

+ 2
- 0
src/app/api/escalation/index.ts Zobrazit soubor

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


+ 45
- 4
src/app/api/inventory/actions.ts Zobrazit soubor

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

export interface InventoryResultByPage {
@@ -70,16 +71,26 @@ export interface SameItemLotInfo {
availableQty: number;
uom: string;
}
export const analyzeQrCode = async (data: {
itemId: number;
stockInLineId: number;
}) => {
/** FG / inventory label modal: same-item lots use in − out − hold. */
export const analyzeQrCode = async (data: { itemId: number; stockInLineId: number }) => {
return serverFetchJson<QrCodeAnalysisResponse>(`${BASE_API_URL}/inventoryLotLine/analyze-qr-code`, {
method: 'POST',
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
});
};

/** DO workbench label modal only: same-item lots use in − out (no hold). */
export const workbenchAnalyzeQrCode = async (data: { itemId: number; stockInLineId: number }) => {
return serverFetchJson<QrCodeAnalysisResponse>(
`${BASE_API_URL}/inventoryLotLine/workbench/analyze-qr-code`,
{
method: 'POST',
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
},
);
};
export const updateInventoryStatus = async (data: {
itemId: number;
lotId: number;
@@ -152,3 +163,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;
};

Některé soubory nejsou zobrazny, neboť je v této revizi změněno mnoho souborů

Načítá se…
Zrušit
Uložit