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:
Dave 2026-08-17 02:17:27 +02:00
parent 4a59288a01
commit d0fb8e81f0
21 changed files with 947 additions and 30 deletions

View 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

View 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

View 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

View 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]

View 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]

View file

@ -0,0 +1,3 @@
- generic [active] [ref=f2e1]:
- region "Notifications alt+T"
- button "Open Next.js Dev Tools" [ref=f2e9] [cursor=pointer]

View file

@ -0,0 +1,2 @@
- generic [active] [ref=f4e1]:
- region "Notifications alt+T"

View 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]

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

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

View file

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

View file

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

View file

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

View file

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