diff --git a/.playwright-mcp/console-2026-08-16T23-49-18-000Z.log b/.playwright-mcp/console-2026-08-16T23-49-18-000Z.log new file mode 100644 index 0000000..f8df1bc --- /dev/null +++ b/.playwright-mcp/console-2026-08-16T23-49-18-000Z.log @@ -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 diff --git a/.playwright-mcp/console-2026-08-16T23-49-49-202Z.log b/.playwright-mcp/console-2026-08-16T23-49-49-202Z.log new file mode 100644 index 0000000..00180ac --- /dev/null +++ b/.playwright-mcp/console-2026-08-16T23-49-49-202Z.log @@ -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 diff --git a/.playwright-mcp/console-2026-08-16T23-50-06-238Z.log b/.playwright-mcp/console-2026-08-16T23-50-06-238Z.log new file mode 100644 index 0000000..e4455a8 --- /dev/null +++ b/.playwright-mcp/console-2026-08-16T23-50-06-238Z.log @@ -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 diff --git a/.playwright-mcp/page-2026-08-16T23-49-21-387Z.yml b/.playwright-mcp/page-2026-08-16T23-49-21-387Z.yml new file mode 100644 index 0000000..3bbde02 --- /dev/null +++ b/.playwright-mcp/page-2026-08-16T23-49-21-387Z.yml @@ -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] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-08-16T23-49-29-761Z.yml b/.playwright-mcp/page-2026-08-16T23-49-29-761Z.yml new file mode 100644 index 0000000..3f6222a --- /dev/null +++ b/.playwright-mcp/page-2026-08-16T23-49-29-761Z.yml @@ -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] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-08-16T23-49-52-556Z.yml b/.playwright-mcp/page-2026-08-16T23-49-52-556Z.yml new file mode 100644 index 0000000..d0f246f --- /dev/null +++ b/.playwright-mcp/page-2026-08-16T23-49-52-556Z.yml @@ -0,0 +1,3 @@ +- generic [active] [ref=f2e1]: + - region "Notifications alt+T" + - button "Open Next.js Dev Tools" [ref=f2e9] [cursor=pointer] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-08-16T23-50-12-290Z.yml b/.playwright-mcp/page-2026-08-16T23-50-12-290Z.yml new file mode 100644 index 0000000..90033d0 --- /dev/null +++ b/.playwright-mcp/page-2026-08-16T23-50-12-290Z.yml @@ -0,0 +1,2 @@ +- generic [active] [ref=f4e1]: + - region "Notifications alt+T" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-08-16T23-50-29-023Z.yml b/.playwright-mcp/page-2026-08-16T23-50-29-023Z.yml new file mode 100644 index 0000000..020d335 --- /dev/null +++ b/.playwright-mcp/page-2026-08-16T23-50-29-023Z.yml @@ -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] \ No newline at end of file diff --git a/src/app/(app)/collection-confirm/page.tsx b/src/app/(app)/collection-confirm/page.tsx index c1c52d8..7b06d81 100644 --- a/src/app/(app)/collection-confirm/page.tsx +++ b/src/app/(app)/collection-confirm/page.tsx @@ -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) } diff --git a/src/app/(app)/collection-confirm/view-model.test.ts b/src/app/(app)/collection-confirm/view-model.test.ts index 14fd41d..0d38d48 100644 --- a/src/app/(app)/collection-confirm/view-model.test.ts +++ b/src/app/(app)/collection-confirm/view-model.test.ts @@ -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" }, + ]); + }); }); \ No newline at end of file diff --git a/src/app/(app)/collection-confirm/view-model.ts b/src/app/(app)/collection-confirm/view-model.ts index 93617fc..ad9c964 100644 --- a/src/app/(app)/collection-confirm/view-model.ts +++ b/src/app/(app)/collection-confirm/view-model.ts @@ -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(); + // 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(); 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(); 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(); 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(); + 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; } diff --git a/src/app/(app)/delivery-notes/[id]/page.tsx b/src/app/(app)/delivery-notes/[id]/page.tsx index 4f53878..4859ff3 100644 --- a/src/app/(app)/delivery-notes/[id]/page.tsx +++ b/src/app/(app)/delivery-notes/[id]/page.tsx @@ -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 = { draft: "Draft", sent: "Sent", + loaded: "Loaded", po_received: "PO received", invoiced: "Invoiced", completed: "Completed", diff --git a/src/app/(app)/delivery-notes/page.tsx b/src/app/(app)/delivery-notes/page.tsx index fd9e8bd..460171b 100644 --- a/src/app/(app)/delivery-notes/page.tsx +++ b/src/app/(app)/delivery-notes/page.tsx @@ -70,10 +70,10 @@ const TAB_LABELS: Record = { }; const TAB_STATUSES: Record = { - 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 = { draft: "Draft", sent: "Sent", + loaded: "Loaded", po_received: "PO received", invoiced: "Invoiced", completed: "Completed", diff --git a/src/app/api/v1/order-items/[id]/qty/route.test.ts b/src/app/api/v1/order-items/[id]/qty/route.test.ts new file mode 100644 index 0000000..0d0738a --- /dev/null +++ b/src/app/api/v1/order-items/[id]/qty/route.test.ts @@ -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); + }); +}); diff --git a/src/app/api/v1/order-items/[id]/qty/route.ts b/src/app/api/v1/order-items/[id]/qty/route.ts new file mode 100644 index 0000000..6758220 --- /dev/null +++ b/src/app/api/v1/order-items/[id]/qty/route.ts @@ -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 { + 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[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 }); + } +} diff --git a/src/app/api/v1/stock-purchase-items/[id]/qty/route.test.ts b/src/app/api/v1/stock-purchase-items/[id]/qty/route.test.ts new file mode 100644 index 0000000..244846a --- /dev/null +++ b/src/app/api/v1/stock-purchase-items/[id]/qty/route.test.ts @@ -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(); + }); +}); diff --git a/src/app/api/v1/stock-purchase-items/[id]/qty/route.ts b/src/app/api/v1/stock-purchase-items/[id]/qty/route.ts new file mode 100644 index 0000000..f41dbf1 --- /dev/null +++ b/src/app/api/v1/stock-purchase-items/[id]/qty/route.ts @@ -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 { + 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[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 }); + } +} diff --git a/src/components/collection-confirm/items-table.tsx b/src/components/collection-confirm/items-table.tsx index d66c4dc..973f413 100644 --- a/src/components/collection-confirm/items-table.tsx +++ b/src/components/collection-confirm/items-table.tsx @@ -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 `` 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 `` 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(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({ )} {row.description ?? row.po_description ?? "—"} {ordered} - {collected} + + {onQtyChange ? ( + isEditing && editingField === "qty" && edit ? ( +
+ 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 ? ( + + {edit.error} + + ) : ( + + Enter to save, Esc to cancel + + )} +
+ ) : ( +
+ ordered + ? "text-emerald-600 dark:text-emerald-400" + : "" + } + > + {collected} + + +
+ ) + ) : ( + ordered + ? "text-emerald-600 dark:text-emerald-400" + : "" + } + > + {collected} + + )} + {isEditing && editingField === "cost" && edit ? (
diff --git a/src/components/collection-confirm/po-card.tsx b/src/components/collection-confirm/po-card.tsx index a18cd48..eaf68f5 100644 --- a/src/components/collection-confirm/po-card.tsx +++ b/src/components/collection-confirm/po-card.tsx @@ -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} />
@@ -665,6 +680,7 @@ export function PoCard({ rows={rows} onCostChange={onCostChange} onSkuChange={onSkuChange} + onQtyChange={onQtyChange} onSupplierSkuChange={onSupplierSkuChange} /> diff --git a/src/lib/server/collection-confirm-service.ts b/src/lib/server/collection-confirm-service.ts index 41ba839..25974d4 100644 --- a/src/lib/server/collection-confirm-service.ts +++ b/src/lib/server/collection-confirm-service.ts @@ -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> { } } +/** + * 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 { + 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 diff --git a/src/lib/server/stock-purchase-items-service.ts b/src/lib/server/stock-purchase-items-service.ts index 1b641ee..c9b9909 100644 --- a/src/lib/server/stock-purchase-items-service.ts +++ b/src/lib/server/stock-purchase-items-service.ts @@ -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 { + 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> { + 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; /**