chore(playwright-mcp): add test snapshot and console logs
This commit is contained in:
parent
2bd8fcf247
commit
e2bcafe4d8
10 changed files with 594 additions and 364 deletions
79
.playwright-mcp/console-2026-08-05T23-55-29-175Z.log
Normal file
79
.playwright-mcp/console-2026-08-05T23-55-29-175Z.log
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
[ 544ms] [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
|
||||
[ 599ms] [LOG] [HMR] connected @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 1044ms] [LOG] SW registered: http://localhost:4001/ @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 1684ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 1765ms] [LOG] [Fast Refresh] done in 81ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 7254ms] [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
|
||||
[ 7298ms] [LOG] [HMR] connected @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 7314ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 7659ms] [LOG] SW registered: http://localhost:4001/ @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 8318ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 8381ms] [LOG] [Fast Refresh] done in 63ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 8791ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 8815ms] [LOG] [Fast Refresh] done in 25ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 8840ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 8881ms] [LOG] [Fast Refresh] done in 42ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 8891ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 9165ms] [LOG] [Fast Refresh] done in 274ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 9209ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 9236ms] [LOG] [Fast Refresh] done in 26ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 9683ms] [LOG] [Fast Refresh] done in 474ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 9735ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 10844ms] Invalid or unexpected token
|
||||
[ 12299ms] [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
|
||||
[ 12333ms] [LOG] [HMR] connected @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 12574ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 12980ms] [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. @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/userspace/app/errors/intercept-console-error.js:57
|
||||
[ 12990ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 13867ms] [LOG] [Fast Refresh] done in 876ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 13980ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 14031ms] [LOG] [Fast Refresh] done in 51ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 14194ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 14235ms] [LOG] [Fast Refresh] done in 42ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 14397ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 14470ms] [LOG] [Fast Refresh] done in 72ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 14682ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 14853ms] [LOG] [Fast Refresh] done in 170ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 15173ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 15213ms] [LOG] [Fast Refresh] done in 40ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 15380ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 15411ms] [LOG] [Fast Refresh] done in 31ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 15550ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 16518ms] [LOG] [Fast Refresh] done in 969ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 16720ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 16803ms] [LOG] [Fast Refresh] done in 84ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 16956ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 17950ms] [LOG] [Fast Refresh] done in 994ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 18202ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 19219ms] [LOG] [Fast Refresh] done in 1018ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 23326ms] [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
|
||||
[ 23475ms] [LOG] [HMR] connected @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 23499ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 23517ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 23740ms] [LOG] SW registered: http://localhost:4001/ @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 24424ms] [LOG] [Fast Refresh] done in 907ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 24503ms] [LOG] [Fast Refresh] done in 986ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 26965ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 27025ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 27070ms] [LOG] [Fast Refresh] done in 45ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 27081ms] [LOG] [Fast Refresh] done in 57ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 27081ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 28248ms] [LOG] [Fast Refresh] done in 1166ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 28322ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 28371ms] [LOG] [Fast Refresh] done in 49ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 30632ms] [LOG] [Fast Refresh] done in 2311ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 30657ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 31856ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 32591ms] [LOG] [Fast Refresh] done in 736ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
|
||||
[ 35593ms] [WARNING] The resource http://localhost:4001/_next/static/media/4de1fea1a954a5b6-s.p.woff2?v=1785974148275 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/dashboard:0
|
||||
[ 35593ms] [WARNING] The resource http://localhost:4001/_next/static/css/app/layout.css?v=1785974159605 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/dashboard:0
|
||||
[ 35593ms] [WARNING] The resource http://localhost:4001/_next/static/media/22a5144ee8d83bca-s.p.woff2?v=1785974148275 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/dashboard:0
|
||||
[ 35593ms] [WARNING] The resource http://localhost:4001/_next/static/media/36966cca54120369-s.p.woff2?v=1785974148275 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/dashboard:0
|
||||
[ 35593ms] [WARNING] The resource http://localhost:4001/_next/static/media/eaead17c7dbfcd5d-s.p.woff2?v=1785974148275 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/dashboard:0
|
||||
[ 35593ms] [WARNING] The resource http://localhost:4001/_next/static/media/ee40bb094c99a29a-s.p.woff2?v=1785974148275 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/dashboard:0
|
||||
[ 35593ms] [WARNING] The resource http://localhost:4001/_next/static/media/558ca1a6aa3cb55e-s.p.woff2?v=1785974148275 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/dashboard:0
|
||||
[ 35594ms] [WARNING] The resource http://localhost:4001/_next/static/media/7b0b24f36b1a6d0b-s.p.woff2?v=1785974148275 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/dashboard:0
|
||||
[ 35594ms] [WARNING] The resource http://localhost:4001/_next/static/media/e4af272ccee01ff0-s.p.woff2?v=1785974148275 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/dashboard:0
|
||||
[ 35594ms] [WARNING] The resource http://localhost:4001/_next/static/media/7d4881bb7e1bf84d-s.p.woff2?v=1785974148275 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/dashboard:0
|
||||
[ 35594ms] [WARNING] The resource http://localhost:4001/_next/static/media/13971731025ec697-s.p.woff2?v=1785974148275 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/dashboard:0
|
||||
[ 35594ms] [WARNING] The resource http://localhost:4001/_next/static/media/6d664cce900333ee-s.p.woff2?v=1785974148275 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/dashboard:0
|
||||
11
.playwright-mcp/console-2026-08-05T23-56-08-778Z.log
Normal file
11
.playwright-mcp/console-2026-08-05T23-56-08-778Z.log
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
[ 5124ms] [WARNING] The resource http://localhost:4001/_next/static/media/6d664cce900333ee-s.p.woff2?v=1785974168950 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/quotes/ybqit9d6150vmg0:0
|
||||
[ 5124ms] [WARNING] The resource http://localhost:4001/_next/static/media/4de1fea1a954a5b6-s.p.woff2?v=1785974168950 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/quotes/ybqit9d6150vmg0:0
|
||||
[ 5124ms] [WARNING] The resource http://localhost:4001/_next/static/media/22a5144ee8d83bca-s.p.woff2?v=1785974168950 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/quotes/ybqit9d6150vmg0:0
|
||||
[ 5124ms] [WARNING] The resource http://localhost:4001/_next/static/media/36966cca54120369-s.p.woff2?v=1785974168950 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/quotes/ybqit9d6150vmg0:0
|
||||
[ 5124ms] [WARNING] The resource http://localhost:4001/_next/static/media/13971731025ec697-s.p.woff2?v=1785974168950 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/quotes/ybqit9d6150vmg0:0
|
||||
[ 5124ms] [WARNING] The resource http://localhost:4001/_next/static/media/7b0b24f36b1a6d0b-s.p.woff2?v=1785974168950 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/quotes/ybqit9d6150vmg0:0
|
||||
[ 5124ms] [WARNING] The resource http://localhost:4001/_next/static/media/7d4881bb7e1bf84d-s.p.woff2?v=1785974168950 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/quotes/ybqit9d6150vmg0:0
|
||||
[ 5124ms] [WARNING] The resource http://localhost:4001/_next/static/media/558ca1a6aa3cb55e-s.p.woff2?v=1785974168950 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/quotes/ybqit9d6150vmg0:0
|
||||
[ 5124ms] [WARNING] The resource http://localhost:4001/_next/static/media/eaead17c7dbfcd5d-s.p.woff2?v=1785974168950 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/quotes/ybqit9d6150vmg0:0
|
||||
[ 5124ms] [WARNING] The resource http://localhost:4001/_next/static/media/ee40bb094c99a29a-s.p.woff2?v=1785974168950 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/quotes/ybqit9d6150vmg0:0
|
||||
[ 5124ms] [WARNING] The resource http://localhost:4001/_next/static/media/e4af272ccee01ff0-s.p.woff2?v=1785974168950 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/quotes/ybqit9d6150vmg0:0
|
||||
3
.playwright-mcp/page-2026-08-05T23-55-29-859Z.yml
Normal file
3
.playwright-mcp/page-2026-08-05T23-55-29-859Z.yml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
- generic [active] [ref=e1]:
|
||||
- region "Notifications alt+T"
|
||||
- button "Open Next.js Dev Tools" [ref=e9] [cursor=pointer]
|
||||
10
.playwright-mcp/page-2026-08-05T23-55-37-598Z.yml
Normal file
10
.playwright-mcp/page-2026-08-05T23-55-37-598Z.yml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
- generic [active] [ref=f1e1]:
|
||||
- region "Notifications alt+T"
|
||||
- button "Open Next.js Dev Tools" [ref=f1e7] [cursor=pointer]:
|
||||
- generic [ref=f1e10]:
|
||||
- text: Compiling
|
||||
- generic [ref=f1e11]:
|
||||
- generic [ref=f1e12]: .
|
||||
- generic [ref=f1e13]: .
|
||||
- generic [ref=f1e14]: .
|
||||
- alert [ref=f1e15]
|
||||
2
.playwright-mcp/page-2026-08-05T23-55-49-667Z.yml
Normal file
2
.playwright-mcp/page-2026-08-05T23-55-49-667Z.yml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
- generic [active] [ref=f3e1]:
|
||||
- region "Notifications alt+T"
|
||||
44
.playwright-mcp/page-2026-08-05T23-56-05-361Z.yml
Normal file
44
.playwright-mcp/page-2026-08-05T23-56-05-361Z.yml
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
- generic [active] [ref=f3e1]:
|
||||
- generic [ref=f3e20]:
|
||||
- complementary [ref=f3e21]:
|
||||
- generic [ref=f3e22]:
|
||||
- link "WFDASH WFDASH Westcott Fasteners" [ref=f3e23] [cursor=pointer]:
|
||||
- /url: /dashboard
|
||||
- img "WFDASH" [ref=f3e24]
|
||||
- generic [ref=f3e25]:
|
||||
- generic [ref=f3e26]: WFDASH
|
||||
- generic [ref=f3e27]: Westcott Fasteners
|
||||
- separator [ref=f3e28]
|
||||
- navigation [ref=f3e31]:
|
||||
- link "Dashboard" [ref=f3e32] [cursor=pointer]:
|
||||
- /url: /dashboard
|
||||
- separator [ref=f3e41]
|
||||
- button "Management" [ref=f3e43]
|
||||
- button "Quotes" [ref=f3e47]
|
||||
- button "Orders" [ref=f3e51]
|
||||
- button "Logistics" [ref=f3e55]
|
||||
- button "Operations" [ref=f3e59]
|
||||
- button "Stock" [ref=f3e63]
|
||||
- button "Other" [ref=f3e67]
|
||||
- separator [ref=f3e70]
|
||||
- generic [ref=f3e71]:
|
||||
- link "Settings" [ref=f3e72] [cursor=pointer]:
|
||||
- /url: /settings
|
||||
- button "Dark mode" [ref=f3e76]
|
||||
- separator [ref=f3e79]
|
||||
- generic [ref=f3e80]:
|
||||
- generic [ref=f3e81]: D
|
||||
- generic [ref=f3e83]:
|
||||
- paragraph [ref=f3e84]: David Els
|
||||
- paragraph [ref=f3e85]: david.els1@outlook.com
|
||||
- button "Logout" [ref=f3e86]
|
||||
- main [ref=f3e91]
|
||||
- region "Notifications alt+T"
|
||||
- button "Open Next.js Dev Tools" [ref=f3e117] [cursor=pointer]:
|
||||
- generic [ref=f3e120]:
|
||||
- text: Rendering
|
||||
- generic [ref=f3e121]:
|
||||
- generic [ref=f3e122]: .
|
||||
- generic [ref=f3e123]: .
|
||||
- generic [ref=f3e124]: .
|
||||
- alert [ref=f3e17]
|
||||
2
.playwright-mcp/page-2026-08-05T23-56-10-918Z.yml
Normal file
2
.playwright-mcp/page-2026-08-05T23-56-10-918Z.yml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
- generic [active] [ref=f5e1]:
|
||||
- region "Notifications alt+T"
|
||||
2
.playwright-mcp/page-2026-08-05T23-56-19-956Z.yml
Normal file
2
.playwright-mcp/page-2026-08-05T23-56-19-956Z.yml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
- generic [active] [ref=f5e1]:
|
||||
- region "Notifications alt+T"
|
||||
2
.playwright-mcp/page-2026-08-05T23-56-33-115Z.yml
Normal file
2
.playwright-mcp/page-2026-08-05T23-56-33-115Z.yml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
- generic [active] [ref=f5e1]:
|
||||
- region "Notifications alt+T"
|
||||
|
|
@ -34,6 +34,12 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
TabsContent,
|
||||
} from "@/components/ui/tabs";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Trash2,
|
||||
|
|
@ -75,6 +81,7 @@ import {
|
|||
RotateCcw,
|
||||
Loader2,
|
||||
Settings,
|
||||
Activity,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -234,6 +241,7 @@ function AttachmentsCard({
|
|||
onUpload,
|
||||
onDelete,
|
||||
onPreview,
|
||||
noWrap,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
|
|
@ -247,6 +255,13 @@ function AttachmentsCard({
|
|||
onUpload: (file: File) => Promise<void>;
|
||||
onDelete: (att: QuoteAttachment) => Promise<void>;
|
||||
onPreview: (att: QuoteAttachment) => void;
|
||||
/**
|
||||
* When true, skip the outer Card + CardHeader + CardTitle wrappers
|
||||
* and render only the body (upload zone + list). Used inside tabbed
|
||||
* containers where the parent already provides the Card chrome and
|
||||
* tab headers — without this, we'd render a card-within-a-card.
|
||||
*/
|
||||
noWrap?: boolean;
|
||||
}) {
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
|
|
@ -257,6 +272,82 @@ function AttachmentsCard({
|
|||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
};
|
||||
|
||||
const body = (
|
||||
<>
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={async (e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
await handleFile(file);
|
||||
}}
|
||||
className={`rounded-md border-2 border-dashed p-6 text-center transition-colors ${
|
||||
dragOver
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border bg-muted/20 hover:bg-muted/30"
|
||||
} ${uploading ? "opacity-60 pointer-events-none" : "cursor-pointer"}`}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") fileInputRef.current?.click();
|
||||
}}
|
||||
aria-label={uploadAriaLabel}
|
||||
>
|
||||
<Paperclip className="h-6 w-6 mx-auto mb-2 text-muted-foreground" />
|
||||
{uploading ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<RefreshCw className="inline h-3.5 w-3.5 animate-spin mr-1" />
|
||||
Uploading…
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm">Drop a file here or click to upload</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
PDFs, images, Word / Excel docs
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={async (e) => {
|
||||
await handleFile(e.target.files?.[0]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{attachments.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">{emptyText}</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5" data-testid={listTestId}>
|
||||
{attachments.map((a) => (
|
||||
<OutboundAttachmentRow
|
||||
key={a.id}
|
||||
attachment={a}
|
||||
deleting={deletingId === a.id}
|
||||
onDelete={() => onDelete(a)}
|
||||
onPreview={() => onPreview(a)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (noWrap) {
|
||||
// Body-only — the parent already provides the Card chrome and
|
||||
// tab headers. Wrapping in <div className="space-y-4"> keeps the
|
||||
// spacing between the upload zone and the file list.
|
||||
return <div className="space-y-4">{body}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
|
@ -268,72 +359,7 @@ function AttachmentsCard({
|
|||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={async (e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
await handleFile(file);
|
||||
}}
|
||||
className={`rounded-md border-2 border-dashed p-6 text-center transition-colors ${
|
||||
dragOver
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border bg-muted/20 hover:bg-muted/30"
|
||||
} ${uploading ? "opacity-60 pointer-events-none" : "cursor-pointer"}`}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") fileInputRef.current?.click();
|
||||
}}
|
||||
aria-label={uploadAriaLabel}
|
||||
>
|
||||
<Paperclip className="h-6 w-6 mx-auto mb-2 text-muted-foreground" />
|
||||
{uploading ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<RefreshCw className="inline h-3.5 w-3.5 animate-spin mr-1" />
|
||||
Uploading…
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm">Drop a file here or click to upload</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
PDFs, images, Word / Excel docs
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={async (e) => {
|
||||
await handleFile(e.target.files?.[0]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{attachments.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">{emptyText}</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5" data-testid={listTestId}>
|
||||
{attachments.map((a) => (
|
||||
<OutboundAttachmentRow
|
||||
key={a.id}
|
||||
attachment={a}
|
||||
deleting={deletingId === a.id}
|
||||
onDelete={() => onDelete(a)}
|
||||
onPreview={() => onPreview(a)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardContent className="space-y-4">{body}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -779,6 +805,12 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
|
|||
// collection exists yet). Full audit trail needs a migration; see the
|
||||
// History card's NOTE for details.
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
// "Original Request" (the inbound email) section toggle. Default
|
||||
// closed — when an operator opens a quote they want to land on the
|
||||
// items table, not on a wall of email text. The header strip still
|
||||
// shows the customer + subject at a glance so the operator can
|
||||
// decide whether to expand the email for context.
|
||||
const [emailOpen, setEmailOpen] = useState(false);
|
||||
// Approve & Send modal — opens when the operator clicks the primary
|
||||
// Approve button. Mirrors the old Django approve modal's layout
|
||||
// (customer block, send-email toggle, To/CC/BCC/Subject/Message
|
||||
|
|
@ -1904,6 +1936,106 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Compact customer strip — replaces the old "Customer" sidebar
|
||||
card. Lifts the customer / company / dates / letterhead /
|
||||
claimed-by info onto a single horizontal band directly below
|
||||
the header so the operator sees "who is this quote for"
|
||||
before they decide which sidebar panel to open. Mobile wraps
|
||||
the columns; desktop shows them inline.
|
||||
*/}
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<div className="flex flex-wrap items-start gap-x-6 gap-y-3 text-sm">
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">Customer</p>
|
||||
<p className="font-medium">{quote.expand?.customer?.customer || "—"}</p>
|
||||
{quote.expand?.customer?.expand?.company && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{quote.expand.customer.expand.company.company}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">Company</p>
|
||||
<p className="font-medium">{quote.expand?.company?.company || "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">Created</p>
|
||||
<p className="font-medium">{formatDate(quote.created)}</p>
|
||||
</div>
|
||||
{quote.quote_date && (
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">Quote date</p>
|
||||
<p className="font-medium">{formatDate(quote.quote_date)}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">Letterhead</p>
|
||||
<p className="font-medium">{quote.company_letterhead || "CNL"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">Claimed by</p>
|
||||
{quote.claimed_by ? (
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<Avatar size="sm">
|
||||
<AvatarFallback>{(claimerName || quote.claimed_by).slice(0, 2).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<p className="font-medium truncate" title={claimerName || quote.claimed_by}>
|
||||
{claimerName || (quote.claimed_by.length > 12 ? `${quote.claimed_by.slice(0, 12)}…` : quote.claimed_by)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Unclaimed</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/*
|
||||
Original request (the customer's email + inbound attachments).
|
||||
Moved here from the BOTTOM of the page — operators wanted it
|
||||
visible at the same time as the items so they can cross-check
|
||||
"did the customer say 5L or 4L?" while pricing without scrolling
|
||||
past everything else. Default closed (email body is verbose);
|
||||
the header still shows the customer + email subject at a glance
|
||||
so the operator can decide whether to expand.
|
||||
*/}
|
||||
{(quote.email_body || attachments.some((a) => a.direction === "inbound")) && (
|
||||
<Card>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEmailOpen((s) => !s)}
|
||||
className="w-full text-left px-6 py-4 flex items-center justify-between gap-3 cursor-pointer hover:bg-accent/30 transition-colors rounded-xl"
|
||||
aria-expanded={emailOpen}
|
||||
aria-controls="quote-email-body"
|
||||
data-testid="toggle-original-request"
|
||||
>
|
||||
<span className="text-sm font-medium flex items-center gap-2">
|
||||
<Mail className="h-4 w-4" />
|
||||
Original request
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
{quote.email_subject || "what the customer sent"}
|
||||
</span>
|
||||
</span>
|
||||
{emailOpen ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
{emailOpen && (
|
||||
<CardContent id="quote-email-body" className="pt-0">
|
||||
<QuoteEmailCard
|
||||
quote={quote}
|
||||
attachments={attachments.filter((a) => a.direction === "inbound")}
|
||||
/>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{showReject && (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="pt-4 space-y-2">
|
||||
|
|
@ -2154,109 +2286,95 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* RIGHT COLUMN — meta + admin panels. The eye should land on the
|
||||
Items card first; the panels on the right are reference / config
|
||||
and sit quietly. Mobile stacks them below the items. */}
|
||||
{/* RIGHT COLUMN — admin panels. The eye should land on the Items
|
||||
card first; the panels on the right are reference / config
|
||||
and sit quietly. Customer info lives in the strip below the
|
||||
header (lifted out of this column during the v3 redesign).
|
||||
Mobile stacks everything below the items.
|
||||
Three cards now (down from six):
|
||||
1. Attachments — tabbed (Outbound / Internal)
|
||||
2. Linked Orders — its own card (specific, deserves focus)
|
||||
3. Activity — tabbed (History / Email Status) */}
|
||||
<div className="space-y-6">
|
||||
{/* Compact meta card — Customer / Company / Dates / Letterhead /
|
||||
Claimed by, packed into a single card with a header icon
|
||||
rather than five separate micro-cards. */}
|
||||
{/*
|
||||
Attachments — single tabbed card with Outbound and Internal
|
||||
tabs. Both write to the same `quote_attachments` PB
|
||||
collection; they differ only in the `direction` value
|
||||
stamped on the row and the labels/icon shown in the UI.
|
||||
|
||||
- `direction = "outbound"` → files the customer receives
|
||||
(picked up by the n8n email-send workflow)
|
||||
- `direction = "internal"` → price-evidence files the
|
||||
office uses to justify the prices (never sent to the
|
||||
buyer)
|
||||
*/}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Building2 className="h-4 w-4 text-muted-foreground" />
|
||||
Customer
|
||||
<Paperclip className="h-4 w-4 text-muted-foreground" />
|
||||
Attachments
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div>
|
||||
<p className="font-medium">{quote.expand?.customer?.customer || "—"}</p>
|
||||
{quote.expand?.customer?.expand?.company && (
|
||||
<p className="text-xs text-muted-foreground">{quote.expand.customer.expand.company.company}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">Company</p>
|
||||
<p className="font-medium">{quote.expand?.company?.company || "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">Letterhead</p>
|
||||
<p className="font-medium">{quote.company_letterhead || "CNL"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">Created</p>
|
||||
<p className="font-medium">{formatDate(quote.created)}</p>
|
||||
</div>
|
||||
{quote.quote_date && (
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">Quote date</p>
|
||||
<p className="font-medium">{formatDate(quote.quote_date)}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="pt-2 border-t">
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Claimed by</p>
|
||||
{quote.claimed_by ? (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Avatar size="sm">
|
||||
<AvatarFallback>{(claimerName || quote.claimed_by).slice(0, 2).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<p className="font-medium truncate" title={claimerName || quote.claimed_by}>
|
||||
{claimerName || (quote.claimed_by.length > 12 ? `${quote.claimed_by.slice(0, 12)}…` : quote.claimed_by)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Unclaimed</p>
|
||||
)}
|
||||
</div>
|
||||
<CardContent className="pt-0">
|
||||
<Tabs defaultValue="outbound" className="w-full">
|
||||
<TabsList className="w-full grid grid-cols-2">
|
||||
<TabsTrigger value="outbound" data-testid="tab-outbound-attachments">
|
||||
Outbound
|
||||
<span className="ml-1.5 text-[10px] font-normal text-muted-foreground">
|
||||
{outboundAttachments.length}
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="internal" data-testid="tab-internal-attachments">
|
||||
Internal
|
||||
<span className="ml-1.5 text-[10px] font-normal text-muted-foreground">
|
||||
{internalAttachments.length}
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="outbound" className="mt-3">
|
||||
<AttachmentsCard
|
||||
noWrap
|
||||
title="Outbound attachments"
|
||||
subtitle="files to send with this quote"
|
||||
icon={Send}
|
||||
emptyText="No outbound attachments yet. The generated quote PDF will be added automatically when you approve and email the quote."
|
||||
listTestId="outbound-attachments"
|
||||
uploadAriaLabel="Upload outbound attachment"
|
||||
attachments={outboundAttachments}
|
||||
uploading={outboundUploading}
|
||||
deletingId={outboundDeleting}
|
||||
onUpload={handleOutboundUpload}
|
||||
onDelete={handleOutboundDelete}
|
||||
onPreview={(att) => {
|
||||
// Outbound file preview reuses the email-card's
|
||||
// dialog via a tiny inline state — opening the
|
||||
// same attachment preview pattern the office
|
||||
// already trusts.
|
||||
setOutboundPreviewAtt(att);
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="internal" className="mt-3">
|
||||
<AttachmentsCard
|
||||
noWrap
|
||||
title="Internal attachments"
|
||||
subtitle="price evidence — never sent to the buyer"
|
||||
icon={FileLock}
|
||||
emptyText="No internal attachments yet. Drop screenshots of supplier quotes, price-list PDFs, or distributor web pages here to justify the prices on each item."
|
||||
listTestId="internal-attachments"
|
||||
uploadAriaLabel="Upload internal attachment"
|
||||
attachments={internalAttachments}
|
||||
uploading={internalUploading}
|
||||
deletingId={internalDeleting}
|
||||
onUpload={handleInternalUpload}
|
||||
onDelete={handleInternalDelete}
|
||||
onPreview={(att) => setInternalPreviewAtt(att)}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AttachmentsCard
|
||||
title="Outbound attachments"
|
||||
subtitle="files to send with this quote"
|
||||
icon={Send}
|
||||
emptyText="No outbound attachments yet. The generated quote PDF will be added automatically when you approve and email the quote."
|
||||
listTestId="outbound-attachments"
|
||||
uploadAriaLabel="Upload outbound attachment"
|
||||
attachments={outboundAttachments}
|
||||
uploading={outboundUploading}
|
||||
deletingId={outboundDeleting}
|
||||
onUpload={handleOutboundUpload}
|
||||
onDelete={handleOutboundDelete}
|
||||
onPreview={(att) => {
|
||||
// Outbound file preview reuses the email-card's dialog via
|
||||
// a tiny inline state — opening the same attachment preview
|
||||
// pattern the office already trusts. Implementation lives
|
||||
// alongside the card to keep the modal scope local.
|
||||
setOutboundPreviewAtt(att);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/*
|
||||
* Internal / price-evidence attachments — the quoter
|
||||
* drops screenshots of supplier quotes, price-list PDFs,
|
||||
* distributor web pages, etc. here to justify the prices
|
||||
* they set on each item. These NEVER go to the buyer —
|
||||
* the `direction = "internal"` tag on the PB row tells
|
||||
* the n8n outbound-email workflow to skip them.
|
||||
*/}
|
||||
<AttachmentsCard
|
||||
title="Internal attachments"
|
||||
subtitle="price evidence — never sent to the buyer"
|
||||
icon={FileLock}
|
||||
emptyText="No internal attachments yet. Drop screenshots of supplier quotes, price-list PDFs, or distributor web pages here to justify the prices on each item."
|
||||
listTestId="internal-attachments"
|
||||
uploadAriaLabel="Upload internal attachment"
|
||||
attachments={internalAttachments}
|
||||
uploading={internalUploading}
|
||||
deletingId={internalDeleting}
|
||||
onUpload={handleInternalUpload}
|
||||
onDelete={handleInternalDelete}
|
||||
onPreview={(att) => setInternalPreviewAtt(att)}
|
||||
/>
|
||||
|
||||
{/* Linked orders — every order that has been hard-linked to
|
||||
this quote, OR that has us as a `potential_quote` suggestion
|
||||
awaiting accept. Single surface for the operator to see
|
||||
|
|
@ -2296,178 +2414,174 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* History sits below Linked Orders — it's a reference panel,
|
||||
the same accordion pattern as before. Quote Settings has
|
||||
moved to the kebab menu ("Quote Settings…") so this card
|
||||
doesn't need to exist inline anymore. */}
|
||||
|
||||
<Card>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHistoryOpen((s) => !s)}
|
||||
className="w-full text-left px-6 py-4 flex items-center justify-between gap-3 cursor-pointer hover:bg-accent/30 transition-colors rounded-xl"
|
||||
aria-expanded={historyOpen}
|
||||
aria-controls="quote-history-body"
|
||||
>
|
||||
<span className="text-sm font-medium flex items-center gap-2">
|
||||
<History className="h-4 w-4" />
|
||||
History
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
basic — full audit needs a migration
|
||||
</span>
|
||||
</span>
|
||||
{historyOpen ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
{historyOpen && (
|
||||
<CardContent id="quote-history-body" className="space-y-3 pt-0">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Derived from the existing quote + email fields. Full audit trail
|
||||
(with actor + per-field diffs) requires a new PB collection and
|
||||
write hooks — tracked as a follow-up migration.
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<HistoryRow
|
||||
icon={<FileText className="h-3.5 w-3.5" />}
|
||||
label="Quote created"
|
||||
timestamp={quote.created}
|
||||
/>
|
||||
<HistoryRow
|
||||
icon={<RefreshCw className="h-3.5 w-3.5" />}
|
||||
label="Last updated"
|
||||
timestamp={quote.updated}
|
||||
muted={quote.updated === quote.created}
|
||||
/>
|
||||
<HistoryRow
|
||||
icon={<User className="h-3.5 w-3.5" />}
|
||||
label={`Status: ${statusLabels[quote.status] || quote.status}`}
|
||||
timestamp={quote.updated}
|
||||
/>
|
||||
{quote.claimed_by && (
|
||||
<HistoryRow
|
||||
icon={<User className="h-3.5 w-3.5" />}
|
||||
label={`Claimed by ${claimerName || quote.claimed_by}`}
|
||||
timestamp={null}
|
||||
muted
|
||||
/>
|
||||
)}
|
||||
{quote.email_received_at && (
|
||||
<HistoryRow
|
||||
icon={<Mail className="h-3.5 w-3.5" />}
|
||||
label="Original email received"
|
||||
timestamp={quote.email_received_at}
|
||||
/>
|
||||
)}
|
||||
{quote.email_delivered && (
|
||||
<HistoryRow
|
||||
icon={<Mail className="h-3.5 w-3.5" />}
|
||||
label="Quote email delivered"
|
||||
timestamp={quote.email_delivered_at}
|
||||
/>
|
||||
)}
|
||||
{quote.email_opened && (
|
||||
<HistoryRow
|
||||
icon={<MailOpen className="h-3.5 w-3.5" />}
|
||||
label="Quote email opened"
|
||||
timestamp={quote.email_opened_at}
|
||||
/>
|
||||
)}
|
||||
{quote.last_reply_at && (
|
||||
<HistoryRow
|
||||
icon={<Mail className="h-3.5 w-3.5" />}
|
||||
label="Customer replied"
|
||||
timestamp={quote.last_reply_at}
|
||||
/>
|
||||
)}
|
||||
{quote.pdf_generated_at && (
|
||||
<HistoryRow
|
||||
icon={<FileDown className="h-3.5 w-3.5" />}
|
||||
label="PDF generated"
|
||||
timestamp={quote.pdf_generated_at}
|
||||
/>
|
||||
)}
|
||||
{quote.email_failed_at && (
|
||||
<HistoryRow
|
||||
icon={<XCircle className="h-3.5 w-3.5" />}
|
||||
label={
|
||||
quote.email_error
|
||||
? `Email send failed — ${quote.email_error}`
|
||||
: "Email send failed"
|
||||
}
|
||||
timestamp={quote.email_failed_at}
|
||||
destructive
|
||||
/>
|
||||
)}
|
||||
{quote.status === "sending" && quote.sending_started_at && (
|
||||
<HistoryRow
|
||||
icon={<Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
label="Email send in progress"
|
||||
timestamp={quote.sending_started_at}
|
||||
/>
|
||||
)}
|
||||
{quote.approved_by && (
|
||||
<HistoryRow
|
||||
icon={<CheckCircle className="h-3.5 w-3.5" />}
|
||||
label={`Approved by ${claimerName || quote.approved_by}`}
|
||||
timestamp={quote.updated}
|
||||
/>
|
||||
)}
|
||||
{quote.follow_up_due && (
|
||||
<HistoryRow
|
||||
icon={<Clock className="h-3.5 w-3.5" />}
|
||||
label={`Follow-up due ${formatDate(quote.follow_up_due)}`}
|
||||
timestamp={null}
|
||||
muted
|
||||
/>
|
||||
)}
|
||||
</ul>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/*
|
||||
Activity — single tabbed card combining History (event log)
|
||||
and Email Status (delivery state). Both are reference data
|
||||
the operator checks occasionally, not work surfaces — putting
|
||||
them under one roof reduces sidebar noise without hiding
|
||||
either. Defaults to Email Status because that's what the
|
||||
operator checks when triaging a "did the customer get it?"
|
||||
question; History is one click away.
|
||||
*/}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
Email Status
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
Activity
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">Delivered</p>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
{quote.email_delivered ? (
|
||||
<Mail className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span>{quote.email_delivered ? "Yes" : "No"}</span>
|
||||
<CardContent className="pt-0">
|
||||
<Tabs defaultValue="email" className="w-full">
|
||||
<TabsList className="w-full grid grid-cols-2">
|
||||
<TabsTrigger value="email" data-testid="tab-email-status">
|
||||
Email Status
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="history" data-testid="tab-history">
|
||||
History
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="email" className="mt-3">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">Delivered</p>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
{quote.email_delivered ? (
|
||||
<Mail className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span>{quote.email_delivered ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">Opened</p>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
{quote.email_opened ? (
|
||||
<MailOpen className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<MailOpen className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span>{quote.email_opened ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">Sender</p>
|
||||
<p className="truncate">{quote.email_sender || "—"}</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">Subject</p>
|
||||
<p className="truncate">{quote.email_subject || "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">Opened</p>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
{quote.email_opened ? (
|
||||
<MailOpen className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<MailOpen className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span>{quote.email_opened ? "Yes" : "No"}</span>
|
||||
</TabsContent>
|
||||
<TabsContent value="history" className="mt-3">
|
||||
{/* History body — always visible inside its tab. No
|
||||
outer accordion anymore; the tab itself is the
|
||||
affordance for "show less / show different". */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Derived from the existing quote + email fields. Full audit trail
|
||||
(with actor + per-field diffs) requires a new PB collection and
|
||||
write hooks — tracked as a follow-up migration.
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm" data-testid="history-list">
|
||||
<HistoryRow
|
||||
icon={<FileText className="h-3.5 w-3.5" />}
|
||||
label="Quote created"
|
||||
timestamp={quote.created}
|
||||
/>
|
||||
<HistoryRow
|
||||
icon={<RefreshCw className="h-3.5 w-3.5" />}
|
||||
label="Last updated"
|
||||
timestamp={quote.updated}
|
||||
muted={quote.updated === quote.created}
|
||||
/>
|
||||
<HistoryRow
|
||||
icon={<User className="h-3.5 w-3.5" />}
|
||||
label={`Status: ${statusLabels[quote.status] || quote.status}`}
|
||||
timestamp={quote.updated}
|
||||
/>
|
||||
{quote.claimed_by && (
|
||||
<HistoryRow
|
||||
icon={<User className="h-3.5 w-3.5" />}
|
||||
label={`Claimed by ${claimerName || quote.claimed_by}`}
|
||||
timestamp={null}
|
||||
muted
|
||||
/>
|
||||
)}
|
||||
{quote.email_received_at && (
|
||||
<HistoryRow
|
||||
icon={<Mail className="h-3.5 w-3.5" />}
|
||||
label="Original email received"
|
||||
timestamp={quote.email_received_at}
|
||||
/>
|
||||
)}
|
||||
{quote.email_delivered && (
|
||||
<HistoryRow
|
||||
icon={<Mail className="h-3.5 w-3.5" />}
|
||||
label="Quote email delivered"
|
||||
timestamp={quote.email_delivered_at}
|
||||
/>
|
||||
)}
|
||||
{quote.email_opened && (
|
||||
<HistoryRow
|
||||
icon={<MailOpen className="h-3.5 w-3.5" />}
|
||||
label="Quote email opened"
|
||||
timestamp={quote.email_opened_at}
|
||||
/>
|
||||
)}
|
||||
{quote.last_reply_at && (
|
||||
<HistoryRow
|
||||
icon={<Mail className="h-3.5 w-3.5" />}
|
||||
label="Customer replied"
|
||||
timestamp={quote.last_reply_at}
|
||||
/>
|
||||
)}
|
||||
{quote.pdf_generated_at && (
|
||||
<HistoryRow
|
||||
icon={<FileDown className="h-3.5 w-3.5" />}
|
||||
label="PDF generated"
|
||||
timestamp={quote.pdf_generated_at}
|
||||
/>
|
||||
)}
|
||||
{quote.email_failed_at && (
|
||||
<HistoryRow
|
||||
icon={<XCircle className="h-3.5 w-3.5" />}
|
||||
label={
|
||||
quote.email_error
|
||||
? `Email send failed — ${quote.email_error}`
|
||||
: "Email send failed"
|
||||
}
|
||||
timestamp={quote.email_failed_at}
|
||||
destructive
|
||||
/>
|
||||
)}
|
||||
{quote.status === "sending" && quote.sending_started_at && (
|
||||
<HistoryRow
|
||||
icon={<Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
label="Email send in progress"
|
||||
timestamp={quote.sending_started_at}
|
||||
/>
|
||||
)}
|
||||
{quote.approved_by && (
|
||||
<HistoryRow
|
||||
icon={<CheckCircle className="h-3.5 w-3.5" />}
|
||||
label={`Approved by ${claimerName || quote.approved_by}`}
|
||||
timestamp={quote.updated}
|
||||
/>
|
||||
)}
|
||||
{quote.follow_up_due && (
|
||||
<HistoryRow
|
||||
icon={<Clock className="h-3.5 w-3.5" />}
|
||||
label={`Follow-up due ${formatDate(quote.follow_up_due)}`}
|
||||
timestamp={null}
|
||||
muted
|
||||
/>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">Sender</p>
|
||||
<p className="truncate">{quote.email_sender || "—"}</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">Subject</p>
|
||||
<p className="truncate">{quote.email_subject || "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
@ -2541,45 +2655,6 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
|
|||
onClose={() => setInternalPreviewAtt(null)}
|
||||
/>
|
||||
|
||||
{/*
|
||||
Original request — moved to the BOTTOM of the page per
|
||||
operator feedback. The previous "Compare with original
|
||||
request" toggle above the items was disruptive: it pushed
|
||||
the items table down when the approver opened it, and
|
||||
most of the time the operator just wanted a quick
|
||||
reference (e.g. "did the customer say 5L or 4L?") without
|
||||
it being in the way of the pricing work.
|
||||
|
||||
Now it's a dedicated section at the bottom. The same
|
||||
`QuoteEmailCard` component renders the original email body
|
||||
+ the inbound attachments (PDFs, drawings, specs) that
|
||||
the customer sent — all direction="inbound" rows.
|
||||
The "inbound" direction is filtered out of the outbound
|
||||
and internal cards above, so there's exactly one place
|
||||
to see each attachment.
|
||||
*/}
|
||||
{(quote.email_body || attachments.some((a) => a.direction === "inbound")) && (
|
||||
<section
|
||||
id="quote-original-request"
|
||||
aria-labelledby="quote-original-request-heading"
|
||||
>
|
||||
<h2
|
||||
id="quote-original-request-heading"
|
||||
className="text-base font-semibold mb-3 flex items-center gap-2"
|
||||
>
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
Original request
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
what the customer sent
|
||||
</span>
|
||||
</h2>
|
||||
<QuoteEmailCard
|
||||
quote={quote}
|
||||
attachments={attachments.filter((a) => a.direction === "inbound")}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<ApproveAndSendDialog
|
||||
open={approveOpen}
|
||||
onOpenChange={setApproveOpen}
|
||||
|
|
|
|||
Loading…
Reference in a new issue