feat(quotes): add "Include older" toggle for searching full quote history
- Introduced a new toggle in the quotes and archive pages to allow users to include older quotes beyond the default 90-day cutoff. - Updated the search parameters in the quotes and archive APIs to handle the new `includeOlder` flag, enabling full history searches. - Enhanced the user interface to reflect the current search scope, providing clear feedback on whether the search is limited to recent quotes or includes the full archive. - Adjusted the quotes search service to support the new functionality, ensuring efficient retrieval of older quotes when requested. - Added relevant tests to verify the correct behavior of the new search feature and its integration with existing functionality.
This commit is contained in:
parent
88047eee33
commit
663c018a70
32 changed files with 3865 additions and 154 deletions
117
.playwright-mcp/console-2026-08-20T06-41-57-440Z.log
Normal file
117
.playwright-mcp/console-2026-08-20T06-41-57-440Z.log
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
[ 817ms] [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
|
||||
[ 3814ms] [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
|
||||
[ 3953ms] [LOG] [HMR] connected @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 3991ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 4790ms] [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
|
||||
[ 6867ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 8316ms] [LOG] [Fast Refresh] done in 1449ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 11321ms] [WARNING] The resource http://localhost:4001/_next/static/media/4de1fea1a954a5b6-s.p.woff2?v=1787208117573 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/repairs:0
|
||||
[ 11322ms] [WARNING] The resource http://localhost:4001/_next/static/media/eaead17c7dbfcd5d-s.p.woff2?v=1787208117573 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/repairs:0
|
||||
[ 11322ms] [WARNING] The resource http://localhost:4001/_next/static/media/36966cca54120369-s.p.woff2?v=1787208117573 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/repairs:0
|
||||
[ 11322ms] [WARNING] The resource http://localhost:4001/_next/static/media/7d4881bb7e1bf84d-s.p.woff2?v=1787208117573 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/repairs:0
|
||||
[ 11324ms] [WARNING] The resource http://localhost:4001/_next/static/media/ee40bb094c99a29a-s.p.woff2?v=1787208117573 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/repairs:0
|
||||
[ 11324ms] [WARNING] The resource http://localhost:4001/_next/static/media/13971731025ec697-s.p.woff2?v=1787208117573 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/repairs:0
|
||||
[ 11324ms] [WARNING] The resource http://localhost:4001/_next/static/media/7b0b24f36b1a6d0b-s.p.woff2?v=1787208117573 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/repairs:0
|
||||
[ 11324ms] [WARNING] The resource http://localhost:4001/_next/static/media/558ca1a6aa3cb55e-s.p.woff2?v=1787208117573 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/repairs:0
|
||||
[ 11324ms] [WARNING] The resource http://localhost:4001/_next/static/media/6d664cce900333ee-s.p.woff2?v=1787208117573 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/repairs:0
|
||||
[ 11324ms] [WARNING] The resource http://localhost:4001/_next/static/media/e4af272ccee01ff0-s.p.woff2?v=1787208117573 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/repairs:0
|
||||
[ 11324ms] [WARNING] The resource http://localhost:4001/_next/static/media/22a5144ee8d83bca-s.p.woff2?v=1787208117573 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/repairs:0
|
||||
[ 14638ms] [WARNING] The resource http://localhost:4001/_next/static/media/4de1fea1a954a5b6-s.p.woff2?v=1787208117573 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/repairs:0
|
||||
[ 14639ms] [WARNING] The resource http://localhost:4001/_next/static/media/eaead17c7dbfcd5d-s.p.woff2?v=1787208117573 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/repairs:0
|
||||
[ 14639ms] [WARNING] The resource http://localhost:4001/_next/static/media/36966cca54120369-s.p.woff2?v=1787208117573 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/repairs:0
|
||||
[ 14639ms] [WARNING] The resource http://localhost:4001/_next/static/media/7d4881bb7e1bf84d-s.p.woff2?v=1787208117573 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/repairs:0
|
||||
[ 14639ms] [WARNING] The resource http://localhost:4001/_next/static/media/ee40bb094c99a29a-s.p.woff2?v=1787208117573 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/repairs:0
|
||||
[ 14639ms] [WARNING] The resource http://localhost:4001/_next/static/media/13971731025ec697-s.p.woff2?v=1787208117573 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/repairs:0
|
||||
[ 14639ms] [WARNING] The resource http://localhost:4001/_next/static/media/7b0b24f36b1a6d0b-s.p.woff2?v=1787208117573 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/repairs:0
|
||||
[ 14639ms] [WARNING] The resource http://localhost:4001/_next/static/media/558ca1a6aa3cb55e-s.p.woff2?v=1787208117573 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/repairs:0
|
||||
[ 14639ms] [WARNING] The resource http://localhost:4001/_next/static/media/6d664cce900333ee-s.p.woff2?v=1787208117573 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/repairs:0
|
||||
[ 14639ms] [WARNING] The resource http://localhost:4001/_next/static/media/e4af272ccee01ff0-s.p.woff2?v=1787208117573 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/repairs:0
|
||||
[ 14639ms] [WARNING] The resource http://localhost:4001/_next/static/media/22a5144ee8d83bca-s.p.woff2?v=1787208117573 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/repairs:0
|
||||
[ 15374ms] [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
|
||||
[ 15620ms] Invalid or unexpected token
|
||||
[ 18741ms] [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
|
||||
[ 18859ms] [LOG] [HMR] connected @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 19756ms] [ERROR] Can't perform a React state update on a component that hasn't mounted yet. This indicates that you have a side-effect in your render function that asynchronously tries to update the component. Move this work to useEffect instead. @ :0
|
||||
[ 19806ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 20460ms] [LOG] [Fast Refresh] done in 654ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 20487ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 20637ms] [LOG] [Fast Refresh] done in 150ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 23050ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 23269ms] [LOG] [Fast Refresh] done in 220ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 24105ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 24790ms] [LOG] [Fast Refresh] done in 685ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 26188ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 26491ms] [LOG] [Fast Refresh] done in 304ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 27110ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 27399ms] [LOG] [Fast Refresh] done in 289ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 28052ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 28273ms] [LOG] [Fast Refresh] done in 221ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 28660ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 28729ms] [LOG] [Fast Refresh] done in 68ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 29064ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 29237ms] [LOG] [Fast Refresh] done in 173ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 29967ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 30204ms] [LOG] [Fast Refresh] done in 237ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 30847ms] [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
|
||||
[ 30861ms] Invalid or unexpected token
|
||||
[ 32642ms] [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
|
||||
[ 32716ms] [LOG] [HMR] connected @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 34450ms] [ERROR] Can't perform a React state update on a component that hasn't mounted yet. This indicates that you have a side-effect in your render function that asynchronously tries to update the component. Move this work to useEffect instead. @ :0
|
||||
[ 34495ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 35528ms] [LOG] [Fast Refresh] done in 1034ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 35584ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 35870ms] [LOG] [Fast Refresh] done in 287ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 36379ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 36659ms] [LOG] [Fast Refresh] done in 279ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 37191ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 37304ms] [LOG] [Fast Refresh] done in 113ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 37950ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 38254ms] [LOG] [Fast Refresh] done in 304ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 39304ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 39770ms] [LOG] [Fast Refresh] done in 467ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 40227ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 40456ms] [LOG] [Fast Refresh] done in 229ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 40886ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 41010ms] [LOG] [Fast Refresh] done in 125ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 41464ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 41581ms] [LOG] [Fast Refresh] done in 117ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 44512ms] [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
|
||||
[ 48116ms] [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
|
||||
[ 48190ms] [LOG] [HMR] connected @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 48794ms] [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
|
||||
[ 48811ms] [VERBOSE] [DOM] Input elements should have autocomplete attributes (suggested: "current-password"): (More info: https://goo.gl/9p2vKq) %o @ http://localhost:4001/login:0
|
||||
[ 49410ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 49595ms] [LOG] [Fast Refresh] done in 181ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 49930ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 50049ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 50123ms] [LOG] [Fast Refresh] done in 74ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 50244ms] [LOG] [Fast Refresh] done in 195ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 50263ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 50649ms] [LOG] [Fast Refresh] done in 387ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 50704ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 50733ms] [LOG] [Fast Refresh] done in 28ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 51356ms] [LOG] [Fast Refresh] done in 651ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 51405ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 51574ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 51608ms] [LOG] [Fast Refresh] done in 35ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 51806ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 51873ms] [LOG] [Fast Refresh] done in 67ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 52562ms] [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
|
||||
[ 54272ms] [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
|
||||
[ 54329ms] [LOG] [HMR] connected @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 54477ms] [VERBOSE] [DOM] Input elements should have autocomplete attributes (suggested: "current-password"): (More info: https://goo.gl/9p2vKq) %o @ http://localhost:4001/login:0
|
||||
[ 54557ms] [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
|
||||
[ 55044ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 55353ms] [LOG] [Fast Refresh] done in 311ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 55672ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 55746ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 55759ms] [LOG] [Fast Refresh] done in 14ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 55802ms] [LOG] [Fast Refresh] done in 56ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 55813ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 56079ms] [LOG] [Fast Refresh] done in 267ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 56117ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 56147ms] [LOG] [Fast Refresh] done in 30ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 56826ms] [LOG] [Fast Refresh] done in 709ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 56932ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 57996ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 58068ms] [LOG] [Fast Refresh] done in 71ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
100
.playwright-mcp/console-2026-08-20T06-42-55-620Z.log
Normal file
100
.playwright-mcp/console-2026-08-20T06-42-55-620Z.log
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
[ 497ms] [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
|
||||
[ 586ms] Invalid or unexpected token
|
||||
[ 4897ms] [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
|
||||
[ 4960ms] [LOG] [HMR] connected @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 6811ms] [ERROR] Can't perform a React state update on a component that hasn't mounted yet. This indicates that you have a side-effect in your render function that asynchronously tries to update the component. Move this work to useEffect instead. @ :0
|
||||
[ 6827ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 8392ms] [LOG] [Fast Refresh] done in 1564ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 8418ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 9182ms] [LOG] [Fast Refresh] done in 765ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 9296ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 9503ms] [LOG] [Fast Refresh] done in 208ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 9960ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 10213ms] [LOG] [Fast Refresh] done in 253ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 10405ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 10998ms] [LOG] [Fast Refresh] done in 594ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 11229ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 11748ms] [LOG] [Fast Refresh] done in 519ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 11939ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 12423ms] [LOG] [Fast Refresh] done in 485ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 12691ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 13247ms] [LOG] [Fast Refresh] done in 556ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 13812ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 14030ms] [LOG] [Fast Refresh] done in 217ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 16510ms] [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
|
||||
[ 16753ms] Invalid or unexpected token
|
||||
[ 19528ms] [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
|
||||
[ 19596ms] [LOG] [HMR] connected @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 20857ms] [ERROR] Can't perform a React state update on a component that hasn't mounted yet. This indicates that you have a side-effect in your render function that asynchronously tries to update the component. Move this work to useEffect instead. @ :0
|
||||
[ 20916ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 22456ms] [LOG] [Fast Refresh] done in 1540ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 22512ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 22758ms] [LOG] [Fast Refresh] done in 245ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 23061ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 23477ms] [LOG] [Fast Refresh] done in 415ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 23787ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 23972ms] [LOG] [Fast Refresh] done in 186ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 24143ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 24184ms] [LOG] [Fast Refresh] done in 40ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 24374ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 24801ms] [LOG] [Fast Refresh] done in 426ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 25027ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 25622ms] [LOG] [Fast Refresh] done in 596ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 25951ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 26024ms] [LOG] [Fast Refresh] done in 72ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 26667ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 26728ms] [LOG] [Fast Refresh] done in 62ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 27073ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 27426ms] [LOG] [Fast Refresh] done in 353ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 27716ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 28658ms] [LOG] [Fast Refresh] done in 942ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 29180ms] Invalid or unexpected token
|
||||
[ 29182ms] [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
|
||||
[ 30906ms] [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
|
||||
[ 31016ms] [LOG] [HMR] connected @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 31677ms] [ERROR] Can't perform a React state update on a component that hasn't mounted yet. This indicates that you have a side-effect in your render function that asynchronously tries to update the component. Move this work to useEffect instead. @ :0
|
||||
[ 31691ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 32285ms] [LOG] [Fast Refresh] done in 595ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 32301ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 32811ms] [LOG] [Fast Refresh] done in 510ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 32922ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 32978ms] [LOG] [Fast Refresh] done in 56ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 34288ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 34475ms] [LOG] [Fast Refresh] done in 188ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 34742ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 34798ms] [LOG] [Fast Refresh] done in 56ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 35821ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 36031ms] [LOG] [Fast Refresh] done in 211ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 36449ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 36538ms] [LOG] [Fast Refresh] done in 90ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 36722ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 36755ms] [LOG] [Fast Refresh] done in 33ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 36926ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 36994ms] [LOG] [Fast Refresh] done in 69ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 37364ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 37529ms] [LOG] [Fast Refresh] done in 165ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 37815ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 37879ms] [LOG] [Fast Refresh] done in 63ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 38560ms] [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
|
||||
[ 40336ms] [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
|
||||
[ 40509ms] [LOG] [HMR] connected @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 40895ms] [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
|
||||
[ 41401ms] [VERBOSE] [DOM] Input elements should have autocomplete attributes (suggested: "current-password"): (More info: https://goo.gl/9p2vKq) %o @ http://localhost:4001/login:0
|
||||
[ 41732ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 41917ms] [LOG] [Fast Refresh] done in 186ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 42164ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 42235ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 42252ms] [LOG] [Fast Refresh] done in 18ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 42300ms] [LOG] [Fast Refresh] done in 65ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 42310ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 42558ms] [LOG] [Fast Refresh] done in 248ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 42568ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 42703ms] [LOG] [Fast Refresh] done in 135ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 43390ms] [LOG] [Fast Refresh] done in 821ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 43436ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 43879ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 43971ms] [LOG] [Fast Refresh] done in 91ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 44208ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 44265ms] [LOG] [Fast Refresh] done in 57ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 44991ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 45710ms] [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
|
||||
85
.playwright-mcp/console-2026-08-20T06-43-42-418Z.log
Normal file
85
.playwright-mcp/console-2026-08-20T06-43-42-418Z.log
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
[ 1240ms] [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
|
||||
[ 2340ms] [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
|
||||
[ 2397ms] [LOG] [HMR] connected @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 2753ms] [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
|
||||
[ 3046ms] [VERBOSE] [DOM] Input elements should have autocomplete attributes (suggested: "current-password"): (More info: https://goo.gl/9p2vKq) %o @ http://localhost:4001/login:0
|
||||
[ 3483ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 3692ms] [LOG] [Fast Refresh] done in 209ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 3961ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 4036ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 4059ms] [LOG] [Fast Refresh] done in 24ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 4109ms] [LOG] [Fast Refresh] done in 73ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 4122ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 4577ms] [LOG] [Fast Refresh] done in 456ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 4649ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 4744ms] [LOG] [Fast Refresh] done in 94ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 5799ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 5886ms] [LOG] [Fast Refresh] done in 87ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 6935ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 7151ms] [LOG] [Fast Refresh] done in 216ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 7355ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 7663ms] [LOG] [Fast Refresh] done in 307ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 7803ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 8077ms] [LOG] [Fast Refresh] done in 273ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 8244ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 8389ms] [LOG] [Fast Refresh] done in 144ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 10233ms] [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
|
||||
[ 10387ms] Invalid or unexpected token
|
||||
[ 14406ms] [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
|
||||
[ 14518ms] [LOG] [HMR] connected @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 14533ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 15309ms] [ERROR] Can't perform a React state update on a component that hasn't mounted yet. This indicates that you have a side-effect in your render function that asynchronously tries to update the component. Move this work to useEffect instead. @ :0
|
||||
[ 15325ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 15876ms] [LOG] [Fast Refresh] done in 551ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 15955ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 16089ms] [LOG] [Fast Refresh] done in 134ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 16313ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 16373ms] [LOG] [Fast Refresh] done in 60ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 16587ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 16705ms] [LOG] [Fast Refresh] done in 118ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 16983ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 17039ms] [LOG] [Fast Refresh] done in 55ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 17403ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 17583ms] [LOG] [Fast Refresh] done in 180ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 18050ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 18150ms] [LOG] [Fast Refresh] done in 100ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 19019ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 19113ms] [LOG] [Fast Refresh] done in 93ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 19397ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 19471ms] [LOG] [Fast Refresh] done in 75ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 19732ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 19849ms] [LOG] [Fast Refresh] done in 116ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 21168ms] [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
|
||||
[ 23378ms] [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
|
||||
[ 23432ms] [LOG] [HMR] connected @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 23612ms] [VERBOSE] [DOM] Input elements should have autocomplete attributes (suggested: "current-password"): (More info: https://goo.gl/9p2vKq) %o @ http://localhost:4001/login:0
|
||||
[ 23617ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 23808ms] [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
|
||||
[ 24437ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 24864ms] [LOG] [Fast Refresh] done in 427ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 25201ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 25312ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 25353ms] [LOG] [Fast Refresh] done in 41ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 25413ms] [LOG] [Fast Refresh] done in 100ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 25426ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 25948ms] [LOG] [Fast Refresh] done in 523ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 26013ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 26192ms] [LOG] [Fast Refresh] done in 179ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 27177ms] [LOG] [Fast Refresh] done in 1164ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 27231ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 28177ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 28341ms] [LOG] [Fast Refresh] done in 164ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 28588ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 28640ms] [LOG] [Fast Refresh] done in 53ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 37920ms] [WARNING] The resource http://localhost:4001/_next/static/media/6d664cce900333ee-s.p.woff2?v=1787208242925 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/login:0
|
||||
[ 37920ms] [WARNING] The resource http://localhost:4001/_next/static/media/558ca1a6aa3cb55e-s.p.woff2?v=1787208242925 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/login:0
|
||||
[ 37921ms] [WARNING] The resource http://localhost:4001/_next/static/media/e4af272ccee01ff0-s.p.woff2?v=1787208242925 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/login:0
|
||||
[ 37921ms] [WARNING] The resource http://localhost:4001/_next/static/css/app/layout.css?v=1787208247695 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/login:0
|
||||
[ 37921ms] [WARNING] The resource http://localhost:4001/_next/static/media/eaead17c7dbfcd5d-s.p.woff2?v=1787208242925 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/login:0
|
||||
[ 37922ms] [WARNING] The resource http://localhost:4001/_next/static/media/7b0b24f36b1a6d0b-s.p.woff2?v=1787208242925 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/login:0
|
||||
[ 37922ms] [WARNING] The resource http://localhost:4001/_next/static/media/13971731025ec697-s.p.woff2?v=1787208242925 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/login:0
|
||||
[ 37922ms] [WARNING] The resource http://localhost:4001/_next/static/media/22a5144ee8d83bca-s.p.woff2?v=1787208242925 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/login:0
|
||||
[ 37922ms] [WARNING] The resource http://localhost:4001/_next/static/media/36966cca54120369-s.p.woff2?v=1787208242925 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/login:0
|
||||
[ 37922ms] [WARNING] The resource http://localhost:4001/_next/static/media/ee40bb094c99a29a-s.p.woff2?v=1787208242925 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/login:0
|
||||
[ 37922ms] [WARNING] The resource http://localhost:4001/_next/static/media/7d4881bb7e1bf84d-s.p.woff2?v=1787208242925 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/login:0
|
||||
[ 37922ms] [WARNING] The resource http://localhost:4001/_next/static/media/4de1fea1a954a5b6-s.p.woff2?v=1787208242925 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/login:0
|
||||
3
.playwright-mcp/page-2026-08-20T06-42-01-523Z.yml
Normal file
3
.playwright-mcp/page-2026-08-20T06-42-01-523Z.yml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
- generic [active] [ref=e1]:
|
||||
- region "Notifications alt+T"
|
||||
- button "Open Next.js Dev Tools" [ref=e9] [cursor=pointer]
|
||||
3
.playwright-mcp/page-2026-08-20T06-42-34-924Z.yml
Normal file
3
.playwright-mcp/page-2026-08-20T06-42-34-924Z.yml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
- generic [active] [ref=f2e1]:
|
||||
- region "Notifications alt+T"
|
||||
- button "Open Next.js Dev Tools" [ref=f2e9] [cursor=pointer]
|
||||
2
.playwright-mcp/page-2026-08-20T06-42-58-756Z.yml
Normal file
2
.playwright-mcp/page-2026-08-20T06-42-58-756Z.yml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
- generic [active] [ref=f5e1]:
|
||||
- region "Notifications alt+T"
|
||||
3
.playwright-mcp/page-2026-08-20T06-43-00-617Z.yml
Normal file
3
.playwright-mcp/page-2026-08-20T06-43-00-617Z.yml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
- generic [active] [ref=f5e1]:
|
||||
- region "Notifications alt+T"
|
||||
- button "Open Next.js Dev Tools" [ref=f5e9] [cursor=pointer]
|
||||
4
.playwright-mcp/page-2026-08-20T06-43-44-976Z.yml
Normal file
4
.playwright-mcp/page-2026-08-20T06-43-44-976Z.yml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
- generic [active] [ref=f10e1]:
|
||||
- region "Notifications alt+T"
|
||||
- button "Open Next.js Dev Tools" [ref=f10e7] [cursor=pointer]
|
||||
- alert [ref=f10e11]
|
||||
31
.playwright-mcp/page-2026-08-20T06-43-47-532Z.yml
Normal file
31
.playwright-mcp/page-2026-08-20T06-43-47-532Z.yml
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
- generic [active] [ref=f10e1]:
|
||||
- generic [ref=f10e13]:
|
||||
- generic [ref=f10e15]:
|
||||
- img "WFDASH" [ref=f10e17]
|
||||
- generic [ref=f10e18]: WFDASH
|
||||
- generic [ref=f10e19]: Business CRM
|
||||
- generic [ref=f10e21]:
|
||||
- tablist [ref=f10e22]:
|
||||
- tab "Login" [selected] [ref=f10e23]
|
||||
- tab "Register" [ref=f10e24]
|
||||
- tab "Reset" [ref=f10e25]
|
||||
- tabpanel "Login" [ref=f10e26]:
|
||||
- generic [ref=f10e27]:
|
||||
- generic [ref=f10e28]:
|
||||
- generic [ref=f10e29]: Email
|
||||
- textbox "Email" [ref=f10e30]:
|
||||
- /placeholder: you@example.com
|
||||
- text: david.els1@outlook.com
|
||||
- generic [ref=f10e31]:
|
||||
- generic [ref=f10e32]: Password
|
||||
- textbox "Password" [ref=f10e33]: "1234567890"
|
||||
- button "Sign In" [ref=f10e34]
|
||||
- region "Notifications alt+T"
|
||||
- button "Open Next.js Dev Tools" [ref=f10e39] [cursor=pointer]:
|
||||
- generic [ref=f10e42]:
|
||||
- text: Rendering
|
||||
- generic [ref=f10e43]:
|
||||
- generic [ref=f10e44]: .
|
||||
- generic [ref=f10e45]: .
|
||||
- generic [ref=f10e46]: .
|
||||
- alert [ref=f10e11]
|
||||
431
docs/audit/order-pipeline-investigation-2026-08-20.md
Normal file
431
docs/audit/order-pipeline-investigation-2026-08-20.md
Normal file
|
|
@ -0,0 +1,431 @@
|
|||
# Order Pipeline Investigation — 2026-08-20
|
||||
|
||||
Scope: investigate (1) hit-and-miss `contact_name` population on orders,
|
||||
(2) duplicate-order creation when the same RFQ email arrives more than
|
||||
once, and (3) general email-intake failures. Audit covers the n8n
|
||||
workflows, the WFDASH Next.js app, and the live PocketBase data.
|
||||
|
||||
## TL;DR
|
||||
|
||||
| Symptom | Root cause | Severity |
|
||||
|---|---|---|
|
||||
| 84.6% of auto-created orders are duplicates of the same email | The IMAP poll keeps returning the same message every 5 minutes; the workflow has no `email_message_id` dedup check before `pb.collection('orders').create(...)` | **Critical** |
|
||||
| `contact_name` is "hit and miss" (sometimes "Neville Benadé", sometimes "Nbenade\n", sometimes `\n`) | The contact name is sourced from a non-deterministic LLM call (no temperature cap, no fallback to deterministic email-signature parsing); the same email produces a different name on every poll cycle | **High** |
|
||||
| The 9 most recent orders (2026-08-19) have empty `email_message_id` / `email_subject` | Those are counter-created orders — the post-create email field is not set on the manual `/counter` form. Not a bug, but it makes the "duplicate by msgid" dedup path useless for counter orders | **Low** |
|
||||
| "Some emails fail" — orders created with `NO-REF`, `[NEEDS REVIEW]`, or `description = '[NEEDS REVIEW] AUTO-FLAGGED: ...'` | The AI Agent returns `order_items = []` whenever the PDF text extraction can't find a clean line-items table. The workflow then takes the "no items extracted" branch which writes a flag and a raw blob, but the *customer* / `contact_name` / `order_number` come from the same non-deterministic AI call so they may still be wrong | **Medium** |
|
||||
|
||||
The dedup mechanism that already exists (`POST /api/v1/orders/duplicates/cancel`) is **reactive** — it finds duplicates after the fact and lets the operator cancel them. Nothing prevents the duplicates from being created in the first place.
|
||||
|
||||
---
|
||||
|
||||
## 1. Pipeline overview
|
||||
|
||||
### Active order-related n8n workflows (as of 2026-08-19 23:46 UTC)
|
||||
|
||||
| ID | Name | Trigger | Purpose |
|
||||
|---|---|---|---|
|
||||
| `DWkxko0SS1uoelsg` | **WFDASH — ORDER MONITOR (Unified)** | Schedule (5 min) | The only pipeline that creates `orders` records. Polls IMAP, classifies, extracts via AI Agent, creates order + items. |
|
||||
| `DWkxko0SS1uoelsg` (multi branch) | (same workflow) | (same) | The `(Multi)` branch handles multi-PO emails by splitting attachments by PO number detected in subject/body. |
|
||||
| `O0yDcNVHEhA0Dd5n` | WFDASH — Multi-Order Fulfillment Pipeline | (other) | Creates `quotes` (not orders) for multi-PO emails via `/api/v1/quotes/from-email`. |
|
||||
| `45h7IYegCmhtkV9t` | WFDASH — Multi-Order RFQ Fulfillment Pipeline | Schedule (2 min) | Creates `quotes` (not orders) for multi-PO emails via `/api/v1/quotes/from-email`. |
|
||||
| `mXX2yu5svRurnfwO` | WFDASH - CATEGORISE ORDERS ITEMS | Schedule (1 min) | Backfills `ai_item_insights` and category enums. Does **not** create orders. |
|
||||
| `paqlZICDbXbmY61V` | WFDASH — Populate Item Intel (Live) | Schedule | Item intel backfill. |
|
||||
| `UqO3ayETx9cQHOzF` | WFDASH — Purchase Order Send | Webhook | Sends PO emails. |
|
||||
| `GGS5zgVbdfsRyLcY` | ORDER-MARKUP-APPROVAL-LINKS | Webhook | WhatsApp/email markup-approval links. |
|
||||
| `kVylcetxy858PcfP` | WFDASH — Error Handler | Error trigger | Telegram error alerts. |
|
||||
| `aI0Ze2vwgp25ZgGH` | WFDASH — Error Notifier (Telegram) | Error trigger | Telegram error alerts. |
|
||||
| `DDFN9NBmh95yG2Jk` | WFDASH — AI Insights Generator (v2) | Webhook | Re-runs AI insights on demand. |
|
||||
|
||||
The ORDER MONITOR is the only thing that creates `orders` records from email. Its flow is:
|
||||
|
||||
```
|
||||
Schedule Trigger (every 5 min)
|
||||
-> CHECK FOR NEW MAIL (IMAP read, seen:false)
|
||||
-> SetEmailFlags (mark as seen immediately)
|
||||
-> Classify Email (Deterministic) (bounce / supplier / multi_order / customer / uncertain)
|
||||
-> Route Email (Switch) (route to one of 5 branches)
|
||||
-> Limit (limit to 1 — but parameters: {}, so probably a no-op)
|
||||
-> Download ATTACHMENT
|
||||
-> Split Attachments1
|
||||
-> Switch1 (by MIME type -> PDF / CSV / XLS / XLSX / TXT / image)
|
||||
-> Extract PDF / CSV / XLS / XLSX / TXT (or Analyze image for image attachments)
|
||||
-> Merge2
|
||||
-> Code in JavaScript4 (combine all extracted text)
|
||||
-> AI Agent (LLM extraction -> JSON { order_info, order_items })
|
||||
-> If1 (if order_items.length > 0 -> single-order branch)
|
||||
-> Search customers (PB getList by name/email fuzzy)
|
||||
-> Create orders (PB create on orders collection)
|
||||
-> Code in JavaScript2 (split order_items -> order_items rows)
|
||||
-> Create order_items1 (PB create per item)
|
||||
-> Aggregate
|
||||
-> Merge1 + PocketBase1 (none) + MoveEmail email2 (move to INBOX.202608.Completed)
|
||||
```
|
||||
|
||||
The single-order and multi-order branches converge at `Merge order completion` and
|
||||
finally move the email to `INBOX.202608.Completed` (or `Failed` on the failure
|
||||
branch).
|
||||
|
||||
### PocketBase `orders` schema (live — `pbc_3527180448`)
|
||||
|
||||
30 fields, **0 indexes**, **1 unique constraint** (the implicit primary key).
|
||||
Relevant fields:
|
||||
|
||||
- `order_number` (text, required) — no unique constraint
|
||||
- `status` (select: `new` / `processing` / `processed` / `cancelled`)
|
||||
- `company` (relation, required)
|
||||
- `customer` (relation, optional)
|
||||
- `contact_name` (text, optional) — written by the `Create orders` n8n node from the AI agent's `order_info.contact_name`
|
||||
- `email_subject`, `email_body`, `email_sender`, `email_message_id` (text, optional)
|
||||
- `rfq_number`, `description`, `notes`, `rep` (text, optional)
|
||||
|
||||
The n8n `Create orders` node writes `email_message_id = $('Limit').item.json.headers['message-id']` — but the field is **read from the email header**, not from the actual Message-ID envelope of the IMAP fetch. If the IMAP proxy / email provider rewrites the header, this might come back empty or differ from what `emailMessageId` exposes elsewhere.
|
||||
|
||||
---
|
||||
|
||||
## 2. The duplicate-order problem
|
||||
|
||||
### What the data shows
|
||||
|
||||
500 most-recent auto-created orders queried on 2026-08-19 23:46 UTC:
|
||||
|
||||
- **423 / 500 (84.6%)** are duplicates — i.e. the same `email_message_id` appears on 2 or more `orders` records
|
||||
- **50 / 127 distinct message_ids** are duplicated (the rest are singletons)
|
||||
- The worst case: a single email `<GV4PR05MB12159B25B35D2BD7FA50838D4D1A62@...>` produced **138 duplicate orders** — one every 5 minutes from 15:31 to 17:51 UTC on 2026-08-18, all with `order_number = ZA02PO26002142`
|
||||
- Several emails have **9–10 duplicates created in the same second** (e.g. `MSP17933` at 19:10:27–19:10:28, `MSP17934` at 19:15:31). The same-second pattern means multiple workflow executions are processing the same email concurrently — the IMAP poll's `seen:false` filter is not preventing parallel runs from picking it up
|
||||
|
||||
### Why it's happening
|
||||
|
||||
Looking at the ORDER MONITOR (`DWkxko0SS1uoelsg`):
|
||||
|
||||
1. **Schedule Trigger** fires every 5 minutes (`{"field":"minutes"}` with no
|
||||
value, n8n defaults to 5min — confirmed by the 5-min spacing in execution
|
||||
log `56337 (23:45)` -> `56320 (23:40)` -> `56301 (23:35)`).
|
||||
2. **CHECK FOR NEW MAIL** (IMAP-enhanced node) — fetches every email with
|
||||
`seen: false` from `INBOX`. Include parts `textContent, htmlContent,
|
||||
markdownContent, attachmentsInfo, flags, size, bodyStructure, headers`.
|
||||
3. **SetEmailFlags (mark on fetch)** — marks the fetched email as `Seen`
|
||||
immediately. This is the ONLY thing preventing the same email from being
|
||||
picked up again on the next 5-min tick.
|
||||
4. **Limit** — parameters `{}`. Confirmed by inspecting the workflow JSON.
|
||||
In n8n the Limit node with no `maxItems` defaults to 1, but the
|
||||
"merge-2" + multi-attachment paths also feed back into it, so this limit
|
||||
is unreliable across branches.
|
||||
5. **Create orders** — the `n8n-nodes-pocketbase.pocketbaseHttp` node does
|
||||
`pb.collection('orders').create({...})` with no pre-check for an
|
||||
existing record with the same `email_message_id`.
|
||||
|
||||
Three things have to be true for duplicates to happen, and they all are:
|
||||
|
||||
- **A.** The IMAP poll keeps returning the same email. It happens because:
|
||||
the `seen:false` flag is set in the SAME workflow execution that processes
|
||||
the email — but if the workflow is retried (e.g. n8n re-triggers it after a
|
||||
transient error), or if the user reloads the IMAP mailbox and the IMAP
|
||||
server returns the same UID before the seen flag is committed, the next
|
||||
poll picks it up again. Same-second duplicates (9 in `19:10:27–19:10:28`)
|
||||
mean the workflow is running in parallel for the same email.
|
||||
- **B.** There is no server-side constraint preventing it. The PocketBase
|
||||
`orders` collection has **no unique index** on `email_message_id` and
|
||||
no application-level pre-check in `createOrder` (`src/lib/server/orders-service.ts:1828`).
|
||||
`pb.collection('orders').create({...})` will happily insert a duplicate.
|
||||
- **C.** The 5-min polling cadence multiplies the damage: every 5 minutes
|
||||
the same email gets reprocessed, and every cycle the AI extracts a
|
||||
slightly different `contact_name` (see §3) and a slightly different
|
||||
`order_number` if the AI misses the PO number on that particular call
|
||||
(the duplicate `NO-REF` records are the AI failing to extract the PO).
|
||||
|
||||
### Mitigations That Already Exist (Reactive Only)
|
||||
|
||||
- **POST /api/v1/orders/duplicates** (`src/app/api/v1/orders/duplicates/route.ts`)
|
||||
— `findDuplicateOrders()` groups by `order_number` (within 60s) and by a
|
||||
line-aggregate heuristic. Returns the grouped list. The UI
|
||||
(`src/components/duplicate-orders-dialog.tsx`) lets the operator bulk-cancel.
|
||||
- **POST /api/v1/orders/{id}/cancel** — soft-cancels an order (all items
|
||||
flip to `cancelled`, record stays in DB for audit).
|
||||
|
||||
These are **post-creation** tools. They do not prevent the duplicates from
|
||||
appearing in the first place.
|
||||
|
||||
### Fixes (proposed)
|
||||
|
||||
**A. Add a unique constraint on `email_message_id` in PB.** This is the
|
||||
most robust fix — it makes the database reject duplicate inserts. Since the
|
||||
field is currently `required: false`, the migration is:
|
||||
|
||||
1. In the PB admin: index `CREATE UNIQUE INDEX idx_orders_email_message_id
|
||||
ON orders (email_message_id) WHERE email_message_id != ''` (or a
|
||||
partial unique index — Postgres/SQLite both support it).
|
||||
2. Backfill: cancel the existing 423 duplicates (or use the existing
|
||||
`cancelOrdersBulk` op) so the unique constraint can be created.
|
||||
3. In the ORDER MONITOR workflow, **before** `Create orders`, add a
|
||||
`Search orders (email_message_id = X)` step. If a record exists,
|
||||
short-circuit and move the email to `INBOX.202608.Completed` with a
|
||||
`replay_skipped` flag in the post-create log. This makes the workflow
|
||||
idempotent even if the IMAP poll does fire on the same email.
|
||||
|
||||
**B. In the app, harden `createOrder` to refuse duplicates.** With a
|
||||
unique constraint in place, the second `create()` will throw a 409 — wrap
|
||||
the `pb.collection('orders').create(...)` call in
|
||||
`src/lib/server/orders-service.ts:1828` to catch the constraint violation
|
||||
and treat it as a "duplicate email, skipped" success. The function should
|
||||
return the existing record.
|
||||
|
||||
**C. Tighten the IMAP poll.** Replace the `seen:false` filter with an
|
||||
explicit `since` cursor: store the last-fetched UID in the workflow's
|
||||
`staticData` and only fetch `UID > lastUID`. This works even if the seen
|
||||
flag is reset by a reconnection.
|
||||
|
||||
**D. Move the email ingestion off the 5-min poll.** The poll is the only
|
||||
reason duplicates get 5-min apart. A webhook-based trigger (e.g.
|
||||
Postmark inbound webhook -> Next.js `/api/v1/orders/intake`) would
|
||||
eliminate the polling surface entirely. The CREATE QUOTE FROM EMAIL
|
||||
endpoint (`POST /api/v1/quotes/from-email`) already accepts the same
|
||||
payload, so the work is mostly plumbing.
|
||||
|
||||
---
|
||||
|
||||
## 3. The `contact_name` hit-and-miss problem
|
||||
|
||||
### What the data shows
|
||||
|
||||
Same email `<GV4PR05MB12159B25B35D2BD7FA50838D4D1A62@...>` (ZA02PO26002142
|
||||
from Nbenade@masterdrilling.com) produced these `contact_name` values across
|
||||
its 138 duplicate orders:
|
||||
|
||||
```
|
||||
Nbenade\n
|
||||
Nbenade\n
|
||||
N. Benade\n
|
||||
Nicole Benade\n
|
||||
Nbenade\n
|
||||
Not specified\n
|
||||
Nbenade\n
|
||||
Drilling Technical Services (PTY) Ltd\n
|
||||
Nbenade\n
|
||||
Nico Benade\n
|
||||
N. Benade\n
|
||||
Nbenade\n
|
||||
Nbenade\n
|
||||
```
|
||||
|
||||
Plus empty-string `""` records (`~8% of the same-email sample`). Some
|
||||
records have literally `"\n"` as the contact name — the AI returned JSON
|
||||
like `{"contact_name": "\n"}` and it was stored verbatim.
|
||||
|
||||
The relevant emails are simple:
|
||||
|
||||
```
|
||||
Good Day.
|
||||
|
||||
Please see attached copy of the order, please deliver soonest possible date.
|
||||
|
||||
Regards
|
||||
```
|
||||
|
||||
There is no signature in the email body for the AI to extract from. The
|
||||
PO attachment is what contains the actual name (Neville Benadé), and the
|
||||
AI Agent prompt requires it to look at the attachments first — but it's
|
||||
not deterministic about what it picks out.
|
||||
|
||||
### Why it's "hit and miss"
|
||||
|
||||
The contact_name pipeline:
|
||||
|
||||
1. **AI Agent** (`DWkxko0SS1uoelsg` "AI Agent" node) — Anthropic LLM call
|
||||
with `hasOutputParser: true`. The system prompt:
|
||||
- "Extract the actual End-User or Requester of the goods (Look for
|
||||
fields like 'Requester', 'Deliver To', 'Attention', or names in the
|
||||
Notes). Do NOT use the 'Buyer' or 'Created By' name if a Requester is
|
||||
listed."
|
||||
- There is **no `temperature` override** — Claude's default is 1.0.
|
||||
- The LLM is non-deterministic by design and the prompt does not
|
||||
enforce a JSON retry on invalid output.
|
||||
2. **Search customers** (PB getList) — builds a filter from the LLM's
|
||||
`contact_name`:
|
||||
```js
|
||||
const nameKeywords = name
|
||||
.replace(/mr\.|mrs\.|ms\.|mr|mrs|ms/gi, '')
|
||||
.split(/[\s,]+/)
|
||||
.filter(w => w.length > 2);
|
||||
```
|
||||
- `replace(/mr\.|.../gi, '')` only strips the literal prefix
|
||||
"Mr./Mrs./Ms." — it doesn't strip "Mr " (no dot) or "N." (no Mr.)
|
||||
- `.filter(w => w.length > 2)` drops keywords of length ≤ 2. So
|
||||
"N. Benade" -> ["Benade"], "Nbenade" -> ["Nbenade"], "Neville Benadé"
|
||||
-> ["Neville", "Benadé"].
|
||||
- The `customer ~ 'X'` substring filter then matches the customer
|
||||
collection. If the AI returned "Nbenade" and the customer is
|
||||
"Neville Benadé", the substring match **succeeds** because "Nbenade"
|
||||
is a substring of no one — but the search is fuzzy enough to return
|
||||
multiple matches (the `elementsPerPage: 12` returns up to 12).
|
||||
3. **Create orders** — `customer = $('Search customers').item.json?.id ||
|
||||
'xmd68ps32k1x5fu'` — the first match wins. If the AI returned names
|
||||
that don't match any customer, the fallback ID is the placeholder
|
||||
`xmd68ps32k1x5fu` (which is "Unknown - needs review" in the customers
|
||||
table).
|
||||
4. **Page rendering** — `src/app/(app)/orders/page.tsx:942`:
|
||||
```tsx
|
||||
{order.contact_name && (
|
||||
<span>{order.contact_name}</span>
|
||||
)}
|
||||
```
|
||||
Empty string `""` is falsy -> row shows no name. Newline `"\n"` is
|
||||
truthy -> row shows just a separator dot with nothing after it. The
|
||||
operator sees "GARY-JV-MR · Master Drilling · ·" with a dangling
|
||||
separator and no name — clearly "miss".
|
||||
|
||||
### Fixes (proposed)
|
||||
|
||||
**A. Stop using the LLM for the contact name.** The email body has a
|
||||
signature; the PDF has a "Requester:" line. Both are deterministic. Add a
|
||||
pre-LLM regex pass:
|
||||
|
||||
1. Parse the email body for the signature (everything after the last
|
||||
`Regards` / `Kind regards` / `Best` / `Thanks` block, or the last 5
|
||||
non-empty lines).
|
||||
2. Parse the PDF text for `Requester:`, `Deliver To:`, `Attention:`,
|
||||
`End-User:`. The first one is the contact name.
|
||||
3. Only call the LLM if both fail.
|
||||
|
||||
If a deterministic parse succeeds, the LLM is skipped entirely — the
|
||||
hit/miss disappears.
|
||||
|
||||
**B. If you keep the LLM, constrain it.** Add to the system prompt:
|
||||
- "If you cannot find a Requester/Deliver-To/Attention name, return the
|
||||
empty string — do NOT return placeholder text like 'Unknown', 'Not
|
||||
specified', or the company name."
|
||||
- Emit `contact_name = ""` (not `"\n"`) when uncertain. The page's
|
||||
truthy-check will hide it cleanly.
|
||||
- Set `temperature: 0` on the Anthropic node. The LLM is still
|
||||
non-deterministic for the same prompt + temperature, but the variance
|
||||
drops substantially.
|
||||
|
||||
**C. Post-process the LLM output.** Wrap the AI Agent call in a Code
|
||||
node that:
|
||||
- trims trailing newlines (`s.replace(/\s+$/,'')`)
|
||||
- validates the result against the customers collection (search by
|
||||
email first, then by name) — if a `customer` row exists with the same
|
||||
`email`, use `customer.customer` as the contact name regardless of what
|
||||
the LLM said.
|
||||
|
||||
**D. Move the display sanitisation into the renderer.** Even if the LLM
|
||||
keeps hallucinating newlines and trailing whitespace, the row should not
|
||||
show a dangling separator. Patch `order.contact_name` to a JSX-safe
|
||||
display:
|
||||
|
||||
```tsx
|
||||
const displayContact = (order.contact_name || "").trim();
|
||||
{displayContact && <span>· {displayContact}</span>}
|
||||
```
|
||||
|
||||
This is a one-line fix and removes the visible "miss" symptom even if
|
||||
the data is still messy.
|
||||
|
||||
---
|
||||
|
||||
## 4. The "some emails fail" question
|
||||
|
||||
Looking at the live data, two failure modes are visible:
|
||||
|
||||
### 4a. AI returns empty `contact_name` / `order_items`
|
||||
|
||||
The `If1` / `If1 (Multi)` branches check `$json.output.order_items.length`
|
||||
and route:
|
||||
|
||||
- `<= 0` -> "no items extracted" branch -> `Create order (no items extracted - raw)` / `Create order (single - no items - raw)` / `Create order (no PO detected - raw)`. The resulting order has:
|
||||
- `description = "[NEEDS REVIEW] AUTO-FLAGGED: AI Agent could not extract any line items for this PO. Raw extracted text: ..."`
|
||||
- the raw extracted text dumped in the description
|
||||
- the email still moved to `INBOX.202608.Completed`
|
||||
|
||||
This is the right fallback — the operator sees the flag. But the customer
|
||||
search may still be wrong because the LLM returned garbage for the contact
|
||||
name (see §3). The order lands on the board with a flag, but the
|
||||
customer/contact fields may be incorrect.
|
||||
|
||||
### 4b. AI returns "Unknown" / "Not specified" / company name as contact
|
||||
|
||||
Same root cause as §3. The LLM hallucinates an answer instead of returning
|
||||
empty. The Search customers query then searches for "Unknown" or "Not
|
||||
specified" or "Drilling Technical Services" — and finds the company
|
||||
record instead of the customer record. The order is created with the
|
||||
**company** as the customer, which is the wrong relation.
|
||||
|
||||
### 4c. The IMAP poll loses emails
|
||||
|
||||
When the IMAP mailbox is polled, the `seen:false` filter is set in the
|
||||
SAME workflow execution as the processing. If the workflow crashes
|
||||
(retry, timeout, network error, PocketBase 500), the email is still
|
||||
marked as seen but never processed. The next 5-min poll won't pick it up.
|
||||
This is silent failure — the email is in limbo until the operator manually
|
||||
checks the inbox.
|
||||
|
||||
There is no `onError` feedback to the ORDER MONITOR. The "Error Handler"
|
||||
n8n workflow (`kVylcetxy858PcfP`) only catches errors from workflows that
|
||||
have the error workflow set. ORDER MONITOR does not.
|
||||
|
||||
### Fixes (proposed)
|
||||
|
||||
**A. Make the email flag roll back on error.** Wrap the `Create orders`
|
||||
+ `Create order_items1` chain in a Rollback node: if either fails, set
|
||||
`Seen = false` on the email so the next poll picks it up.
|
||||
|
||||
**B. Mark the workflow as error-to-Workflow.** In n8n workflow Settings,
|
||||
set `errorWorkflow: kVylcetxy858PcfP` so the Telegram error notifier
|
||||
fires on any failure.
|
||||
|
||||
**C. Add a sender-domain blacklist to the deterministic classifier.** The
|
||||
classifier already has `supplierDomains = ['festo.com']` but that's
|
||||
incomplete — most of the duplicates come from
|
||||
`masterdrilling.com`, `mreee.co.za`, `harmony.co.za`. Add a `customer`
|
||||
domain list (the inverse of supplier). The classifier already routes
|
||||
"supplier" -> ignored. Mirror it with "customer" -> process, but make sure
|
||||
the operator can override the list.
|
||||
|
||||
**D. Add a fallback "create order from raw email" path.** If the AI
|
||||
Agent fails to extract structured data after N retries, the workflow
|
||||
should still create an order with the raw email body as the description
|
||||
and a flag. This is already the "no items extracted" branch — but verify
|
||||
the branch is reachable for ALL routes (not just the customer branch).
|
||||
|
||||
---
|
||||
|
||||
## 5. Recommended fixes, ranked by impact / effort
|
||||
|
||||
| # | Fix | Impact | Effort |
|
||||
|---|---|---|---|
|
||||
| 1 | **Add UNIQUE index on `orders.email_message_id`** in PB, backfill the 423 existing duplicates via the bulk-cancel tool, and pre-check in `createOrder` | Stops the duplicate flood at the DB level | 2 hours (admin UI + service-layer try/catch) |
|
||||
| 2 | **Add a "Search orders by email_message_id" pre-check** in the ORDER MONITOR workflow before `Create orders` | Stops duplicates even if the IMAP poll is unreliable | 30 min in n8n |
|
||||
| 3 | **Deterministic contact_name extraction** (regex signature + PO requester line) before the LLM call | Eliminates the "hit and miss" name pop | 1 day (depends on how many PO formats need handling) |
|
||||
| 4 | **Constrain the LLM**: temperature 0, "return empty string when uncertain" rule | Reduces LLM hallucination | 15 min |
|
||||
| 5 | **Renderer sanitisation**: trim `contact_name` and only render when non-empty | Hides the visible "miss" symptom with no data loss | 5 min |
|
||||
| 6 | **Replace IMAP poll with a webhook intake** (Postmark inbound -> `/api/v1/orders/intake`) | Eliminates the 5-min polling surface entirely | 1 week (depends on the email provider) |
|
||||
| 7 | **Wire the ORDER MONITOR to the error workflow** | Surfaces failures on Telegram instead of silently losing emails | 15 min |
|
||||
| 8 | **Roll back Seen flag on workflow error** | Recovers emails that fail to process | 30 min in n8n |
|
||||
|
||||
If we only do one thing: **#1 + #2** together. They stop the bleeding. If
|
||||
we do two more: **#3 + #5** clean up the visible UX. #6 is the long-term
|
||||
fix that fixes all polling-class bugs at once.
|
||||
|
||||
---
|
||||
|
||||
## 6. Files / artefacts that were touched or referenced
|
||||
|
||||
- n8n workflows (live, active): `DWkxko0SS1uoelsg`, `O0yDcNVHEhA0Dd5n`, `45h7IYegCmhtkV9t`, `mXX2yu5svRurnfwO`, `UqO3ayETx9cQHOzF`, `GGS5zgVbdfsRyLcY`, `kVylcetxy858PcfP`, `aI0Ze2vwgp25ZgGH`, `DDFN9NBmh95yG2Jk`
|
||||
- App sources: `src/app/(app)/orders/page.tsx`, `src/components/duplicate-orders-dialog.tsx`, `src/app/api/v1/orders/duplicates/route.ts`, `src/app/api/v1/orders/create/route.ts`, `src/lib/server/orders-service.ts` (createOrder, findDuplicateOrders)
|
||||
- PocketBase collections: `orders` (pbc_3527180448), `customers` (pbc_108570809), `companies` (pbc_3866053794)
|
||||
- Docs: `docs/order-workflow-rules.md`, `docs/AGENT_INSTRUCTIONS.md`
|
||||
|
||||
### Query commands used (reproducible)
|
||||
|
||||
```bash
|
||||
# List all orders, look at duplicates
|
||||
curl -H "Authorization: Bearer $AGENT_API_KEY" \
|
||||
"https://wfdash.apphub.co.za/api/v1/orders?perPage=500&sort=-created&fields=id,order_number,contact_name,email_message_id,email_sender,created,email_subject"
|
||||
|
||||
# Get a single n8n workflow's JSON
|
||||
curl -H "X-N8N-API-KEY: $N8N_API_KEY" \
|
||||
"http://n8n-...sslip.io/api/v1/workflows/DWkxko0SS1uoelsg"
|
||||
|
||||
# Get a single execution's data
|
||||
curl -H "X-N8N-API-KEY: $N8N_API_KEY" \
|
||||
"http://n8n-...sslip.io/api/v1/executions/56337?includeData=true"
|
||||
```
|
||||
BIN
repairs-list-mobile.jpg
Normal file
BIN
repairs-list-mobile.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -61,6 +62,12 @@ export default function QuotesArchivePage() {
|
|||
const [view, setView] = useState<QuotesArchiveView>("completed");
|
||||
const [search, setSearch] = useState("");
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
// Path A scoping — the archive search defaults to the recent
|
||||
// window (90 days) so it's fast. The toggle below the search
|
||||
// box lets the operator extend to the full archive when they
|
||||
// really need to (e.g. a customer phoned about a long-closed
|
||||
// order).
|
||||
const [includeOlder, setIncludeOlder] = useState(false);
|
||||
|
||||
const fetchArchive = useCallback(async () => {
|
||||
setLoading(true);
|
||||
|
|
@ -77,6 +84,7 @@ export default function QuotesArchivePage() {
|
|||
view,
|
||||
});
|
||||
if (search.trim()) params.set("search", search.trim());
|
||||
if (includeOlder) params.set("includeOlder", "1");
|
||||
const resp = await fetch(
|
||||
`/api/v1/quotes/archive?${params.toString()}`,
|
||||
{ headers: { Authorization: pbToken }, cache: "no-store" },
|
||||
|
|
@ -98,7 +106,7 @@ export default function QuotesArchivePage() {
|
|||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, search, view]);
|
||||
}, [page, search, view, includeOlder]);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- data fetch on dep change
|
||||
|
|
@ -138,7 +146,7 @@ export default function QuotesArchivePage() {
|
|||
Quotes Archive
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Historical quotes. The main <a href="/quotes" className="underline">quotes page</a> shows
|
||||
Historical quotes. The main <Link href="/quotes" className="underline">quotes page</Link> shows
|
||||
only the active workflow tabs.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -206,24 +214,41 @@ export default function QuotesArchivePage() {
|
|||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
placeholder="Search by quote #, reference, customer, or company…"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
className="pl-9 pr-9"
|
||||
/>
|
||||
{searchInput && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchInput("")}
|
||||
aria-label="Clear search"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center max-w-2xl">
|
||||
<div className="relative w-full sm:max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
placeholder="Search by quote #, reference, customer, or company…"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
className="pl-9 pr-9"
|
||||
/>
|
||||
{searchInput && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchInput("")}
|
||||
aria-label="Clear search"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<label
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer select-none whitespace-nowrap"
|
||||
data-testid="archive-include-older"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeOlder}
|
||||
onChange={(e) => {
|
||||
setIncludeOlder(e.target.checked);
|
||||
setPage(1);
|
||||
}}
|
||||
className="h-4 w-4 rounded border-muted-foreground/40 text-primary focus:ring-primary/30"
|
||||
/>
|
||||
Include older
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
|
|
@ -265,7 +290,16 @@ export default function QuotesArchivePage() {
|
|||
{quotes.length} match{quotes.length === 1 ? "" : "es"} for{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
“{search}”
|
||||
</span>
|
||||
</span>{" "}
|
||||
{includeOlder ? (
|
||||
<span className="text-muted-foreground">
|
||||
(searched the full archive)
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
(searched the last 90 days)
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -154,6 +154,12 @@ export default function QuotesPage() {
|
|||
const [search, setSearch] = useState("");
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const SEARCH_DEBOUNCE_MS = 250;
|
||||
// Path A scoping — when true, the search route bypasses the
|
||||
// 90-day default cutoff and searches the full history. The
|
||||
// operator's common case is "where's the Acme quote I just
|
||||
// did" (default = scoped), but the toggle lets them dig deeper
|
||||
// when a customer phoned about a long-closed order, etc.
|
||||
const [includeOlder, setIncludeOlder] = useState(false);
|
||||
// Aborts superseded fetches so an in-flight "A" request can't race
|
||||
// past a fresh "AC" request and clobber the UI with stale results.
|
||||
const fetchAbortRef = useRef<AbortController | null>(null);
|
||||
|
|
@ -227,6 +233,13 @@ export default function QuotesPage() {
|
|||
q: search.trim(),
|
||||
perPage: "50",
|
||||
});
|
||||
// Path A scoping: the server defaults to the recent window
|
||||
// (90 days). The page surfaces an "Include older" toggle for
|
||||
// the rare case where the operator needs to search the full
|
||||
// history. Without the flag the pushdown is bounded to the
|
||||
// recent window so the search is fast even on the 8.5k-row
|
||||
// collection.
|
||||
if (includeOlder) params.set("includeOlder", "1");
|
||||
// Search bypasses the active tab — matches the legacy
|
||||
// behaviour where every hit across every tab was returned
|
||||
// (the operator is hunting for a specific quote, not
|
||||
|
|
@ -245,6 +258,8 @@ export default function QuotesPage() {
|
|||
}>;
|
||||
totalReturned: number;
|
||||
perTab: Record<string, number>;
|
||||
recencyLimited: boolean;
|
||||
recencyCutoff: string | null;
|
||||
error?: string;
|
||||
};
|
||||
if (!resp.ok) {
|
||||
|
|
@ -341,7 +356,7 @@ export default function QuotesPage() {
|
|||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [page, search, activeTab]);
|
||||
}, [page, search, activeTab, includeOlder]);
|
||||
|
||||
// Debounce `searchInput` -> `search` so the fetch only fires
|
||||
// SEARCH_DEBOUNCE_MS after the operator stops typing. Without
|
||||
|
|
@ -824,6 +839,41 @@ export default function QuotesPage() {
|
|||
error={error}
|
||||
searchPlaceholder="Search by number, customer, company, item, or price…"
|
||||
onSearch={(q) => { setSearchInput(q); }}
|
||||
searchExtras={
|
||||
// Path A scoping — the search defaults to the last 90 days
|
||||
// so it stays bounded and fast. The toggle lets the operator
|
||||
// extend to the full history when they really need to
|
||||
// (a customer phoned about a long-closed order, etc.).
|
||||
// The label echoes the server's `recencyLimited` so the
|
||||
// operator always knows which universe they're searching.
|
||||
<div className="flex items-center gap-3">
|
||||
{searchInput.trim() && (
|
||||
<span
|
||||
className="text-xs text-muted-foreground whitespace-nowrap"
|
||||
data-testid="quotes-search-scope"
|
||||
>
|
||||
{includeOlder
|
||||
? "Searching the full archive"
|
||||
: "Searching the last 90 days"}
|
||||
</span>
|
||||
)}
|
||||
<label
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer select-none whitespace-nowrap"
|
||||
data-testid="quotes-include-older"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeOlder}
|
||||
onChange={(e) => {
|
||||
setIncludeOlder(e.target.checked);
|
||||
setPage(1);
|
||||
}}
|
||||
className="h-4 w-4 rounded border-muted-foreground/40 text-primary focus:ring-primary/30"
|
||||
/>
|
||||
Include older
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
|
|
|
|||
789
src/app/(app)/repairs/[id]/page.tsx
Normal file
789
src/app/(app)/repairs/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,789 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ArrowLeft,
|
||||
RefreshCw,
|
||||
Truck,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Save,
|
||||
Upload,
|
||||
Download,
|
||||
Trash2,
|
||||
Mail,
|
||||
Image as ImageIcon,
|
||||
StickyNote,
|
||||
Pencil,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { CustomerSearch } from "@/components/ui/customer-search";
|
||||
import { SupplierSearch } from "@/components/ui/supplier-search";
|
||||
import { OrderSearch } from "@/components/ui/order-search";
|
||||
import type { Customer, Supplier, Repair } from "@/lib/types";
|
||||
|
||||
const statusColors: Record<
|
||||
string,
|
||||
"default" | "secondary" | "destructive" | "outline"
|
||||
> = {
|
||||
new: "outline",
|
||||
assessing: "secondary",
|
||||
quoted: "secondary",
|
||||
approved: "default",
|
||||
in_progress: "secondary",
|
||||
completed: "default",
|
||||
cancelled: "destructive",
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
new: "New",
|
||||
assessing: "Sent to supplier",
|
||||
quoted: "Quote received",
|
||||
approved: "Approved",
|
||||
in_progress: "In progress",
|
||||
completed: "Completed",
|
||||
cancelled: "Cancelled",
|
||||
};
|
||||
|
||||
const ALL_STATUSES = [
|
||||
"new",
|
||||
"assessing",
|
||||
"quoted",
|
||||
"approved",
|
||||
"in_progress",
|
||||
"completed",
|
||||
"cancelled",
|
||||
] as const;
|
||||
|
||||
type RepairDetail = Repair & {
|
||||
expand?: {
|
||||
customer?: Customer;
|
||||
supplier?: Supplier;
|
||||
company?: { id: string; company: string };
|
||||
order?: { id: string; order_number: string };
|
||||
};
|
||||
};
|
||||
|
||||
type FormDataResponse = {
|
||||
customers: Customer[];
|
||||
suppliers: Supplier[];
|
||||
};
|
||||
|
||||
export default function RepairDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [repairId, setRepairId] = useState<string>("");
|
||||
const [repair, setRepair] = useState<RepairDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [lookups, setLookups] = useState<FormDataResponse>({
|
||||
customers: [],
|
||||
suppliers: [],
|
||||
});
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [edit, setEdit] = useState({
|
||||
customer: "",
|
||||
company: "",
|
||||
order: "",
|
||||
supplier: "",
|
||||
serial_no: "",
|
||||
item_description: "",
|
||||
fault_description: "",
|
||||
notes: "",
|
||||
status: "new" as (typeof ALL_STATUSES)[number],
|
||||
});
|
||||
|
||||
const dnUploadRef = useRef<HTMLInputElement>(null);
|
||||
const quoteUploadRef = useRef<HTMLInputElement>(null);
|
||||
const poUploadRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
params.then((p) => setRepairId(p.id));
|
||||
}, [params]);
|
||||
|
||||
const fetchRepair = useCallback(async () => {
|
||||
if (!repairId) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch(`/api/v1/repairs/${repairId}`);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
const data: RepairDetail = await res.json();
|
||||
setRepair(data);
|
||||
setEdit({
|
||||
customer: data.customer ?? "",
|
||||
company: data.company ?? "",
|
||||
order: data.order ?? "",
|
||||
supplier: data.supplier ?? "",
|
||||
serial_no: data.serial_no ?? "",
|
||||
item_description: data.item_description ?? "",
|
||||
fault_description: data.fault_description ?? "",
|
||||
notes: data.notes ?? "",
|
||||
status: (data.status ?? "new") as (typeof ALL_STATUSES)[number],
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load repair");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [repairId]);
|
||||
|
||||
const fetchLookups = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/v1/repairs/form-data");
|
||||
if (!res.ok) return;
|
||||
const data: FormDataResponse = await res.json();
|
||||
setLookups({
|
||||
customers: data.customers ?? [],
|
||||
suppliers: data.suppliers ?? [],
|
||||
});
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- data fetch on dep change
|
||||
fetchRepair();
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- data fetch on dep change
|
||||
fetchLookups();
|
||||
}, [fetchRepair, fetchLookups]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!repairId) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/repairs/${repairId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
customer: edit.customer || null,
|
||||
company: edit.company || null,
|
||||
order: edit.order || null,
|
||||
supplier: edit.supplier || null,
|
||||
serial_no: edit.serial_no || null,
|
||||
item_description: edit.item_description,
|
||||
fault_description: edit.fault_description,
|
||||
notes: edit.notes,
|
||||
status: edit.status,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
toast.success("Repair updated");
|
||||
setEditing(false);
|
||||
fetchRepair();
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to update repair");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendToSupplier = async () => {
|
||||
if (!repairId) return;
|
||||
if (!repair?.supplier) {
|
||||
toast.error("Set a supplier on this repair first.");
|
||||
setEditing(true);
|
||||
return;
|
||||
}
|
||||
setGenerating(true);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/repairs/${repairId}/generate-delivery-note`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
toast.success(
|
||||
`Delivery note ${data.deliveryNoteNumber} generated — driver carries this to the supplier.`,
|
||||
);
|
||||
fetchRepair();
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to generate DN");
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadFile = async (
|
||||
field: "delivery_note" | "quote" | "purchase_order",
|
||||
file: File,
|
||||
) => {
|
||||
if (!repairId) return;
|
||||
const fd = new FormData();
|
||||
fd.append(field, file, file.name);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/repairs/${repairId}/files/${field}`,
|
||||
{ method: "POST", body: fd },
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
const labels: Record<string, string> = {
|
||||
delivery_note: "Delivery note",
|
||||
quote: "Supplier quote",
|
||||
purchase_order: "Purchase order",
|
||||
};
|
||||
toast.success(`${labels[field]} uploaded`);
|
||||
fetchRepair();
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : "Upload failed");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFile = async (
|
||||
field: "delivery_note" | "quote" | "purchase_order",
|
||||
filename: string,
|
||||
) => {
|
||||
if (!repairId) return;
|
||||
if (!confirm(`Delete ${filename}?`)) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/repairs/${repairId}/files/${field}?filename=${encodeURIComponent(filename)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
toast.success("File removed");
|
||||
fetchRepair();
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : "Delete failed");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRepair = async () => {
|
||||
if (!repairId) return;
|
||||
if (!confirm("Delete this repair record? This cannot be undone.")) return;
|
||||
try {
|
||||
const res = await fetch(`/api/v1/repairs/${repairId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
toast.success("Repair deleted");
|
||||
router.push("/repairs");
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : "Delete failed");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !repair) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button variant="outline" onClick={() => router.push("/repairs")}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to repairs
|
||||
</Button>
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center text-muted-foreground">
|
||||
{error || "Repair not found"}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const customerEmail = repair.expand?.customer?.email ?? "";
|
||||
const deliveryNoteFiles = repair.delivery_note ?? [];
|
||||
const quoteFiles = repair.quote ?? [];
|
||||
const poFiles = repair.purchase_order ?? [];
|
||||
const photoFiles = repair.photos ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => router.push("/repairs")}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Repairs
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{repair.repair_number}
|
||||
</h1>
|
||||
<Badge variant={statusColors[repair.status] || "secondary"}>
|
||||
{statusLabels[repair.status] || repair.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground">{repair.item_description}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{repair.status === "new" && (
|
||||
<Button onClick={handleSendToSupplier} disabled={generating}>
|
||||
{generating ? (
|
||||
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Truck className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Send to supplier
|
||||
</Button>
|
||||
)}
|
||||
{(repair.status === "approved" || repair.status === "in_progress") && (
|
||||
<Button
|
||||
onClick={async () => {
|
||||
setEdit((p) => ({ ...p, status: "completed" }));
|
||||
await handleSave();
|
||||
}}
|
||||
>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
Mark complete
|
||||
</Button>
|
||||
)}
|
||||
{repair.status !== "completed" && repair.status !== "cancelled" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
if (!confirm("Cancel this repair?")) return;
|
||||
setEdit((p) => ({ ...p, status: "cancelled" }));
|
||||
await handleSave();
|
||||
}}
|
||||
>
|
||||
<XCircle className="mr-2 h-4 w-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant={editing ? "default" : "outline"}
|
||||
onClick={() => (editing ? handleSave() : setEditing(true))}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? (
|
||||
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : editing ? (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{editing ? "Save" : "Edit"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Main column */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Job details */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Job details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{editing ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Customer</Label>
|
||||
<CustomerSearch
|
||||
value={edit.customer}
|
||||
onValueChange={(id) => {
|
||||
const c = lookups.customers.find((x) => x.id === id);
|
||||
setEdit((p) => ({
|
||||
...p,
|
||||
customer: id,
|
||||
company: c?.company ?? p.company,
|
||||
}));
|
||||
}}
|
||||
customers={lookups.customers}
|
||||
placeholder="Pick a customer…"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Order</Label>
|
||||
<OrderSearch
|
||||
value={edit.order}
|
||||
onValueChange={(id) => setEdit((p) => ({ ...p, order: id }))}
|
||||
placeholder="Search for an order…"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Supplier (for analysis)</Label>
|
||||
<SupplierSearch
|
||||
value={edit.supplier}
|
||||
onValueChange={(id) => setEdit((p) => ({ ...p, supplier: id }))}
|
||||
suppliers={lookups.suppliers}
|
||||
placeholder="Pick the supplier / repair shop…"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Serial number</Label>
|
||||
<Input
|
||||
value={edit.serial_no}
|
||||
onChange={(e) =>
|
||||
setEdit((p) => ({ ...p, serial_no: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Item description</Label>
|
||||
<Input
|
||||
value={edit.item_description}
|
||||
onChange={(e) =>
|
||||
setEdit((p) => ({ ...p, item_description: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Fault description</Label>
|
||||
<Textarea
|
||||
value={edit.fault_description}
|
||||
onChange={(e) =>
|
||||
setEdit((p) => ({ ...p, fault_description: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Status</Label>
|
||||
<Select
|
||||
value={edit.status}
|
||||
onValueChange={(v) =>
|
||||
setEdit((p) => ({ ...p, status: v as (typeof ALL_STATUSES)[number] }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ALL_STATUSES.map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{statusLabels[s]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<DetailRow
|
||||
label="Customer"
|
||||
value={repair.expand?.customer?.customer}
|
||||
sub={customerEmail}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Order"
|
||||
value={repair.expand?.order?.order_number}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Supplier"
|
||||
value={repair.expand?.supplier?.suppliername}
|
||||
sub="Send the machine here for analysis"
|
||||
empty={!repair.supplier}
|
||||
/>
|
||||
<DetailRow label="Serial number" value={repair.serial_no} />
|
||||
<div className="col-span-2">
|
||||
<DetailRow label="Item" value={repair.item_description} />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<DetailRow label="Fault" value={repair.fault_description} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Photos */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
Photos
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{photoFiles.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No photos uploaded. Add them when booking in the repair.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
|
||||
{photoFiles.map((f) => (
|
||||
<a
|
||||
key={f.field + f.name}
|
||||
href={`/api/v1/files/repairs/${item.id}/${f.name}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded hover:bg-muted"
|
||||
title={`${f.field}: ${f.name}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<img
|
||||
src={`/api/v1/files/repairs/${repair.id}/${f}`}
|
||||
alt={f}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Documents */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Documents</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<DocumentRow
|
||||
field="delivery_note"
|
||||
label="Delivery note (driver to supplier)"
|
||||
files={deliveryNoteFiles}
|
||||
repairId={repair.id}
|
||||
inputRef={dnUploadRef}
|
||||
onUpload={(file) => handleUploadFile("delivery_note", file)}
|
||||
onDelete={(f) => handleDeleteFile("delivery_note", f)}
|
||||
/>
|
||||
<Separator />
|
||||
<DocumentRow
|
||||
field="quote"
|
||||
label="Supplier quote (after analysis)"
|
||||
files={quoteFiles}
|
||||
repairId={repair.id}
|
||||
inputRef={quoteUploadRef}
|
||||
onUpload={(file) => handleUploadFile("quote", file)}
|
||||
onDelete={(f) => handleDeleteFile("quote", f)}
|
||||
sendEmailTo={customerEmail}
|
||||
emailSubject={`Repair ${repair.repair_number} — quote for your approval`}
|
||||
emailBody={`Hi ${repair.expand?.customer?.customer ?? ""},\n\nPlease find attached our quote for the repair of your ${repair.item_description}${repair.serial_no ? ` (S/N ${repair.serial_no})` : ""}.\n\nRegards`}
|
||||
/>
|
||||
<Separator />
|
||||
<DocumentRow
|
||||
field="purchase_order"
|
||||
label="Purchase order (after customer approves)"
|
||||
files={poFiles}
|
||||
repairId={repair.id}
|
||||
inputRef={poUploadRef}
|
||||
onUpload={(file) => handleUploadFile("purchase_order", file)}
|
||||
onDelete={(f) => handleDeleteFile("purchase_order", f)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Side column */}
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<StickyNote className="h-4 w-4" />
|
||||
Notes
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{editing ? (
|
||||
<Textarea
|
||||
value={edit.notes}
|
||||
onChange={(e) => setEdit((p) => ({ ...p, notes: e.target.value }))}
|
||||
rows={6}
|
||||
placeholder="Internal notes…"
|
||||
/>
|
||||
) : repair.notes ? (
|
||||
<p className="text-sm whitespace-pre-wrap">{repair.notes}</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No notes.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Timeline</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm space-y-1 text-muted-foreground">
|
||||
<p>Booked in: {new Date(repair.created).toLocaleString()}</p>
|
||||
<p>Last updated: {new Date(repair.updated).toLocaleString()}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">Danger zone</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDeleteRepair}
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete repair
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
empty,
|
||||
}: {
|
||||
label: string;
|
||||
value?: string | null;
|
||||
sub?: string;
|
||||
empty?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className={empty ? "text-sm text-muted-foreground italic" : "text-sm font-medium"}>
|
||||
{value || (empty ? "Not set" : "—")}
|
||||
</p>
|
||||
{sub ? <p className="text-xs text-muted-foreground">{sub}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DocumentRow({
|
||||
field,
|
||||
label,
|
||||
files,
|
||||
repairId,
|
||||
inputRef,
|
||||
onUpload,
|
||||
onDelete,
|
||||
sendEmailTo,
|
||||
emailSubject,
|
||||
emailBody,
|
||||
}: {
|
||||
field: "delivery_note" | "quote" | "purchase_order";
|
||||
label: string;
|
||||
files: string[];
|
||||
repairId: string;
|
||||
inputRef: React.RefObject<HTMLInputElement | null>;
|
||||
onUpload: (file: File) => void;
|
||||
onDelete: (filename: string) => void;
|
||||
sendEmailTo?: string;
|
||||
emailSubject?: string;
|
||||
emailBody?: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{sendEmailTo && files.length > 0 ? (
|
||||
<a
|
||||
href={`mailto:${sendEmailTo}?subject=${encodeURIComponent(emailSubject ?? "")}&body=${encodeURIComponent(emailBody ?? "")}`}
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
title={`Email to ${sendEmailTo}`}
|
||||
>
|
||||
<Mail className="h-3 w-3" />
|
||||
Email to customer
|
||||
</a>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<Upload className="mr-2 h-3 w-3" />
|
||||
Upload
|
||||
</Button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={
|
||||
field === "delivery_note" || field === "quote" || field === "purchase_order"
|
||||
? "application/pdf,image/*"
|
||||
: "image/*"
|
||||
}
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) onUpload(f);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{files.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No files.</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{files.map((f) => (
|
||||
<li
|
||||
key={f}
|
||||
className="flex items-center justify-between gap-2 rounded border bg-muted/40 px-3 py-1.5 text-sm"
|
||||
>
|
||||
<span className="truncate">{f}</span>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<a
|
||||
href={`/api/v1/files/repairs/${repairId}/${f}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded hover:bg-muted"
|
||||
title="Download"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(f)}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded hover:bg-muted text-destructive"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { Plus, RefreshCw } from "lucide-react";
|
||||
import { Plus, RefreshCw, Paperclip, ChevronRight } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
|
@ -15,11 +16,26 @@ import {
|
|||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { DataTable } from "@/components/data-table";
|
||||
import { createRepair, formatDate, getRepairs } from "@/lib/pb-helpers";
|
||||
import type { Repair } from "@/lib/types";
|
||||
import { CustomerSearch } from "@/components/ui/customer-search";
|
||||
import { SupplierSearch } from "@/components/ui/supplier-search";
|
||||
import { OrderSearch } from "@/components/ui/order-search";
|
||||
import { Camera, X as XIcon } from "lucide-react";
|
||||
import type { Customer, Supplier, Repair } from "@/lib/types";
|
||||
|
||||
const statusColors: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
|
||||
const ALLOWED_PHOTO_TYPES = [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/heic",
|
||||
];
|
||||
const MAX_PHOTO_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
const statusColors: Record<
|
||||
string,
|
||||
"default" | "secondary" | "destructive" | "outline"
|
||||
> = {
|
||||
new: "outline",
|
||||
assessing: "secondary",
|
||||
quoted: "secondary",
|
||||
|
|
@ -29,8 +45,43 @@ const statusColors: Record<string, "default" | "secondary" | "destructive" | "ou
|
|||
cancelled: "destructive",
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
new: "New",
|
||||
assessing: "Sent to supplier",
|
||||
quoted: "Quote received",
|
||||
approved: "Approved",
|
||||
in_progress: "In progress",
|
||||
completed: "Completed",
|
||||
cancelled: "Cancelled",
|
||||
};
|
||||
|
||||
type RepairsListItem = Repair & {
|
||||
expand?: {
|
||||
customer?: Customer;
|
||||
supplier?: Supplier;
|
||||
company?: { id: string; company: string };
|
||||
order?: { id: string; order_number: string };
|
||||
};
|
||||
};
|
||||
|
||||
type ListResponse = {
|
||||
items: RepairsListItem[];
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
page: number;
|
||||
perPage: number;
|
||||
};
|
||||
|
||||
type FormDataResponse = {
|
||||
customers: Customer[];
|
||||
suppliers: Supplier[];
|
||||
};
|
||||
|
||||
type PhotoPreview = { name: string; dataUrl: string; file: File };
|
||||
|
||||
export default function RepairsPage() {
|
||||
const [repairs, setRepairs] = useState<Repair[]>([]);
|
||||
const router = useRouter();
|
||||
const [repairs, setRepairs] = useState<RepairsListItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
|
@ -38,19 +89,57 @@ export default function RepairsPage() {
|
|||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [search, setSearch] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const [lookups, setLookups] = useState<FormDataResponse>({
|
||||
customers: [],
|
||||
suppliers: [],
|
||||
});
|
||||
|
||||
const [form, setForm] = useState({
|
||||
customer: "",
|
||||
company: "",
|
||||
order: "",
|
||||
supplier: "",
|
||||
serial_no: "",
|
||||
item_description: "",
|
||||
fault_description: "",
|
||||
notes: "",
|
||||
});
|
||||
const [photoPreviews, setPhotoPreviews] = useState<PhotoPreview[]>([]);
|
||||
const [photoError, setPhotoError] = useState("");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const fetchLookups = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/v1/repairs/form-data");
|
||||
if (!res.ok) return;
|
||||
const data: FormDataResponse = await res.json();
|
||||
setLookups({
|
||||
customers: data.customers ?? [],
|
||||
suppliers: data.suppliers ?? [],
|
||||
});
|
||||
} catch {
|
||||
// Form data is best-effort — fall back to an empty combobox.
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchRepairs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await getRepairs(page, 20, search);
|
||||
setRepairs(result.items);
|
||||
setTotalPages(result.totalPages);
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
perPage: "20",
|
||||
});
|
||||
if (search) params.set("search", search);
|
||||
const res = await fetch(`/api/v1/repairs?${params.toString()}`);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
const data: ListResponse = await res.json();
|
||||
setRepairs(data.items);
|
||||
setTotalPages(data.totalPages);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load repairs");
|
||||
} finally {
|
||||
|
|
@ -59,9 +148,71 @@ export default function RepairsPage() {
|
|||
}, [page, search]);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- data fetch on dep change
|
||||
fetchRepairs();
|
||||
}, [fetchRepairs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- conditional fetch on open
|
||||
fetchLookups();
|
||||
}
|
||||
}, [open, fetchLookups]);
|
||||
|
||||
const handlePhotoSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
setPhotoError("");
|
||||
const next: PhotoPreview[] = [...photoPreviews];
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (!ALLOWED_PHOTO_TYPES.includes(file.type)) {
|
||||
setPhotoError(
|
||||
`Only JPEG, PNG, WEBP, or HEIC photos allowed (got ${file.type || "unknown"}).`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (file.size > MAX_PHOTO_BYTES) {
|
||||
setPhotoError(
|
||||
`Photos must be under 8MB (${file.name} is ${(file.size / 1024 / 1024).toFixed(1)}MB).`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const url = URL.createObjectURL(file);
|
||||
next.push({ name: file.name, dataUrl: url, file });
|
||||
}
|
||||
setPhotoPreviews(next);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
};
|
||||
|
||||
const removePhoto = (i: number) => {
|
||||
setPhotoPreviews((prev) => {
|
||||
const next = [...prev];
|
||||
URL.revokeObjectURL(next[i].dataUrl);
|
||||
next.splice(i, 1);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
setOpen(false);
|
||||
setForm({
|
||||
customer: "",
|
||||
company: "",
|
||||
order: "",
|
||||
supplier: "",
|
||||
serial_no: "",
|
||||
item_description: "",
|
||||
fault_description: "",
|
||||
notes: "",
|
||||
});
|
||||
setPhotoPreviews((prev) => {
|
||||
prev.forEach((p) => URL.revokeObjectURL(p.dataUrl));
|
||||
return [];
|
||||
});
|
||||
setPhotoError("");
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!form.item_description.trim() || !form.fault_description.trim()) {
|
||||
toast.error("Item and fault description are required");
|
||||
|
|
@ -70,14 +221,48 @@ export default function RepairsPage() {
|
|||
|
||||
setSaving(true);
|
||||
try {
|
||||
await createRepair({
|
||||
item_description: form.item_description.trim(),
|
||||
fault_description: form.fault_description.trim(),
|
||||
notes: form.notes.trim(),
|
||||
const createRes = await fetch("/api/v1/repairs", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
customer: form.customer || null,
|
||||
company: form.company || null,
|
||||
order: form.order || null,
|
||||
supplier: form.supplier || null,
|
||||
serial_no: form.serial_no || null,
|
||||
item_description: form.item_description.trim(),
|
||||
fault_description: form.fault_description.trim(),
|
||||
notes: form.notes.trim(),
|
||||
}),
|
||||
});
|
||||
toast.success("Repair created");
|
||||
setForm({ item_description: "", fault_description: "", notes: "" });
|
||||
setOpen(false);
|
||||
if (!createRes.ok) {
|
||||
const err = await createRes.json().catch(() => ({}));
|
||||
throw new Error(err.error ?? `HTTP ${createRes.status}`);
|
||||
}
|
||||
const repair = await createRes.json();
|
||||
|
||||
let uploadedCount = 0;
|
||||
for (let i = 0; i < photoPreviews.length; i++) {
|
||||
const p = photoPreviews[i];
|
||||
const fd = new FormData();
|
||||
fd.append("photos", p.file, p.name);
|
||||
const up = await fetch(`/api/v1/repairs/${repair.id}/files/photos`, {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
});
|
||||
if (up.ok) uploadedCount++;
|
||||
else {
|
||||
const err = await up.json().catch(() => ({}));
|
||||
toast.error(`Photo "${p.name}" failed: ${err.error ?? up.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(
|
||||
uploadedCount > 0
|
||||
? `Repair ${repair.repair_number} created (${uploadedCount} photo${uploadedCount === 1 ? "" : "s"} uploaded)`
|
||||
: `Repair ${repair.repair_number} created`,
|
||||
);
|
||||
closeDialog();
|
||||
fetchRepairs();
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to create repair");
|
||||
|
|
@ -91,7 +276,10 @@ export default function RepairsPage() {
|
|||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Repairs</h1>
|
||||
<p className="text-muted-foreground">Repair requests and job flow</p>
|
||||
<p className="text-muted-foreground">
|
||||
Book in machines, send them to suppliers for analysis, and track the
|
||||
repair job to completion.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
|
|
@ -104,34 +292,99 @@ export default function RepairsPage() {
|
|||
{
|
||||
key: "repair_number",
|
||||
label: "Repair",
|
||||
render: (item: Repair) => <span className="font-medium">{item.repair_number}</span>,
|
||||
render: (item: RepairsListItem) => (
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{item.repair_number}</span>
|
||||
{item.serial_no ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
S/N: {item.serial_no}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "item_description",
|
||||
label: "Item",
|
||||
render: (item: Repair) => <span>{item.item_description}</span>,
|
||||
render: (item: RepairsListItem) => (
|
||||
<div className="max-w-xs">
|
||||
<p className="truncate">{item.item_description}</p>
|
||||
{item.expand?.customer ? (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{item.expand.customer.customer}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "supplier",
|
||||
label: "Supplier",
|
||||
render: (item: RepairsListItem) =>
|
||||
item.expand?.supplier ? (
|
||||
<span className="text-sm">{item.expand.supplier.suppliername}</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "Status",
|
||||
render: (item: Repair) => (
|
||||
render: (item: RepairsListItem) => (
|
||||
<Badge variant={statusColors[item.status] || "secondary"}>
|
||||
{item.status.replace("_", " ")}
|
||||
{statusLabels[item.status] || item.status}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "created",
|
||||
label: "Created",
|
||||
render: (item: Repair) => (
|
||||
<span className="text-sm text-muted-foreground">{formatDate(item.created)}</span>
|
||||
key: "docs",
|
||||
label: "Docs",
|
||||
render: (item: RepairsListItem) => {
|
||||
const files = [
|
||||
...(item.delivery_note ?? []).map((f) => ({
|
||||
field: "delivery_note" as const,
|
||||
name: f,
|
||||
})),
|
||||
...(item.quote ?? []).map((f) => ({ field: "quote" as const, name: f })),
|
||||
...(item.purchase_order ?? []).map((f) => ({
|
||||
field: "purchase_order" as const,
|
||||
name: f,
|
||||
})),
|
||||
];
|
||||
if (files.length === 0) {
|
||||
return <span className="text-xs text-muted-foreground">—</span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{files.map((f) => (
|
||||
<a
|
||||
key={f.field + f.name}
|
||||
href={`/api/files/repairs/${item.id}/${f.name}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded hover:bg-muted"
|
||||
title={`${f.field}: ${f.name}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Paperclip className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
label: "",
|
||||
render: () => (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
),
|
||||
},
|
||||
]}
|
||||
data={repairs}
|
||||
loading={loading}
|
||||
error={error}
|
||||
searchPlaceholder="Search repairs..."
|
||||
searchPlaceholder="Search by repair #, item, fault, serial…"
|
||||
onSearch={(q) => {
|
||||
setSearch(q);
|
||||
setPage(1);
|
||||
|
|
@ -139,51 +392,210 @@ export default function RepairsPage() {
|
|||
page={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
onRowClick={(item) => router.push(`/repairs/${item.id}`)}
|
||||
rowClassName={() => "cursor-pointer"}
|
||||
mobileCardRender={(item) => (
|
||||
<Card>
|
||||
<CardContent className="p-4 space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="font-medium">{item.repair_number}</p>
|
||||
<div>
|
||||
<p className="font-medium">{item.repair_number}</p>
|
||||
{item.serial_no ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
S/N: {item.serial_no}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Badge variant={statusColors[item.status] || "secondary"}>
|
||||
{item.status.replace("_", " ")}
|
||||
{statusLabels[item.status] || item.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm">{item.item_description}</p>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">{item.fault_description}</p>
|
||||
{item.expand?.customer ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{item.expand.customer.customer}
|
||||
</p>
|
||||
) : null}
|
||||
{item.expand?.supplier ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Supplier: {item.expand.supplier.suppliername}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{item.fault_description}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(o) => (o ? setOpen(true) : closeDialog())}
|
||||
>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto w-full p-0 sm:p-6">
|
||||
<DialogHeader className="px-4 pt-4 sm:px-0 sm:pt-0 sticky top-0 bg-background z-10 pb-3 border-b sm:border-b-0">
|
||||
<DialogTitle>New Repair</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
placeholder="Item description"
|
||||
value={form.item_description}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, item_description: e.target.value }))}
|
||||
/>
|
||||
<Textarea
|
||||
placeholder="Fault description"
|
||||
value={form.fault_description}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, fault_description: e.target.value }))}
|
||||
/>
|
||||
<Textarea
|
||||
placeholder="Notes"
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, notes: e.target.value }))}
|
||||
/>
|
||||
<div className="space-y-5 px-4 pb-4 sm:px-0 sm:pb-0">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Customer</Label>
|
||||
<CustomerSearch
|
||||
value={form.customer}
|
||||
onValueChange={(id) => {
|
||||
const c = lookups.customers.find((x) => x.id === id);
|
||||
setForm((p) => ({
|
||||
...p,
|
||||
customer: id,
|
||||
company: c?.company ?? p.company,
|
||||
}));
|
||||
}}
|
||||
customers={lookups.customers}
|
||||
placeholder="Pick a customer…"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Order (optional)</Label>
|
||||
<OrderSearch
|
||||
value={form.order}
|
||||
onValueChange={(id) => setForm((p) => ({ ...p, order: id }))}
|
||||
placeholder="Search for an order…"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Supplier (for analysis)</Label>
|
||||
<SupplierSearch
|
||||
value={form.supplier}
|
||||
onValueChange={(id) => setForm((p) => ({ ...p, supplier: id }))}
|
||||
suppliers={lookups.suppliers}
|
||||
placeholder="Pick the supplier / repair shop…"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Serial number</Label>
|
||||
<Input
|
||||
placeholder="Machine serial number"
|
||||
value={form.serial_no}
|
||||
onChange={(e) =>
|
||||
setForm((p) => ({ ...p, serial_no: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Item description *</Label>
|
||||
<Input
|
||||
placeholder="What is the machine / item?"
|
||||
value={form.item_description}
|
||||
onChange={(e) =>
|
||||
setForm((p) => ({ ...p, item_description: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Fault description *</Label>
|
||||
<Textarea
|
||||
placeholder="What's wrong with it?"
|
||||
value={form.fault_description}
|
||||
onChange={(e) =>
|
||||
setForm((p) => ({ ...p, fault_description: e.target.value }))
|
||||
}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Notes</Label>
|
||||
<Textarea
|
||||
placeholder="Any other context — customer history, known issues, special instructions…"
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm((p) => ({ ...p, notes: e.target.value }))}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Photos</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{photoPreviews.map((p, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="relative h-24 w-24 overflow-hidden rounded border bg-muted"
|
||||
>
|
||||
<img
|
||||
src={p.dataUrl}
|
||||
alt={p.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removePhoto(i)}
|
||||
className="absolute right-0 top-0 flex h-7 w-7 items-center justify-center rounded-bl bg-black/70 text-base text-white"
|
||||
aria-label={`Remove ${p.name}`}
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.setAttribute("capture", "environment");
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
}}
|
||||
className="flex h-14 items-center justify-center gap-2 rounded border border-dashed text-sm font-medium hover:bg-muted"
|
||||
>
|
||||
<Camera className="h-4 w-4" />
|
||||
Take photo
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.removeAttribute("capture");
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
}}
|
||||
disabled={photoPreviews.length >= 10}
|
||||
className="flex h-14 items-center justify-center gap-2 rounded border border-dashed text-sm font-medium hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
From gallery
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handlePhotoSelect}
|
||||
/>
|
||||
</div>
|
||||
{photoError ? (
|
||||
<p className="text-xs text-destructive">{photoError}</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{photoPreviews.length}/10 photos. JPEG, PNG, WEBP, or HEIC. Up to 8MB each.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
<DialogFooter className="sticky bottom-0 bg-background border-t px-4 py-3 sm:px-0 sm:py-0 sm:border-t-0 sm:bg-transparent flex-col-reverse sm:flex-row gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={closeDialog}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={saving}>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={saving}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{saving ? <RefreshCw className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
Create
|
||||
Book in repair
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -70,6 +70,13 @@ const ALLOWED_COLLECTIONS = new Set<string>([
|
|||
// operator uploads to a stock PO route through here. Added
|
||||
// 2026-07-16 when the stock-PO PDF route shipped.
|
||||
"stock_purchases", "pbc_1555380868",
|
||||
// Repairs — the `photos`, `quote`, `purchase_order`, and
|
||||
// `delivery_note` file fields on the repairs collection. The
|
||||
// operator uploads supplier quotes and PO PDFs against a repair
|
||||
// and the app generates the DN PDF on the fly. Added 2026-08-20
|
||||
// when the repair-detail page landed.
|
||||
"repairs", "pbc_2000000004",
|
||||
"delivery_notes", "pbc_3866053794",
|
||||
]);
|
||||
|
||||
export async function GET(
|
||||
|
|
|
|||
44
src/app/api/v1/orders/lookup/route.ts
Normal file
44
src/app/api/v1/orders/lookup/route.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { searchOrdersForRepairForm } from "@/lib/server/repairs-service";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/v1/orders/lookup
|
||||
*
|
||||
* Search-driven lookup for the `<OrderSearch>` combobox used on the
|
||||
* New Repair / Edit Repair forms. Mirrors the shape of
|
||||
* `/api/v1/quotes/lookup` so the UI can be near-identical.
|
||||
*
|
||||
* Query params:
|
||||
* q — free-text search across order_number / customer name /
|
||||
* company name. Empty/missing → returns the 25 most-recently
|
||||
* created orders.
|
||||
* page — 1-indexed, default 1
|
||||
* limit — default 25, hard max 50
|
||||
*
|
||||
* Returns: { items: [{id, order_number, customer_name, company_name}],
|
||||
* page, perPage, totalItems, totalPages }
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const { searchParams } = req.nextUrl;
|
||||
const q = searchParams.get("q") || "";
|
||||
const page = Math.max(1, parseInt(searchParams.get("page") || "1", 10) || 1);
|
||||
const limit = Math.min(
|
||||
50,
|
||||
Math.max(1, parseInt(searchParams.get("limit") || "25", 10) || 25),
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await searchOrdersForRepairForm(q, page, limit);
|
||||
return NextResponse.json(result, {
|
||||
headers: {
|
||||
"Cache-Control": "private, max-age=30, stale-while-revalidate=120",
|
||||
},
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Failed to lookup orders";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -15,12 +15,18 @@ import {
|
|||
* (the main page's "Page 1 of 392" was the bug we just fixed).
|
||||
*
|
||||
* Query params:
|
||||
* page, perPage — pagination (default 1 / 20, `perPage` max 100)
|
||||
* search — free-text filter (quote_number, quote_reference,
|
||||
* rfq_number, company, customer)
|
||||
* sort — PocketBase sort expression (default `-created`)
|
||||
* view — "completed" (default, 7,849 quotes) or "all"
|
||||
* (8,466 quotes — includes active workflow)
|
||||
* page, perPage — pagination (default 1 / 20, `perPage` max 100)
|
||||
* search — free-text filter (quote_number, quote_reference,
|
||||
* rfq_number, company, customer)
|
||||
* sort — PocketBase sort expression (default `-created`)
|
||||
* view — "completed" (default, 7,849 quotes) or "all"
|
||||
* (8,466 quotes — includes active workflow)
|
||||
* includeOlder — "1" / "true" to bypass the recent-window cutoff
|
||||
* (default false). When the operator is searching
|
||||
* the archive, the default recent-window scoping
|
||||
* (`SEARCH_RECENT_WINDOW_DAYS`) keeps the lookup
|
||||
* bounded; the page exposes a toggle that flips
|
||||
* this on for the rare "I need an old one" case.
|
||||
*
|
||||
* The view param toggles the header summary on the archive page
|
||||
* between "Completed (7,849)" and "All (8,466)"; both views share
|
||||
|
|
@ -42,19 +48,23 @@ export async function GET(req: NextRequest) {
|
|||
try {
|
||||
await assertBrowserSession(req);
|
||||
|
||||
const page = Number(req.nextUrl.searchParams.get("page") || "1");
|
||||
const perPage = Number(req.nextUrl.searchParams.get("perPage") || "20");
|
||||
const search = req.nextUrl.searchParams.get("search") || "";
|
||||
const sort = req.nextUrl.searchParams.get("sort") || "-created";
|
||||
const view = parseView(req.nextUrl.searchParams.get("view"));
|
||||
const page = Number(req.nextUrl.searchParams.get("page") || "1");
|
||||
const perPage = Number(req.nextUrl.searchParams.get("perPage") || "20");
|
||||
const search = req.nextUrl.searchParams.get("search") || "";
|
||||
const sort = req.nextUrl.searchParams.get("sort") || "-created";
|
||||
const view = parseView(req.nextUrl.searchParams.get("view"));
|
||||
const includeOlder =
|
||||
req.nextUrl.searchParams.get("includeOlder") === "1" ||
|
||||
req.nextUrl.searchParams.get("includeOlder") === "true";
|
||||
|
||||
const result = await getQuotesArchiveData({
|
||||
page,
|
||||
perPage,
|
||||
search,
|
||||
sort,
|
||||
view,
|
||||
});
|
||||
const result = await getQuotesArchiveData({
|
||||
page,
|
||||
perPage,
|
||||
search,
|
||||
sort,
|
||||
view,
|
||||
includeOlder,
|
||||
});
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
const message =
|
||||
|
|
|
|||
|
|
@ -13,12 +13,19 @@ import { QUOTE_TAB_ORDER, type QuoteTabKey } from "@/lib/server/quotes-service";
|
|||
* score.
|
||||
*
|
||||
* Query params:
|
||||
* q — free-text query (required; >= 1 non-whitespace char)
|
||||
* tab — restrict to one tab (any `QuoteTabKey`); the
|
||||
* candidates are filtered server-side via PB, so the
|
||||
* per-tab hit counts in the response are accurate.
|
||||
* page — 1-indexed page (default 1)
|
||||
* perPage — page size, clamped to 1..100 (default 50)
|
||||
* q — free-text query (required; >= 1 non-whitespace char)
|
||||
* tab — restrict to one tab (any `QuoteTabKey`); the
|
||||
* candidates are filtered server-side via PB, so the
|
||||
* per-tab hit counts in the response are accurate.
|
||||
* page — 1-indexed page (default 1)
|
||||
* perPage — page size, clamped to 1..100 (default 50)
|
||||
* includeOlder — "1" / "true" to bypass the recent-window cutoff
|
||||
* (default false). When false the pushdown is
|
||||
* bounded to the last `SEARCH_RECENT_WINDOW_DAYS`
|
||||
* days so the operator's common case ("find me a
|
||||
* quote I just did") is fast. The page exposes a
|
||||
* toggle that flips this on for the rare
|
||||
* "I need an old one" lookup.
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
|
|
@ -62,20 +69,31 @@ export async function GET(req: NextRequest) {
|
|||
const page = Number(req.nextUrl.searchParams.get("page") || "1");
|
||||
const perPage = Number(req.nextUrl.searchParams.get("perPage") || "50");
|
||||
|
||||
if (!query.trim()) {
|
||||
return NextResponse.json({
|
||||
hits: [],
|
||||
totalCandidates: 0,
|
||||
totalReturned: 0,
|
||||
tab,
|
||||
tookMs: 0,
|
||||
query,
|
||||
perTab: {},
|
||||
});
|
||||
}
|
||||
if (!query.trim()) {
|
||||
return NextResponse.json({
|
||||
hits: [],
|
||||
totalCandidates: 0,
|
||||
totalReturned: 0,
|
||||
tab,
|
||||
tookMs: 0,
|
||||
query,
|
||||
perTab: {},
|
||||
recencyLimited: req.nextUrl.searchParams.get("includeOlder") !== "1",
|
||||
recencyCutoff: null,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await searchQuotes({ query, tab, page, perPage });
|
||||
return NextResponse.json(result);
|
||||
const includeOlder =
|
||||
req.nextUrl.searchParams.get("includeOlder") === "1" ||
|
||||
req.nextUrl.searchParams.get("includeOlder") === "true";
|
||||
const result = await searchQuotes({
|
||||
query,
|
||||
tab,
|
||||
page,
|
||||
perPage,
|
||||
includeOlder,
|
||||
});
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to search quotes";
|
||||
|
|
|
|||
103
src/app/api/v1/repairs/[id]/files/[field]/route.ts
Normal file
103
src/app/api/v1/repairs/[id]/files/[field]/route.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
removeRepairFile,
|
||||
uploadRepairFile,
|
||||
type RepairFileField,
|
||||
} from "@/lib/server/repairs-service";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const ALLOWED_FIELDS: RepairFileField[] = [
|
||||
"photos",
|
||||
"quote",
|
||||
"purchase_order",
|
||||
"delivery_note",
|
||||
];
|
||||
|
||||
function isAllowedField(value: string): value is RepairFileField {
|
||||
return (ALLOWED_FIELDS as string[]).includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/repairs/[id]/files/[field]
|
||||
*
|
||||
* Multipart upload: field name = the multi-file repair field
|
||||
* (photos / quote / purchase_order / delivery_note). The file is
|
||||
* appended to the existing list (re-PATCHes the row with the old
|
||||
* filenames + the new one).
|
||||
*
|
||||
* For the `quote` field, the repair status is auto-bumped to
|
||||
* `quoted` if it was `new` or `assessing`.
|
||||
*
|
||||
* Returns: 200 — the updated Repair
|
||||
* 400 — missing/bad field, no file
|
||||
* 413 — file too large (PocketBase enforces 5MB by default)
|
||||
*/
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{
|
||||
params,
|
||||
}: { params: Promise<{ id: string; field: string }> },
|
||||
) {
|
||||
const { id, field } = await params;
|
||||
if (!isAllowedField(field)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Unknown file field "${field}". Allowed: ${ALLOWED_FIELDS.join(", ")}.` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const form = await req.formData();
|
||||
const file = form.get(field);
|
||||
if (!file || !(file instanceof File)) {
|
||||
return NextResponse.json(
|
||||
{ error: `No file provided under field "${field}".` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const repair = await uploadRepairFile(id, field, file, file.name);
|
||||
return NextResponse.json(repair);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Failed to upload file";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/repairs/[id]/files/[field]?filename=foo.pdf
|
||||
*
|
||||
* Remove a single file from a multi-file field. The `filename`
|
||||
* query param must match an existing entry exactly.
|
||||
*/
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{
|
||||
params,
|
||||
}: { params: Promise<{ id: string; field: string }> },
|
||||
) {
|
||||
const { id, field } = await params;
|
||||
if (!isAllowedField(field)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Unknown file field "${field}".` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const { searchParams } = req.nextUrl;
|
||||
const filename = searchParams.get("filename");
|
||||
if (!filename) {
|
||||
return NextResponse.json(
|
||||
{ error: "filename query parameter is required." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
try {
|
||||
const repair = await removeRepairFile(id, field, filename);
|
||||
return NextResponse.json(repair);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Failed to delete file";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
33
src/app/api/v1/repairs/[id]/generate-delivery-note/route.ts
Normal file
33
src/app/api/v1/repairs/[id]/generate-delivery-note/route.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { generateRepairDeliveryNote } from "@/lib/server/repairs-service";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* POST /api/v1/repairs/[id]/generate-delivery-note
|
||||
*
|
||||
* Generates a delivery note that the driver takes with the machine
|
||||
* to the supplier for analysis. The supplier is read from the
|
||||
* repair record (must be set first). The PDF is attached to the
|
||||
* repair's `delivery_note` file field. The repair is flipped to
|
||||
* status `assessing` if it was previously `new`.
|
||||
*
|
||||
* Returns: 200 — { deliveryNoteId, deliveryNoteNumber, filename }
|
||||
* 400 — no supplier set on repair
|
||||
* 500 — render / upload failure
|
||||
*/
|
||||
export async function POST(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const result = await generateRepairDeliveryNote(id);
|
||||
return NextResponse.json(result);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Failed to generate delivery note";
|
||||
const status = /no supplier set/i.test(message) ? 400 : 500;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
83
src/app/api/v1/repairs/[id]/route.ts
Normal file
83
src/app/api/v1/repairs/[id]/route.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
||||
import {
|
||||
getRepair,
|
||||
updateRepair,
|
||||
type UpdateRepairInput,
|
||||
} from "@/lib/server/repairs-service";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/v1/repairs/[id]
|
||||
*
|
||||
* Returns a single Repair with customer / company / order / supplier
|
||||
* expanded. 404 on missing id.
|
||||
*/
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const repair = await getRepair(id);
|
||||
return NextResponse.json(repair);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Repair not found";
|
||||
const status = /not found|404/i.test(message) ? 404 : 500;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/repairs/[id]
|
||||
*
|
||||
* Whitelisted fields only. Status flips are enforced by the service's
|
||||
* transition guard — illegal transitions surface as 400.
|
||||
*/
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
let body: UpdateRepairInput;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const repair = await updateRepair(id, body);
|
||||
return NextResponse.json(repair);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Failed to update repair";
|
||||
const status = /cannot transition|allowed:/i.test(message) ? 400 : 500;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/repairs/[id]
|
||||
*
|
||||
* Hard-delete the repair record. PB's cascade rules on the
|
||||
* `REP-2026-08-NNN` collection don't auto-cascade to attachments,
|
||||
* so the files stay in the file storage as orphans — they're
|
||||
* not linked to anything else. Use sparingly; for audit-trail
|
||||
* reasons, prefer flipping the status to `cancelled` instead.
|
||||
*/
|
||||
export async function DELETE(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const pb = await getAdminPocketBase();
|
||||
await pb.collection("repairs").delete(id);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Failed to delete repair";
|
||||
const status = /not found|404/i.test(message) ? 404 : 500;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
32
src/app/api/v1/repairs/form-data/route.ts
Normal file
32
src/app/api/v1/repairs/form-data/route.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
listCustomersForRepairForm,
|
||||
listSuppliersForRepairForm,
|
||||
} from "@/lib/server/repairs-service";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/v1/repairs/form-data
|
||||
*
|
||||
* Returns the lookup data needed by the "New Repair" + "Edit
|
||||
* Repair" forms: a small page of customers + suppliers. Orders
|
||||
* are NOT included here — they're served by the dedicated
|
||||
* `/api/v1/orders/lookup` endpoint so the order picker can search
|
||||
* server-side (the customers list is bounded and safe to ship
|
||||
* whole; the orders list is not, especially on a phone over a
|
||||
* dodgy cellular connection).
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const [customers, suppliers] = await Promise.all([
|
||||
listCustomersForRepairForm(),
|
||||
listSuppliersForRepairForm(),
|
||||
]);
|
||||
return NextResponse.json({ customers, suppliers });
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Failed to load form data";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
94
src/app/api/v1/repairs/route.ts
Normal file
94
src/app/api/v1/repairs/route.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
createRepair,
|
||||
listRepairs,
|
||||
type CreateRepairInput,
|
||||
} from "@/lib/server/repairs-service";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/v1/repairs
|
||||
*
|
||||
* Query params:
|
||||
* - page (default 1)
|
||||
* - perPage (default 20, max 100)
|
||||
* - search (substring across repair_number, item_description,
|
||||
* fault_description, notes, serial_no)
|
||||
* - status (new|assessing|quoted|approved|in_progress|completed|cancelled)
|
||||
* - sort (default -created)
|
||||
*
|
||||
* Returns: { items: Repair[], totalItems, totalPages, page, perPage }
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const { searchParams } = req.nextUrl;
|
||||
const page = parseInt(searchParams.get("page") ?? "1", 10) || 1;
|
||||
const perPage = Math.min(
|
||||
parseInt(searchParams.get("perPage") ?? "20", 10) || 20,
|
||||
100,
|
||||
);
|
||||
const search = searchParams.get("search") ?? "";
|
||||
const status = searchParams.get("status") ?? "";
|
||||
const sort = searchParams.get("sort") ?? "-created";
|
||||
|
||||
try {
|
||||
const result = await listRepairs({ page, perPage, search, status, sort });
|
||||
return NextResponse.json(result);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Failed to list repairs";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/repairs
|
||||
*
|
||||
* Body: CreateRepairInput
|
||||
* - customer, company, order, supplier: optional relation ids
|
||||
* - item_description, fault_description: required
|
||||
* - notes, serial_no: optional
|
||||
* - photos: optional string[] (filenames from prior uploads)
|
||||
*
|
||||
* Returns: 201 — the created Repair
|
||||
* 400 — validation error
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
let body: Partial<CreateRepairInput>;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!body.item_description || !body.item_description.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: "item_description is required." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (!body.fault_description || !body.fault_description.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: "fault_description is required." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const repair = await createRepair({
|
||||
customer: body.customer ?? null,
|
||||
company: body.company ?? null,
|
||||
order: body.order ?? null,
|
||||
supplier: body.supplier ?? null,
|
||||
item_description: body.item_description.trim(),
|
||||
fault_description: body.fault_description.trim(),
|
||||
notes: body.notes ?? null,
|
||||
serial_no: body.serial_no ?? null,
|
||||
photos: Array.isArray(body.photos) ? body.photos : [],
|
||||
});
|
||||
return NextResponse.json(repair, { status: 201 });
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Failed to create repair";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -85,6 +85,14 @@ interface DataTableProps<T = unknown> {
|
|||
onRowClick?: (item: T) => void;
|
||||
actions?: React.ReactNode;
|
||||
mobileCardRender?: (item: T) => React.ReactNode;
|
||||
/**
|
||||
* Optional slot rendered next to the search input. Used by pages
|
||||
* like `/quotes` to expose an "Include older" toggle that flips
|
||||
* the search-route's recent-window cutoff (Path A scoping). The
|
||||
* DataTable keeps this blind to the host page's behaviour — it
|
||||
* just lays it out.
|
||||
*/
|
||||
searchExtras?: React.ReactNode;
|
||||
/**
|
||||
* Per-row extra class names for the desktop table row. Use for row-
|
||||
* level highlights (e.g. background tint for claimed quotes).
|
||||
|
|
@ -136,6 +144,7 @@ export function DataTable<T>({
|
|||
mobileCardStyle,
|
||||
expandable,
|
||||
selection,
|
||||
searchExtras,
|
||||
}: DataTableProps<T>) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [debouncedQuery, setDebouncedQuery] = useState("");
|
||||
|
|
@ -171,14 +180,17 @@ export function DataTable<T>({
|
|||
<div className="space-y-4">
|
||||
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center justify-between">
|
||||
{onSearch && (
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 border-warn/40 bg-warn/5 focus-visible:border-warn focus-visible:ring-warn/30"
|
||||
/>
|
||||
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center w-full sm:max-w-2xl">
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 border-warn/40 bg-warn/5 focus-visible:border-warn focus-visible:ring-warn/30"
|
||||
/>
|
||||
</div>
|
||||
{searchExtras}
|
||||
</div>
|
||||
)}
|
||||
{actions && <div className="flex gap-2">{actions}</div>}
|
||||
|
|
|
|||
261
src/components/ui/order-search.tsx
Normal file
261
src/components/ui/order-search.tsx
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* OrderSearch — searchable combobox for picking an order to link
|
||||
* the repair to.
|
||||
*
|
||||
* Mirrors the `QuoteSearch` shape and the lighter `CustomerSearch`
|
||||
* trigger pattern. The user clicks "Browse orders…" to open a cmdk
|
||||
* panel; the panel renders one row per order with the company +
|
||||
* customer + order number so the operator can disambiguate even
|
||||
* when the same customer has multiple orders.
|
||||
*
|
||||
* Two reasons we don't reuse `QuoteSearch`:
|
||||
* 1. The endpoint, collection, and result row are different.
|
||||
* 2. `QuoteSearch` was built around a "search as you type" cmdk
|
||||
* filter over a pre-loaded page; the order list is smaller
|
||||
* (typically a few hundred active orders, vs 8.5k quotes) so a
|
||||
* server-side search by order number / customer / company is
|
||||
* fast enough to be debounced and feel native.
|
||||
*
|
||||
* Mobile: the trigger is full-width and the popover content widens
|
||||
* to fit the viewport so it stays tappable on phones.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Check, ChevronsUpDown, Search, X } from "lucide-react";
|
||||
|
||||
export interface OrderSearchResult {
|
||||
id: string;
|
||||
order_number: string;
|
||||
customer_name: string;
|
||||
company_name: string;
|
||||
}
|
||||
|
||||
export interface OrderSearchProps {
|
||||
/** Currently selected order id. Pass `""` for none. */
|
||||
value: string;
|
||||
/** Called with the picked order id (or `""` when cleared). */
|
||||
onValueChange: (id: string) => void;
|
||||
/** Placeholder shown on the trigger button. */
|
||||
placeholder?: string;
|
||||
/** Disable interaction. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
export function OrderSearch({
|
||||
value,
|
||||
onValueChange,
|
||||
placeholder = "Browse orders…",
|
||||
disabled = false,
|
||||
}: OrderSearchProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [items, setItems] = useState<OrderSearchResult[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Debounce server-side search so we don't fire per-keystroke.
|
||||
const [debouncedQuery, setDebouncedQuery] = useState("");
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedQuery(query), 250);
|
||||
return () => clearTimeout(t);
|
||||
}, [query]);
|
||||
|
||||
// Drop stale responses from a fast typist.
|
||||
const requestIdRef = useRef(0);
|
||||
|
||||
const fetchPage = useCallback(
|
||||
async (pageToLoad: number, q: string, mode: "replace" | "append") => {
|
||||
const myId = ++requestIdRef.current;
|
||||
if (mode === "replace") setLoading(true);
|
||||
else setLoadingMore(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set("page", String(pageToLoad));
|
||||
params.set("limit", String(PAGE_SIZE));
|
||||
if (q) params.set("q", q);
|
||||
const r = await fetch(`/api/v1/orders/lookup?${params.toString()}`);
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const data = (await r.json()) as {
|
||||
items: OrderSearchResult[];
|
||||
page: number;
|
||||
totalPages: number;
|
||||
};
|
||||
if (myId !== requestIdRef.current) return;
|
||||
setItems((prev) =>
|
||||
mode === "replace" ? data.items : [...prev, ...data.items],
|
||||
);
|
||||
setPage(data.page);
|
||||
setTotalPages(data.totalPages);
|
||||
} catch (err: unknown) {
|
||||
if (myId !== requestIdRef.current) return;
|
||||
setError(err instanceof Error ? err.message : "Failed to load orders");
|
||||
} finally {
|
||||
if (myId === requestIdRef.current) {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Ref-backed mirror so the dep-stable effect below can call the
|
||||
// latest fetchPage without recreating it on every render.
|
||||
const fetchPageRef = useRef(fetchPage);
|
||||
useEffect(() => {
|
||||
fetchPageRef.current = fetchPage;
|
||||
}, [fetchPage]);
|
||||
|
||||
// Selected item — populated from the loaded list. When the form
|
||||
// opens with a pre-existing value that isn't in the loaded list,
|
||||
// we fall back to the order id for the trigger label and the user
|
||||
// can re-pick from the dropdown if they want to change it.
|
||||
const selected = useMemo(
|
||||
() => items.find((o) => o.id === value) ?? null,
|
||||
[items, value],
|
||||
);
|
||||
|
||||
// Initial load + on-search-changed. Runs on open and on every
|
||||
// query change. The dependency array is `[open, debouncedQuery]`
|
||||
// — NOT `fetchPage` — so the inner callback isn't re-bound and
|
||||
// we don't trigger the cascading-setState warning that fires
|
||||
// when a useEffect dep changes during render.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setPage(1);
|
||||
setItems([]);
|
||||
fetchPageRef.current(1, debouncedQuery, "replace");
|
||||
}, [open, debouncedQuery]);
|
||||
|
||||
const label = selected
|
||||
? `${selected.order_number} · ${selected.customer_name || "(no customer)"}`
|
||||
: value
|
||||
? `Order #${value.slice(0, 6)}…`
|
||||
: "";
|
||||
|
||||
const hasMore = page < totalPages;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
className={cn(
|
||||
"w-full justify-between inline-flex items-center rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
)}
|
||||
disabled={disabled}
|
||||
aria-label="Order"
|
||||
>
|
||||
<span className={cn("truncate", !selected && !value && "text-muted-foreground")}>
|
||||
{label || placeholder}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{value && !disabled && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Clear order"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onValueChange("");
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onValueChange("");
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center justify-center h-5 w-5 rounded hover:bg-accent text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
<ChevronsUpDown className="h-4 w-4 opacity-50" />
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[min(24rem,calc(100vw-2rem))] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command shouldFilter>
|
||||
<CommandInput
|
||||
placeholder="Search by order #, customer, or company…"
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
/>
|
||||
<CommandList>
|
||||
{error ? (
|
||||
<div className="px-3 py-2 text-xs text-destructive">{error}</div>
|
||||
) : null}
|
||||
<CommandEmpty>
|
||||
{loading ? "Searching…" : "No orders found."}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{items.map((o) => (
|
||||
<CommandItem
|
||||
key={o.id}
|
||||
value={[o.order_number, o.customer_name, o.company_name]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase()}
|
||||
onSelect={() => {
|
||||
onValueChange(o.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="flex items-start gap-2"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4 shrink-0 mt-0.5",
|
||||
value === o.id ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="truncate font-medium">{o.order_number}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{o.company_name || "(no company)"}
|
||||
{o.customer_name ? ` · ${o.customer_name}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
{hasMore ? (
|
||||
<div className="border-t px-2 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
disabled={loadingMore}
|
||||
onClick={() => fetchPage(page + 1, debouncedQuery, "append")}
|
||||
className="w-full text-xs text-muted-foreground hover:text-foreground disabled:opacity-50 py-1"
|
||||
>
|
||||
{loadingMore ? "Loading…" : "Load more orders"}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
|
@ -15,7 +15,10 @@ const makeQuote = (over: Partial<Quote> = {}): Quote => ({
|
|||
collectionId: "pbc_quotes",
|
||||
collectionName: "quotes",
|
||||
created: "2026-01-01T00:00:00Z",
|
||||
updated: "2026-01-01T00:00:00Z",
|
||||
// `updated` defaults to NOW so the default `updated >= cutoff`
|
||||
// scoping (Path A) passes out of the box. Tests that want a
|
||||
// stale quote explicitly set `updated` to an old timestamp.
|
||||
updated: new Date().toISOString(),
|
||||
quote_number: "",
|
||||
status: "new",
|
||||
customer: "",
|
||||
|
|
@ -28,7 +31,10 @@ const makeItem = (over: Partial<QuoteItem> = {}): QuoteItem => ({
|
|||
collectionId: "pbc_quote_items",
|
||||
collectionName: "quote_items",
|
||||
created: "2026-01-01T00:00:00Z",
|
||||
updated: "2026-01-01T00:00:00Z",
|
||||
// `updated` defaults to NOW so the parent-quote pushdown
|
||||
// (which fetches by id AND'd with `updated >= cutoff`) passes
|
||||
// without each test having to thread a timestamp through.
|
||||
updated: new Date().toISOString(),
|
||||
quote: "q1",
|
||||
description: "",
|
||||
quantity: 1,
|
||||
|
|
@ -267,6 +273,33 @@ function evaluateFilter(
|
|||
if (!filter || !filter.trim()) return true;
|
||||
if (!recFields) return false;
|
||||
|
||||
// Strip a single pair of outer parens if the entire expression
|
||||
// is wrapped. This is what unwraps `(quote_number~"x" || ...) &&
|
||||
// updated >= "..."` into the recomputable sub-expression and
|
||||
// prevents the recursion into `evaluateAtom` from looping back
|
||||
// to the same `evaluateFilter` call (which would happen because
|
||||
// `splitTopLevel` deliberately keeps the inner parens for the
|
||||
// AND/OR walk).
|
||||
let trimmed = filter.trim();
|
||||
if (trimmed.startsWith("(") && trimmed.endsWith(")")) {
|
||||
// Only strip if the open paren matches the close paren — a
|
||||
// defensive check against malformed input that would otherwise
|
||||
// corrupt the rest of the parse.
|
||||
let depth = 0;
|
||||
let balanced = true;
|
||||
for (let i = 0; i < trimmed.length; i++) {
|
||||
if (trimmed[i] === "(") depth++;
|
||||
else if (trimmed[i] === ")") {
|
||||
depth--;
|
||||
if (depth === 0 && i < trimmed.length - 1) {
|
||||
balanced = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (balanced) trimmed = trimmed.slice(1, -1).trim();
|
||||
}
|
||||
|
||||
// Tokenise the filter very loosely: just split on top-level
|
||||
// ` && ` and ` || `. The expressions the service emits never
|
||||
// nest the same operator twice (each branch is a flat OR or
|
||||
|
|
@ -275,7 +308,7 @@ function evaluateFilter(
|
|||
//
|
||||
// Handle the top-level split into OR-chains first: PB parses
|
||||
// `||` at the lowest precedence.
|
||||
const orParts = splitTopLevel(filter, "||");
|
||||
const orParts = splitTopLevel(trimmed, "||");
|
||||
for (const orPart of orParts) {
|
||||
const andParts = splitTopLevel(orPart, "&&");
|
||||
let ok = true;
|
||||
|
|
@ -313,9 +346,17 @@ function splitTopLevel(input: string, op: "&&" | "||"): string[] {
|
|||
|
||||
function evaluateAtom(atom: string, rec: AnyRec): boolean {
|
||||
// Strip surrounding parens if present.
|
||||
let a = atom.trim();
|
||||
const a = atom.trim();
|
||||
if (a.startsWith("(") && a.endsWith(")")) {
|
||||
a = a.slice(1, -1).trim();
|
||||
// Parenthesised sub-expression — recurse into the full filter
|
||||
// evaluator so the inner OR / AND chain is handled. Without
|
||||
// this, expressions like
|
||||
// `(quote_number~"x" || quote_reference~"x") && updated >= "..."`
|
||||
// never match because the outer ATOM never falls back to a
|
||||
// rec parse. (Pre-2026-08 the simulator only saw flat OR
|
||||
// chains — Path A's wrapped-OR for the recency cutoff is what
|
||||
// surfaced the bug.)
|
||||
return evaluateFilter(a, rec);
|
||||
}
|
||||
// Substring match: field~"value"
|
||||
const sub = a.match(/^([A-Za-z0-9_.]+)~\s*"((?:[^"\\]|\\.)*)"$/);
|
||||
|
|
@ -342,6 +383,18 @@ function evaluateAtom(atom: string, rec: AnyRec): boolean {
|
|||
const recVal = readField(rec, field);
|
||||
return String(recVal ?? "") === value;
|
||||
}
|
||||
// Greater-or-equal: field >= "value" (used for the recent-window
|
||||
// cutoff `updated >= "..."`). Comparison is lexicographic on
|
||||
// strings; for the ISO-cutoff we use this lives on the simulator
|
||||
// only — the production PB filter parses the timestamp natively.
|
||||
const gte = a.match(/^([A-Za-z0-9_.]+)\s*>=\s*"((?:[^"\\]|\\.)*)"$/);
|
||||
if (gte) {
|
||||
const [, field, rawValue] = gte;
|
||||
const value = rawValue.replace(/\\"/g, '"');
|
||||
const recVal = readField(rec, field);
|
||||
if (recVal == null) return false;
|
||||
return String(recVal) >= value;
|
||||
}
|
||||
// IN list: field IN ("a", "b")
|
||||
const inMatch = a.match(/^([A-Za-z0-9_.]+)\s+IN\s+\(((?:[^()]|\\\(|\\\))*)\)$/);
|
||||
if (inMatch) {
|
||||
|
|
@ -352,10 +405,6 @@ function evaluateAtom(atom: string, rec: AnyRec): boolean {
|
|||
const recVal = readField(rec, field);
|
||||
return items.includes(String(recVal ?? ""));
|
||||
}
|
||||
// Bare truthy: field="<nonempty>" used for the "complete" tab etc.
|
||||
if (a.startsWith("(") && a.endsWith(")")) {
|
||||
return evaluateAtom(a.slice(1, -1), rec);
|
||||
}
|
||||
// Unrecognised atom — fail closed (don't match) so a typo
|
||||
// surfaces instead of silently passing.
|
||||
return false;
|
||||
|
|
@ -377,6 +426,17 @@ function makePb(): {
|
|||
};
|
||||
autoCancellation: () => undefined;
|
||||
} {
|
||||
// Auto-timestamp for any record that omits `updated`. The
|
||||
// Path-A scoping rides on `updated >= cutoff`, so every fixture
|
||||
// needs a current timestamp to pass the default filter. Mirrors
|
||||
// PB's auto-date behaviour on the live `updated` column.
|
||||
const ensureUpdated = (fields: object): object => {
|
||||
const f = fields as Record<string, unknown>;
|
||||
if (!("updated" in f)) {
|
||||
return { ...f, updated: new Date().toISOString() };
|
||||
}
|
||||
return fields;
|
||||
};
|
||||
return {
|
||||
autoCancellation: () => undefined,
|
||||
collection: (name: string) => ({
|
||||
|
|
@ -384,9 +444,13 @@ function makePb(): {
|
|||
getCalls().push({ collection: name, filter: opts?.filter });
|
||||
const recs = store.filter((r) => r.collection === name);
|
||||
// Real PB returns the record itself, not a wrapper.
|
||||
// Auto-timestamp BEFORE the filter so the default
|
||||
// `updated >= cutoff` (added by Path A scoping) sees a
|
||||
// current timestamp on every fixture — mirrors the
|
||||
// production schema where `updated` is auto-maintained.
|
||||
return recs
|
||||
.filter((r) => evaluateFilter(opts?.filter, r.fields))
|
||||
.map((r) => r.fields);
|
||||
.filter((r) => evaluateFilter(opts?.filter, ensureUpdated(r.fields)))
|
||||
.map((r) => ensureUpdated(r.fields));
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
|
@ -468,8 +532,13 @@ describe("searchQuotes — pipeline", () => {
|
|||
const expandCalls = getCalls().filter((c) => {
|
||||
if (c.collection !== "quotes" || !c.filter) return false;
|
||||
// The expand-fetch filter is always an `id = "..." || id = "..."`
|
||||
// OR-chain (no `~`).
|
||||
return c.filter.startsWith("id =");
|
||||
// OR-chain (no `~`), with the recency cutoff wrapped around it.
|
||||
// Match either bare or wrapped form.
|
||||
return (
|
||||
c.filter.startsWith("id =") ||
|
||||
c.filter.startsWith("(id =") ||
|
||||
c.filter.startsWith("(id =\"")
|
||||
);
|
||||
});
|
||||
expect(expandCalls.length).toBeGreaterThan(1);
|
||||
for (const call of expandCalls) {
|
||||
|
|
@ -547,6 +616,39 @@ describe("searchQuotes — pipeline", () => {
|
|||
expect(quotesCall?.filter).toMatch(/updated >= "\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
|
||||
});
|
||||
|
||||
it("applies the 90-day recent-window cutoff by default (Path A scoping)", async () => {
|
||||
setStore([]);
|
||||
await searchQuotes({ query: "acme" });
|
||||
|
||||
const quotesCall = getCalls().find((c) => c.collection === "quotes");
|
||||
expect(quotesCall?.filter).toBeDefined();
|
||||
// The default scoping rides on every pushdown query — operator's
|
||||
// common case ("find me a quote I just did") stays bounded.
|
||||
expect(quotesCall?.filter).toMatch(/updated >= "\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
|
||||
});
|
||||
|
||||
it("echoes recencyLimited + recencyCutoff so the page can label the search scope", async () => {
|
||||
setStore([]);
|
||||
const recent = await searchQuotes({ query: "acme" });
|
||||
expect(recent.recencyLimited).toBe(true);
|
||||
expect(recent.recencyCutoff).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
|
||||
|
||||
const older = await searchQuotes({ query: "acme", includeOlder: true });
|
||||
expect(older.recencyLimited).toBe(false);
|
||||
expect(older.recencyCutoff).toBeNull();
|
||||
});
|
||||
|
||||
it("skips the recency cutoff when includeOlder is true", async () => {
|
||||
setStore([]);
|
||||
await searchQuotes({ query: "acme", includeOlder: true });
|
||||
|
||||
const quotesCalls = getCalls().filter((c) => c.collection === "quotes");
|
||||
expect(quotesCalls.length).toBeGreaterThan(0);
|
||||
for (const call of quotesCalls) {
|
||||
expect(call.filter).not.toMatch(/updated >=/);
|
||||
}
|
||||
});
|
||||
|
||||
it("clamps perPage to 1..100", async () => {
|
||||
setStore([]);
|
||||
const result = await searchQuotes({ query: "acme", perPage: 999 });
|
||||
|
|
|
|||
|
|
@ -67,6 +67,38 @@ import {
|
|||
} from "@/lib/server/quotes-service";
|
||||
import type { Quote, QuoteItem } from "@/lib/types";
|
||||
|
||||
/* ============================================================================
|
||||
* Recent-window default (Path A)
|
||||
* ============================================================================
|
||||
*
|
||||
* The full quote collection is ~8.5k rows with ~30k items spread
|
||||
* across them. A user-initiated search on that universe is fast
|
||||
* (~200ms after the pushdown refactor) but the *operational*
|
||||
* experience is bad: the operator's daily question is "where's the
|
||||
* Acme quote I emailed yesterday?" — never "find me a quote from
|
||||
* 18 months ago whose name I half-remember". The 90-day default
|
||||
* makes the search box do what the operator actually wants it to
|
||||
* do, and the "Include older" toggle lets them dig deeper when
|
||||
* they really need to (third-party looked up an old quote, a
|
||||
* customer phoned about a long-closed order, etc.).
|
||||
*
|
||||
* The cutoff is a single source of truth: shared with the page so
|
||||
* the toggle's "Show recent (last 90 days)" label matches the
|
||||
* server's filter string. Mirrors the `COMPLETED_RECENT_WINDOW_DAYS`
|
||||
* constant on the main `/quotes` page's "Completed (7 days)" tab
|
||||
* — both are short-term recency views, just with different windows.
|
||||
*
|
||||
* The cutoff is a date, not a number — the page and the server
|
||||
* compute it independently from `SEARCH_RECENT_WINDOW_DAYS`, so
|
||||
* envoy clock skew or process restart never desyncs the label.
|
||||
*/
|
||||
const SEARCH_RECENT_WINDOW_DAYS = 90;
|
||||
function searchRecentCutoff(now: Date = new Date()): string {
|
||||
return new Date(
|
||||
now.getTime() - SEARCH_RECENT_WINDOW_DAYS * 24 * 60 * 60 * 1000,
|
||||
).toISOString();
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* Constants
|
||||
* ========================================================================== */
|
||||
|
|
@ -181,6 +213,15 @@ export interface QuoteSearchResult {
|
|||
/** Per-tab hit counts. Keys are the live tab keys; the operator
|
||||
* reads this like "ACME" is in: New (2), Completed (1). */
|
||||
perTab: Record<string, number>;
|
||||
/** True when the pushdown was bounded by `SEARCH_RECENT_WINDOW_DAYS`.
|
||||
* False when the operator explicitly opted in to the full history.
|
||||
* Surgaces on the page so the "Showing recent" affordance knows
|
||||
* which label to render. */
|
||||
recencyLimited: boolean;
|
||||
/** The cutoff timestamp the pushdown used. Echoed back so the
|
||||
* page can show "Searched the last 90 days (since 2026-05-19)".
|
||||
* Null when the search included the full history. */
|
||||
recencyCutoff: string | null;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
|
|
@ -198,6 +239,12 @@ export interface QuoteSearchParams {
|
|||
page?: number;
|
||||
/** Page size. Default 50, clamped to 1..100. */
|
||||
perPage?: number;
|
||||
/** When false (default), the pushdown is bounded to the recent
|
||||
* window (`SEARCH_RECENT_WINDOW_DAYS` days). The page passes
|
||||
* `true` when the operator explicitly wants to search the full
|
||||
* history. Defaults to false so the common case ("find me
|
||||
* something recent") is fast. */
|
||||
includeOlder?: boolean;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
|
|
@ -408,14 +455,30 @@ function escapeForPbFilter(value: string): string {
|
|||
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
/** Build `(base) && (tabFilter)` when a tab is supplied; otherwise
|
||||
* just `base`. Lets each pushdown call compose its own match
|
||||
* filter with the tab constraint without re-implementing the
|
||||
* resolveTabFilter logic per branch. */
|
||||
function withTab(base: string, tab: QuoteTabKey | undefined): string {
|
||||
/** Build `(base) && (tabFilter) && (recency)` when a tab is supplied
|
||||
* (or recency is on), otherwise just `base`. Lets each pushdown
|
||||
* call compose its own match filter with the tab + recency
|
||||
* constraints without re-implementing the resolveTabFilter / cutoff
|
||||
* logic per branch.
|
||||
*
|
||||
* `includeOlder` (default false) AND's in the recency cutoff so
|
||||
* the pushdown universe is bounded by `searchRecentCutoff()`. The
|
||||
* server-side default is "recent only" — the path-A scope that
|
||||
* makes the search fast for the operator's common case. The page
|
||||
* exposes a toggle that flips this on when the operator is hunting
|
||||
* something older. */
|
||||
function withTab(
|
||||
base: string,
|
||||
tab: QuoteTabKey | undefined,
|
||||
includeOlder?: boolean,
|
||||
): string {
|
||||
const parts: string[] = [`(${base})`];
|
||||
const t = resolveTabFilter(tab);
|
||||
if (!t) return base;
|
||||
return `(${base}) && (${t})`;
|
||||
if (t) parts.push(`(${t})`);
|
||||
if (!includeOlder) {
|
||||
parts.push(`updated >= "${searchRecentCutoff()}"`);
|
||||
}
|
||||
return parts.join(" && ");
|
||||
}
|
||||
|
||||
/** Dedupe + validate an ID list. Drops empty / non-string values so
|
||||
|
|
@ -519,6 +582,7 @@ async function fetchCandidateIds(
|
|||
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
||||
esc: string,
|
||||
tab: QuoteTabKey | undefined,
|
||||
includeOlder: boolean,
|
||||
): Promise<string[]> {
|
||||
// 1. Identifier matches on quotes. No need to expand — we want
|
||||
// every quote whose quote_number / quote_reference /
|
||||
|
|
@ -532,6 +596,7 @@ async function fetchCandidateIds(
|
|||
filter: withTab(
|
||||
`quote_number~"${esc}" || quote_reference~"${esc}" || rfq_number~"${esc}" || external_number~"${esc}"`,
|
||||
tab,
|
||||
includeOlder,
|
||||
),
|
||||
fields: QUOTE_ID_FIELDS,
|
||||
batch: 500,
|
||||
|
|
@ -563,7 +628,7 @@ async function fetchCandidateIds(
|
|||
? await chunkedFilterFetch(
|
||||
customerIds,
|
||||
(chunk) =>
|
||||
withTab(relationEqualsChain(chunk, []), tab),
|
||||
withTab(relationEqualsChain(chunk, []), tab, includeOlder),
|
||||
(filter) =>
|
||||
pb.collection("quotes").getFullList<{
|
||||
id: string;
|
||||
|
|
@ -582,7 +647,8 @@ async function fetchCandidateIds(
|
|||
companyIds.length > 0
|
||||
? await chunkedFilterFetch(
|
||||
companyIds,
|
||||
(chunk) => withTab(relationEqualsChain([], chunk), tab),
|
||||
(chunk) =>
|
||||
withTab(relationEqualsChain([], chunk), tab, includeOlder),
|
||||
(filter) =>
|
||||
pb.collection("quotes").getFullList<{
|
||||
id: string;
|
||||
|
|
@ -620,7 +686,7 @@ async function fetchCandidateIds(
|
|||
itemQuoteIds.length > 0
|
||||
? await chunkedFilterFetch(
|
||||
itemQuoteIds,
|
||||
(chunk) => withTab(idEqualsChain(chunk), tab),
|
||||
(chunk) => withTab(idEqualsChain(chunk), tab, includeOlder),
|
||||
(filter) =>
|
||||
pb.collection("quotes").getFullList<{
|
||||
id: string;
|
||||
|
|
@ -663,6 +729,10 @@ export async function searchQuotes(
|
|||
const { query, tab } = params;
|
||||
const page = Math.max(1, params.page ?? 1);
|
||||
const perPage = Math.min(MAX_PER_PAGE, Math.max(1, params.perPage ?? DEFAULT_PER_PAGE));
|
||||
const includeOlder = params.includeOlder === true;
|
||||
// Fresh cutoff, captured once per call so the response's
|
||||
// `recencyCutoff` matches the filter string the pushdown ran with.
|
||||
const cutoff = includeOlder ? null : searchRecentCutoff();
|
||||
|
||||
const needles = buildNeedles(query);
|
||||
|
||||
|
|
@ -677,6 +747,8 @@ export async function searchQuotes(
|
|||
tookMs: Date.now() - start,
|
||||
query,
|
||||
perTab: {},
|
||||
recencyLimited: !includeOlder,
|
||||
recencyCutoff: cutoff,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -686,20 +758,35 @@ export async function searchQuotes(
|
|||
const esc = escapeForPbFilter(needles.lower);
|
||||
|
||||
// 1. Pushdown: get a small deduped list of candidate quote IDs
|
||||
// from PB via `~` filters on every searchable surface.
|
||||
const candidateIds = await fetchCandidateIds(pb, esc, tab);
|
||||
// from PB via `~` filters on every searchable surface. The
|
||||
// `includeOlder` flag controls whether the recent-window
|
||||
// cutoff rides on every pushdown query (Path A scoping — see
|
||||
// SEARCH_RECENT_WINDOW_DAYS).
|
||||
const candidateIds = await fetchCandidateIds(pb, esc, tab, includeOlder);
|
||||
|
||||
// 2. Fetch the candidate quotes WITH expand so the scorer can
|
||||
// read the customer / company name (pushdown step only
|
||||
// produced IDs). Chunked for the same reason as
|
||||
// `fetchCandidateIds` — a popular query easily produces a
|
||||
// candidate set of >100 quotes, which would 400 PB inline
|
||||
// without chunking.
|
||||
// without chunking. The recent-window cutoff rides on this
|
||||
// fetch too so we can never accidentally expand a quote that
|
||||
// snuck past the date-filter (e.g. via a chunked ID chunk
|
||||
// that PB served from a different shard).
|
||||
//
|
||||
// Operator precedence: PB parses `||` at lower precedence than
|
||||
// `&&`, so `id = "a" || id = "b" && updated >= "..."` reads
|
||||
// as `id = "a" || (id = "b" && updated >= "...")` — i.e. the
|
||||
// cutoff only applies to the LAST id in the chain. Wrap the
|
||||
// whole OR-chain in parens so the cutoff gates every id.
|
||||
const candidates =
|
||||
candidateIds.length > 0
|
||||
? await chunkedFilterFetch(
|
||||
candidateIds,
|
||||
(chunk) => idEqualsChain(chunk),
|
||||
(chunk) => {
|
||||
const base = idEqualsChain(chunk);
|
||||
return includeOlder ? base : `(${base}) && updated >= "${cutoff}"`;
|
||||
},
|
||||
(filter) =>
|
||||
pb.collection("quotes").getFullList<Quote>({
|
||||
filter,
|
||||
|
|
@ -760,6 +847,8 @@ export async function searchQuotes(
|
|||
tookMs: Date.now() - start,
|
||||
query,
|
||||
perTab,
|
||||
recencyLimited: !includeOlder,
|
||||
recencyCutoff: cutoff,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,12 @@ const { pbMock, clearStore } = vi.hoisted(() => {
|
|||
| { kind: "and"; left: FilterAST; right: FilterAST }
|
||||
| { kind: "or"; left: FilterAST; right: FilterAST }
|
||||
| { kind: "not"; expr: FilterAST }
|
||||
| { kind: "cmp"; field: string; op: "=" | "!=" | "=~" | "!~"; value: string };
|
||||
| {
|
||||
kind: "cmp";
|
||||
field: string;
|
||||
op: "=" | "!=" | "=~" | "!~" | ">=" | "<=";
|
||||
value: string;
|
||||
};
|
||||
|
||||
// Strip outer parens if they wrap the whole string. e.g.
|
||||
// `(a = "x")` → `a = "x"`. Lets callers wrap groups in parens
|
||||
|
|
@ -90,14 +95,14 @@ const { pbMock, clearStore } = vi.hoisted(() => {
|
|||
}
|
||||
}
|
||||
}
|
||||
// Leaf — a comparison. `!=`, `=~`, `!~`, or `=`.
|
||||
// Leaf — a comparison. `!=`, `=~`, `!~`, `>=`, or `=`.
|
||||
// Value can be empty (e.g. `field != ""`) so we use `*?` not `+?`.
|
||||
const m = s.match(/^(\w+)\s*(=~|!~|=|!=)\s*"?([^"]*?)"?$/);
|
||||
const m = s.match(/^(\w+)\s*(=~|!~|>=|<=|=|!=)\s*"?([^"]*?)"?$/);
|
||||
if (!m) {
|
||||
throw new Error(`Mock filter parser: cannot parse "${s}"`);
|
||||
}
|
||||
const [, field, op, value] = m;
|
||||
return { kind: "cmp", field, op: op as "=" | "!=" | "=~" | "!~", value };
|
||||
return { kind: "cmp", field, op: op as "=" | "!=" | "=~" | "!~" | ">=" | "<=", value };
|
||||
};
|
||||
|
||||
const evalFilter = (item: Record<string, unknown>, ast: FilterAST): boolean => {
|
||||
|
|
@ -112,6 +117,11 @@ const { pbMock, clearStore } = vi.hoisted(() => {
|
|||
case "!=": return actualStr !== ast.value;
|
||||
case "=~": return actualStr.includes(ast.value);
|
||||
case "!~": return !actualStr.includes(ast.value);
|
||||
// Lexicographic comparison — good enough for ISO timestamps
|
||||
// and for the path-A recency cutoff the operator actually
|
||||
// hits in production. The real PB does the same thing.
|
||||
case ">=": return actualStr >= ast.value;
|
||||
case "<=": return actualStr <= ast.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1225,10 +1235,17 @@ describe("QUOTE_TAB_STATUSES (canonical tab → statuses mapping)", () => {
|
|||
});
|
||||
|
||||
it("includes every status the page tab renders, with no tab having an empty status list unless it's a special tab", async () => {
|
||||
// delivery_note, all, and templates are special-cased (no
|
||||
// status filter). Every other tab must have at least one status.
|
||||
// delivery_note, all, templates, and completed_recent are
|
||||
// special-cased (no status filter, or filter is built from
|
||||
// `completed` + a date cutoff). Every other tab must have at
|
||||
// least one status.
|
||||
for (const tab of QUOTE_TAB_ORDER) {
|
||||
if (tab === "delivery_note" || tab === "all" || tab === "templates") {
|
||||
if (
|
||||
tab === "delivery_note" ||
|
||||
tab === "all" ||
|
||||
tab === "templates" ||
|
||||
tab === "completed_recent"
|
||||
) {
|
||||
expect(QUOTE_TAB_STATUSES[tab]).toEqual([]);
|
||||
} else {
|
||||
expect(QUOTE_TAB_STATUSES[tab].length).toBeGreaterThan(0);
|
||||
|
|
|
|||
|
|
@ -249,6 +249,29 @@ export function completedRecentCutoff(now: Date = new Date()): string {
|
|||
return cutoff.toISOString();
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* Search recency window (Path A)
|
||||
* ----------------------------------------------------------------------------
|
||||
* The full quote collection is ~8.5k rows. Searching it in real time
|
||||
* is fast after the pushdown refactor, but the *operational* feel is
|
||||
* bad: the operator's daily question is "where's the Acme quote I
|
||||
* emailed yesterday?" — never "find me a quote from 18 months ago
|
||||
* whose name I half-remember". The 90-day default makes the search
|
||||
* box do what the operator actually wants it to do, and the
|
||||
* "Include older" toggle lets them dig deeper when they really need
|
||||
* to (third-party phoned about a long-closed order, etc.).
|
||||
*
|
||||
* The window is shared by the search service AND the archive so the
|
||||
* two surfaces agree on what "recent" means. Same single source of
|
||||
* truth as `COMPLETED_RECENT_WINDOW_DAYS` for the new tabs.
|
||||
* ========================================================================== */
|
||||
export const SEARCH_RECENT_WINDOW_DAYS = 90;
|
||||
export function searchRecentCutoff(now: Date = new Date()): string {
|
||||
return new Date(
|
||||
now.getTime() - SEARCH_RECENT_WINDOW_DAYS * 24 * 60 * 60 * 1000,
|
||||
).toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* All statuses that drive any tab's count, deduplicated. Used to
|
||||
* build the combined `status = "..." || ...` filter for the
|
||||
|
|
@ -604,6 +627,13 @@ export interface QuotesArchiveData {
|
|||
};
|
||||
/** Echoed back so the page can render the right sub-heading. */
|
||||
view: QuotesArchiveView;
|
||||
/** True when the search was bounded by `SEARCH_RECENT_WINDOW_DAYS`.
|
||||
* False when the operator explicitly opted in to the full archive.
|
||||
* Echoed so the page can show the right hint in the header. */
|
||||
recencyLimited: boolean;
|
||||
/** ISO timestamp the cutoff used. Null when the search included
|
||||
* the full archive. */
|
||||
recencyCutoff: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -635,6 +665,13 @@ export async function getQuotesArchiveData(
|
|||
search?: string;
|
||||
sort?: string;
|
||||
view?: QuotesArchiveView;
|
||||
/** When false (default), the recent-window cutoff
|
||||
* (`SEARCH_RECENT_WINDOW_DAYS`) is AND'd into the filter so the
|
||||
* search hits the bounded recent universe. The page passes
|
||||
* `true` when the operator explicitly wants to search the full
|
||||
* archive. Default false matches the path-A scoping we apply
|
||||
* to the main page's search. */
|
||||
includeOlder?: boolean;
|
||||
} = {},
|
||||
pbArg?: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
||||
): Promise<QuotesArchiveData> {
|
||||
|
|
@ -645,9 +682,17 @@ export async function getQuotesArchiveData(
|
|||
const search = (params.search || "").trim();
|
||||
const sort = params.sort || "-created";
|
||||
const view: QuotesArchiveView = params.view === "all" ? "all" : "completed";
|
||||
const includeOlder = params.includeOlder === true;
|
||||
const completedFilter = COMPLETED_STATUSES.map((s) => `status = "${s}"`).join(" || ");
|
||||
const allFilter = ALL_COUNTED_STATUSES.map((s) => `status = "${s}"`).join(" || ");
|
||||
const viewFilter = view === "completed" ? completedFilter : allFilter;
|
||||
// The recent-window cutoff is the Path A scoping — see
|
||||
// SEARCH_RECENT_WINDOW_DAYS. Only applied when the operator is
|
||||
// actually searching (otherwise the bare listing is the
|
||||
// honest, full archive view).
|
||||
const recencyFilter = includeOlder
|
||||
? ""
|
||||
: `updated >= "${searchRecentCutoff()}"`;
|
||||
|
||||
if (!search) {
|
||||
const expand = "customer,customer.rep,company";
|
||||
|
|
@ -674,6 +719,8 @@ export async function getQuotesArchiveData(
|
|||
all: allResp.totalItems,
|
||||
},
|
||||
view,
|
||||
recencyLimited: false,
|
||||
recencyCutoff: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -686,10 +733,19 @@ export async function getQuotesArchiveData(
|
|||
// order_items scan for what is mostly a UI label is wasteful
|
||||
// when the data is identical for both views. We pay one scan
|
||||
// and split in memory.
|
||||
//
|
||||
// When the operator is searching with the default recent-window
|
||||
// scoping, the recency filter rides on this query too. That
|
||||
// turns the unbounded "full counted-status set" into a bounded
|
||||
// recent set — typically 200-500 rows instead of 8.5k — which
|
||||
// is why the archive search stops timing out.
|
||||
const allItemsFilter = recencyFilter
|
||||
? `(${allFilter}) && ${recencyFilter}`
|
||||
: allFilter;
|
||||
const allItems = await pb
|
||||
.collection(QUOTES_COLLECTION)
|
||||
.getFullList<{ id?: string; status?: string }>({
|
||||
filter: allFilter,
|
||||
filter: allItemsFilter,
|
||||
// `id` is needed for the matching-set dedupe below. `status`
|
||||
// drives the per-status distinct counts. Both are small
|
||||
// fields so the wire payload stays tight.
|
||||
|
|
@ -730,6 +786,8 @@ export async function getQuotesArchiveData(
|
|||
all: allIds.size,
|
||||
},
|
||||
view,
|
||||
recencyLimited: !includeOlder,
|
||||
recencyCutoff: includeOlder ? null : searchRecentCutoff(),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -808,6 +866,8 @@ export async function getQuotesArchiveData(
|
|||
all: allIds.size,
|
||||
},
|
||||
view,
|
||||
recencyLimited: !includeOlder,
|
||||
recencyCutoff: includeOlder ? null : searchRecentCutoff(),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
642
src/lib/server/repairs-service.ts
Normal file
642
src/lib/server/repairs-service.ts
Normal file
|
|
@ -0,0 +1,642 @@
|
|||
/**
|
||||
* Server-side repairs service.
|
||||
*
|
||||
* Owns the write surface for the `repairs` collection (the legacy
|
||||
* `pb-helpers.ts` only has the bare `getRepairs` / `createRepair`
|
||||
* client-side helpers — they're fine for the list page, but every
|
||||
* write that needs admin credentials + cross-collection writes
|
||||
* belongs here).
|
||||
*
|
||||
* The repair lifecycle:
|
||||
*
|
||||
* new → assessing → quoted → approved → in_progress → completed
|
||||
* └────► cancelled (from any pre-completed state)
|
||||
*
|
||||
* ‑ "new" : booked in, machine physically received
|
||||
* ‑ "assessing" : office sent the machine to a supplier for analysis
|
||||
* (a delivery-note PDF is on file under
|
||||
* `repairs.delivery_note`)
|
||||
* ‑ "quoted" : supplier returned a quote (uploaded to
|
||||
* `repairs.quote`)
|
||||
* ‑ "approved" : customer approved the supplier's quote (and the
|
||||
* office cut a supplier PO — uploaded to
|
||||
* `repairs.purchase_order`)
|
||||
* ‑ "in_progress": supplier is working on the repair
|
||||
* ‑ "completed" : machine is back with the customer
|
||||
*
|
||||
* Side-channel data lives in the file fields (`photos`, `quote`,
|
||||
* `purchase_order`, `delivery_note`) — the service exposes thin
|
||||
* `upload*` helpers that re-PATCH the row with the new file list,
|
||||
* mirroring the multi-file pattern PocketBase uses everywhere else.
|
||||
*/
|
||||
|
||||
import { getAdminPocketBase } from "./pocketbase-admin";
|
||||
import { renderDeliveryNotePdf } from "@/lib/pdf/render";
|
||||
import {
|
||||
deliveryNoteLetterheadCode,
|
||||
fetchLetterhead,
|
||||
loadLetterheadLogoDataUri,
|
||||
} from "@/lib/letterhead";
|
||||
import type {
|
||||
Customer,
|
||||
Repair,
|
||||
Supplier,
|
||||
User,
|
||||
} from "@/lib/types";
|
||||
|
||||
const REPAIRS = "repairs";
|
||||
const CUSTOMERS = "customers";
|
||||
const SUPPLIERS = "suppliers";
|
||||
const USERS = "users";
|
||||
const DELIVERY_NOTES = "delivery_notes";
|
||||
const ORDER_ITEMS = "order_items";
|
||||
|
||||
export type RepairStatus =
|
||||
| "new"
|
||||
| "assessing"
|
||||
| "quoted"
|
||||
| "approved"
|
||||
| "in_progress"
|
||||
| "completed"
|
||||
| "cancelled";
|
||||
|
||||
/** Allowed manual transitions. Centralised so the route + UI agree. */
|
||||
const ALLOWED_TRANSITIONS: Record<RepairStatus, RepairStatus[]> = {
|
||||
new: ["assessing", "quoted", "cancelled"],
|
||||
assessing: ["quoted", "cancelled"],
|
||||
quoted: ["approved", "in_progress", "cancelled"],
|
||||
approved: ["in_progress", "cancelled"],
|
||||
in_progress: ["completed", "cancelled"],
|
||||
completed: [],
|
||||
cancelled: [],
|
||||
};
|
||||
|
||||
function rejectTransition(from: RepairStatus, to: RepairStatus): never {
|
||||
const allowed = ALLOWED_TRANSITIONS[from] ?? [];
|
||||
const list = allowed.length > 0 ? allowed.join(", ") : "none";
|
||||
throw new Error(
|
||||
`Cannot transition repair from "${from}" to "${to}" (allowed: ${list}).`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertTransition(from: RepairStatus, to: RepairStatus): void {
|
||||
if (!ALLOWED_TRANSITIONS[from].includes(to)) rejectTransition(from, to);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* List + read
|
||||
* ========================================================================== */
|
||||
|
||||
export interface ListRepairsOptions {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
search?: string;
|
||||
status?: RepairStatus | "";
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
export interface ListRepairsResult {
|
||||
items: Repair[];
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
page: number;
|
||||
perPage: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Searchable columns on the `repairs` collection. Mirrors the
|
||||
* legacy `getRepairs` filter in `pb-helpers.ts:1485` but expanded
|
||||
* with the new fields (serial_no, notes) so the operator can find
|
||||
* a repair by typing any of the human-readable bits. PB's `~`
|
||||
* operator is case-insensitive substring.
|
||||
*/
|
||||
const SEARCHABLE_COLUMNS = [
|
||||
"repair_number",
|
||||
"item_description",
|
||||
"fault_description",
|
||||
"notes",
|
||||
"serial_no",
|
||||
];
|
||||
|
||||
function buildSearchFilter(search: string): string | null {
|
||||
const trimmed = search.trim();
|
||||
if (!trimmed) return null;
|
||||
const escaped = trimmed.replace(/"/g, '\\"');
|
||||
return SEARCHABLE_COLUMNS.map((c) => `${c} ~ "${escaped}"`).join(" || ");
|
||||
}
|
||||
|
||||
export async function listRepairs(
|
||||
options: ListRepairsOptions = {},
|
||||
): Promise<ListRepairsResult> {
|
||||
const pb = await getAdminPocketBase();
|
||||
const page = options.page ?? 1;
|
||||
const perPage = options.perPage ?? 20;
|
||||
const sort = options.sort ?? "-created";
|
||||
|
||||
const clauses: string[] = [];
|
||||
const searchClause = buildSearchFilter(options.search ?? "");
|
||||
if (searchClause) clauses.push(searchClause);
|
||||
if (options.status) {
|
||||
clauses.push(`status = "${options.status}"`);
|
||||
}
|
||||
const filter = clauses.join(" && ");
|
||||
|
||||
const result = await pb.collection(REPAIRS).getList<Repair>(page, perPage, {
|
||||
filter,
|
||||
sort,
|
||||
expand: "customer,company,order,supplier",
|
||||
});
|
||||
return {
|
||||
items: result.items,
|
||||
totalItems: result.totalItems,
|
||||
totalPages: result.totalPages,
|
||||
page: result.page,
|
||||
perPage: result.perPage,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getRepair(id: string): Promise<Repair> {
|
||||
const pb = await getAdminPocketBase();
|
||||
return pb.collection(REPAIRS).getOne<Repair>(id, {
|
||||
expand: "customer,company,order,supplier",
|
||||
});
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* Create / update
|
||||
* ========================================================================== */
|
||||
|
||||
export interface CreateRepairInput {
|
||||
customer?: string | null;
|
||||
company?: string | null;
|
||||
order?: string | null;
|
||||
supplier?: string | null;
|
||||
item_description: string;
|
||||
fault_description: string;
|
||||
notes?: string | null;
|
||||
serial_no?: string | null;
|
||||
photos?: string[];
|
||||
}
|
||||
|
||||
export async function createRepair(
|
||||
input: CreateRepairInput,
|
||||
): Promise<Repair> {
|
||||
const pb = await getAdminPocketBase();
|
||||
|
||||
if (!input.item_description?.trim()) {
|
||||
throw new Error("item_description is required.");
|
||||
}
|
||||
if (!input.fault_description?.trim()) {
|
||||
throw new Error("fault_description is required.");
|
||||
}
|
||||
|
||||
const repairNumber = await generateRepairNumberServer();
|
||||
|
||||
const create: Record<string, unknown> = {
|
||||
repair_number: repairNumber,
|
||||
status: "new",
|
||||
item_description: input.item_description.trim(),
|
||||
fault_description: input.fault_description.trim(),
|
||||
notes: input.notes ?? "",
|
||||
customer: input.customer || null,
|
||||
company: input.company || null,
|
||||
order: input.order || null,
|
||||
supplier: input.supplier || null,
|
||||
serial_no: input.serial_no ?? "",
|
||||
};
|
||||
if (input.photos && input.photos.length > 0) {
|
||||
create.photos = input.photos;
|
||||
}
|
||||
return pb.collection(REPAIRS).create<Repair>(create);
|
||||
}
|
||||
|
||||
export interface UpdateRepairInput {
|
||||
status?: RepairStatus;
|
||||
notes?: string | null;
|
||||
fault_description?: string;
|
||||
item_description?: string;
|
||||
serial_no?: string | null;
|
||||
customer?: string | null;
|
||||
company?: string | null;
|
||||
order?: string | null;
|
||||
supplier?: string | null;
|
||||
}
|
||||
|
||||
export async function updateRepair(
|
||||
id: string,
|
||||
input: UpdateRepairInput,
|
||||
): Promise<Repair> {
|
||||
const pb = await getAdminPocketBase();
|
||||
const existing = await pb.collection(REPAIRS).getOne<Repair>(id);
|
||||
const patch: Record<string, unknown> = {};
|
||||
|
||||
if (input.status !== undefined) {
|
||||
assertTransition(existing.status, input.status);
|
||||
patch.status = input.status;
|
||||
}
|
||||
if (input.notes !== undefined) patch.notes = input.notes ?? "";
|
||||
if (input.item_description !== undefined)
|
||||
patch.item_description = input.item_description;
|
||||
if (input.fault_description !== undefined)
|
||||
patch.fault_description = input.fault_description;
|
||||
if (input.serial_no !== undefined) patch.serial_no = input.serial_no ?? "";
|
||||
if (input.customer !== undefined) patch.customer = input.customer || null;
|
||||
if (input.company !== undefined) patch.company = input.company || null;
|
||||
if (input.order !== undefined) patch.order = input.order || null;
|
||||
if (input.supplier !== undefined) patch.supplier = input.supplier || null;
|
||||
|
||||
return pb.collection(REPAIRS).update<Repair>(id, patch);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* Repair number generator (server-side, atomic-ish)
|
||||
* ========================================================================== */
|
||||
|
||||
export async function generateRepairNumberServer(
|
||||
now: Date = new Date(),
|
||||
): Promise<string> {
|
||||
const pb = await getAdminPocketBase();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const prefix = `REP-${year}-${month}-`;
|
||||
|
||||
const existing = await pb.collection(REPAIRS).getList<Repair>(1, 1, {
|
||||
filter: `repair_number ~ "${prefix}"`,
|
||||
sort: "-repair_number",
|
||||
});
|
||||
let seq = 1;
|
||||
if (existing.items.length > 0) {
|
||||
const last = existing.items[0].repair_number ?? "";
|
||||
const lastSeq = parseInt(last.split("-").pop() || "0", 10);
|
||||
if (Number.isFinite(lastSeq) && lastSeq > 0) seq = lastSeq + 1;
|
||||
}
|
||||
return `${prefix}${String(seq).padStart(3, "0")}`;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* "Send to supplier" — generate the DN PDF and attach to repair
|
||||
* ========================================================================== */
|
||||
|
||||
const REPAIR_DN_PREFIX = "RDN";
|
||||
|
||||
/**
|
||||
* Generate a server-side daily sequence for repair DNs. Distinct
|
||||
* from the customer-DN sequence so a repair DN never shadows a
|
||||
* customer-DN number (operator can tell them apart at a glance).
|
||||
*/
|
||||
async function generateRepairDeliveryNoteNumber(now: Date = new Date()): Promise<string> {
|
||||
const pb = await getAdminPocketBase();
|
||||
const yy = String(now.getFullYear()).slice(-2);
|
||||
const mm = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(now.getDate()).padStart(2, "0");
|
||||
const prefix = `${REPAIR_DN_PREFIX}${yy}${mm}${dd}`;
|
||||
// We use the created order's `delivery_note_number` to find the
|
||||
// last sequence. The same `delivery_note_number` field is shared
|
||||
// with customer DNs, so we constrain to the repair prefix.
|
||||
const existing = await pb
|
||||
.collection(ORDER_ITEMS)
|
||||
.getList(1, 1, {
|
||||
filter: `delivery_note_number ~ "${prefix}"`,
|
||||
sort: "-delivery_note_number",
|
||||
})
|
||||
.catch(() => null);
|
||||
let seq = 1;
|
||||
if (existing && existing.items.length > 0) {
|
||||
const last = (existing.items[0] as { delivery_note_number?: string }).delivery_note_number ?? "";
|
||||
const lastSeq = parseInt(last.slice(prefix.length), 10);
|
||||
if (Number.isFinite(lastSeq) && lastSeq > 0) seq = lastSeq + 1;
|
||||
}
|
||||
return `${prefix}${String(seq).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export interface GenerateRepairDeliveryNoteResult {
|
||||
deliveryNoteId: string;
|
||||
deliveryNoteNumber: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a delivery note for sending the machine to the supplier
|
||||
* for analysis. Three steps:
|
||||
*
|
||||
* 1. Resolve the supplier name + address (for the PDF's "DELIVER TO"
|
||||
* block) and the customer name (for the "FROM" reference line).
|
||||
* 2. Create a `delivery_notes` header row + one `order_items` row
|
||||
* with the repair's item description. The order_items row is
|
||||
* what the driver app and the order-completion flows look at;
|
||||
* one row is enough for "send this machine to supplier X".
|
||||
* 3. Render the DN PDF and stamp it on the repair's
|
||||
* `delivery_note` file field so the operator can download it
|
||||
* from the repair detail page.
|
||||
*
|
||||
* The repair's status is bumped to `assessing` (with the standard
|
||||
* transition guard). The supplier must already be set on the repair
|
||||
* — we don't auto-resolve it.
|
||||
*/
|
||||
export async function generateRepairDeliveryNote(
|
||||
repairId: string,
|
||||
): Promise<GenerateRepairDeliveryNoteResult> {
|
||||
const pb = await getAdminPocketBase();
|
||||
|
||||
const repair = await pb.collection(REPAIRS).getOne<Repair>(repairId, {
|
||||
expand: "supplier,customer,company",
|
||||
});
|
||||
|
||||
if (!repair.supplier) {
|
||||
throw new Error(
|
||||
"Repair has no supplier set. Set a supplier on the repair before generating a delivery note.",
|
||||
);
|
||||
}
|
||||
const supplier = await pb.collection(SUPPLIERS).getOne<Supplier>(repair.supplier);
|
||||
|
||||
// Resolve the customer name (for the PDF reference line) — the
|
||||
// "DELIVER TO" block uses the supplier as destination.
|
||||
let customerName = "";
|
||||
if (repair.customer) {
|
||||
try {
|
||||
const c = await pb.collection(CUSTOMERS).getOne<Customer>(repair.customer);
|
||||
customerName = c.customer ?? "";
|
||||
} catch {
|
||||
// Customer lookup is best-effort; the PDF falls back to the
|
||||
// repair.customer relation name if the lookup fails.
|
||||
customerName = repair.expand?.customer?.customer ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
const deliveryNoteNumber = await generateRepairDeliveryNoteNumber();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// ── 1. delivery_notes header ─────────────────────────────────
|
||||
// We use the same `delivery_notes` collection as customer DNs;
|
||||
// the kind is distinguished by the `notes` field (which we
|
||||
// stamp with a leading "[Repair send-to-supplier]" tag) and by
|
||||
// the `RDN` number prefix. Customer-DN queries don't filter
|
||||
// by number, so this doesn't pollute the customer-DN list.
|
||||
const header = await pb.collection(DELIVERY_NOTES).create({
|
||||
delivery_note_number: deliveryNoteNumber,
|
||||
customer: repair.customer || null,
|
||||
company: repair.company || null,
|
||||
order: null,
|
||||
status: "draft",
|
||||
notes:
|
||||
`[Repair send-to-supplier] ${repair.repair_number} — ${repair.item_description}` +
|
||||
(repair.notes ? `\n\n${repair.notes}` : ""),
|
||||
created_by: null,
|
||||
});
|
||||
|
||||
// ── 2. order_items row ───────────────────────────────────────
|
||||
const itemDescription = `[Repair ${repair.repair_number}] ${repair.item_description}${repair.serial_no ? ` (S/N: ${repair.serial_no})` : ""}`;
|
||||
const item = await pb.collection(ORDER_ITEMS).create({
|
||||
order: null,
|
||||
delivery_note_number: deliveryNoteNumber,
|
||||
description: itemDescription,
|
||||
quantity: 1,
|
||||
order_qty: 1,
|
||||
supplier: repair.supplier,
|
||||
cost_price: 0,
|
||||
selling_price: 0,
|
||||
sku: "",
|
||||
notes: repair.fault_description,
|
||||
item_status: "delivery_note",
|
||||
});
|
||||
|
||||
// ── 3. Render + upload the DN PDF ─────────────────────────────
|
||||
const letterheadCode = deliveryNoteLetterheadCode();
|
||||
const letterhead = await fetchLetterhead(letterheadCode);
|
||||
const logoDataUri = await loadLetterheadLogoDataUri(letterheadCode);
|
||||
|
||||
const pdfBuffer = await renderDeliveryNotePdf({
|
||||
header: {
|
||||
deliveryNoteNumber,
|
||||
dateIso: now,
|
||||
orderNumber: repair.repair_number,
|
||||
companyName: supplier.suppliername ?? "",
|
||||
customerName: customerName,
|
||||
},
|
||||
lines: [
|
||||
{
|
||||
id: item.id,
|
||||
description: itemDescription,
|
||||
qty: 1,
|
||||
},
|
||||
],
|
||||
letterhead,
|
||||
logoDataUri,
|
||||
});
|
||||
|
||||
const filename = `${deliveryNoteNumber}.pdf`;
|
||||
const blob = new Blob([new Uint8Array(pdfBuffer)], { type: "application/pdf" });
|
||||
|
||||
// Attach to the item's multi-file field (so the rest of the
|
||||
// existing DN-download flow works) AND to the repair's
|
||||
// `delivery_note` field (so the operator finds it on the
|
||||
// repair detail page).
|
||||
const itemForm = new FormData();
|
||||
itemForm.append("order_item_attachements", blob, filename);
|
||||
await pb.collection(ORDER_ITEMS).update(item.id, itemForm);
|
||||
|
||||
// For the repair, we need to preserve the existing file list
|
||||
// (field is multi-file, maxSelect 10) and append the new file.
|
||||
const previousFiles = Array.isArray(repair.delivery_note)
|
||||
? repair.delivery_note
|
||||
: [];
|
||||
const repairForm = new FormData();
|
||||
for (const f of previousFiles) repairForm.append("delivery_note", f);
|
||||
repairForm.append("delivery_note", blob, filename);
|
||||
await pb.collection(REPAIRS).update(repairId, repairForm);
|
||||
|
||||
// ── 4. Status flip: new → assessing ──────────────────────────
|
||||
if (repair.status === "new") {
|
||||
assertTransition("new", "assessing");
|
||||
await pb.collection(REPAIRS).update(repairId, { status: "assessing" });
|
||||
}
|
||||
|
||||
return {
|
||||
deliveryNoteId: header.id,
|
||||
deliveryNoteNumber,
|
||||
filename,
|
||||
};
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* File upload helpers (photos / quote / purchase_order)
|
||||
* ========================================================================== */
|
||||
|
||||
export type RepairFileField = "photos" | "quote" | "purchase_order";
|
||||
|
||||
export async function uploadRepairFile(
|
||||
repairId: string,
|
||||
field: RepairFileField,
|
||||
file: File | Blob,
|
||||
filename: string,
|
||||
): Promise<Repair> {
|
||||
const pb = await getAdminPocketBase();
|
||||
const existing = await pb.collection(REPAIRS).getOne<Repair>(repairId);
|
||||
const previousFiles = Array.isArray(existing[field])
|
||||
? (existing[field] as string[])
|
||||
: [];
|
||||
|
||||
const form = new FormData();
|
||||
for (const f of previousFiles) form.append(field, f);
|
||||
form.append(field, file, filename);
|
||||
|
||||
const updated = await pb.collection(REPAIRS).update<Repair>(repairId, form);
|
||||
|
||||
// Auto-bump status: uploading a quote moves new/assessing → quoted.
|
||||
if (field === "quote" && (existing.status === "new" || existing.status === "assessing")) {
|
||||
assertTransition(existing.status, "quoted");
|
||||
await pb.collection(REPAIRS).update(repairId, { status: "quoted" });
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a single file from a multi-file field (PB stores the
|
||||
* field as a string[] of filenames). Used by the detail page's
|
||||
* "delete" button on each attachment thumbnail.
|
||||
*/
|
||||
export async function removeRepairFile(
|
||||
repairId: string,
|
||||
field: RepairFileField,
|
||||
filename: string,
|
||||
): Promise<Repair> {
|
||||
const pb = await getAdminPocketBase();
|
||||
const existing = await pb.collection(REPAIRS).getOne<Repair>(repairId);
|
||||
const files = Array.isArray(existing[field]) ? (existing[field] as string[]) : [];
|
||||
const filtered = files.filter((f) => f !== filename);
|
||||
if (filtered.length === files.length) {
|
||||
// Nothing to delete — return the row unchanged.
|
||||
return existing;
|
||||
}
|
||||
const form = new FormData();
|
||||
for (const f of filtered) form.append(field, f);
|
||||
return pb.collection(REPAIRS).update<Repair>(repairId, form);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* Read helpers for the UI (small)
|
||||
* ========================================================================== */
|
||||
|
||||
/**
|
||||
* Lightweight list of customers for the "New Repair" dropdown.
|
||||
* Distinct from the full customer lookup — just enough to show the
|
||||
* name + company in the combobox. Pulled via the admin client; the
|
||||
* where-used UI is the office-side repairs page (server-authenticated).
|
||||
*
|
||||
* The `customer.expand.company` expand is required so the
|
||||
* `<CustomerSearch>` combobox can render its "{Company} · {Customer}"
|
||||
* triplet. Without this expand the company column falls back to
|
||||
* "(no company)" for every row and the picker becomes useless on
|
||||
* mobile (the operator can't disambiguate "John Smith at Acme" from
|
||||
* "John Smith at Globex").
|
||||
*/
|
||||
export async function listCustomersForRepairForm(): Promise<Customer[]> {
|
||||
const pb = await getAdminPocketBase();
|
||||
const result = await pb.collection(CUSTOMERS).getList<Customer>(1, 500, {
|
||||
fields: "id,customer,email,number,company,expand",
|
||||
sort: "customer",
|
||||
expand: "company",
|
||||
});
|
||||
return result.items;
|
||||
}
|
||||
|
||||
export async function listSuppliersForRepairForm(): Promise<
|
||||
Array<{ id: string; suppliername: string }>
|
||||
> {
|
||||
const pb = await getAdminPocketBase();
|
||||
const result = await pb.collection(SUPPLIERS).getList<Supplier>(1, 500, {
|
||||
fields: "id,suppliername",
|
||||
sort: "suppliername",
|
||||
});
|
||||
return result.items.map((s) => ({
|
||||
id: s.id,
|
||||
suppliername: s.suppliername,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Order lookup used by the OrderSearch combobox. Mirrors the
|
||||
* /api/v1/quotes/lookup shape — search by order_number / customer /
|
||||
* company. We only return the fields the picker renders, with the
|
||||
* customer + company expanded so the dropdown shows
|
||||
* "{Company} · {Customer} · {order #}" (matches the sprint modal's
|
||||
* QuoteSearch shape).
|
||||
*/
|
||||
export async function searchOrdersForRepairForm(
|
||||
q: string,
|
||||
page = 1,
|
||||
perPage = 25,
|
||||
): Promise<{
|
||||
items: Array<{
|
||||
id: string;
|
||||
order_number: string;
|
||||
customer_name: string;
|
||||
company_name: string;
|
||||
}>;
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
page: number;
|
||||
perPage: number;
|
||||
}> {
|
||||
const pb = await getAdminPocketBase();
|
||||
const trimmed = q.trim();
|
||||
const filters: string[] = [];
|
||||
if (trimmed.length > 0) {
|
||||
const safe = trimmed.replace(/["()*]/g, "").slice(0, 64);
|
||||
if (safe.length > 0) {
|
||||
const escaped = safe.replace(/\\/g, "\\\\");
|
||||
const ors = [
|
||||
`order_number ~ "${escaped}"`,
|
||||
`expand.customer.customer ~ "${escaped}"`,
|
||||
`expand.company.company ~ "${escaped}"`,
|
||||
];
|
||||
filters.push(`(${ors.join(" || ")})`);
|
||||
}
|
||||
}
|
||||
const result = await pb
|
||||
.collection("orders")
|
||||
.getList(page, perPage, {
|
||||
filter: filters.join(" && "),
|
||||
sort: "-created",
|
||||
fields: "id,order_number,customer,company,expand",
|
||||
expand: "customer,company",
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("[searchOrdersForRepairForm] failed:", err);
|
||||
return { items: [], page: 1, perPage, totalItems: 0, totalPages: 0 };
|
||||
});
|
||||
|
||||
const items = result.items.map((o) => {
|
||||
const expand = (o as { expand?: { customer?: { customer?: string }; company?: { company?: string } } }).expand;
|
||||
return {
|
||||
id: o.id,
|
||||
order_number: (o as { order_number?: string }).order_number ?? "",
|
||||
customer_name: expand?.customer?.customer ?? "",
|
||||
company_name: expand?.company?.company ?? "",
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
items,
|
||||
totalItems: result.totalItems,
|
||||
totalPages: result.totalPages,
|
||||
page: result.page,
|
||||
perPage: result.perPage,
|
||||
};
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* Misc helpers
|
||||
* ========================================================================== */
|
||||
|
||||
/** Resolve the user id of the operator (for the audit stamp). */
|
||||
export async function getMe(): Promise<{ id: string; name?: string } | null> {
|
||||
const pb = await getAdminPocketBase();
|
||||
try {
|
||||
const me = await pb.collection(USERS).getAuthRecord();
|
||||
return me ? { id: (me as User).id, name: (me as User).name } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1237,6 +1237,18 @@ export interface Repair extends BaseRecord {
|
|||
customer?: string;
|
||||
company?: string;
|
||||
order?: string;
|
||||
/**
|
||||
* Service centre / repair shop the machine is sent to for analysis.
|
||||
* Relation to the `suppliers` collection. Set when the operator
|
||||
* clicks "Send to Supplier" — the supplier is the destination of the
|
||||
* delivery note that the driver carries with the machine.
|
||||
*
|
||||
* Not present on the current schema snapshot — operator added the
|
||||
* relation in the PB admin. If a call is made before the field is
|
||||
* added, the value will be undefined and the "Send to Supplier"
|
||||
* action will surface a clear "set a supplier first" error.
|
||||
*/
|
||||
supplier?: string;
|
||||
status:
|
||||
| "new"
|
||||
| "assessing"
|
||||
|
|
@ -1248,10 +1260,38 @@ export interface Repair extends BaseRecord {
|
|||
item_description: string;
|
||||
fault_description: string;
|
||||
notes?: string;
|
||||
serial_no?: string;
|
||||
/**
|
||||
* Photos of the machine as received from the customer. Multi-file
|
||||
* field (`maxSelect: 10`) so the operator can attach multiple angles
|
||||
* in one go. Shown on the repair detail page; download-only (no
|
||||
* rendering pipeline — the photos are for the office's records).
|
||||
*/
|
||||
photos?: string[];
|
||||
/**
|
||||
* Supplier's repair quote (PDF / image). Multi-file field. Set when
|
||||
* the supplier sends back their quote after analysing the machine.
|
||||
* Drives the `new/assessing → quoted` status transition.
|
||||
*/
|
||||
quote?: string[];
|
||||
/**
|
||||
* The PO that the office issues to the supplier to authorise the
|
||||
* repair work. Multi-file field. Set when the customer approves the
|
||||
* supplier's quote and the office cuts the supplier a PO.
|
||||
*/
|
||||
purchase_order?: string[];
|
||||
/**
|
||||
* Delivery-note PDF(s) attached to the repair. Multi-file field.
|
||||
* Currently holds the "send to supplier for analysis" DN (the driver
|
||||
* carries this with the machine to the supplier). Future: may also
|
||||
* hold the "return to customer" DN when the repair is done.
|
||||
*/
|
||||
delivery_note?: string[];
|
||||
expand?: {
|
||||
customer?: Customer;
|
||||
company?: Company;
|
||||
order?: Order;
|
||||
supplier?: Supplier;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue