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:
Dave 2026-08-20 10:18:44 +02:00
parent 88047eee33
commit 663c018a70
32 changed files with 3865 additions and 154 deletions

View 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

View 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

View 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

View file

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

View file

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

View file

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

View file

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

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

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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View file

@ -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">
&ldquo;{search}&rdquo;
</span>
</span>{" "}
{includeOlder ? (
<span className="text-muted-foreground">
(searched the full archive)
</span>
) : (
<span className="text-muted-foreground">
(searched the last 90 days)
</span>
)}
</>
) : (
<>

View file

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

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

View file

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

View file

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

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

View file

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

View file

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

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

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

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

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

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

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

View file

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