feat(collection-confirm): add quantity collection handling and deduplication improvements
- Introduced a new `handleQtyChange` function to manage per-row quantity updates for both customer and stock items, ensuring correct data submission to the appropriate endpoints. - Enhanced the `dedupeAttachments` function to handle additional document sources, including parent purchase orders, and skip empty filenames to prevent 404 errors. - Updated the `ItemsTable` and `PoCard` components to support quantity editing, allowing users to modify collected quantities directly in the UI. - Added tests to validate the new quantity handling logic and attachment deduplication, ensuring robust functionality across various scenarios.
This commit is contained in:
parent
4a59288a01
commit
d0fb8e81f0
21 changed files with 947 additions and 30 deletions
34
.playwright-mcp/console-2026-08-16T23-49-18-000Z.log
Normal file
34
.playwright-mcp/console-2026-08-16T23-49-18-000Z.log
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
[ 374ms] [LOG] %c Console Ninja extension is connected to Next.js browser, background: rgb(30,30,30); color: rgb(255,213,92) see https://tinyurl.com/2vt8jxzw for more info. @ :0
|
||||
[ 3039ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 3149ms] [LOG] [HMR] connected @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 3180ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 3614ms] [LOG] SW registered: /sw.js http://localhost:4001/ @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 5575ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 5766ms] [LOG] [Fast Refresh] done in 192ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 6669ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 6791ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 6823ms] [LOG] [Fast Refresh] done in 32ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 6949ms] [LOG] [Fast Refresh] done in 158ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 7001ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 7503ms] [LOG] [Fast Refresh] done in 501ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 7558ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 8207ms] [LOG] [Fast Refresh] done in 649ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 9356ms] [LOG] [Fast Refresh] done in 1798ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 9420ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 11185ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 11232ms] [LOG] [Fast Refresh] done in 48ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 11479ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 11513ms] [LOG] [Fast Refresh] done in 35ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 11752ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 11818ms] [LOG] [Fast Refresh] done in 66ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 20609ms] [WARNING] The resource http://localhost:4001/_next/static/media/e4af272ccee01ff0-s.p.woff2?v=1786924170467 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/collection-confirm:0
|
||||
[ 20609ms] [WARNING] The resource http://localhost:4001/_next/static/media/558ca1a6aa3cb55e-s.p.woff2?v=1786924170467 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/collection-confirm:0
|
||||
[ 20609ms] [WARNING] The resource http://localhost:4001/_next/static/media/22a5144ee8d83bca-s.p.woff2?v=1786924170467 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/collection-confirm:0
|
||||
[ 20609ms] [WARNING] The resource http://localhost:4001/_next/static/media/13971731025ec697-s.p.woff2?v=1786924170467 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/collection-confirm:0
|
||||
[ 20610ms] [WARNING] The resource http://localhost:4001/_next/static/media/4de1fea1a954a5b6-s.p.woff2?v=1786924170467 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/collection-confirm:0
|
||||
[ 20610ms] [WARNING] The resource http://localhost:4001/_next/static/media/6d664cce900333ee-s.p.woff2?v=1786924170467 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/collection-confirm:0
|
||||
[ 20610ms] [WARNING] The resource http://localhost:4001/_next/static/media/7b0b24f36b1a6d0b-s.p.woff2?v=1786924170467 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/collection-confirm:0
|
||||
[ 20610ms] [WARNING] The resource http://localhost:4001/_next/static/media/eaead17c7dbfcd5d-s.p.woff2?v=1786924170467 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/collection-confirm:0
|
||||
[ 20610ms] [WARNING] The resource http://localhost:4001/_next/static/media/36966cca54120369-s.p.woff2?v=1786924170467 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/collection-confirm:0
|
||||
[ 20610ms] [WARNING] The resource http://localhost:4001/_next/static/media/7d4881bb7e1bf84d-s.p.woff2?v=1786924170467 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/collection-confirm:0
|
||||
[ 20610ms] [WARNING] The resource http://localhost:4001/_next/static/media/ee40bb094c99a29a-s.p.woff2?v=1786924170467 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/collection-confirm:0
|
||||
23
.playwright-mcp/console-2026-08-16T23-49-49-202Z.log
Normal file
23
.playwright-mcp/console-2026-08-16T23-49-49-202Z.log
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
[ 740ms] [LOG] %c Console Ninja extension is connected to Next.js browser, background: rgb(30,30,30); color: rgb(255,213,92) see https://tinyurl.com/2vt8jxzw for more info. @ :0
|
||||
[ 3020ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 3110ms] [LOG] [HMR] connected @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 3288ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 3352ms] [VERBOSE] [DOM] Input elements should have autocomplete attributes (suggested: "current-password"): (More info: https://goo.gl/9p2vKq) %o @ http://localhost:4001/login:0
|
||||
[ 3908ms] [LOG] SW registered: /sw.js http://localhost:4001/ @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 5964ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 6474ms] [LOG] [Fast Refresh] done in 510ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 7488ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 7785ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 7859ms] [LOG] [Fast Refresh] done in 74ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 8001ms] [LOG] [Fast Refresh] done in 215ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 8073ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 8585ms] [LOG] [Fast Refresh] done in 511ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 8683ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 9042ms] [LOG] [Fast Refresh] done in 358ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 10188ms] [LOG] [Fast Refresh] done in 1506ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 10225ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 10475ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 10554ms] [LOG] [Fast Refresh] done in 80ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 11072ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 11285ms] [LOG] [Fast Refresh] done in 212ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 12939ms] [LOG] %c Console Ninja extension is connected to Next.js browser, background: rgb(30,30,30); color: rgb(255,213,92) see https://tinyurl.com/2vt8jxzw for more info. @ :0
|
||||
53
.playwright-mcp/console-2026-08-16T23-50-06-238Z.log
Normal file
53
.playwright-mcp/console-2026-08-16T23-50-06-238Z.log
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
[ 1132ms] [LOG] %c Console Ninja extension is connected to Next.js browser, background: rgb(30,30,30); color: rgb(255,213,92) see https://tinyurl.com/2vt8jxzw for more info. @ :0
|
||||
[ 9395ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 9494ms] [LOG] [HMR] connected @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 9509ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 9845ms] [LOG] SW registered: /sw.js http://localhost:4001/ @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 10799ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 11111ms] [LOG] [Fast Refresh] done in 313ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 11722ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 11760ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 11847ms] [LOG] [Fast Refresh] done in 88ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 12090ms] [LOG] [Fast Refresh] done in 330ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 12159ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 13259ms] [LOG] [Fast Refresh] done in 1101ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 13330ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 13424ms] [LOG] [Fast Refresh] done in 94ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 14595ms] [LOG] [Fast Refresh] done in 1265ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 14653ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 15250ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 15347ms] [LOG] [Fast Refresh] done in 98ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 15626ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 15657ms] [LOG] [Fast Refresh] done in 31ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 17705ms] [LOG] %c Console Ninja extension is connected to Next.js browser, background: rgb(30,30,30); color: rgb(255,213,92) see https://tinyurl.com/2vt8jxzw for more info. @ :0
|
||||
[ 20669ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 21072ms] [LOG] [HMR] connected @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 24414ms] [LOG] SW registered: /sw.js http://localhost:4001/ @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 24642ms] [WARNING] The resource http://localhost:4001/_next/static/media/4de1fea1a954a5b6-s.p.woff2?v=1786924222556 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/collection-confirm:0
|
||||
[ 24642ms] [WARNING] The resource http://localhost:4001/_next/static/media/6d664cce900333ee-s.p.woff2?v=1786924222556 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/collection-confirm:0
|
||||
[ 24642ms] [WARNING] The resource http://localhost:4001/_next/static/media/e4af272ccee01ff0-s.p.woff2?v=1786924222556 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/collection-confirm:0
|
||||
[ 24642ms] [WARNING] The resource http://localhost:4001/_next/static/media/7b0b24f36b1a6d0b-s.p.woff2?v=1786924222556 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/collection-confirm:0
|
||||
[ 24644ms] [WARNING] The resource http://localhost:4001/_next/static/media/eaead17c7dbfcd5d-s.p.woff2?v=1786924222556 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/collection-confirm:0
|
||||
[ 24644ms] [WARNING] The resource http://localhost:4001/_next/static/media/558ca1a6aa3cb55e-s.p.woff2?v=1786924222556 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/collection-confirm:0
|
||||
[ 24644ms] [WARNING] The resource http://localhost:4001/_next/static/media/36966cca54120369-s.p.woff2?v=1786924222556 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/collection-confirm:0
|
||||
[ 24644ms] [WARNING] The resource http://localhost:4001/_next/static/media/7d4881bb7e1bf84d-s.p.woff2?v=1786924222556 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/collection-confirm:0
|
||||
[ 24644ms] [WARNING] The resource http://localhost:4001/_next/static/media/ee40bb094c99a29a-s.p.woff2?v=1786924222556 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/collection-confirm:0
|
||||
[ 24647ms] [WARNING] The resource http://localhost:4001/_next/static/media/13971731025ec697-s.p.woff2?v=1786924222556 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/collection-confirm:0
|
||||
[ 24647ms] [WARNING] The resource http://localhost:4001/_next/static/media/22a5144ee8d83bca-s.p.woff2?v=1786924222556 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:4001/collection-confirm:0
|
||||
[ 28423ms] [VERBOSE] [DOM] Input elements should have autocomplete attributes (suggested: "current-password"): (More info: https://goo.gl/9p2vKq) %o @ http://localhost:4001/login:0
|
||||
[ 29362ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 29691ms] [LOG] [Fast Refresh] done in 330ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 30442ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 30479ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 31014ms] [LOG] [Fast Refresh] done in 535ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 31332ms] [LOG] [Fast Refresh] done in 852ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 31380ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 32059ms] [LOG] [Fast Refresh] done in 680ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 32180ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 32297ms] [LOG] [Fast Refresh] done in 118ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 34677ms] [LOG] [Fast Refresh] done in 2498ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 34704ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 35301ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 35901ms] [LOG] [Fast Refresh] done in 600ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 36785ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 36928ms] [LOG] [Fast Refresh] done in 143ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
4
.playwright-mcp/page-2026-08-16T23-49-21-387Z.yml
Normal file
4
.playwright-mcp/page-2026-08-16T23-49-21-387Z.yml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
- generic [active] [ref=e1]:
|
||||
- region "Notifications alt+T"
|
||||
- button "Open Next.js Dev Tools" [ref=e7] [cursor=pointer]
|
||||
- alert [ref=e11]
|
||||
10
.playwright-mcp/page-2026-08-16T23-49-29-761Z.yml
Normal file
10
.playwright-mcp/page-2026-08-16T23-49-29-761Z.yml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
- generic [active] [ref=e1]:
|
||||
- region "Notifications alt+T"
|
||||
- button "Open Next.js Dev Tools" [ref=e12] [cursor=pointer]:
|
||||
- generic [ref=e21]:
|
||||
- text: Rendering
|
||||
- generic [ref=e22]:
|
||||
- generic [ref=e23]: .
|
||||
- generic [ref=e24]: .
|
||||
- generic [ref=e25]: .
|
||||
- alert [ref=e11]
|
||||
3
.playwright-mcp/page-2026-08-16T23-49-52-556Z.yml
Normal file
3
.playwright-mcp/page-2026-08-16T23-49-52-556Z.yml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
- generic [active] [ref=f2e1]:
|
||||
- region "Notifications alt+T"
|
||||
- button "Open Next.js Dev Tools" [ref=f2e9] [cursor=pointer]
|
||||
2
.playwright-mcp/page-2026-08-16T23-50-12-290Z.yml
Normal file
2
.playwright-mcp/page-2026-08-16T23-50-12-290Z.yml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
- generic [active] [ref=f4e1]:
|
||||
- region "Notifications alt+T"
|
||||
10
.playwright-mcp/page-2026-08-16T23-50-29-023Z.yml
Normal file
10
.playwright-mcp/page-2026-08-16T23-50-29-023Z.yml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
- generic [active] [ref=f5e1]:
|
||||
- region "Notifications alt+T"
|
||||
- button "Open Next.js Dev Tools" [ref=f5e7] [cursor=pointer]:
|
||||
- generic [ref=f5e10]:
|
||||
- text: Rendering
|
||||
- generic [ref=f5e11]:
|
||||
- generic [ref=f5e12]: .
|
||||
- generic [ref=f5e13]: .
|
||||
- generic [ref=f5e14]: .
|
||||
- alert [ref=f5e15]
|
||||
|
|
@ -230,7 +230,7 @@ export default function CollectionConfirmPage() {
|
|||
* had no UI affordance because the order-items route 404'd).
|
||||
*/
|
||||
const endpointFor = useCallback(
|
||||
(row: ConfirmRow, field: "sku" | "cost" | "supplier_sku"): string => {
|
||||
(row: ConfirmRow, field: "sku" | "cost" | "qty" | "supplier_sku"): string => {
|
||||
const collection =
|
||||
row.source === "stock" ? "stock-purchase-items" : "order-items";
|
||||
const fieldSegment =
|
||||
|
|
@ -277,6 +277,47 @@ export default function CollectionConfirmPage() {
|
|||
[rows, endpointFor],
|
||||
);
|
||||
|
||||
/**
|
||||
* Per-row collected-qty change handler. Mirrors `handleCostChange`
|
||||
* but targets the row's `qty_collected` field (or
|
||||
* `quantity_received` on stock-source rows). Routes through the
|
||||
* appropriate endpoint based on `row.source` (customer →
|
||||
* order-items, stock → stock-purchase-items). Optimistically
|
||||
* patches the row, posts the change, and rolls back on failure.
|
||||
*/
|
||||
const handleQtyChange = useCallback(
|
||||
async (id: string, qty: number) => {
|
||||
const prevRow = rows?.find((r) => r.id === id) ?? null;
|
||||
const prevQty = prevRow?.qty_collected ?? null;
|
||||
const endpoint = prevRow ? endpointFor(prevRow, "qty") : null;
|
||||
setRows((prev) =>
|
||||
prev?.map((r) => (r.id === id ? { ...r, qty_collected: qty } : r)) ?? null,
|
||||
);
|
||||
if (!endpoint) return;
|
||||
try {
|
||||
// Stock side uses `quantity_received`; customer side uses
|
||||
// `qty_collected`. Both routes accept the same numeric value
|
||||
// but the body key differs.
|
||||
const bodyKey = prevRow?.source === "stock" ? "quantity_received" : "qty_collected";
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ [bodyKey]: qty }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to update qty (HTTP ${response.status})`);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
toast.error(message);
|
||||
setRows((prev) =>
|
||||
prev?.map((r) => (r.id === id ? { ...r, qty_collected: prevQty ?? undefined } : r)) ?? null,
|
||||
);
|
||||
}
|
||||
},
|
||||
[rows, endpointFor],
|
||||
);
|
||||
|
||||
/**
|
||||
* Per-row supplier-SKU change handler. Stock-only — customer order
|
||||
* items have no supplier_sku field. The pencil is rendered only
|
||||
|
|
@ -811,6 +852,7 @@ export default function CollectionConfirmPage() {
|
|||
supplier={{ id: supplierId, name: supplierName }}
|
||||
onCostChange={(id, cost) => void handleCostChange(id, cost)}
|
||||
onSkuChange={(id, sku) => void handleSkuChange(id, sku)}
|
||||
onQtyChange={(id, qty) => void handleQtyChange(id, qty)}
|
||||
onSupplierSkuChange={(id, sku) =>
|
||||
void handleSupplierSkuChange(id, sku)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -528,4 +528,26 @@ describe("dedupeAttachments", () => {
|
|||
const out = dedupeAttachments([{ id: "a", order_item_attachements: ["INV.pdf", "INV.pdf"] }]);
|
||||
expect(out).toEqual([{ filename: "INV.pdf", itemIds: ["a"] }]);
|
||||
});
|
||||
|
||||
it("skips empty / whitespace-only filenames so legacy rows with `\"\"` do not produce a chip that 404s", () => {
|
||||
// Real-world data: legacy stock_purchase rows were written with
|
||||
// `additional_attachments: [""]` (one empty-string element).
|
||||
// The chip URL built from an empty filename would 404, so the
|
||||
// helper must drop these entries.
|
||||
const out = dedupeAttachments([
|
||||
{ id: "a", stock_purchase_id: "PO123", stock_purchase_attachments: ["", " "] },
|
||||
{ id: "b", order_item_attachements: [""] },
|
||||
{
|
||||
id: "c",
|
||||
expand: { purchase_order: { id: "PO-1", documents: [""] } },
|
||||
},
|
||||
{
|
||||
id: "d",
|
||||
expand: { purchase_order: { id: "PO-2", documents: ["real.pdf", " "] } },
|
||||
},
|
||||
]);
|
||||
expect(out).toEqual([
|
||||
{ filename: "real.pdf", itemIds: ["d"], purchaseOrderId: "PO-2" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -331,6 +331,14 @@ export type DedupedAttachment = {
|
|||
* Undefined for customer-only attachments.
|
||||
*/
|
||||
stockPurchaseId?: string;
|
||||
/**
|
||||
* For customer-source rows whose PO PDF lives on the parent
|
||||
* `purchase_orders.documents` file field (rather than the line's
|
||||
* `order_item_attachements`). The chip's preview URL uses
|
||||
* `/api/v1/files/purchase_orders/{id}/{filename}` so the file
|
||||
* resolves even when no line-level attachment is present.
|
||||
*/
|
||||
purchaseOrderId?: string;
|
||||
};
|
||||
|
||||
export function dedupeAttachments(
|
||||
|
|
@ -340,31 +348,53 @@ export function dedupeAttachments(
|
|||
source?: "stock" | "customer";
|
||||
stock_purchase_id?: string | null;
|
||||
stock_purchase_attachments?: string[] | null;
|
||||
expand?: {
|
||||
purchase_order?: {
|
||||
id?: string;
|
||||
documents?: string[];
|
||||
};
|
||||
};
|
||||
}>,
|
||||
): DedupedAttachment[] {
|
||||
// Order rows write to `order_items.order_item_attachements`; stock
|
||||
// rows write to the parent `stock_purchases.additional_attachments`.
|
||||
// Two separate maps so a filename that appears on both (rare but
|
||||
// possible — the same file might be uploaded to both) doesn't get
|
||||
// collapsed into the wrong preview URL.
|
||||
const customerByFilename = new Map<string, string[]>();
|
||||
// Three sources per filename:
|
||||
// 1. `order_items.order_item_attachements` (customer line-level)
|
||||
// 2. `stock_purchases.additional_attachments` (parent stock PO)
|
||||
// 3. `purchase_orders.documents` (parent customer PO — PO PDF
|
||||
// and any operator uploads to the PO itself)
|
||||
// A filename can appear on both the line and the parent PO (rare
|
||||
// — same file uploaded to both), so each source gets its own map
|
||||
// keyed by filename. The caller picks the right URL per chip.
|
||||
//
|
||||
// Empty / whitespace-only entries are skipped. Legacy stock PO
|
||||
// rows were written with `additional_attachments: [""]` (single
|
||||
// empty-string element) — passing those through would generate a
|
||||
// chip with an empty filename, whose preview URL would 404.
|
||||
const customerLineByFilename = new Map<string, string[]>();
|
||||
const stockByFilename = new Map<
|
||||
string,
|
||||
{ itemIds: string[]; stockPurchaseId?: string }
|
||||
>();
|
||||
const purchaseOrderByFilename = new Map<
|
||||
string,
|
||||
{ itemIds: string[]; purchaseOrderId?: string }
|
||||
>();
|
||||
const isUsableFilename = (fn: unknown): fn is string =>
|
||||
typeof fn === "string" && fn.trim().length > 0;
|
||||
for (const row of rows) {
|
||||
const seen = new Set<string>();
|
||||
for (const filename of row.order_item_attachements ?? []) {
|
||||
if (!isUsableFilename(filename)) continue;
|
||||
if (seen.has(filename)) continue;
|
||||
seen.add(filename);
|
||||
const bucket = customerByFilename.get(filename) ?? [];
|
||||
const bucket = customerLineByFilename.get(filename) ?? [];
|
||||
if (row.id) bucket.push(row.id);
|
||||
customerByFilename.set(filename, bucket);
|
||||
customerLineByFilename.set(filename, bucket);
|
||||
}
|
||||
if (row.stock_purchase_attachments && row.stock_purchase_attachments.length > 0) {
|
||||
const stockSeen = new Set<string>();
|
||||
const stockPurchaseId = row.stock_purchase_id ?? undefined;
|
||||
for (const filename of row.stock_purchase_attachments) {
|
||||
if (!isUsableFilename(filename)) continue;
|
||||
if (stockSeen.has(filename)) continue;
|
||||
stockSeen.add(filename);
|
||||
const existing = stockByFilename.get(filename) ?? {
|
||||
|
|
@ -378,14 +408,39 @@ export function dedupeAttachments(
|
|||
stockByFilename.set(filename, existing);
|
||||
}
|
||||
}
|
||||
// Parent customer PO documents (PO PDF and operator uploads).
|
||||
// Each customer row carries the same parent PO id, so collapse
|
||||
// duplicates within the row's seen-set before merging into the
|
||||
// shared map. The first row that contributes a filename wins
|
||||
// for purchaseOrderId; the parent PO is identical across rows.
|
||||
const parentPoId = row.expand?.purchase_order?.id;
|
||||
const parentDocs = row.expand?.purchase_order?.documents;
|
||||
if (parentPoId && parentDocs && parentDocs.length > 0) {
|
||||
const poSeen = new Set<string>();
|
||||
for (const filename of parentDocs) {
|
||||
if (!isUsableFilename(filename)) continue;
|
||||
if (poSeen.has(filename)) continue;
|
||||
poSeen.add(filename);
|
||||
const existing = purchaseOrderByFilename.get(filename) ?? {
|
||||
itemIds: [],
|
||||
purchaseOrderId: parentPoId,
|
||||
};
|
||||
if (row.id) existing.itemIds.push(row.id);
|
||||
existing.purchaseOrderId = parentPoId;
|
||||
purchaseOrderByFilename.set(filename, existing);
|
||||
}
|
||||
}
|
||||
}
|
||||
const out: DedupedAttachment[] = [];
|
||||
for (const [filename, itemIds] of customerByFilename.entries()) {
|
||||
for (const [filename, itemIds] of customerLineByFilename.entries()) {
|
||||
out.push({ filename, itemIds });
|
||||
}
|
||||
for (const [filename, { itemIds, stockPurchaseId }] of stockByFilename.entries()) {
|
||||
out.push({ filename, itemIds, stockPurchaseId });
|
||||
}
|
||||
for (const [filename, { itemIds, purchaseOrderId }] of purchaseOrderByFilename.entries()) {
|
||||
out.push({ filename, itemIds, purchaseOrderId });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ const statusColors: Record<
|
|||
> = {
|
||||
draft: "outline",
|
||||
sent: "secondary",
|
||||
loaded: "secondary",
|
||||
po_received: "default",
|
||||
invoiced: "default",
|
||||
completed: "default",
|
||||
|
|
@ -110,6 +111,7 @@ const statusColors: Record<
|
|||
const statusLabel: Record<DeliveryNoteStatus, string> = {
|
||||
draft: "Draft",
|
||||
sent: "Sent",
|
||||
loaded: "Loaded",
|
||||
po_received: "PO received",
|
||||
invoiced: "Invoiced",
|
||||
completed: "Completed",
|
||||
|
|
|
|||
|
|
@ -70,10 +70,10 @@ const TAB_LABELS: Record<DnTab, string> = {
|
|||
};
|
||||
|
||||
const TAB_STATUSES: Record<DnTab, DeliveryNoteStatus[]> = {
|
||||
active: ["draft", "sent", "po_received", "invoiced"],
|
||||
active: ["draft", "sent", "loaded", "po_received", "invoiced"],
|
||||
cancelled: ["cancelled"],
|
||||
completed: ["completed"],
|
||||
all: ["draft", "sent", "po_received", "invoiced", "completed", "cancelled"],
|
||||
all: ["draft", "sent", "loaded", "po_received", "invoiced", "completed", "cancelled"],
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -91,6 +91,7 @@ const statusColors: Record<
|
|||
> = {
|
||||
draft: "outline",
|
||||
sent: "secondary",
|
||||
loaded: "secondary",
|
||||
po_received: "default",
|
||||
invoiced: "default",
|
||||
completed: "default",
|
||||
|
|
@ -100,6 +101,7 @@ const statusColors: Record<
|
|||
const statusLabel: Record<DeliveryNoteStatus, string> = {
|
||||
draft: "Draft",
|
||||
sent: "Sent",
|
||||
loaded: "Loaded",
|
||||
po_received: "PO received",
|
||||
invoiced: "Invoiced",
|
||||
completed: "Completed",
|
||||
|
|
|
|||
107
src/app/api/v1/order-items/[id]/qty/route.test.ts
Normal file
107
src/app/api/v1/order-items/[id]/qty/route.test.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const getOneMock = vi.fn();
|
||||
const updateMock = vi.fn();
|
||||
|
||||
vi.mock("@/lib/server/pocketbase-admin", () => ({
|
||||
getAdminPocketBase: vi.fn().mockResolvedValue({
|
||||
collection: () => ({ getOne: getOneMock, update: updateMock }),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server/orders-service", () => ({
|
||||
getOrderItemsCollectionName: vi.fn(() => "order_items_v2"),
|
||||
}));
|
||||
|
||||
import { POST } from "./route";
|
||||
|
||||
function makeRequest(body: unknown): NextRequest {
|
||||
return new NextRequest(
|
||||
"http://localhost/api/v1/order-items/row-1/qty",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function callPOST(body: unknown) {
|
||||
return POST(makeRequest(body), { params: Promise.resolve({ id: "row-1" }) });
|
||||
}
|
||||
|
||||
describe("POST /api/v1/order-items/[id]/qty", () => {
|
||||
beforeEach(() => {
|
||||
getOneMock.mockReset();
|
||||
updateMock.mockReset();
|
||||
});
|
||||
|
||||
it("returns 400 when qty_collected is missing", async () => {
|
||||
const res = await callPOST({});
|
||||
expect(res.status).toBe(400);
|
||||
expect(updateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 when qty_collected is a negative number", async () => {
|
||||
const res = await callPOST({ qty_collected: -3 });
|
||||
expect(res.status).toBe(400);
|
||||
expect(updateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 when qty_collected is a non-integer (e.g. 1.5)", async () => {
|
||||
const res = await callPOST({ qty_collected: 1.5 });
|
||||
expect(res.status).toBe(400);
|
||||
expect(updateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("writes the new qty and appends a quantity audit note on a real change", async () => {
|
||||
getOneMock.mockResolvedValueOnce({
|
||||
id: "row-1",
|
||||
qty_collected: 70,
|
||||
notes: "",
|
||||
});
|
||||
updateMock.mockResolvedValueOnce({ id: "row-1", qty_collected: 75 });
|
||||
|
||||
const res = await callPOST({ qty_collected: 75 });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.qty_collected).toBe(75);
|
||||
expect(updateMock).toHaveBeenCalledTimes(1);
|
||||
const patch = updateMock.mock.calls[0]?.[1] as {
|
||||
qty_collected: number;
|
||||
notes: string;
|
||||
};
|
||||
expect(patch.qty_collected).toBe(75);
|
||||
const notes = JSON.parse(patch.notes) as Array<{
|
||||
category: string;
|
||||
body: { qtyChange: { prev: number; next: number } };
|
||||
}>;
|
||||
expect(notes).toHaveLength(1);
|
||||
expect(notes[0]?.category).toBe("quantity");
|
||||
expect(notes[0]?.body.qtyChange).toEqual({ prev: 70, next: 75 });
|
||||
});
|
||||
|
||||
it("no-ops (and skips the audit note) when the new qty equals the old one", async () => {
|
||||
getOneMock.mockResolvedValueOnce({
|
||||
id: "row-1",
|
||||
qty_collected: 70,
|
||||
notes: "",
|
||||
});
|
||||
const res = await callPOST({ qty_collected: 70 });
|
||||
expect(res.status).toBe(200);
|
||||
expect(updateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows zero (e.g. short delivery)", async () => {
|
||||
getOneMock.mockResolvedValueOnce({
|
||||
id: "row-1",
|
||||
qty_collected: 70,
|
||||
notes: "",
|
||||
});
|
||||
updateMock.mockResolvedValueOnce({ id: "row-1", qty_collected: 0 });
|
||||
const res = await callPOST({ qty_collected: 0 });
|
||||
expect(res.status).toBe(200);
|
||||
expect(updateMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
69
src/app/api/v1/order-items/[id]/qty/route.ts
Normal file
69
src/app/api/v1/order-items/[id]/qty/route.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { getServerIdentity } from "@/lib/dev-identity";
|
||||
import {
|
||||
CollectionConfirmValidationError,
|
||||
updateOrderItemQty,
|
||||
} from "@/lib/server/collection-confirm-service";
|
||||
|
||||
/**
|
||||
* POST /api/v1/order-items/[id]/qty
|
||||
*
|
||||
* Thin wrapper around `updateOrderItemQty(id, { qty_collected, actor })`
|
||||
* from the collection-confirm service layer. Mirrors the cost +
|
||||
* sku endpoints: the service performs the prev-vs-new comparison and
|
||||
* the audit-note append; this route validates the body, derives the
|
||||
* actor from the dev-identity cookie / header, and reports server
|
||||
* failures with a generic 500 (the real error is logged server-side).
|
||||
*
|
||||
* ## Request
|
||||
*
|
||||
* ```json
|
||||
* { "qty_collected": 70 }
|
||||
* ```
|
||||
*
|
||||
* The `actor` field is intentionally NOT accepted from the body — a
|
||||
* stale client-side cookie value would otherwise flow straight into
|
||||
* the audit trail.
|
||||
*
|
||||
* ## Responses
|
||||
*
|
||||
* - `200 { ...row }` on success
|
||||
* - `400 { error, code }` when `qty_collected` is missing, non-numeric,
|
||||
* NaN, negative, or non-integer
|
||||
* - `500 { error }` on internal failure
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
qty_collected?: unknown;
|
||||
};
|
||||
const { qty_collected } = body;
|
||||
if (typeof qty_collected !== "number" || !Number.isFinite(qty_collected)) {
|
||||
return NextResponse.json(
|
||||
{ error: "qty_collected must be a finite number" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const actorId = getServerIdentity(
|
||||
request as unknown as Parameters<typeof getServerIdentity>[0],
|
||||
);
|
||||
const updated = await updateOrderItemQty(id, {
|
||||
qty_collected,
|
||||
actor: { id: actorId, name: actorId },
|
||||
});
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
if (error instanceof CollectionConfirmValidationError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, code: error.code },
|
||||
{ status: error.status },
|
||||
);
|
||||
}
|
||||
console.error("[POST /api/v1/order-items/[id]/qty]", error);
|
||||
return NextResponse.json({ error: "internal error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
97
src/app/api/v1/stock-purchase-items/[id]/qty/route.test.ts
Normal file
97
src/app/api/v1/stock-purchase-items/[id]/qty/route.test.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const getOneMock = vi.fn();
|
||||
const updateMock = vi.fn();
|
||||
|
||||
vi.mock("@/lib/server/pocketbase-admin", () => ({
|
||||
getAdminPocketBase: vi.fn().mockResolvedValue({
|
||||
collection: () => ({ getOne: getOneMock, update: updateMock }),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { POST } from "./route";
|
||||
|
||||
function makeRequest(body: unknown): NextRequest {
|
||||
return new NextRequest(
|
||||
"http://localhost/api/v1/stock-purchase-items/stock-row-1/qty",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function callPOST(body: unknown) {
|
||||
return POST(makeRequest(body), {
|
||||
params: Promise.resolve({ id: "stock-row-1" }),
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/v1/stock-purchase-items/[id]/qty", () => {
|
||||
beforeEach(() => {
|
||||
getOneMock.mockReset();
|
||||
updateMock.mockReset();
|
||||
});
|
||||
|
||||
it("returns 400 when quantity_received is missing", async () => {
|
||||
const res = await callPOST({});
|
||||
expect(res.status).toBe(400);
|
||||
expect(updateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 when quantity_received is negative", async () => {
|
||||
const res = await callPOST({ quantity_received: -5 });
|
||||
expect(res.status).toBe(400);
|
||||
expect(updateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 when quantity_received is a non-integer", async () => {
|
||||
const res = await callPOST({ quantity_received: 2.5 });
|
||||
expect(res.status).toBe(400);
|
||||
expect(updateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("writes the new quantity_received and appends a quantity audit note", async () => {
|
||||
getOneMock.mockResolvedValueOnce({
|
||||
id: "stock-row-1",
|
||||
quantity_received: 30,
|
||||
notes: "",
|
||||
});
|
||||
updateMock.mockResolvedValueOnce({
|
||||
id: "stock-row-1",
|
||||
quantity_received: 35,
|
||||
});
|
||||
|
||||
const res = await callPOST({ quantity_received: 35 });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.item.quantity_received).toBe(35);
|
||||
expect(body.previous_quantity_received).toBe(30);
|
||||
expect(updateMock).toHaveBeenCalledTimes(1);
|
||||
const patch = updateMock.mock.calls[0]?.[1] as {
|
||||
quantity_received: number;
|
||||
notes: string;
|
||||
};
|
||||
expect(patch.quantity_received).toBe(35);
|
||||
const notes = JSON.parse(patch.notes) as Array<{
|
||||
category: string;
|
||||
body: { qtyChange: { prev: number; next: number } };
|
||||
}>;
|
||||
expect(notes).toHaveLength(1);
|
||||
expect(notes[0]?.category).toBe("quantity");
|
||||
expect(notes[0]?.body.qtyChange).toEqual({ prev: 30, next: 35 });
|
||||
});
|
||||
|
||||
it("no-ops when the new qty equals the old one", async () => {
|
||||
getOneMock.mockResolvedValueOnce({
|
||||
id: "stock-row-1",
|
||||
quantity_received: 30,
|
||||
notes: "",
|
||||
});
|
||||
const res = await callPOST({ quantity_received: 30 });
|
||||
expect(res.status).toBe(200);
|
||||
expect(updateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
72
src/app/api/v1/stock-purchase-items/[id]/qty/route.ts
Normal file
72
src/app/api/v1/stock-purchase-items/[id]/qty/route.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { getServerIdentity } from "@/lib/dev-identity";
|
||||
import { updateStockPurchaseItemQty } from "@/lib/server/stock-purchase-items-service";
|
||||
|
||||
/**
|
||||
* POST /api/v1/stock-purchase-items/[id]/qty
|
||||
*
|
||||
* Stock-side mirror of `/api/v1/order-items/[id]/qty`. Writes
|
||||
* `stock_purchase_items.quantity_received` (the field that backs
|
||||
* the page's unified `qty_collected` column for stock-source rows).
|
||||
* Used by the inline Qty pencil on `/collection-confirm` so stock
|
||||
* lines have the same edit affordance as customer lines.
|
||||
*
|
||||
* Mirrors the order-items route:
|
||||
* - Reads `quantity_received` from the JSON body
|
||||
* - Validates type + non-negative + integer
|
||||
* - No upper bound (suppliers can legitimately ship more than PO'd)
|
||||
* - Stamps a `quantity`-category audit note via the service
|
||||
*
|
||||
* Request:
|
||||
* ```json
|
||||
* { "quantity_received": 70 }
|
||||
* ```
|
||||
*
|
||||
* Responses:
|
||||
* - `200 { item, previous_quantity_received }` on success
|
||||
* - `400 { error }` on validation failure
|
||||
* - `500 { error }` on internal failure
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
quantity_received?: unknown;
|
||||
};
|
||||
const { quantity_received } = body;
|
||||
if (
|
||||
typeof quantity_received !== "number" ||
|
||||
!Number.isFinite(quantity_received)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "quantity_received must be a finite number" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const actorId = getServerIdentity(
|
||||
request as unknown as Parameters<typeof getServerIdentity>[0],
|
||||
);
|
||||
const result = await updateStockPurchaseItemQty({
|
||||
id,
|
||||
quantity_received,
|
||||
actor: { id: actorId, name: actorId },
|
||||
});
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to update qty";
|
||||
// Service-level validation throws plain Error with a stable
|
||||
// prefix-free message; surface those as 400 so the pencil
|
||||
// shows a clean error instead of a generic 500.
|
||||
const isValidation =
|
||||
message.startsWith("quantity_received must be") ||
|
||||
message.startsWith("id is required");
|
||||
if (isValidation) {
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
console.error("[POST /api/v1/stock-purchase-items/[id]/qty]", message);
|
||||
return NextResponse.json({ error: "internal error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,14 @@ type Props = {
|
|||
rows: ConfirmRow[];
|
||||
onCostChange: (id: string, cost: number) => void;
|
||||
onSkuChange: (id: string, sku: string) => void;
|
||||
/**
|
||||
* Per-row collected-qty save handler. Wired to `order_items.qty_collected`
|
||||
* (customer rows) or `stock_purchase_items.quantity_received` (stock
|
||||
* rows); the parent routes through the right REST endpoint based on
|
||||
* `row.source`. Optional so legacy callers that don't need the
|
||||
* pencil can hide it.
|
||||
*/
|
||||
onQtyChange?: (id: string, qty: number) => void;
|
||||
/**
|
||||
* Per-row supplier-SKU save handler. Mirrors `onCostChange` /
|
||||
* `onSkuChange` but targets `stock_purchase_items.supplier_sku` —
|
||||
|
|
@ -26,26 +34,27 @@ type EditState = {
|
|||
value: string;
|
||||
error: string | null;
|
||||
/**
|
||||
* Which field is being edited. The cost, SKU, and supplier-SKU
|
||||
* cells share this state so only one cell per row is ever in edit
|
||||
* mode (a save on one cancels the others), and the closing of one
|
||||
* doesn't reset the others' values.
|
||||
* Which field is being edited. The cost, SKU, supplier-SKU, and
|
||||
* collected-qty cells share this state so only one cell per row
|
||||
* is ever in edit mode (a save on one cancels the others), and
|
||||
* the closing of one doesn't reset the others' values.
|
||||
*/
|
||||
field: "cost" | "sku" | "supplierSku";
|
||||
field: "cost" | "sku" | "supplierSku" | "qty";
|
||||
};
|
||||
|
||||
/**
|
||||
* Compact table for the collection-confirm review view.
|
||||
*
|
||||
* Each Cost / SKU / Supplier-SKU cell renders read-only by default
|
||||
* with a pencil icon. Clicking the pencil swaps the cell to an
|
||||
* inline `<Input>` that auto-focuses and selects the current value.
|
||||
* Enter / blur save (via the corresponding on*Change callback), Esc
|
||||
* reverts. Validation per field:
|
||||
* Each Cost / SKU / Supplier-SKU / Collected-qty cell renders
|
||||
* read-only by default with a pencil icon. Clicking the pencil
|
||||
* swaps the cell to an inline `<Input>` that auto-focuses and
|
||||
* selects the current value. Enter / blur save (via the corresponding
|
||||
* on*Change callback), Esc reverts. Validation per field:
|
||||
*
|
||||
* - cost: must parse as a non-negative number
|
||||
* - sku: must be non-empty and ≤ 64 characters
|
||||
* - supplierSku: same as sku (≤ 64 chars, non-empty)
|
||||
* - qty: must parse as a non-negative integer
|
||||
*
|
||||
* The parent owns the optimistic update + rollback; this component
|
||||
* just surfaces a callback with the new value. The spinner state
|
||||
|
|
@ -59,11 +68,16 @@ type EditState = {
|
|||
* - supplierSku: visible only for `source: "stock"` rows (customer
|
||||
* order_items have no supplier_sku field). Caller passes
|
||||
* `onSupplierSkuChange` to opt in.
|
||||
* - qty: visible when caller passes `onQtyChange`. Stock and
|
||||
* customer rows both expose the pencil because both have a
|
||||
* collected-qty value (stock rows back it with
|
||||
* `quantity_received`).
|
||||
*/
|
||||
export function ItemsTable({
|
||||
rows,
|
||||
onCostChange,
|
||||
onSkuChange,
|
||||
onQtyChange,
|
||||
onSupplierSkuChange,
|
||||
}: Props) {
|
||||
const [edit, setEdit] = useState<EditState | null>(null);
|
||||
|
|
@ -95,6 +109,15 @@ export function ItemsTable({
|
|||
});
|
||||
};
|
||||
|
||||
const startQtyEdit = (row: ConfirmRow) => {
|
||||
setEdit({
|
||||
id: row.id,
|
||||
value: String(row.qty_collected ?? 0),
|
||||
error: null,
|
||||
field: "qty",
|
||||
});
|
||||
};
|
||||
|
||||
const cancel = () => setEdit(null);
|
||||
|
||||
const save = (rowId: string) => {
|
||||
|
|
@ -114,6 +137,25 @@ export function ItemsTable({
|
|||
onCostChange(rowId, parsed);
|
||||
return;
|
||||
}
|
||||
if (edit.field === "qty") {
|
||||
const trimmed = edit.value.trim();
|
||||
if (trimmed === "") {
|
||||
setEdit({ ...edit, error: "Required" });
|
||||
return;
|
||||
}
|
||||
const parsed = Number(trimmed);
|
||||
if (Number.isNaN(parsed) || parsed < 0) {
|
||||
setEdit({ ...edit, error: "Must be a non-negative number" });
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(parsed)) {
|
||||
setEdit({ ...edit, error: "Must be a whole number" });
|
||||
return;
|
||||
}
|
||||
setEdit(null);
|
||||
onQtyChange?.(rowId, parsed);
|
||||
return;
|
||||
}
|
||||
// Both sku and supplierSku share the same validation + length cap;
|
||||
// the only difference is which callback fires.
|
||||
const trimmed = edit.value.trim();
|
||||
|
|
@ -288,7 +330,79 @@ export function ItemsTable({
|
|||
)}
|
||||
<td className="py-2">{row.description ?? row.po_description ?? "—"}</td>
|
||||
<td className="py-2 text-right">{ordered}</td>
|
||||
<td className="py-2 text-right">{collected}</td>
|
||||
<td className="py-2 text-right">
|
||||
{onQtyChange ? (
|
||||
isEditing && editingField === "qty" && edit ? (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
autoFocus
|
||||
value={edit.value}
|
||||
onValueChange={(value) => updateValue(row.id, value)}
|
||||
onBlur={() => save(row.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") save(row.id);
|
||||
if (e.key === "Escape") cancel();
|
||||
}}
|
||||
className="h-8 w-20 px-2 py-1 text-right"
|
||||
aria-label="Edit collected qty"
|
||||
data-testid={`qty-input-${row.id}`}
|
||||
aria-invalid={edit.error ? true : undefined}
|
||||
/>
|
||||
{edit.error ? (
|
||||
<span
|
||||
className="text-xs text-red-500"
|
||||
data-testid={`qty-error-${row.id}`}
|
||||
>
|
||||
{edit.error}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Enter to save, Esc to cancel
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<span
|
||||
data-qty-value={String(collected)}
|
||||
className={
|
||||
collected < ordered
|
||||
? "text-amber-600 dark:text-amber-400"
|
||||
: collected > ordered
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{collected}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startQtyEdit(row)}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
aria-label={`Edit collected qty for ${row.sku ?? row.id}`}
|
||||
data-testid={`qty-edit-${row.id}`}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<span
|
||||
className={
|
||||
collected < ordered
|
||||
? "text-amber-600 dark:text-amber-400"
|
||||
: collected > ordered
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{collected}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
{isEditing && editingField === "cost" && edit ? (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
|
|
|
|||
|
|
@ -65,6 +65,14 @@ type Props = {
|
|||
initiallyOpen?: boolean;
|
||||
onCostChange: (id: string, cost: number) => void;
|
||||
onSkuChange: (id: string, sku: string) => void;
|
||||
/**
|
||||
* Per-row collected-qty save handler. Stock and customer rows
|
||||
* both expose the pencil (via `order_items.qty_collected` or
|
||||
* `stock_purchase_items.quantity_received` — the parent routes
|
||||
* based on `row.source`). Optional so legacy callers can hide
|
||||
* the pencil by omitting the prop.
|
||||
*/
|
||||
onQtyChange?: (id: string, qty: number) => void;
|
||||
/**
|
||||
* Per-row supplier-SKU save handler. Stock-only — `ItemsTable`
|
||||
* only renders the supplier-SKU pencil on `source === "stock"`
|
||||
|
|
@ -156,6 +164,7 @@ export function PoCard({
|
|||
initiallyOpen = false,
|
||||
onCostChange,
|
||||
onSkuChange,
|
||||
onQtyChange,
|
||||
onSupplierSkuChange,
|
||||
onConfirm,
|
||||
onConfirmStock,
|
||||
|
|
@ -238,18 +247,23 @@ export function PoCard({
|
|||
);
|
||||
|
||||
// Distinct documents on this PO. Empty array → no preview block.
|
||||
// Customer rows use `/api/v1/files/order_items/...`; stock rows
|
||||
// use `/api/v1/files/stock_purchases/...` (the file lives on the
|
||||
// parent stock_purchase, not on the line). The dedupe helper tags
|
||||
// each entry with `stockPurchaseId` when it came from a stock
|
||||
// upload so we can build the right URL.
|
||||
// Three possible sources for a chip's file, in priority order:
|
||||
// - stock rows: `/api/v1/files/stock_purchases/...` (file lives
|
||||
// on the parent stock_purchase, not the line)
|
||||
// - customer rows whose PO PDF was uploaded to the parent
|
||||
// customer PO: `/api/v1/files/purchase_orders/...`
|
||||
// - customer row-level attachments: `/api/v1/files/order_items/...`
|
||||
// The dedupe helper tags each entry with `stockPurchaseId` or
|
||||
// `purchaseOrderId` so we can build the right URL.
|
||||
const attachments = useMemo(
|
||||
() =>
|
||||
dedupeAttachments(rows).map(({ filename, itemIds, stockPurchaseId }) => {
|
||||
dedupeAttachments(rows).map(({ filename, itemIds, stockPurchaseId, purchaseOrderId }) => {
|
||||
const itemId = itemIds[0];
|
||||
const collectionPath = stockPurchaseId
|
||||
? `stock_purchases/${encodeURIComponent(stockPurchaseId)}`
|
||||
: `order_items/${encodeURIComponent(itemId)}`;
|
||||
: purchaseOrderId
|
||||
? `purchase_orders/${encodeURIComponent(purchaseOrderId)}`
|
||||
: `order_items/${encodeURIComponent(itemId)}`;
|
||||
return {
|
||||
filename,
|
||||
itemId,
|
||||
|
|
@ -600,6 +614,7 @@ export function PoCard({
|
|||
rows={rows}
|
||||
onCostChange={onCostChange}
|
||||
onSkuChange={onSkuChange}
|
||||
onQtyChange={onQtyChange}
|
||||
onSupplierSkuChange={onSupplierSkuChange}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -665,6 +680,7 @@ export function PoCard({
|
|||
rows={rows}
|
||||
onCostChange={onCostChange}
|
||||
onSkuChange={onSkuChange}
|
||||
onQtyChange={onQtyChange}
|
||||
onSupplierSkuChange={onSupplierSkuChange}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -139,6 +139,15 @@ export type ConfirmRow = {
|
|||
purchase_order?: {
|
||||
id: string;
|
||||
po_number?: string;
|
||||
/**
|
||||
* Multi-file field on the parent `purchase_orders` record.
|
||||
* Surfaces the PO PDF (and any operator-uploaded scans) as
|
||||
* chips on the collection-confirm page. The collection-confirm
|
||||
* page's `dedupeAttachments` reads this so a PO whose file was
|
||||
* uploaded directly to the parent PO (rather than the line's
|
||||
* `order_item_attachements`) still shows a document chip.
|
||||
*/
|
||||
documents?: string[];
|
||||
expand?: { supplier?: { id: string; suppliername?: string } };
|
||||
};
|
||||
order?: {
|
||||
|
|
@ -567,6 +576,80 @@ function parseStoredNotesForSku(raw: string): Array<Record<string, unknown>> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the `qty_collected` value on a single collected order_item
|
||||
* and stamp a `quantity`-category note onto the row so the audit trail
|
||||
* records the before/after values.
|
||||
*
|
||||
* Mirrors `updateOrderItemCost`: a no-op when the new qty is
|
||||
* byte-for-byte equal to the existing one (so the route returns 200
|
||||
* without writing a new audit entry). For a genuine change the
|
||||
* patch writes both the new `qty_collected` and the appended notes
|
||||
* feed in a single round-trip.
|
||||
*
|
||||
* Validates non-negative integer input (PB stores `qty_collected`
|
||||
* as a number; we treat fractions as 400 because the UI's pencil
|
||||
* is integer-only and the downstream invoices expect whole units).
|
||||
* Upper bound: we deliberately do NOT cap at the ordered quantity
|
||||
* — a row can be legitimately over-collected (driver delivered
|
||||
* +extras, supplier shipped more than PO'd). The "all received"
|
||||
* badge in the items table is purely informational; the qty pencil
|
||||
* never silently clamps.
|
||||
*/
|
||||
export async function updateOrderItemQty(
|
||||
id: string,
|
||||
args: { qty_collected: number; actor: ConfirmActor },
|
||||
): Promise<ConfirmRow> {
|
||||
if (
|
||||
typeof args.qty_collected !== "number" ||
|
||||
!Number.isFinite(args.qty_collected)
|
||||
) {
|
||||
throw new CollectionConfirmValidationError(
|
||||
"qty_collected must be a finite number",
|
||||
"invalid_qty_collected",
|
||||
400,
|
||||
);
|
||||
}
|
||||
if (args.qty_collected < 0) {
|
||||
throw new CollectionConfirmValidationError(
|
||||
"qty_collected must be a non-negative number",
|
||||
"invalid_qty_collected",
|
||||
400,
|
||||
);
|
||||
}
|
||||
if (!Number.isInteger(args.qty_collected)) {
|
||||
throw new CollectionConfirmValidationError(
|
||||
"qty_collected must be a whole number",
|
||||
"invalid_qty_collected",
|
||||
400,
|
||||
);
|
||||
}
|
||||
const pb = await getAdminPocketBase();
|
||||
const collectionName = getOrderItemsCollectionName();
|
||||
const existing = await pb.collection(collectionName).getOne(id);
|
||||
const prevQty = Number(
|
||||
(existing as { qty_collected?: number | null }).qty_collected ?? 0,
|
||||
);
|
||||
if (prevQty === args.qty_collected) {
|
||||
return existing as ConfirmRow;
|
||||
}
|
||||
const existingNotes =
|
||||
(existing as { notes?: string | null }).notes ?? "";
|
||||
const qtyNote = {
|
||||
id: `qty-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
ts: new Date().toISOString(),
|
||||
actorId: args.actor.id,
|
||||
actorName: args.actor.name,
|
||||
category: "quantity" as const,
|
||||
body: { qtyChange: { prev: prevQty, next: args.qty_collected } },
|
||||
};
|
||||
const updated = await pb.collection(collectionName).update(id, {
|
||||
qty_collected: args.qty_collected,
|
||||
notes: JSON.stringify([...parseStoredNotesForSku(existingNotes), qtyNote]),
|
||||
});
|
||||
return updated as ConfirmRow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the most recent `price_diff_report` entry from a notes feed.
|
||||
* The `po-agent` writes one of these per scanned PO (see
|
||||
|
|
|
|||
|
|
@ -363,6 +363,103 @@ export async function assignDriverWithPb(
|
|||
return { item: updated };
|
||||
}
|
||||
|
||||
export type UpdateStockPurchaseItemQtyInput = {
|
||||
id: string;
|
||||
/**
|
||||
* New value for `quantity_received`. Must be a non-negative
|
||||
* integer. No upper bound — a row can be legitimately
|
||||
* over-collected (supplier shipped more than PO'd) so we don't
|
||||
* clamp at `quantity`.
|
||||
*/
|
||||
quantity_received: number;
|
||||
actor: { id: string; name: string };
|
||||
};
|
||||
|
||||
export type UpdateStockPurchaseItemQtyResult = {
|
||||
item: StockPurchaseItem;
|
||||
previous_quantity_received: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Edit `quantity_received` on a single collected stock_purchase_item
|
||||
* from the collection-confirm page's Qty pencil.
|
||||
*
|
||||
* The pencil is wired when the operator notices the warehouse-side
|
||||
* count disagrees with what the driver originally reported (e.g.
|
||||
* re-counted at intake, found a damaged unit, etc.). The change is
|
||||
* stamped onto the line's `notes` feed as a `quantity`-category
|
||||
* entry so the audit trail records the before/after values.
|
||||
*
|
||||
* Validation: non-negative integer (PB stores quantity_received as
|
||||
* a number; fractions are rejected because the UI's pencil is
|
||||
* integer-only and downstream invoices expect whole units). No
|
||||
* upper bound — see input type.
|
||||
*
|
||||
* No-op when the new value equals the existing one (route returns
|
||||
* 200 without writing a new audit entry). Mirrors
|
||||
* `updateOrderItemQty` in the order-items service.
|
||||
*/
|
||||
export async function updateStockPurchaseItemQty(
|
||||
input: UpdateStockPurchaseItemQtyInput,
|
||||
): Promise<UpdateStockPurchaseItemQtyResult> {
|
||||
if (
|
||||
typeof input.quantity_received !== "number" ||
|
||||
!Number.isFinite(input.quantity_received)
|
||||
) {
|
||||
throw new Error("quantity_received must be a finite number");
|
||||
}
|
||||
if (input.quantity_received < 0) {
|
||||
throw new Error("quantity_received must be a non-negative number");
|
||||
}
|
||||
if (!Number.isInteger(input.quantity_received)) {
|
||||
throw new Error("quantity_received must be a whole number");
|
||||
}
|
||||
const pb = await getAdminPocketBase();
|
||||
const before = (await pb
|
||||
.collection(STOCK_PURCHASE_ITEMS)
|
||||
.getOne(input.id)) as StockPurchaseItem;
|
||||
const previous_quantity_received = Number(
|
||||
before.quantity_received ?? 0,
|
||||
);
|
||||
if (previous_quantity_received === input.quantity_received) {
|
||||
return { item: before, previous_quantity_received };
|
||||
}
|
||||
const existingNotes = (before as { notes?: string | null }).notes ?? "";
|
||||
const qtyNote = {
|
||||
id: `qty-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
ts: new Date().toISOString(),
|
||||
actorId: input.actor.id,
|
||||
actorName: input.actor.name,
|
||||
category: "quantity" as const,
|
||||
body: {
|
||||
qtyChange: {
|
||||
prev: previous_quantity_received,
|
||||
next: input.quantity_received,
|
||||
},
|
||||
},
|
||||
};
|
||||
const updated = (await pb.collection(STOCK_PURCHASE_ITEMS).update(input.id, {
|
||||
quantity_received: input.quantity_received,
|
||||
notes: JSON.stringify([
|
||||
...parseStoredNotesForQty(existingNotes),
|
||||
qtyNote,
|
||||
]),
|
||||
})) as StockPurchaseItem;
|
||||
return { item: updated, previous_quantity_received };
|
||||
}
|
||||
|
||||
function parseStoredNotesForQty(raw: string): Array<Record<string, unknown>> {
|
||||
const trimmed = (raw ?? "").trim();
|
||||
if (!trimmed) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter((entry) => entry && typeof entry === "object");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export type MarkLineCollectedInput = {
|
||||
id: string;
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in a new issue