Compare commits

...

10 commits

Author SHA1 Message Date
Alexandre Brandizzi
8813cb2e75
Merge pull request #37 from Sea-Haven-Industries/feature/wo-domain-utils-mocks
Some checks are pending
CI / ci (push) Waiting to run
Deploy / deploy (push) Waiting to run
feat(work-orders): add board utils and mock data
2026-07-21 14:10:45 -03:00
Alexandre Brandizzi
a55beb903c
Merge branch 'dev' into feature/wo-domain-utils-mocks 2026-07-21 14:07:59 -03:00
Alexandre Brandizzi
31e4fb1907
Merge pull request #36 from Sea-Haven-Industries/feature/wo-domain-core
feat(work-orders): add domain core types, api, mappers and use-cases
2026-07-21 14:07:44 -03:00
Alexandre Brandizzi
579af60278
Merge branch 'dev' into feature/wo-domain-core 2026-07-21 14:04:31 -03:00
Alexandre Brandizzi
ec20c693d3
Merge pull request #35 from Sea-Haven-Industries/feature/wo-shared-ui
feat(ui): add shared board UI primitives and plumbing
2026-07-21 14:03:58 -03:00
Arthur Bassi
4beefba719 fix(work-orders): align consumers with domain-core types 2026-07-21 09:47:43 -03:00
Arthur Bassi
df8f3553e8 feat(work-orders): add board utils and mock data 2026-07-17 13:59:31 -03:00
Arthur Bassi
e13bc4c1a2 feat(work-orders): add domain core types, api, mappers and use-cases 2026-07-17 13:58:19 -03:00
Arthur Bassi
a3e8a533b1 fix(ui): align shared board plumbing with ky v2 and react-router
- api.ts: use ky v2 hook object args, prefix (not prefixUrl), and normalizeApiRequestError
- replace react-router-dom with react-router in layout, topbar, and vendor-pos view
- move MUI style props (justifyContent, alignItems, flexWrap, display, fontWeight) into sx
- resource-list-table/query-key: use local types instead of missing work-order-list module
2026-07-17 13:31:56 -03:00
Arthur Bassi
01939cbb70 feat(ui): add shared board UI primitives and plumbing 2026-07-17 11:59:05 -03:00
87 changed files with 4598 additions and 438 deletions

108
public/seahaven-logo.svg Normal file
View file

@ -0,0 +1,108 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Seahaven Industries — official client wordmark. viewBox cropped to the
artwork bounds (original art sits in a middle band of a 100x100 canvas);
aspect ratio preserved, nothing distorted. -->
<svg version="1.2" baseProfile="tiny-ps" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="9 40 81 22" xml:space="preserve">
<title>Seahaven Industries</title>
<path fill="#A8D6EF" d="M6.3,55.1C6.3,55.1,6.3,55.1,6.3,55.1C6.3,55.1,6.3,55.1,6.3,55.1C6.3,55.1,6.3,55.1,6.3,55.1z"/>
<g>
<path fill="#2BABE2" d="M19.4,43.8c-0.5-0.3-1-0.5-1.6-0.6c-0.6-0.1-1.2-0.2-1.8,0c-0.1,0-0.2,0.1-0.3,0.1
c-0.1,0.1-0.2,0.2-0.2,0.3c0,0.2,0.1,0.3,0.2,0.3c0.3,0.1,0.5,0.1,0.8,0.2c0.6,0.1,1.2,0.2,1.7,0.3c0.5,0.1,1,0.3,1.5,0.7
c0.5,0.4,0.8,0.9,0.8,1.6c0.1,1.5-0.5,2.6-1.8,3.4c-0.6,0.3-1.2,0.5-1.9,0.6c-0.8,0.1-1.6,0.1-2.4-0.1c-0.8-0.2-1.6-0.5-2.3-1
c-0.1-0.1-0.3-0.2-0.4-0.3c0.1-0.3,0.3-0.5,0.4-0.7c0.1-0.3,0.3-0.5,0.4-0.8c0.1-0.2,0.3-0.5,0.4-0.7c0.1,0.1,0.2,0.1,0.3,0.2
c1.1,0.7,2.2,1,3.5,0.9c0.2,0,0.4-0.1,0.6-0.2c0.1,0,0.2-0.1,0.2-0.2c0.2-0.2,0.1-0.4-0.1-0.6c-0.2-0.1-0.4-0.1-0.6-0.2
c-0.5-0.1-0.9-0.2-1.4-0.2c-0.5-0.1-0.9-0.2-1.3-0.4c-0.3-0.1-0.6-0.3-0.8-0.5c-0.6-0.5-0.8-1.1-0.8-1.9c0.1-1.3,0.7-2.2,1.8-2.8
c0.5-0.3,1-0.5,1.6-0.5c1.1-0.2,2.2-0.1,3.2,0.2c0.4,0.1,0.7,0.3,1,0.5c0.2,0.1,0.3,0.2,0.5,0.4C20.2,42.4,19.8,43.1,19.4,43.8z"/>
<g>
<path fill="#2A2627" d="M84.1,54.2c0.4,0.3,0.7,0.4,1.2,0.3c0.1,0,0.3,0,0.3-0.2c-0.1-0.1-0.2-0.1-0.3-0.2c-0.2,0-0.3-0.1-0.5-0.1
c-0.1,0-0.2-0.1-0.3-0.1c-0.5-0.2-0.7-0.7-0.4-1.2c0.1-0.3,0.3-0.4,0.6-0.5c0.6-0.2,1.1-0.2,1.7,0.1c0,0,0.1,0,0.1,0.1
c0,0,0,0,0.1,0.1c0,0.1-0.1,0.2-0.2,0.3c-0.1,0.1-0.1,0.2-0.2,0.3C85.8,53,85.4,52.9,85,53c0,0-0.1,0.1-0.1,0.1
c0.1,0.1,0.2,0.1,0.4,0.2c0.2,0,0.4,0.1,0.6,0.1c0.7,0.2,0.8,0.7,0.5,1.3c-0.1,0.2-0.3,0.4-0.5,0.5c-0.7,0.3-1.5,0.2-2.1-0.3
c0.1-0.1,0.1-0.2,0.2-0.4C84,54.5,84.1,54.4,84.1,54.2z"/>
<path fill="#2A2627" d="M82.6,52.3c0,0.2,0,0.4-0.1,0.6c0,0.1,0,0.1-0.1,0.2c-0.4,0-0.7,0-1.1,0c0,0.1,0,0.2-0.1,0.3
c0.3,0,0.6,0,0.9,0c0.1,0.3-0.1,0.5-0.1,0.7c-0.3,0-0.6,0-0.9,0c0,0.1-0.1,0.1-0.1,0.2c0,0.1,0,0.1,0,0.2c0,0,0.1,0,0.1,0
c0.2,0,0.5,0,0.7,0c0.1,0,0.2,0,0.3,0c0.1,0.2,0,0.3,0,0.4c0,0.1,0,0.2-0.1,0.4c-0.2,0-0.4,0-0.7,0c-0.2,0-0.4,0-0.7,0
c-0.2,0-0.4,0-0.7,0c0-0.5,0.1-1,0.2-1.5c0.1-0.5,0.1-1,0.2-1.5C80.9,52.2,82.3,52.2,82.6,52.3z"/>
<g>
<path fill="#252122" d="M78.6,40.8c0.9,0,1.8,0,2.8,0c1,1.7,2,3.4,3,5.2c0-0.1,0.1-0.1,0.1-0.2c0.2-1.5,0.4-3,0.5-4.4
c0-0.2,0-0.4,0.1-0.6c0.4-0.1,2.4,0,2.7,0c0,0.3-1,8.8-1.2,9.6c-0.4,0.1-0.9,0-1.4,0c-0.5,0-0.9,0-1.4,0c-0.5-0.9-1-1.7-1.5-2.6
c-0.5-0.9-1-1.7-1.5-2.6c-0.1,0.2-0.1,0.3-0.1,0.5c0,0.2,0,0.3-0.1,0.5c0,0.2,0,0.3-0.1,0.5c0,0.2,0,0.3-0.1,0.5
c0,0.2,0,0.3-0.1,0.5c0,0.2,0,0.3-0.1,0.5c0,0.2,0,0.3-0.1,0.5c0,0.2,0,0.3-0.1,0.5c0,0.2,0,0.3,0,0.5c0,0.2,0,0.3-0.1,0.5
c0,0.2,0,0.3-0.1,0.5c-0.4,0.1-2.4,0.1-2.7,0C77.4,50.1,78.5,41.5,78.6,40.8z"/>
<path fill="#252122" d="M39.5,50.5c0-0.1,0-0.2,0-0.3c0.4-3.1,0.8-6.2,1.1-9.3c0.2,0,0.5,0,0.7,0c0.2,0,0.5,0,0.7,0
c0.2,0,0.4,0,0.7,0c0.2,0,0.4,0,0.7,0c0,0.6-0.1,1.2-0.2,1.8c-0.1,0.6-0.1,1.2-0.2,1.7c0,0,0,0,0,0c0,0,0,0,0,0c1,0,2,0,3,0
c0,0,0.1,0,0.1,0c0.1-1.2,0.3-2.3,0.4-3.5c0,0,0,0,0,0c0,0,0,0,0,0c0.1,0,0.2,0,0.4,0c0.8,0,1.5,0,2.3,0c0,0.1,0,0.2,0,0.2
c-0.1,0.8-0.2,1.6-0.3,2.4c-0.1,0.8-0.2,1.5-0.3,2.3c-0.1,0.8-0.2,1.5-0.3,2.3c-0.1,0.7-0.2,1.5-0.3,2.2c0,0.1,0,0.2,0,0.2
c-0.3,0.1-2.4,0.1-2.8,0c0.1-1.2,0.3-2.3,0.4-3.5c0,0,0-0.1,0-0.1c0,0,0,0,0,0c-0.9,0-1.8,0-2.7,0c-0.1,0-0.2,0-0.3,0
c0,0,0,0-0.1,0c-0.1,1.2-0.3,2.3-0.4,3.5C41.4,50.5,40.5,50.5,39.5,50.5z"/>
<path fill="#252122" d="M54.2,40.8c0.9,0,1.7,0,2.6,0c0.2,0.4,2.8,9.3,2.8,9.6c0,0,0,0,0,0c0,0,0,0,0,0c-0.8,0-1.7,0-2.5,0
c-0.1,0-0.1,0-0.2,0c-0.2-0.5-0.3-1-0.4-1.5c-0.3-0.1-2.8-0.1-3.8,0c-0.3,0.5-0.5,1-0.8,1.6c-0.3,0.1-2.4,0.1-2.8,0c0,0,0,0,0,0
c0,0,0,0,0,0c1.7-3.2,3.4-6.3,5.1-9.5C54.1,40.9,54.1,40.8,54.2,40.8z M55.1,44.1C55.1,44.1,55,44.1,55.1,44.1
c-0.5,0.9-0.9,1.7-1.4,2.5c0,0,0,0.1,0,0.1c0.2,0,0.4,0,0.7,0c0.2,0,0.5,0,0.7,0c0.2,0,0.4,0,0.7,0C55.6,45.8,55.3,45,55.1,44.1z
"/>
<path fill="#2BABE2" d="M33.4,40.8c0.9,0,1.7,0,2.6,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0.1,0.1,0.2,0.1,0.3c0.9,3.1,1.8,6.1,2.7,9.2
c0,0.1-0.1,0.1-0.2,0.1c-0.8,0-1.6,0-2.5,0c0,0-0.1,0-0.1,0c-0.2-0.5-0.3-1.1-0.5-1.6c-0.3-0.1-3.3-0.1-3.8,0
c-0.3,0.5-0.5,1-0.8,1.5c-0.3,0.1-2.2,0.1-2.7,0c-0.1-0.1,0-0.2,0.1-0.2c0.5-1,1.1-2,1.6-3c0.9-1.7,1.9-3.5,2.8-5.2
C32.9,41.6,33.1,41.2,33.4,40.8z M35,46.7c-0.2-0.8-0.4-1.6-0.6-2.4c0-0.1-0.1-0.1-0.1-0.2c0,0-0.1,0.1-0.1,0.1
c-0.4,0.8-0.8,1.6-1.3,2.5c0,0,0,0,0,0.1C33.3,46.8,34.7,46.8,35,46.7z"/>
<path fill="#252122" d="M64.9,50.5c-0.9,0-1.8,0-2.7,0c0,0,0,0,0,0c0,0,0,0,0,0c-0.9-3.2-1.8-6.3-2.7-9.5c0,0,0-0.1,0-0.1
c0.3-0.1,2.8-0.1,3.2,0c0.5,1.9,0.9,3.8,1.4,5.7c0.3-0.4,0.4-0.8,0.6-1.2c0.2-0.4,0.4-0.8,0.5-1.1c0.2-0.4,0.4-0.7,0.5-1.1
c0.2-0.4,0.4-0.8,0.5-1.1c0.2-0.4,0.4-0.8,0.6-1.1c1.1,0,2.1,0,3.2,0c0,0.1-0.1,0.2-0.1,0.4c-1.1,2-2.1,4-3.2,6.1
c-0.5,1-1,1.9-1.5,2.9C65.1,50.2,65,50.3,64.9,50.5z"/>
<path fill="#252122" d="M76,50.4c-0.3,0.1-6,0.1-6.5,0c0-0.1,0-0.1,0-0.2c0.1-1,0.2-1.9,0.4-2.9c0.1-1,0.2-1.9,0.3-2.9
c0.1-1,0.2-1.9,0.4-2.9c0-0.3,0.1-0.5,0.1-0.8c0,0,0,0,0.1,0c0,0,0,0,0,0c2,0,4,0,6,0c0.1,0,0.2,0,0.3,0c0,0,0,0,0.1,0
c0,0.4-0.1,0.8-0.1,1.2c0,0.4-0.1,0.8-0.2,1.2c-0.1,0-0.2,0-0.3,0c-1,0-2,0-3,0c-0.1,0-0.3,0-0.4,0c0,0.1,0,0.2-0.1,0.3
c0,0.2-0.1,0.5-0.1,0.7c0.1,0.1,0.2,0.1,0.3,0.1c0.8,0,1.5,0,2.3,0c0.1,0,0.3,0,0.4,0c0,0.3-0.2,1.8-0.3,2.3c-0.1,0-0.2,0-0.3,0
c-0.8,0-1.5,0-2.3,0c-0.1,0-0.3,0-0.4,0c-0.1,0.3-0.2,0.8-0.2,1.1c0.6,0,1.2,0,1.8,0c0.6,0,1.2,0,1.8,0c0,0,0,0.1,0,0.1
C76.2,48.8,76.1,49.6,76,50.4z"/>
<path fill="#2BABE2" d="M28.8,40.8C28.9,40.8,28.9,40.9,28.8,40.8c0,0.8-0.1,1.5-0.2,2.2c0,0.1,0,0.2-0.1,0.3
c-0.3,0.1-0.6,0-0.9,0c-0.3,0-0.6,0-0.9,0s-0.6,0-0.9,0c-0.3,0-0.6,0-0.9,0c-0.1,0.4-0.1,0.7-0.1,1c0.1,0.1,0.2,0.1,0.3,0.1
c0.7,0,1.5,0,2.2,0c0.1,0,0.3,0,0.4,0c0,0.3-0.1,1.7-0.3,2.3c-0.1,0-0.2,0-0.3,0c-0.8,0-1.5,0-2.3,0c-0.1,0-0.3,0-0.4,0
c-0.1,0.4-0.1,0.7-0.1,1.1c0.3,0.1,0.6,0,0.9,0c0.3,0,0.6,0,0.9,0c0.3,0,0.6,0,0.9,0c0.3,0,0.6,0,0.9,0c0,0.3-0.2,2-0.3,2.5
c0,0-0.1,0-0.1,0c-2.1,0-4.1,0-6.2,0c0,0-0.1,0-0.1,0c0,0,0,0,0,0c0,0,0,0,0,0c0.4-3.2,0.8-6.4,1.2-9.6c0.1,0,0.2,0,0.3,0
c2,0,4,0,5.9,0C28.7,40.8,28.8,40.8,28.8,40.8z"/>
<path fill="#282425" d="M52,55.3c0.1-1,0.2-2,0.4-3c0.3,0,0.6,0,0.9,0c0.4,0.5,0.6,1,1,1.6c0.1-0.1,0.1-0.2,0.1-0.3
c0-0.1,0-0.2,0-0.3c0-0.1,0-0.2,0-0.3c0-0.1,0-0.2,0-0.3c0-0.1,0-0.2,0-0.3c0.2,0,0.4,0,0.6,0c0.1,0,0.1,0,0.2,0
c0,0.3-0.2,1.9-0.3,3c-0.1,0.1-0.3,0-0.4,0c-0.1,0-0.3,0-0.4,0c-0.3-0.5-0.6-1-0.9-1.6c-0.1,0.5-0.1,1-0.2,1.5
C52.6,55.3,52.3,55.3,52,55.3z"/>
<path fill="#282425" d="M56.6,55.3c0.1-1,0.2-2,0.4-3c0.1,0,0.2,0,0.2,0c0.3,0,0.7,0,1,0c0.2,0,0.4,0,0.7,0.1
c0.5,0.2,0.7,0.5,0.8,1c0.1,1-0.6,1.9-1.6,2C57.6,55.3,57.1,55.3,56.6,55.3z M57.6,54.5c0.1,0,0.2,0,0.3,0c0.4,0,0.6-0.2,0.8-0.5
c0.2-0.4-0.1-0.9-0.6-0.9c-0.1,0-0.2,0-0.4,0C57.7,53.5,57.7,54,57.6,54.5z"/>
<path fill="#282425" d="M73.8,52.2c0,0,0.1,0,0.1,0c0.4,0,0.8,0,1.1,0c0.1,0,0.3,0,0.4,0c0.5,0.1,0.7,0.5,0.7,0.9
c0,0.4-0.2,0.7-0.5,1c-0.1,0-0.1,0.1-0.2,0.1c0.1,0.3,0.4,0.6,0.6,1c-0.3,0.1-0.7,0-1,0c-0.2-0.3-0.3-0.7-0.6-1
c-0.1,0.1-0.1,0.1-0.1,0.2c0,0.1,0,0.2,0,0.4c0,0.1,0,0.3-0.1,0.4c-0.3,0-0.6,0-0.8,0C73.4,54.9,73.7,52.6,73.8,52.2z M74.4,53.6
c0.2,0,0.3,0,0.5,0c0.1,0,0.2-0.1,0.2-0.1c0.1-0.1,0.1-0.2,0.1-0.4c0-0.1-0.1-0.2-0.2-0.2c-0.2,0-0.3,0-0.5,0
C74.5,53.2,74.5,53.4,74.4,53.6z"/>
<path fill="#292627" d="M61.3,52.2c0.3,0,0.6,0,0.9,0c0.1,0.2,0,0.3,0,0.4c-0.1,0.4-0.1,0.9-0.2,1.3c0,0.1,0,0.2,0,0.2
c0,0.3,0.3,0.4,0.6,0.3c0.2-0.1,0.4-0.2,0.4-0.5c0-0.2,0.1-0.4,0.1-0.5c0.1-0.4,0.1-0.8,0.2-1.3c0.2,0,0.3,0,0.5,0
c0.1,0,0.3,0,0.4,0c0,0.1,0,0.1,0,0.1c-0.1,0.6-0.1,1.1-0.2,1.7c-0.1,0.9-0.8,1.3-1.5,1.3c-0.1,0-0.2,0-0.4,0
c-0.7-0.1-1.1-0.6-1.1-1.3c0-0.5,0.1-0.9,0.2-1.4C61.3,52.5,61.3,52.4,61.3,52.2z"/>
<path fill="#2A2627" d="M66.6,53.1c0.1,0.1,0.2,0.1,0.4,0.2c0.2,0,0.4,0.1,0.6,0.1c0.7,0.2,0.8,0.8,0.5,1.3
c-0.1,0.3-0.3,0.5-0.6,0.5c-0.7,0.2-1.3,0.2-2-0.2c0,0-0.1-0.1-0.1-0.1c0.1-0.2,0.2-0.5,0.4-0.7c0.1,0.1,0.2,0.1,0.3,0.2
c0.3,0.1,0.5,0.2,0.8,0.2c0.1,0,0.1,0,0.2,0c0.1,0,0.1-0.1,0.1-0.1c0-0.1,0-0.1-0.1-0.1c-0.1,0-0.3-0.1-0.4-0.1
c-0.2-0.1-0.4-0.1-0.6-0.2c-0.4-0.2-0.6-0.6-0.4-1.1c0.1-0.3,0.4-0.5,0.7-0.7c0.5-0.2,1-0.2,1.5,0c0.1,0,0.1,0.1,0.2,0.1
c0,0,0.1,0.1,0.1,0.1c-0.1,0.2-0.2,0.4-0.4,0.6c-0.1,0-0.1,0-0.2-0.1c-0.3-0.1-0.5-0.1-0.8-0.1C66.7,53,66.6,53,66.6,53.1z"/>
<path fill="#292626" d="M72.2,52.3c0,0.2,0,0.5-0.1,0.8c-0.2,0.1-0.5,0-0.8,0c-0.1,0.4-0.1,0.7-0.2,1.1c-0.1,0.4-0.1,0.7-0.1,1.1
c-0.3,0-0.6,0-0.9,0c0-0.4,0.1-0.7,0.1-1.1c0-0.4,0.1-0.7,0.1-1.1c-0.3-0.1-0.5,0-0.7,0c-0.1-0.3,0-0.5,0.1-0.8
C70,52.2,71.8,52.2,72.2,52.3z"/>
<path fill="#292627" d="M49.9,52.2c0.3,0,0.5,0,0.8,0c0,0.5-0.1,1-0.2,1.5c0,0.5-0.1,1-0.2,1.5c-0.3,0-0.6,0-0.8,0
C49.5,55,49.7,52.8,49.9,52.2z"/>
<path fill="#292526" d="M78,52.2c0.3,0,0.6,0,0.8,0c0,0.1,0,0.1,0,0.2c-0.1,0.6-0.1,1.1-0.2,1.7c0,0.4-0.1,0.7-0.1,1.1
c-0.1,0.1-0.3,0-0.4,0c-0.1,0-0.3,0-0.5,0C77.7,54.3,77.8,53.3,78,52.2z"/>
<g>
<path fill="#60B3E3" d="M42.6,55.2c-0.9,0.1-2,0.1-3.4,0.5c-0.7,0.2-2,0.4-3.5,1.1c-0.7,0.3-0.5,0.3-2.2,1.2
c-1.2,0.6-2,1.1-3.1,1.5c-1.3,0.6-2,0.9-2.8,1c-0.8,0.2-2.1,0.3-3.5,0c-0.5-0.1-1.1-0.2-1.9-0.6c-0.9-0.5-1.5-1.1-1.8-1.5
c0.2,0,0.4,0,0.7,0c1-0.1,2.3-0.1,3.7-0.6c0.4-0.1,0.8-0.3,1.6-0.6c0.1,0,0.8-0.3,1.6-0.7c1.9-0.9,3.9-1.9,4-2
c0,0,3.2-1.7,5-2.6c0.2-0.1,0.6-0.3,1.2-0.3c0.3,0,0.6,0,0.9,0c0.6,0,1.4,0.1,2.4,0.3c1.2,0.3,2,0.7,2.4,0.9
c0.2,0.1,1,0.6,2,1.4c0.5,0.4,0.8,0.7,1.1,1C45.3,55.1,43.7,55.1,42.6,55.2z"/>
<path fill="#2BABE2" d="M11.5,55c1.1-0.3,1.5-0.3,1.5-0.3c0.5-0.1,0.9-0.1,1.2-0.1c0.8,0,1.4,0,1.7,0.1c1,0.1,1.8,0.3,2.2,0.5
c0.4,0.1,1,0.4,2.3,0.9c0.7,0.3,0.8,0.3,1.1,0.4c0.4,0.1,1.1,0.4,1.9,0.5c0.6,0.1,1.6,0.3,3,0.2c1.2-0.1,2-0.4,2.2-0.5
c0.4-0.2,0.7-0.3,1.4-0.7c1.1-0.6,1.7-1,2.8-1.7c1.5-0.9,2.2-1.4,2.9-1.7c1.1-0.5,1.9-0.8,1.9-0.9c0-0.1-0.8,0-1.3,0.1
c-0.7,0.2-1.2,0.3-1.2,0.4c-1.1,0.4-2.4,0.8-3.6,1.1c-1.4,0.3-2.4,0.6-3.8,0.6c-1.6,0-2.8-0.3-3.5-0.5c-1-0.2-1.1-0.4-2.7-0.8
c-1.1-0.3-1.6-0.4-2.2-0.5c-0.6-0.1-1.7-0.2-3.1,0.1c-0.5,0.1-1.2,0.3-2,0.7C12.6,53.7,11.5,55,11.5,55z"/>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

View file

@ -5,6 +5,7 @@ import { API_PATHS } from "@/api/api-paths";
import { mapHttpStatusToMessage, normalizeApiRequestError } from "@/api/api-error";
import { queryKeys } from "@/infra/query-key/query-key";
import queryClient from "@/lib/query/query-client";
import { isAuthBypassEnabled } from "@/lib/auth/auth-bypass";
import { clearAuth, getAccessToken } from "@/lib/auth/auth-storage";
function clearSessionAndRedirectToLogin(): void {
@ -38,7 +39,7 @@ export const api: KyInstance = ky.create({
beforeError: [({ error }) => normalizeApiRequestError(error)],
afterResponse: [
({ request, response }) => {
if (response.status !== 401 || isAuthLoginRequest(request)) {
if (response.status !== 401 || isAuthLoginRequest(request) || isAuthBypassEnabled()) {
return response;
}

View file

@ -24,7 +24,7 @@ export default function AdminShellLayout() {
<Box
component="main"
className="flex-1 overflow-auto p-2.5"
className="flex-1 overflow-auto"
sx={{ backgroundColor: "var(--color-bg-page)" }}
>
<Outlet />

View file

@ -22,7 +22,7 @@ import {
} from "@mui/material";
import { useApproveUplift, useDenyUplift } from "@/domain/uplifts/use-cases/use-uplift-actions";
import { useVendorPoDetail } from "@/domain/vendor-pos/use-cases/use-vendor-po-detail";
import type { UpliftRequest } from "@/domain/work-orders/types/work-order";
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
import { formatDateTime, toDateInputValue } from "@/lib/time-utils";
function formatMoney(value: number | null | undefined): string {
@ -33,13 +33,7 @@ function formatMoney(value: number | null | undefined): string {
function InfoRow({ label, children }: { label: string; children: ReactNode }) {
return (
<Box>
<Typography
variant="caption"
sx={{
color: "text.secondary",
display: "block",
}}
>
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
{label}
</Typography>
<Typography variant="body2">
@ -116,10 +110,7 @@ export default function VendorPoViewPage() {
<Stack
direction="row"
className="mb-4"
sx={{
justifyContent: "space-between",
alignItems: "center",
}}
sx={{ justifyContent: "space-between", alignItems: "center" }}
>
<Button variant="text" onClick={() => navigate(-1)}>
← Back
@ -128,47 +119,27 @@ export default function VendorPoViewPage() {
Print
</Button>
</Stack>
<Stack spacing={1} className="mb-4">
<Typography variant="h5">{dispatch.poNumber || "(no PO #)"}</Typography>
<Stack
direction="row"
spacing={1}
sx={{
alignItems: "center",
flexWrap: "wrap",
}}
>
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
<Stack direction="row" spacing={1} sx={{ alignItems: "center", flexWrap: "wrap" }}>
<Typography variant="body2" color="text.secondary">
Dispatch {dispatch.dispatchNumber || "—"}
</Typography>
<Chip size="small" label={dispatch.status || "—"} />
{vendor?.companyName && (
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
<Typography variant="body2" color="text.secondary">
· {vendor.companyName}
</Typography>
)}
{dispatch.nteAmount != null && (
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
<Typography variant="body2" color="text.secondary">
· NTE {formatMoney(dispatch.nteAmount)}
</Typography>
)}
</Stack>
</Stack>
<Stack spacing={3}>
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
@ -186,13 +157,7 @@ export default function VendorPoViewPage() {
<InfoRow label="Trades">{vendor.tradeSpecialties}</InfoRow>
</Box>
) : (
<Typography
sx={{
color: "text.secondary",
}}
>
No vendor attached.
</Typography>
<Typography color="text.secondary">No vendor attached.</Typography>
)}
</Paper>
@ -214,12 +179,7 @@ export default function VendorPoViewPage() {
</Box>
{dispatch.description && (
<Box className="mt-3">
<Typography
variant="caption"
sx={{
color: "text.secondary",
}}
>
<Typography variant="caption" color="text.secondary">
Description
</Typography>
<Typography variant="body2" className="whitespace-pre-wrap">
@ -255,13 +215,7 @@ export default function VendorPoViewPage() {
Linked Work Orders ({dispatch.workOrders.length})
</Typography>
{dispatch.workOrders.length === 0 ? (
<Typography
sx={{
color: "text.secondary",
}}
>
No work orders linked.
</Typography>
<Typography color="text.secondary">No work orders linked.</Typography>
) : (
<Table size="small">
<TableHead>
@ -301,33 +255,22 @@ export default function VendorPoViewPage() {
NTE Uplift History ({dispatch.upliftRequests.length})
</Typography>
{dispatch.upliftRequests.length === 0 ? (
<Typography
sx={{
color: "text.secondary",
}}
>
No uplift requests on this PO.
</Typography>
<Typography color="text.secondary">No uplift requests on this PO.</Typography>
) : (
<Stack spacing={2}>
{dispatch.upliftRequests.map((u) => (
<Box key={String(u.id)} className="rounded border border-border p-3">
<Stack
direction="row"
sx={{
justifyContent: "space-between",
alignItems: "center",
}}
sx={{ justifyContent: "space-between", alignItems: "center" }}
>
<Typography variant="body2" className="font-semibold">
{formatMoney(u.currentNTE)} → {formatMoney(u.requestedNTE)}
<Typography
component="span"
variant="caption"
color="text.secondary"
className="ml-2"
sx={{
color: "text.secondary",
}}
>
(Δ {formatMoney(u.delta)}, Tier {u.requiredTier})
</Typography>
@ -371,6 +314,7 @@ export default function VendorPoViewPage() {
)}
</Paper>
</Stack>
<Dialog open={Boolean(upliftAction)} onClose={() => setUpliftAction(null)}>
<DialogTitle>
{upliftAction?.type === "approve" ? "Approve Uplift" : "Deny Uplift"}

View file

@ -192,7 +192,10 @@ export default function WorkOrderViewPage() {
}
commentMutation.mutate(
{ text: commentText.trim(), commentType: activeTab },
{
text: commentText.trim(),
commentType: activeTab === "customer" ? "customer" : "internal",
},
{ onSuccess: () => setCommentText("") },
);
};

View file

@ -96,7 +96,7 @@ export function DispatchCreateModal({
.filter(
(item) =>
!woSearch ||
item.number.includes(woSearch) ||
item.woNumber.includes(woSearch) ||
item.title.toLowerCase().includes(woSearch.toLowerCase()),
)
.slice(0, 10),
@ -180,13 +180,13 @@ export function DispatchCreateModal({
onClick={() => {
setSelectedWOs((prev) => [
...prev,
{ id: item.id, number: item.number, title: item.title },
{ id: item.id, number: item.woNumber, title: item.title },
]);
setWoSearch("");
}}
>
<Typography variant="body2">
<strong>{item.number}</strong> — {item.title}
<strong>{item.woNumber}</strong> — {item.title}
</Typography>
</Box>
))}

View file

@ -28,7 +28,7 @@ import {
Typography,
} from "@mui/material";
import { useDebounce } from "@/hooks/use-debounce";
import type { WorkOrderListItem } from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import {
useWorkOrderUsers,
useWorkOrdersList,
@ -271,7 +271,7 @@ export default function WorkOrdersListPage() {
</TableCell>
</TableRow>
) : (
rows.map((row: WorkOrderListItem) => (
rows.map((row: WorkOrderTableRow) => (
<TableRow key={String(row.id)} hover>
<TableCell>
<Button
@ -280,7 +280,7 @@ export default function WorkOrdersListPage() {
className="font-semibold"
onClick={() => navigate(`/workorders/${row.id}`)}
>
{row.number || row.id}
{row.woNumber || row.id}
</Button>
</TableCell>
<TableCell>{row.title}</TableCell>
@ -290,12 +290,10 @@ export default function WorkOrdersListPage() {
<WorkOrderStatusBadge status={row.status || "Open"} />
</TableCell>
<TableCell className="whitespace-nowrap">
{formatDateTime(row.createdDate)}
</TableCell>
<TableCell>{row.assignedTo || "Unassigned"}</TableCell>
<TableCell className="whitespace-nowrap">
{formatDateTime(row.lastUpdated)}
{formatDateTime(row.scheduledOn)}
</TableCell>
<TableCell>{row.dispatcherName || "Unassigned"}</TableCell>
<TableCell className="whitespace-nowrap">{formatDateTime(row.dueDate)}</TableCell>
<TableCell align="right">
<Tooltip title="View">
<IconButton

View file

@ -0,0 +1,22 @@
import type { CommentTimelineItem } from "./comment-timeline";
type CommentSource = {
id: string | number;
userName?: string;
commenter?: string | null;
createdDate?: string;
commenttext: string;
};
export function mapWorkOrderCommentsToTimeline(comments: CommentSource[]): CommentTimelineItem[] {
return comments.map((comment) => ({
id: comment.id,
author: comment.userName || comment.commenter || "System",
createdAt: comment.createdDate,
body: comment.commenttext,
}));
}
export function mapDispatchCommentsToTimeline(comments: CommentSource[]): CommentTimelineItem[] {
return mapWorkOrderCommentsToTimeline(comments);
}

View file

@ -0,0 +1,54 @@
import { useMemo } from "react";
import { Box, Stack, Typography } from "@mui/material";
import { formatDateTime } from "@/lib/time-utils";
export type CommentTimelineItem = {
id: string | number;
author: string;
createdAt?: string;
body: string;
};
type CommentTimelineProps = {
items: CommentTimelineItem[];
emptyMessage?: string;
maxHeightClass?: string;
};
export function CommentTimeline({
items,
emptyMessage = "No comments yet.",
maxHeightClass = "max-h-72",
}: CommentTimelineProps) {
const sorted = useMemo(
() =>
[...items].sort((a, b) => {
const aTime = a.createdAt ? new Date(a.createdAt).getTime() : 0;
const bTime = b.createdAt ? new Date(b.createdAt).getTime() : 0;
return aTime - bTime;
}),
[items],
);
if (!sorted.length) {
return <Typography color="text.secondary">{emptyMessage}</Typography>;
}
return (
<Stack spacing={2} className={`${maxHeightClass} overflow-y-auto`}>
{sorted.map((item) => (
<Box key={String(item.id)} className="rounded border border-border p-3">
<Stack direction="row" className="mb-1" sx={{ justifyContent: "space-between" }}>
<Typography variant="caption" className="font-semibold">
{item.author}
</Typography>
<Typography variant="caption" color="text.secondary">
{item.createdAt ? formatDateTime(item.createdAt) : ""}
</Typography>
</Stack>
<Typography variant="body2">{item.body}</Typography>
</Box>
))}
</Stack>
);
}

View file

@ -0,0 +1,36 @@
import { Box, Typography } from "@mui/material";
type DetailFieldHighlight = "green" | "yellow" | "red";
type DetailFieldProps = {
label: string;
value: string;
className?: string;
highlight?: DetailFieldHighlight | "";
};
export function DetailField({ label, value, className, highlight }: DetailFieldProps) {
const colorClass =
highlight === "green"
? "text-green-600"
: highlight === "yellow"
? "text-yellow-600"
: highlight === "red"
? "text-red-600"
: undefined;
return (
<Box className={className}>
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
{label}
</Typography>
<Typography variant="body2" className={colorClass}>
{value}
</Typography>
</Box>
);
}
export function DetailFieldGrid({ children }: { children: React.ReactNode }) {
return <Box className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">{children}</Box>;
}

View file

@ -51,13 +51,8 @@ export function StatusBadge({ status, label }: StatusBadgeProps) {
return (
<span
className="inline-flex items-center whitespace-nowrap"
className="inline-flex h-5 items-center whitespace-nowrap rounded px-2 text-xs font-medium"
style={{
height: 20,
padding: "0 8px",
borderRadius: 4,
fontSize: 12,
fontWeight: 500,
background: tokens.bg,
color: tokens.text,
}}

View file

@ -1,10 +1,11 @@
export type TypeVariant = "pm" | "reactive" | "emergency" | "addon";
export type TypeVariant = "pm" | "reactive" | "emergency" | "addon" | "overdue";
const TYPE_TOKENS: Record<TypeVariant, { bg: string; text: string }> = {
pm: { bg: "var(--type-pm-bg)", text: "var(--type-pm-text)" },
reactive: { bg: "var(--type-reactive-bg)", text: "var(--type-reactive-text)" },
emergency: { bg: "var(--type-emergency-bg)", text: "var(--type-emergency-text)" },
addon: { bg: "var(--type-addon-bg)", text: "var(--type-addon-text)" },
overdue: { bg: "var(--type-overdue-bg)", text: "var(--type-overdue-text)" },
};
import { resolveTypeVariant } from "./type-badge-utils";

View file

@ -52,7 +52,7 @@ export function AppTopbar({ onToggleNav }: AppTopbarProps) {
sx={{
height: "var(--spacing-topbar)",
background: "var(--color-header-gradient)",
boxShadow: "var(--color-header-shadow)",
boxShadow: "none",
color: "var(--color-header-text)",
borderRadius: 0,
}}
@ -169,6 +169,7 @@ export function AppTopbar({ onToggleNav }: AppTopbarProps) {
/>
</Box>
</Toolbar>
<Menu
anchorEl={userAnchor}
open={Boolean(userAnchor)}
@ -198,20 +199,10 @@ export function AppTopbar({ onToggleNav }: AppTopbarProps) {
{userInitials}
</Box>
<Box>
<Typography
variant="body2"
sx={{
fontWeight: 600,
}}
>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{user?.fullname || "User"}
</Typography>
<Typography
variant="caption"
sx={{
color: "text.secondary",
}}
>
<Typography variant="caption" color="text.secondary">
{user?.email || ""}
</Typography>
</Box>

View file

@ -0,0 +1,51 @@
import { X } from "lucide-react";
import { CountBadge } from "@/components/ui/count-badge";
import { cn } from "@/lib/utils";
export type AppliedFilterChipProps = {
group: string;
value?: string;
count?: number;
accent?: boolean;
onDismiss: () => void;
dismissLabel?: string;
};
export function AppliedFilterChip({
group,
value,
count,
accent,
onDismiss,
dismissLabel = "Remove filter",
}: AppliedFilterChipProps) {
return (
<span className="inline-flex h-[26px] items-center gap-1.5 rounded-md border border-border bg-background pr-1 pl-2.5">
<span className="text-[10.5px] font-semibold tracking-wide text-muted-foreground uppercase">
{group}
</span>
{count != null ? (
<CountBadge count={count} />
) : (
<span
className={cn(
accent
? "rounded-full bg-primary/10 px-2 py-px text-xs font-semibold text-primary"
: "text-xs font-semibold text-foreground",
)}
>
{value}
</span>
)}
<button
type="button"
onClick={onDismiss}
aria-label={dismissLabel}
className="flex size-[18px] cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted"
>
<X size={12} />
</button>
</span>
);
}

View file

@ -0,0 +1,36 @@
import type { ReactNode } from "react";
import { Filter } from "lucide-react";
type AppliedFiltersBarProps = {
countLabel: string;
children: ReactNode;
onClearAll: () => void;
clearAllLabel?: string;
filteredByLabel?: string;
};
export function AppliedFiltersBar({
countLabel,
children,
onClearAll,
clearAllLabel = "Clear all",
filteredByLabel = "· filtered by",
}: AppliedFiltersBarProps) {
return (
<div className="-mx-2.5 shrink-0 border-b border-border bg-(--color-bg-surface) px-4 py-2.5">
<div className="flex flex-wrap items-center gap-2">
<Filter size={15} className="shrink-0 text-muted-foreground" />
<span className="text-[13px] font-semibold text-foreground">{countLabel}</span>
<span className="text-[13px] text-muted-foreground">{filteredByLabel}</span>
{children}
<button
type="button"
onClick={onClearAll}
className="cursor-pointer px-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
>
{clearAllLabel}
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,33 @@
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
type CheckboxIndicatorSize = "sm" | "md";
const sizeClasses: Record<CheckboxIndicatorSize, { box: string; icon: number }> = {
sm: { box: "h-3.5 w-3.5", icon: 9 },
md: { box: "size-4", icon: 11 },
};
export type CheckboxIndicatorProps = {
checked: boolean;
size?: CheckboxIndicatorSize;
className?: string;
};
export function CheckboxIndicator({ checked, size = "md", className }: CheckboxIndicatorProps) {
const { box, icon } = sizeClasses[size];
return (
<span
className={cn(
"flex shrink-0 items-center justify-center rounded border",
box,
checked ? "border-primary bg-primary" : "border-border bg-transparent",
className,
)}
>
{checked && <Check size={icon} className="text-white" />}
</span>
);
}

View file

@ -0,0 +1,38 @@
import type { ButtonHTMLAttributes, ReactNode } from "react";
import { CheckboxIndicator } from "@/components/ui/checkbox-indicator";
import { cn } from "@/lib/utils";
export type CheckboxListItemProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & {
selected: boolean;
label: ReactNode;
left?: ReactNode;
};
export function CheckboxListItem({
selected,
label,
left,
className,
type = "button",
disabled,
...props
}: CheckboxListItemProps) {
return (
<button
type={type}
disabled={disabled}
className={cn(
"flex w-full items-center gap-2 rounded-md px-1.5 py-1.5 text-left transition-colors",
selected ? "bg-primary/10" : "hover:bg-muted",
disabled && "cursor-not-allowed opacity-50",
className,
)}
{...props}
>
<CheckboxIndicator checked={selected} />
{left}
<span className="truncate text-[13px] text-foreground">{label}</span>
</button>
);
}

View file

@ -0,0 +1,50 @@
import type { ButtonHTMLAttributes, ReactNode } from "react";
import { CheckboxIndicator } from "@/components/ui/checkbox-indicator";
import { cn } from "@/lib/utils";
export type CheckboxMenuItemProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & {
checked: boolean;
label: ReactNode;
left?: ReactNode;
right?: ReactNode;
labelClassName?: string;
};
export function CheckboxMenuItem({
checked,
label,
left,
right,
labelClassName,
className,
type = "button",
disabled,
...props
}: CheckboxMenuItemProps) {
return (
<button
type={type}
disabled={disabled}
className={cn(
"flex w-full cursor-pointer items-center gap-2.5 rounded px-2.5 py-1.5 transition-colors hover:bg-[#F9FAFB]",
disabled && "cursor-not-allowed opacity-50",
className,
)}
{...props}
>
<CheckboxIndicator checked={checked} size="sm" />
{left}
<span
className={cn(
"text-[12px] text-foreground",
(left != null || right != null) && "flex-1 text-left",
labelClassName,
)}
>
{label}
</span>
{right}
</button>
);
}

View file

@ -0,0 +1,50 @@
import { useState, type ElementType, type ReactNode } from "react";
import { ChevronDown, ChevronUp } from "lucide-react";
import { CountBadge } from "@/components/ui/count-badge";
import { cn } from "@/lib/utils";
export type CollapsibleSectionProps = {
icon?: ElementType;
label: string;
count?: number;
defaultOpen?: boolean;
children: ReactNode;
className?: string;
};
export function CollapsibleSection({
icon: Icon,
label,
count = 0,
defaultOpen = false,
children,
className,
}: CollapsibleSectionProps) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className={cn("border-b border-border", open && "pb-3", className)}>
<button
type="button"
onClick={() => setOpen((current) => !current)}
className="flex w-full cursor-pointer items-center justify-between py-3 text-left"
aria-expanded={open}
>
<div className="flex items-center gap-2">
{Icon && <Icon size={16} className="text-primary" />}
<span className="text-sm font-semibold text-foreground">{label}</span>
</div>
<div className="flex items-center gap-2">
<CountBadge count={count} />
{open ? (
<ChevronUp size={16} className="text-muted-foreground" />
) : (
<ChevronDown size={16} className="text-muted-foreground" />
)}
</div>
</button>
{open && children}
</div>
);
}

View file

@ -0,0 +1,23 @@
import { cn } from "@/lib/utils";
export type CountBadgeProps = {
count: number;
className?: string;
};
export function CountBadge({ count, className }: CountBadgeProps) {
if (count <= 0) {
return null;
}
return (
<span
className={cn(
"inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-primary px-1.5 text-[10.5px] font-bold text-white",
className,
)}
>
{count}
</span>
);
}

View file

@ -0,0 +1,40 @@
import type { InputHTMLAttributes } from "react";
import { cn } from "@/lib/utils";
const dateInputClasses =
"w-full rounded-md border border-border bg-background px-2.5 py-1.5 text-[13px] text-foreground focus:outline-none";
export type DateInputProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "onChange"> & {
onChange?: (value: string) => void;
};
export function DateInput({ className, onChange, disabled, ...props }: DateInputProps) {
return (
<input
type="date"
disabled={disabled}
className={cn(dateInputClasses, disabled && "cursor-not-allowed opacity-50", className)}
onChange={(event) => onChange?.(event.target.value)}
{...props}
/>
);
}
export type DateFieldProps = DateInputProps & {
label: string;
fieldClassName?: string;
};
export function DateField({ label, fieldClassName, id, ...inputProps }: DateFieldProps) {
const inputId = id ?? label.toLowerCase().replace(/\s+/g, "-");
return (
<div className={cn("flex-1", fieldClassName)}>
<label htmlFor={inputId} className="mb-1 block text-[11px] text-muted-foreground">
{label}
</label>
<DateInput id={inputId} {...inputProps} />
</div>
);
}

View file

@ -0,0 +1,12 @@
import type { SxProps, Theme } from "@mui/material";
/** Light blur overlay — matches design DialogOverlay (bg-black/10 + backdrop-blur). */
export const DIALOG_BLUR_BACKDROP_SX: SxProps<Theme> = {
backgroundColor: "rgba(0, 0, 0, 0.1)",
backdropFilter: "blur(4px)",
WebkitBackdropFilter: "blur(4px)",
};
export const dialogBlurBackdropSlotProps = {
backdrop: { sx: DIALOG_BLUR_BACKDROP_SX },
} as const;

View file

@ -0,0 +1,50 @@
import type { ButtonHTMLAttributes, CSSProperties } from "react";
import { cn } from "@/lib/utils";
export type DotTogglePillProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & {
label: string;
dotColor: string;
selected: boolean;
selectedBg?: string;
selectedText?: string;
};
export function DotTogglePill({
label,
dotColor,
selected,
selectedBg,
selectedText,
className,
type = "button",
disabled,
style,
...props
}: DotTogglePillProps) {
const selectedStyle: CSSProperties | undefined =
selected && selectedBg ? { background: selectedBg } : undefined;
return (
<button
type={type}
disabled={disabled}
className={cn(
"inline-flex h-[26px] cursor-pointer items-center gap-1.5 rounded-full border px-2.5 transition-colors",
selected ? "border-primary" : "border-border bg-background",
disabled && "cursor-not-allowed opacity-50",
className,
)}
style={{ ...selectedStyle, ...style }}
{...props}
>
<span className="size-[7px] shrink-0 rounded-full" style={{ background: dotColor }} />
<span
className={cn("text-xs font-medium", !selected && "text-muted-foreground")}
style={selected && selectedText ? { color: selectedText } : undefined}
>
{label}
</span>
</button>
);
}

View file

@ -0,0 +1,51 @@
import type { ReactNode } from "react";
import { Popover } from "@mui/material";
type FilterPopoverProps = {
open: boolean;
anchorEl: HTMLElement | null;
onClose: () => void;
activeCount: number;
onClear?: () => void;
children: ReactNode;
};
export function FilterPopover({
open,
anchorEl,
onClose,
activeCount,
onClear,
children,
}: FilterPopoverProps) {
return (
<Popover
open={open}
anchorEl={anchorEl}
onClose={onClose}
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
transformOrigin={{ vertical: "top", horizontal: "right" }}
slotProps={{
paper: {
className: "mt-0.5 min-w-80 rounded-lg border border-border p-4 shadow-md",
},
}}
>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-foreground">Filters</span>
{activeCount > 0 && onClear && (
<button
type="button"
onClick={onClear}
className="cursor-pointer text-xs text-primary hover:underline"
>
Clear filters
</button>
)}
</div>
{children}
</div>
</Popover>
);
}

View file

@ -0,0 +1,35 @@
import type { ButtonHTMLAttributes } from "react";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
export type IconCloseButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & {
iconSize?: number;
label?: string;
};
export function IconCloseButton({
iconSize = 17,
label = "Close",
className,
type = "button",
disabled,
"aria-label": ariaLabel,
...props
}: IconCloseButtonProps) {
return (
<button
type={type}
disabled={disabled}
aria-label={ariaLabel ?? label}
className={cn(
"flex size-7 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted",
disabled && "cursor-not-allowed opacity-50",
className,
)}
{...props}
>
<X size={iconSize} />
</button>
);
}

View file

@ -0,0 +1,32 @@
import type { ButtonHTMLAttributes, ReactNode } from "react";
import { cn } from "@/lib/utils";
type MenuAnchorButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onClick" | "type"> & {
onAnchor: (element: HTMLElement) => void;
children: ReactNode;
open?: boolean;
};
export function MenuAnchorButton({
onAnchor,
children,
className,
open,
disabled,
...props
}: MenuAnchorButtonProps) {
return (
<button
type="button"
disabled={disabled}
onClick={(event) => onAnchor(event.currentTarget)}
className={cn("w-fit cursor-pointer", disabled && "cursor-not-allowed opacity-50", className)}
aria-haspopup="menu"
aria-expanded={open}
{...props}
>
{children}
</button>
);
}

View file

@ -0,0 +1,30 @@
import type { ButtonHTMLAttributes, ReactNode } from "react";
import { cn } from "@/lib/utils";
export type NavArrowButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & {
children: ReactNode;
};
export function NavArrowButton({
children,
className,
disabled,
type = "button",
...props
}: NavArrowButtonProps) {
return (
<button
type={type}
disabled={disabled}
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-card transition-colors disabled:cursor-not-allowed",
disabled ? "text-border" : "text-muted-foreground",
className,
)}
{...props}
>
{children}
</button>
);
}

View file

@ -0,0 +1,34 @@
import type { ButtonHTMLAttributes, ReactNode } from "react";
import { cn } from "@/lib/utils";
export type NavPillButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & {
active?: boolean;
children: ReactNode;
};
export function NavPillButton({
active = false,
children,
className,
disabled,
type = "button",
...props
}: NavPillButtonProps) {
return (
<button
type={type}
disabled={disabled}
className={cn(
"flex h-8 shrink-0 cursor-pointer items-center gap-1.5 whitespace-nowrap rounded-md px-[13px] text-[13px] font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-40",
active
? "border border-[#bfe2f5] bg-(--color-primary-tint) text-primary"
: "border border-transparent bg-transparent text-muted-foreground hover:bg-(--color-bg-muted)",
className,
)}
{...props}
>
{children}
</button>
);
}

View file

@ -9,47 +9,53 @@ type PageHeaderProps = {
subtitle?: string;
actions?: ReactNode;
className?: string;
/** White surface bar (list page headers). */
surface?: boolean;
};
export function PageHeader({ eyebrow, title, subtitle, actions, className }: PageHeaderProps) {
export function PageHeader({
eyebrow,
title,
subtitle,
actions,
className,
surface = false,
}: PageHeaderProps) {
return (
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
className={cn("mb-4", className)}
direction="row"
className={cn(
surface &&
"-mx-2.5 -mt-2.5 shrink-0 border-b border-(--color-border) bg-(--color-bg-surface) px-5 py-4",
className,
)}
sx={{
alignItems: { xs: "stretch", md: "center" },
alignItems: "center",
justifyContent: "space-between",
}}
>
<Stack spacing={0.5}>
<Stack spacing={0}>
{eyebrow && (
<Typography variant="overline" className="text-muted-foreground">
<p className="m-0 font-sans text-(length:--text-xs) font-bold leading-none tracking-widest text-primary uppercase">
{eyebrow}
</Typography>
</p>
)}
<Typography variant="h5" component="h1" className="text-foreground">
<h1
className={cn(
"m-0 font-display mt-1! text-[30px]! font-extrabold leading-[1.1] tracking-[-0.02em] text-foreground",
eyebrow && "mt-1",
)}
>
{title}
</Typography>
</h1>
{subtitle && (
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
<Typography variant="body2" color="text.secondary">
{subtitle}
</Typography>
)}
</Stack>
{actions && (
<Stack
direction="row"
spacing={1}
sx={{
alignItems: "center",
}}
>
<Stack direction="row" spacing={1} sx={{ alignItems: "center", flexWrap: "nowrap" }}>
{actions}
</Stack>
)}

View file

@ -0,0 +1,96 @@
import type { ReactNode } from "react";
import {
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
} from "@mui/material";
import { TableEmptyRow, TableLoadingRow } from "@/components/ui/table-state-rows";
export type SortDirection = "asc" | "desc" | "";
export type ResourceListColumn<T> = {
id: string;
header: ReactNode;
render: (row: T) => ReactNode;
sortable?: boolean;
headerClassName?: string;
cellClassName?: string;
align?: "left" | "center" | "right";
};
type ResourceListTableProps<T> = {
columns: ResourceListColumn<T>[];
rows: T[];
rowKey: (row: T) => string;
isLoading: boolean;
emptyMessage?: string;
sort?: { column: string; direction: SortDirection };
onSort?: (column: string) => void;
size?: "small" | "medium";
};
function sortIndicator(direction: SortDirection): string {
if (direction === "desc") {
return " ↓";
}
if (direction === "asc") {
return " ↑";
}
return "";
}
export function ResourceListTable<T>({
columns,
rows,
rowKey,
isLoading,
emptyMessage,
sort,
onSort,
size = "small",
}: ResourceListTableProps<T>) {
const colSpan = columns.length;
return (
<TableContainer component={Paper} variant="outlined">
<Table size={size}>
<TableHead>
<TableRow>
{columns.map((column) => (
<TableCell
key={column.id}
align={column.align}
className={`font-semibold ${column.sortable ? "cursor-pointer whitespace-nowrap" : ""} ${column.headerClassName ?? ""}`}
onClick={column.sortable && onSort ? () => onSort(column.id) : undefined}
>
{column.header}
{column.sortable && sort?.column === column.id ? sortIndicator(sort.direction) : ""}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
<TableLoadingRow colSpan={colSpan} />
) : rows.length === 0 ? (
<TableEmptyRow colSpan={colSpan} message={emptyMessage} />
) : (
rows.map((row) => (
<TableRow key={rowKey(row)} hover>
{columns.map((column) => (
<TableCell key={column.id} align={column.align} className={column.cellClassName}>
{column.render(row)}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
);
}

View file

@ -0,0 +1,53 @@
import type { InputHTMLAttributes } from "react";
import { Search } from "lucide-react";
import { cn } from "@/lib/utils";
type SearchInputProps = Omit<
InputHTMLAttributes<HTMLInputElement>,
"value" | "onChange" | "type"
> & {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
inputClassName?: string;
fullWidth?: boolean;
"aria-label"?: string;
};
export function SearchInput({
value,
onChange,
placeholder = "Search…",
className,
inputClassName,
fullWidth = false,
"aria-label": ariaLabel = "Search",
...props
}: SearchInputProps) {
return (
<div className={cn("relative shrink-0", className)}>
<Search
size={13}
aria-hidden
className="pointer-events-none absolute top-1/2 left-2.5 -translate-y-1/2 text-muted-foreground"
/>
<input
type="search"
value={value}
onChange={(event) => onChange(event.currentTarget.value)}
placeholder={placeholder}
aria-label={ariaLabel}
className={cn(
"box-border rounded-md border border-border bg-background py-1.5 pl-8 text-foreground focus:outline-none",
fullWidth
? "w-full pr-2.5 text-[13px]"
: "w-56 pr-7 font-sans text-[12px] placeholder:text-muted-foreground placeholder:opacity-100 hover:border-border-hover focus:border-border",
inputClassName,
)}
{...props}
/>
</div>
);
}

View file

@ -0,0 +1,49 @@
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import { cn } from "@/lib/utils";
export type SegmentGroupProps = HTMLAttributes<HTMLDivElement> & {
children: ReactNode;
};
export function SegmentGroup({ children, className, ...props }: SegmentGroupProps) {
return (
<div
className={cn("flex items-center gap-0.5 rounded-md bg-(--color-bg-muted) p-0.5", className)}
{...props}
>
{children}
</div>
);
}
export type SegmentButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & {
active?: boolean;
children: ReactNode;
};
export function SegmentButton({
active = false,
children,
className,
type = "button",
disabled,
...props
}: SegmentButtonProps) {
return (
<button
type={type}
disabled={disabled}
className={cn(
"h-7 cursor-pointer whitespace-nowrap rounded-[5px] px-3 text-[13px] font-semibold transition-all disabled:cursor-not-allowed disabled:opacity-50",
active
? "bg-card text-primary shadow-[0_1px_2px_rgba(38,34,98,.1)]"
: "bg-transparent text-muted-foreground",
className,
)}
{...props}
>
{children}
</button>
);
}

View file

@ -0,0 +1,31 @@
import type { ButtonHTMLAttributes } from "react";
import { cn } from "@/lib/utils";
export type SelectAllActionProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & {
anySelected: boolean;
selectAllLabel?: string;
clearAllLabel?: string;
};
export function SelectAllAction({
anySelected,
selectAllLabel = "Select all",
clearAllLabel = "Clear all",
className,
type = "button",
...props
}: SelectAllActionProps) {
return (
<button
type={type}
className={cn(
"cursor-pointer text-[11px] font-medium text-primary hover:underline",
className,
)}
{...props}
>
{anySelected ? clearAllLabel : selectAllLabel}
</button>
);
}

View file

@ -0,0 +1,42 @@
import type { ReactNode } from "react";
import { Drawer } from "@mui/material";
import { DIALOG_BLUR_BACKDROP_SX } from "@/components/ui/dialog-blur-backdrop";
import { cn } from "@/lib/utils";
type SideSheetProps = {
open: boolean;
onClose: () => void;
children: ReactNode;
widthClass?: string;
"aria-label"?: string;
};
export function SideSheet({
open,
onClose,
children,
widthClass = "w-full max-w-sm",
"aria-label": ariaLabel = "Side panel",
}: SideSheetProps) {
return (
<Drawer
anchor="right"
open={open}
onClose={onClose}
slotProps={{
backdrop: {
sx: DIALOG_BLUR_BACKDROP_SX,
},
paper: {
className: cn(
"flex h-full flex-col gap-0 border-l border-border bg-background p-0 shadow-lg",
widthClass,
),
"aria-label": ariaLabel,
},
}}
>
{children}
</Drawer>
);
}

View file

@ -0,0 +1,42 @@
import { Chip, Stack } from "@mui/material";
type StatusChipFilterProps = {
options: readonly string[];
value: string;
onChange: (status: string) => void;
label?: string;
};
export function StatusChipFilter({
options,
value,
onChange,
label = "Status",
}: StatusChipFilterProps) {
return (
<div>
<span className="mb-1 block text-[11px] font-semibold text-muted-foreground">{label}</span>
<Stack direction="row" spacing={0.75} useFlexGap sx={{ flexWrap: "wrap" }}>
<Chip
label="All"
size="small"
clickable
color={value === "" ? "primary" : "default"}
variant={value === "" ? "filled" : "outlined"}
onClick={() => onChange("")}
/>
{options.map((status) => (
<Chip
key={status}
label={status}
size="small"
clickable
color={value === status ? "primary" : "default"}
variant={value === status ? "filled" : "outlined"}
onClick={() => onChange(status)}
/>
))}
</Stack>
</div>
);
}

View file

@ -0,0 +1,31 @@
import { CircularProgress, TableCell, TableRow, Typography } from "@mui/material";
type TableLoadingRowProps = {
colSpan: number;
size?: number;
};
export function TableLoadingRow({ colSpan, size = 28 }: TableLoadingRowProps) {
return (
<TableRow>
<TableCell colSpan={colSpan} align="center" className="py-10">
<CircularProgress size={size} />
</TableCell>
</TableRow>
);
}
type TableEmptyRowProps = {
colSpan: number;
message?: string;
};
export function TableEmptyRow({ colSpan, message = "No items found" }: TableEmptyRowProps) {
return (
<TableRow>
<TableCell colSpan={colSpan} align="center" className="py-10">
<Typography color="text.secondary">{message}</Typography>
</TableCell>
</TableRow>
);
}

View file

@ -0,0 +1,40 @@
import type { ButtonHTMLAttributes, ReactNode } from "react";
import { cn } from "@/lib/utils";
export type ToggleCardProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & {
selected: boolean;
title: ReactNode;
description?: ReactNode;
};
export function ToggleCard({
selected,
title,
description,
className,
type = "button",
disabled,
...props
}: ToggleCardProps) {
return (
<button
type={type}
disabled={disabled}
className={cn(
"cursor-pointer rounded-lg text-left transition-colors",
selected
? "border-2 border-primary bg-primary/10 px-[11px] py-[9px]"
: "border border-border bg-background px-3 py-2.5 hover:bg-muted",
disabled && "cursor-not-allowed opacity-50",
className,
)}
{...props}
>
<div className="text-[13px] font-semibold text-foreground">{title}</div>
{description != null && (
<div className="mt-0.5 text-[11px] leading-snug text-muted-foreground">{description}</div>
)}
</button>
);
}

View file

@ -0,0 +1,35 @@
import type { ButtonHTMLAttributes, ReactNode } from "react";
import { cn } from "@/lib/utils";
export type TogglePillProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & {
selected: boolean;
children: ReactNode;
};
export function TogglePill({
selected,
children,
className,
type = "button",
disabled,
...props
}: TogglePillProps) {
return (
<button
type={type}
disabled={disabled}
className={cn(
"h-7 cursor-pointer rounded-full border px-3 text-[12.5px] font-medium transition-colors",
selected
? "border-primary bg-primary/10 text-primary"
: "border-border bg-background text-muted-foreground hover:bg-muted",
disabled && "cursor-not-allowed opacity-50",
className,
)}
{...props}
>
{children}
</button>
);
}

View file

@ -0,0 +1,35 @@
import type { ButtonHTMLAttributes, ReactNode } from "react";
import { cn } from "@/lib/utils";
export type ToggleSegmentProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & {
selected: boolean;
children: ReactNode;
};
export function ToggleSegment({
selected,
children,
className,
type = "button",
disabled,
...props
}: ToggleSegmentProps) {
return (
<button
type={type}
disabled={disabled}
className={cn(
"h-8 flex-1 cursor-pointer rounded-md text-[12.5px] font-semibold transition-colors",
selected
? "border-2 border-primary bg-primary/10 text-primary"
: "border border-border bg-background text-muted-foreground hover:bg-muted",
disabled && "cursor-not-allowed opacity-50",
className,
)}
{...props}
>
{children}
</button>
);
}

View file

@ -0,0 +1,55 @@
import type { ButtonHTMLAttributes, ReactNode } from "react";
import { cn } from "@/lib/utils";
type ToolbarButtonVariant = "outline" | "primary";
export type ToolbarButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & {
variant?: ToolbarButtonVariant;
icon?: ReactNode;
children: ReactNode;
badge?: number;
};
const baseClasses =
"inline-flex h-8 shrink-0 cursor-pointer items-center gap-1.5 rounded-md text-[12px] font-medium whitespace-nowrap [&_svg]:pointer-events-none [&_svg]:shrink-0";
const variantClasses: Record<ToolbarButtonVariant, string> = {
outline:
"relative border border-border bg-background px-3 text-foreground transition-colors hover:bg-muted",
primary:
"justify-center border border-transparent bg-clip-padding px-2.5 bg-primary text-primary-foreground transition-all outline-none select-none hover:bg-primary/80 active:translate-y-px",
};
export function ToolbarButton({
variant = "outline",
icon,
children,
badge,
className,
disabled,
type = "button",
...props
}: ToolbarButtonProps) {
return (
<button
type={type}
disabled={disabled}
className={cn(
baseClasses,
variantClasses[variant],
disabled && "cursor-not-allowed opacity-50",
className,
)}
{...props}
>
{icon}
{children}
{badge != null && badge > 0 && (
<span className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-bold text-white">
{badge}
</span>
)}
</button>
);
}

View file

@ -6,6 +6,11 @@
import { API_PATHS } from "@/api/api-paths";
import { apiPost } from "@/api/api";
import { handleApiResponse } from "@/api/handle-api-response";
import {
disableAuthBypassForSession,
getBypassAuthUser,
isAuthBypassEnabled,
} from "@/lib/auth/auth-bypass";
import { clearAuth, getAuthUser, isSessionValid, setAuthUser } from "@/lib/auth/auth-storage";
import type {
AuthUser,
@ -22,10 +27,18 @@ export async function login(credentials: LoginCredentials): Promise<AuthUser> {
}
export async function logout(): Promise<void> {
if (isAuthBypassEnabled()) {
disableAuthBypassForSession();
}
clearAuth();
}
export async function getSession(): Promise<AuthUser | null> {
if (isAuthBypassEnabled()) {
return getBypassAuthUser();
}
const user = getAuthUser();
if (!user || !isSessionValid(user)) {

View file

@ -4,7 +4,7 @@ import { handleApiResponse } from "@/api/handle-api-response";
import { mapUpliftQueueItems } from "@/domain/uplifts/mappers/uplift-mapper";
import type { UpliftsQueueParams, UpliftsQueueResult } from "@/domain/uplifts/types/uplift";
import { mapUpliftRequests } from "@/domain/work-orders/mappers/work-order-mapper";
import type { UpliftRequest } from "@/domain/work-orders/types/work-order";
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
function buildQueueParams(params: UpliftsQueueParams): Record<string, string | number> {
const searchParams: Record<string, string | number> = {

View file

@ -1,4 +1,18 @@
import type { UpliftRequest } from "@/domain/work-orders/types/work-order";
export interface UpliftRequest {
id: string | number;
status: string;
currentNTE: number | null;
requestedNTE: number;
delta: number;
vendorReason: string;
requestedAt: string;
requestedByVendorName: string;
decidedAt: string;
decidedByName: string;
decisionNote: string;
requiredTier: number;
canDecide: boolean;
}
export interface UpliftQueueItem extends UpliftRequest {
dispatchNumber: string;

View file

@ -7,7 +7,7 @@ import {
} from "@tanstack/react-query";
import { toast } from "react-toastify";
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
import type { UpliftRequest } from "@/domain/work-orders/types/work-order";
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
import { queryKeys } from "@/infra/query-key/query-key";
import { requireQueryParam } from "@/lib/query/require-query-param";
@ -33,12 +33,12 @@ function invalidateUpliftQueries(
}
}
interface ApproveUpliftInput {
export interface ApproveUpliftInput {
id: string | number;
note?: string;
}
interface DenyUpliftInput {
export interface DenyUpliftInput {
id: string | number;
note: string;
}

View file

@ -1,4 +1,4 @@
import type { UpliftRequest } from "@/domain/work-orders/types/work-order";
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
export interface VendorPoListItem {
id: string | number;

View file

@ -1,6 +1,18 @@
import { API_PATHS } from "@/api/api-paths";
import { apiDelete, apiGet, apiPost } from "@/api/api";
import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
import {
isWoTableMockEnabled,
shouldUseMockWorkOrderDetail,
} from "@/domain/work-orders/mocks/is-wo-table-mock-enabled";
import {
buildMockWorkOrderDetailFromTableRow,
getMockWorkOrderLocations,
getMockWorkOrderTableRow,
getMockWorkOrderVendors,
getMockWorkOrdersList,
MOCK_WO_TABLE_USERS,
} from "@/domain/work-orders/mocks/wo-table-mock-data";
import {
mapDispatchComment,
mapDispatchDetail,
@ -15,7 +27,7 @@ import {
mapWorkOrderDetail,
mapWorkOrderFormToCreatePayload,
mapWorkOrderFormToUpdatePayload,
mapWorkOrderListItems,
mapWorkOrderTableRows,
} from "@/domain/work-orders/mappers/work-order-mapper";
import type { WorkOrderFormSchemaValues } from "@/domain/work-orders/schemas/work-order-schema";
import type {
@ -30,9 +42,12 @@ import type {
WorkOrderDetail,
WorkOrderFormValues,
WorkOrderUser,
WorkOrdersListParams,
WorkOrdersListResult,
} from "@/domain/work-orders/types/work-order";
import {
DISPATCH_LOCATION_WO_PAGE_SIZE,
type WorkOrdersListParams,
type WorkOrdersListResult,
} from "@/domain/work-orders/types/work-order-list";
function buildSearchParams(params: WorkOrdersListParams): Record<string, string | number> {
const searchParams: Record<string, string | number> = {
@ -56,6 +71,18 @@ function buildSearchParams(params: WorkOrdersListParams): Record<string, string
if (params.locationId) {
searchParams.locationId = params.locationId;
}
if (params.dateFrom) {
searchParams.dateFrom = params.dateFrom;
}
if (params.dateTo) {
searchParams.dateTo = params.dateTo;
}
if (params.type) {
searchParams.type = params.type;
}
if (params.assigneeIds) {
searchParams.assigneeIds = params.assigneeIds;
}
return searchParams;
}
@ -75,8 +102,17 @@ function mapUser(raw: unknown): WorkOrderUser {
};
}
async function mockNetworkDelay(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 250));
}
export const workOrdersApi = {
getList: async (params: WorkOrdersListParams = {}): Promise<WorkOrdersListResult> => {
if (isWoTableMockEnabled()) {
await mockNetworkDelay();
return getMockWorkOrdersList(params);
}
const searchParams = buildSearchParams(params);
const data = await apiGet<unknown>(API_PATHS.workOrder.getList, { searchParams });
const paginated = handlePaginatedResponse<unknown>(data, {
@ -85,20 +121,45 @@ export const workOrdersApi = {
});
return {
items: mapWorkOrderListItems(paginated.items),
items: mapWorkOrderTableRows(paginated.items),
totalCount: paginated.totalCount,
page: paginated.page,
pageSize: paginated.pageSize,
};
},
getListByLocation: async (
locationId: string | number,
search = "",
): Promise<WorkOrdersListResult> => {
return workOrdersApi.getList({
locationId,
search,
page: 1,
pageSize: DISPATCH_LOCATION_WO_PAGE_SIZE,
sortBy: "number",
sortDir: "asc",
});
},
fetchUsers: async (): Promise<WorkOrderUser[]> => {
if (isWoTableMockEnabled()) {
return MOCK_WO_TABLE_USERS;
}
const data = await apiGet<unknown>(API_PATHS.user.list);
const users = handleApiResponse<unknown[]>(data);
return Array.isArray(users) ? users.map(mapUser) : [];
},
getById: async (id: string | number): Promise<WorkOrderDetail> => {
if (shouldUseMockWorkOrderDetail(id)) {
await mockNetworkDelay();
const row = getMockWorkOrderTableRow(id);
if (!row) {
throw new Error("Mock work order not found");
}
return buildMockWorkOrderDetailFromTableRow(row);
}
const data = await apiGet<unknown>(API_PATHS.workOrder.getById, {
searchParams: { id },
});
@ -117,6 +178,22 @@ export const workOrdersApi = {
id: string | number,
values: WorkOrderFormSchemaValues | WorkOrderFormValues,
): Promise<WorkOrderDetail> => {
if (shouldUseMockWorkOrderDetail(id)) {
await mockNetworkDelay();
const row = getMockWorkOrderTableRow(id);
if (!row) {
throw new Error("Mock work order not found");
}
return buildMockWorkOrderDetailFromTableRow({
...row,
woNumber: values.workOrderNumber || row.woNumber,
dueDate: values.dueDate || row.dueDate,
scheduledOn: values.scheduledDate || row.scheduledOn,
dispatcherName: values.assignedTo || row.dispatcherName,
pm: values.problem || row.pm,
type: (values.source || row.type) as typeof row.type,
});
}
const payload = mapWorkOrderFormToUpdatePayload(id, values);
const data = await apiPost<unknown>(API_PATHS.workOrder.editWorkorder, payload);
return mapWorkOrderDetail(handleApiResponse(data));
@ -142,50 +219,44 @@ export const workOrdersApi = {
},
changeStatus: async (id: string | number, status: string): Promise<void> => {
if (shouldUseMockWorkOrderDetail(id)) {
await mockNetworkDelay();
return;
}
await apiPost<unknown>(API_PATHS.workOrder.changeStatus, undefined, {
searchParams: { id, status },
});
},
changeAssignment: async (id: string | number, assignTo: string | number): Promise<void> => {
if (shouldUseMockWorkOrderDetail(id)) {
await mockNetworkDelay();
return;
}
await apiPost<unknown>(API_PATHS.workOrder.changeAssignment, undefined, {
searchParams: { id, assignTo: assignTo || "" },
});
},
fetchLocations: async (): Promise<LocationOption[]> => {
if (isWoTableMockEnabled()) {
return getMockWorkOrderLocations();
}
const data = await apiGet<unknown>(API_PATHS.contact.getLocations);
const items = handleApiResponse<unknown[]>(data);
return Array.isArray(items) ? mapLocationOptions(items) : [];
},
fetchEmployeesDropdown: async (): Promise<EmployeeOption[]> => {
try {
const data = await apiGet<unknown>(API_PATHS.employee.dropdown);
const items = handleApiResponse<unknown[]>(data);
return Array.isArray(items) ? mapEmployeeOptions(items) : [];
} catch {
const data = await apiGet<unknown>(API_PATHS.employee.getList, {
searchParams: { page: 1, pageSize: 1000 },
});
const paginated = handlePaginatedResponse<unknown>(data);
const items = paginated.items.map((item) => {
const record = (typeof item === "object" && item !== null ? item : {}) as Record<
string,
unknown
>;
const firstName = String(record.firstName ?? record.FirstName ?? "");
const lastName = String(record.lastName ?? record.LastName ?? "");
return {
id: record.id ?? record.Id,
name: `${firstName} ${lastName}`.trim(),
};
});
return mapEmployeeOptions(items);
}
const data = await apiGet<unknown>(API_PATHS.employee.dropdown);
const items = handleApiResponse<unknown[]>(data);
return Array.isArray(items) ? mapEmployeeOptions(items) : [];
},
fetchVendorsDropdown: async (trade?: string, siteZip?: string): Promise<VendorDropdownItem[]> => {
if (isWoTableMockEnabled()) {
return getMockWorkOrderVendors();
}
const searchParams: Record<string, string> = {};
if (trade) {
searchParams.trade = trade;

View file

@ -0,0 +1,122 @@
import type { WorkOrderFormValues } from "@/domain/work-orders/types/work-order-detail";
import type {
WOType,
WizardWOStatus,
WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard";
import type { WorkOrderStatus } from "@/domain/work-orders/types/work-order-common";
import { formatApptRange, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
const WIZARD_TO_API_STATUS: Record<WizardWOStatus, WorkOrderStatus> = {
Incomplete: "Open",
Pending: "Open",
Scheduled: "Open",
"En Route": "In Progress",
"On Site": "In Progress",
"In Progress": "In Progress",
Completed: "Done",
Rescheduled: "On Hold",
"Pending Quote": "On Hold",
};
const API_TO_WIZARD_STATUS: Partial<Record<WorkOrderStatus, WizardWOStatus>> = {
Open: "Incomplete",
"In Progress": "In Progress",
"On Hold": "Pending",
Done: "Completed",
};
const TYPE_TO_PRIORITY: Record<WOType, string> = {
Emergency: "Sev 1",
Reactive: "Sev 2",
Overdue: "Sev 2",
"Add-On": "Sev 3",
PM: "Sev 3",
};
export function mapWizardStatusToApi(status: WizardWOStatus | ""): WorkOrderStatus {
if (!status) {
return "Open";
}
return WIZARD_TO_API_STATUS[status] ?? "Open";
}
export function mapApiStatusToWizard(status: string): WizardWOStatus | "" {
if (!status) {
return "";
}
if (API_TO_WIZARD_STATUS[status as WorkOrderStatus]) {
return API_TO_WIZARD_STATUS[status as WorkOrderStatus]!;
}
const wizardStatuses = Object.keys(WIZARD_TO_API_STATUS) as WizardWOStatus[];
if (wizardStatuses.includes(status as WizardWOStatus)) {
return status as WizardWOStatus;
}
if (status === "Canceled" || status === "Cancelled") {
return "Canceled" as WizardWOStatus;
}
return "Incomplete";
}
export function mapTypeToPriority(type: WOType | ""): string {
if (!type) {
return "Sev 3";
}
return TYPE_TO_PRIORITY[type];
}
function buildTitle(draft: WorkOrderWizardDraft): string {
if (draft.woNumber) {
return `${draft.type || "Work Order"} — #${draft.woNumber}`;
}
if (draft.locationName) {
return `${draft.type || "Work Order"} — ${draft.locationName}`;
}
return draft.type || "Work Order";
}
function buildDescription(draft: WorkOrderWizardDraft): string {
const parts: string[] = [];
if (draft.pocNotes) {
parts.push(`POC notes: ${draft.pocNotes}`);
}
if (draft.pocName || draft.pocPhone) {
parts.push(`POC: ${[draft.pocName, draft.pocPhone].filter(Boolean).join(" · ")}`);
}
if (draft.apptStart) {
parts.push(`Appt: ${formatApptRange(draft.apptStart, draft.apptEnd)}`);
}
if (draft.vendorNotes) {
parts.push(`Vendor notes: ${draft.vendorNotes}`);
}
if (draft.vendorName) {
parts.push(`Vendor: ${draft.vendorName}`);
}
return parts.join("\n");
}
export function mapWizardDraftToFormValues(draft: WorkOrderWizardDraft): WorkOrderFormValues {
return {
internalWONumber: "",
workOrderNumber: draft.woNumber,
title: buildTitle(draft),
locationId: draft.locationId,
priority: mapTypeToPriority(draft.type),
status: mapWizardStatusToApi(draft.status),
createdDate: todayIso(),
dueDate: draft.dueDate,
scheduledDate: draft.scheduleWeekOnly ? "" : draft.scheduledOn,
completedDate: "",
assignedTo: draft.assigneeName,
description: buildDescription(draft),
po: "",
tt: "",
problem: draft.pm,
trade: "",
subTrade: "",
vendorNTE: "",
source: draft.type,
};
}

View file

@ -1,3 +1,4 @@
import { mapApiStatusToWizard } from "@/domain/work-orders/mappers/wizard-draft-mapper";
import type {
DispatchChecklistItem,
DispatchComment,
@ -9,7 +10,6 @@ import type {
LocationOption,
TaskListTemplate,
UpdateDispatchPayload,
UpliftRequest,
VendorDropdownItem,
WorkOrderAuditEntry,
WorkOrderComment,
@ -18,12 +18,29 @@ import type {
WorkOrderFormValues,
WorkOrderListItem,
} from "@/domain/work-orders/types/work-order";
import type {
CompletionDocStatus,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
import { formatApptRange } from "@/domain/work-orders/utils/wizard-date-utils";
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
import type { WorkOrderStatus } from "@/domain/work-orders/types/work-order-common";
import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order-common";
import { toDateInputValue } from "@/lib/time-utils";
function asRecord(raw: unknown): Record<string, unknown> {
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
}
function toWorkOrderStatus(value: string): WorkOrderStatus {
if (WORK_ORDER_STATUS_OPTIONS.includes(value as WorkOrderStatus)) {
return value as WorkOrderStatus;
}
return "Open";
}
function readString(record: Record<string, unknown>, ...keys: string[]): string {
for (const key of keys) {
const value = record[key];
@ -92,6 +109,114 @@ export function mapWorkOrderListItems(rawItems: unknown[]): WorkOrderListItem[]
return rawItems.map(mapWorkOrderListItem);
}
const WO_TYPES_SET = new Set(["PM", "Reactive", "Emergency", "Add-On", "Overdue"]);
function readWoType(record: Record<string, unknown>): WOType | "" {
const raw = readString(record, "type", "Type", "source", "Source", "woType", "WoType");
if (WO_TYPES_SET.has(raw)) {
return raw as WOType;
}
return "";
}
function readDocStatus(record: Record<string, unknown>): CompletionDocStatus {
const raw = readString(record, "docStatus", "DocStatus", "completionDoc", "CompletionDoc");
if (raw === "Yes" || raw === "No" || raw === "NN") {
return raw;
}
return "";
}
export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
const item = asRecord(raw);
const scheduledOn = toDateInputValue(
readString(item, "scheduledOn", "scheduledDate", "ScheduledDate", "ScheduledOn"),
);
const { dayGroup, dayLabel } = deriveDayFields(scheduledOn);
const woNumber = readString(
item,
"woNumber",
"internalWONumber",
"InternalWONumber",
"number",
"workOrderNumber",
"WorkerOrderNumber",
);
const site = readString(item, "site", "Site", "locationCode", "LocationCode");
const location = readString(item, "location", "Location", "locationName", "LocationName");
const dispatcherId = readString(
item,
"dispatcherId",
"DispatcherId",
"assigneeId",
"AssigneeId",
"employeeId",
"EmployeeId",
);
const dispatcherName = readString(
item,
"dispatcherName",
"DispatcherName",
"assignedTo",
"AssignedTo",
"employeeName",
"EmployeeName",
"assignee",
"Assignee",
);
const apptStart = readString(item, "apptStart", "ApptStart", "appointmentStart");
const apptEnd = readString(item, "apptEnd", "ApptEnd", "appointmentEnd");
const apptTime =
readString(item, "apptTime", "ApptTime") ||
(apptStart ? formatApptRange(apptStart, apptEnd) : "");
const apiStatus = readString(item, "status", "Status");
const locationIdRaw = item.locationId ?? item.LocationId;
const row: WorkOrderTableRow = {
id: readId(item),
woNumber,
site: site || location.slice(0, 8),
type: readWoType(item),
dispatcherId,
dispatcherName,
scheduledOn,
targetWeek: readString(item, "targetWeek", "TargetWeek"),
dayGroup,
dayLabel,
location,
locationId:
typeof locationIdRaw === "string" || typeof locationIdRaw === "number" ? locationIdRaw : "",
pm: readString(item, "pm", "PM", "problem", "Problem", "service", "Service"),
company: readString(item, "company", "Company", "vendorName", "VendorName"),
tech: readString(item, "tech", "Tech", "technician", "Technician"),
techPhone: readString(item, "techPhone", "TechPhone"),
apptStart,
apptEnd,
apptTime,
status: mapApiStatusToWizard(apiStatus),
docStatus: readDocStatus(item),
pocName: readString(item, "pocName", "PocName", "POCName"),
pocPhone: readString(item, "pocPhone", "PocPhone", "POCPhone"),
pocNotes: readString(item, "pocNotes", "PocNotes", "POCNotes"),
dueDate: toDateInputValue(readString(item, "dueDate", "DueDate")),
rescheduleCount: readNumber(item, "rescheduleCount", "RescheduleCount") ?? 0,
carriedOver: readNumber(item, "carriedOver", "CarriedOver") ?? 0,
originalWeek: readString(item, "originalWeek", "OriginalWeek"),
originalDate: toDateInputValue(readString(item, "originalDate", "OriginalDate")),
isPastDue: readBool(item, "isPastDue", "IsPastDue"),
scheduleWeekOnly: readBool(item, "scheduleWeekOnly", "ScheduleWeekOnly"),
mediaCount: readNumber(item, "mediaCount", "MediaCount") ?? 0,
title: readString(item, "title", "Title", "workerOrderTitle", "WorkerOrderTitle"),
priority: readString(item, "priority", "Priority"),
};
return row;
}
export function mapWorkOrderTableRows(rawItems: unknown[]): WorkOrderTableRow[] {
return rawItems.map(mapWorkOrderTableRow);
}
export function mapWorkOrderComment(raw: unknown): WorkOrderComment {
const item = asRecord(raw);
@ -187,7 +312,7 @@ export function mapWorkOrderDetailToFormValues(detail: WorkOrderDetail): WorkOrd
title: detail.title,
locationId: detail.locationId,
priority: detail.priority,
status: detail.status,
status: toWorkOrderStatus(detail.status),
createdDate: detail.createdDate,
dueDate: detail.dueDate,
scheduledDate: detail.scheduledDate,

View file

@ -0,0 +1,45 @@
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
export type MockDispatcherUser = WorkOrderUser & { color: string };
/** UI-only dispatchers until the list filter is wired to the API. */
export const MOCK_DISPATCHER_USERS: MockDispatcherUser[] = [
{ id: "mock-dispatcher-sarah", name: "Sarah Chen", color: "#F59E0B" },
{ id: "mock-dispatcher-alyssa", name: "Alyssa Reeves", color: "#0D9488" },
{ id: "mock-dispatcher-derek", name: "Derek Walsh", color: "#6366F1" },
{ id: "mock-dispatcher-morgan", name: "Morgan Blake", color: "#F43F5E" },
{ id: "mock-dispatcher-james", name: "James Torres", color: "#7C3AED" },
{ id: "mock-dispatcher-rachel", name: "Rachel Kim", color: "#0EA5E9" },
{ id: "mock-dispatcher-tiffany", name: "Tiffany Moore", color: "#16A34A" },
{ id: "mock-dispatcher-brandon", name: "Brandon Lee", color: "#EA580C" },
];
const MOCK_DISPATCHER_COLOR_BY_ID = Object.fromEntries(
MOCK_DISPATCHER_USERS.map((dispatcher) => [dispatcher.id, dispatcher.color]),
) as Record<string, string>;
const FALLBACK_AVATAR_COLORS = [
"#F59E0B",
"#0D9488",
"#6366F1",
"#F43F5E",
"#7C3AED",
"#0EA5E9",
"#16A34A",
"#EA580C",
];
function paletteColorFromKey(key: string): string {
let hash = 0;
for (let i = 0; i < key.length; i += 1) {
hash = key.charCodeAt(i) + ((hash << 5) - hash);
}
return FALLBACK_AVATAR_COLORS[Math.abs(hash) % FALLBACK_AVATAR_COLORS.length];
}
export function getDispatcherAvatarColor(user: WorkOrderUser): string {
return (
MOCK_DISPATCHER_COLOR_BY_ID[String(user.id)] ??
paletteColorFromKey(String(user.id || user.name))
);
}

View file

@ -0,0 +1,10 @@
import { isAuthBypassEnabled } from "@/lib/auth/auth-bypass";
/** Use seeded WO table rows instead of GetWorkOrderList (dev / auth bypass). */
export function isWoTableMockEnabled(): boolean {
return isAuthBypassEnabled() || import.meta.env.VITE_WO_TABLE_MOCK === "true";
}
export function shouldUseMockWorkOrderDetail(id: string | number): boolean {
return isWoTableMockEnabled() && String(id).startsWith("mock-wo-");
}

View file

@ -0,0 +1,10 @@
import { env } from "@/lib/env";
import { isAuthBypassEnabled } from "@/lib/auth/auth-bypass";
export function isWoWizardMockEnabled(): boolean {
return env.isDev;
}
export function shouldUseMockWoWizardCreate(locationId: string): boolean {
return isAuthBypassEnabled() || locationId.startsWith("mock-");
}

View file

@ -0,0 +1,521 @@
import type {
LocationOption,
VendorDropdownItem,
WorkOrderDetail,
WorkOrderUser,
} from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WorkOrdersListParams } from "@/domain/work-orders/types/work-order-list";
import { MOCK_DISPATCHER_USERS } from "@/domain/work-orders/mocks/dispatcher-filter-mock-data";
import {
MOCK_WO_WIZARD_LOCATIONS,
MOCK_WO_WIZARD_SERVICES,
MOCK_WO_WIZARD_VENDORS,
} from "@/domain/work-orders/mocks/wo-wizard-mock-data";
import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
import { deriveDayFields, isDateInWeek } from "@/domain/work-orders/utils/table-day-utils";
import { enrichTableRow } from "@/domain/work-orders/utils/table-filter-utils";
import { addDaysIso, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
export const MOCK_WO_TABLE_USERS: WorkOrderUser[] = MOCK_DISPATCHER_USERS.map(({ id, name }) => ({
id,
name,
}));
function sched(iso: string): Pick<WorkOrderTableRow, "scheduledOn" | "dayGroup" | "dayLabel"> {
const { dayGroup, dayLabel } = deriveDayFields(iso);
return { scheduledOn: iso, dayGroup, dayLabel };
}
type RowSeed = {
id: string | number;
woNumber: string;
site: string;
location: string;
locationId: string | number;
type: WorkOrderTableRow["type"];
dispatcherId: string;
dispatcherName: string;
scheduledOn?: string;
targetWeek?: string;
dueDate: string;
pm: string;
company: string;
tech: string;
techPhone: string;
apptStart: string;
apptEnd: string;
apptTime: string;
status: WorkOrderTableRow["status"];
docStatus: WorkOrderTableRow["docStatus"];
pocName: string;
pocPhone: string;
pocNotes: string;
rescheduleCount: number;
carriedOver: number;
originalWeek: string;
originalDate: string;
scheduleWeekOnly: boolean;
mediaCount: number;
title?: string;
priority?: string;
};
function buildRow(seed: RowSeed): WorkOrderTableRow {
const scheduledOn = seed.scheduledOn ?? "";
const dayFields = scheduledOn ? sched(scheduledOn) : deriveDayFields("");
return enrichTableRow({
title: seed.title ?? `${seed.type || "Work Order"} — ${seed.site}`,
priority: seed.priority ?? "Sev 3",
...seed,
...dayFields,
scheduledOn,
targetWeek: seed.targetWeek ?? "",
isPastDue: false,
});
}
function buildMockRows(): WorkOrderTableRow[] {
const mon = currentWeekMonday();
const tue = addDaysIso(mon, 1);
const wed = addDaysIso(mon, 2);
const thu = addDaysIso(mon, 3);
const fri = addDaysIso(mon, 4);
const yesterday = addDaysIso(todayIso(), -1);
const sarah = MOCK_DISPATCHER_USERS[0];
const alyssa = MOCK_DISPATCHER_USERS[1];
const derek = MOCK_DISPATCHER_USERS[2];
const morgan = MOCK_DISPATCHER_USERS[3];
return [
buildRow({
id: "mock-wo-1",
woNumber: "20260623001",
site: "CHI-1042",
location: "Chicago Loop",
locationId: "mock-loc-1",
type: "PM",
dispatcherId: String(sarah.id),
dispatcherName: sarah.name,
...sched(mon),
dueDate: fri,
pm: "HVAC — Quarterly PM",
company: "Midwest HVAC Co.",
tech: "Mike Torres",
techPhone: "(312) 555-0198",
apptStart: "09:00",
apptEnd: "11:00",
apptTime: "09:00 – 11:00",
status: "Scheduled",
docStatus: "No",
pocName: "Maria Gonzalez",
pocPhone: "(312) 555-0142",
pocNotes: "Loading dock B",
rescheduleCount: 0,
carriedOver: 0,
originalWeek: "",
originalDate: "",
scheduleWeekOnly: false,
mediaCount: 0,
}),
buildRow({
id: "mock-wo-2",
woNumber: "20260623002",
site: "IND-2208",
location: "Indianapolis North",
locationId: "mock-loc-2",
type: "Emergency",
dispatcherId: String(alyssa.id),
dispatcherName: alyssa.name,
...sched(tue),
dueDate: tue,
pm: "Plumbing — Leak repair",
company: "Great Lakes Plumbing",
tech: "Jen Walsh",
techPhone: "(317) 555-4412",
apptStart: "08:00",
apptEnd: "",
apptTime: "08:00",
status: "On Site",
docStatus: "No",
pocName: "Chris Bennett",
pocPhone: "(317) 555-2208",
pocNotes: "",
rescheduleCount: 1,
carriedOver: 0,
originalWeek: "",
originalDate: "",
scheduleWeekOnly: false,
mediaCount: 3,
}),
buildRow({
id: "mock-wo-3",
woNumber: "20260623003",
site: "MIL-3315",
location: "Milwaukee West",
locationId: "mock-loc-3",
type: "Reactive",
dispatcherId: String(derek.id),
dispatcherName: derek.name,
...sched(wed),
dueDate: thu,
pm: "Electrical — Panel inspection",
company: "SparkLine Electric",
tech: "Pat Nguyen",
techPhone: "",
apptStart: "13:00",
apptEnd: "15:00",
apptTime: "13:00 – 15:00",
status: "In Progress",
docStatus: "Yes",
pocName: "Taylor Brooks",
pocPhone: "(414) 555-3315",
pocNotes: "Badge required at front desk",
rescheduleCount: 0,
carriedOver: 0,
originalWeek: "",
originalDate: "",
scheduleWeekOnly: false,
mediaCount: 0,
}),
buildRow({
id: "mock-wo-4",
woNumber: "20260623004",
site: "CHI-1042",
location: "Chicago Loop",
locationId: "mock-loc-1",
type: "Add-On",
dispatcherId: String(morgan.id),
dispatcherName: morgan.name,
...sched(thu),
dueDate: fri,
pm: "Roof — Membrane patch",
company: "Midwest HVAC Co.",
tech: "Mike Torres",
techPhone: "(312) 555-0198",
apptStart: "10:30",
apptEnd: "12:00",
apptTime: "10:30 – 12:00",
status: "Pending",
docStatus: "NN",
pocName: "Maria Gonzalez",
pocPhone: "(312) 555-0142",
pocNotes: "",
rescheduleCount: 0,
carriedOver: 0,
originalWeek: "",
originalDate: "",
scheduleWeekOnly: false,
mediaCount: 0,
}),
buildRow({
id: "mock-wo-5",
woNumber: "20260623005",
site: "IND-2208",
location: "Indianapolis North",
locationId: "mock-loc-2",
type: "PM",
dispatcherId: "",
dispatcherName: "",
...sched(fri),
dueDate: fri,
pm: "HVAC — Quarterly PM",
company: "",
tech: "",
techPhone: "",
apptStart: "",
apptEnd: "",
apptTime: "",
status: "Incomplete",
docStatus: "No",
pocName: "",
pocPhone: "",
pocNotes: "",
rescheduleCount: 0,
carriedOver: 0,
originalWeek: "",
originalDate: "",
scheduleWeekOnly: false,
mediaCount: 0,
}),
buildRow({
id: "mock-wo-6",
woNumber: "20260623006",
site: "MIL-3315",
location: "Milwaukee West",
locationId: "mock-loc-3",
type: "Overdue",
dispatcherId: String(sarah.id),
dispatcherName: sarah.name,
...sched(mon),
dueDate: yesterday,
pm: "Plumbing — Leak repair",
company: "Great Lakes Plumbing",
tech: "Jen Walsh",
techPhone: "(317) 555-4412",
apptStart: "07:00",
apptEnd: "09:00",
apptTime: "07:00 – 09:00",
status: "Scheduled",
docStatus: "No",
pocName: "Taylor Brooks",
pocPhone: "(414) 555-3315",
pocNotes: "",
rescheduleCount: 2,
carriedOver: 1,
originalWeek: "",
originalDate: yesterday,
scheduleWeekOnly: false,
mediaCount: 0,
}),
buildRow({
id: "mock-wo-7",
woNumber: "20260623007",
site: "CHI-1042",
location: "Chicago Loop",
locationId: "mock-loc-1",
type: "Reactive",
dispatcherId: String(alyssa.id),
dispatcherName: alyssa.name,
scheduledOn: "",
targetWeek: mon,
dueDate: wed,
pm: "Electrical — Panel inspection",
company: "SparkLine Electric",
tech: "Pat Nguyen",
techPhone: "",
apptStart: "",
apptEnd: "",
apptTime: "",
status: "Incomplete",
docStatus: "No",
pocName: "Maria Gonzalez",
pocPhone: "(312) 555-0142",
pocNotes: "Week-only target",
rescheduleCount: 0,
carriedOver: 0,
originalWeek: mon,
originalDate: "",
scheduleWeekOnly: true,
mediaCount: 0,
}),
buildRow({
id: "mock-wo-8",
woNumber: "20260623008",
site: "IND-2208",
location: "Indianapolis North",
locationId: "mock-loc-2",
type: "PM",
dispatcherId: String(derek.id),
dispatcherName: derek.name,
scheduledOn: "",
targetWeek: "",
dueDate: thu,
pm: "HVAC — Quarterly PM",
company: "Midwest HVAC Co.",
tech: "Mike Torres",
techPhone: "(312) 555-0198",
apptStart: "",
apptEnd: "",
apptTime: "",
status: "Pending",
docStatus: "No",
pocName: "Chris Bennett",
pocPhone: "(317) 555-2208",
pocNotes: "",
rescheduleCount: 0,
carriedOver: 0,
originalWeek: "",
originalDate: "",
scheduleWeekOnly: false,
mediaCount: 0,
}),
buildRow({
id: "mock-wo-9",
woNumber: "20260623009",
site: "MIL-3315",
location: "Milwaukee West",
locationId: "mock-loc-3",
type: "PM",
dispatcherId: String(morgan.id),
dispatcherName: morgan.name,
...sched(wed),
dueDate: fri,
pm: "Roof — Membrane patch",
company: "SparkLine Electric",
tech: "Pat Nguyen",
techPhone: "",
apptStart: "14:00",
apptEnd: "16:00",
apptTime: "14:00 – 16:00",
status: "En Route",
docStatus: "No",
pocName: "Taylor Brooks",
pocPhone: "(414) 555-3315",
pocNotes: "",
rescheduleCount: 0,
carriedOver: 0,
originalWeek: "",
originalDate: "",
scheduleWeekOnly: false,
mediaCount: 0,
}),
buildRow({
id: "mock-wo-10",
woNumber: "20260623010",
site: "CHI-1042",
location: "Chicago Loop",
locationId: "mock-loc-1",
type: "Reactive",
dispatcherId: String(sarah.id),
dispatcherName: sarah.name,
...sched(tue),
dueDate: wed,
pm: "Plumbing — Leak repair",
company: "Great Lakes Plumbing",
tech: "Jen Walsh",
techPhone: "(317) 555-4412",
apptStart: "11:00",
apptEnd: "12:30",
apptTime: "11:00 – 12:30",
status: "Completed",
docStatus: "Yes",
pocName: "Maria Gonzalez",
pocPhone: "(312) 555-0142",
pocNotes: "",
rescheduleCount: 0,
carriedOver: 0,
originalWeek: "",
originalDate: "",
scheduleWeekOnly: false,
mediaCount: 2,
}),
];
}
let cachedRows: WorkOrderTableRow[] | null = null;
export function getMockWorkOrderTableRows(): WorkOrderTableRow[] {
if (!cachedRows) {
cachedRows = buildMockRows();
}
return cachedRows;
}
function rowMatchesSearch(row: WorkOrderTableRow, search: string): boolean {
const q = search.trim().toLowerCase();
if (!q) {
return true;
}
const haystack = [
row.site,
row.location,
row.woNumber,
row.type,
row.dispatcherName,
row.pm,
row.company,
row.tech,
row.status,
row.title,
]
.join(" ")
.toLowerCase();
return haystack.includes(q);
}
export function getMockWorkOrdersList(params: WorkOrdersListParams = {}): {
items: WorkOrderTableRow[];
totalCount: number;
page: number;
pageSize: number;
} {
let items = getMockWorkOrderTableRows();
if (params.search) {
items = items.filter((row) => rowMatchesSearch(row, params.search!));
}
if (params.type) {
items = items.filter((row) => row.type === params.type);
}
if (params.dateFrom && params.dateTo) {
const weekMonday = params.dateFrom;
items = items.filter((row) => {
if (row.scheduledOn) {
return row.scheduledOn >= params.dateFrom! && row.scheduledOn <= params.dateTo!;
}
if (row.targetWeek) {
return isDateInWeek(row.targetWeek, weekMonday);
}
return true;
});
}
const page = params.page ?? 1;
const pageSize = params.pageSize ?? items.length;
return {
items,
totalCount: items.length,
page,
pageSize,
};
}
export function getMockWorkOrderTableRow(id: string | number): WorkOrderTableRow | undefined {
return getMockWorkOrderTableRows().find((row) => String(row.id) === String(id));
}
export function buildMockWorkOrderDetailFromTableRow(row: WorkOrderTableRow): WorkOrderDetail {
return {
id: row.id,
internalWONumber: row.woNumber,
workOrderNumber: row.woNumber,
title: row.title,
location: row.location,
locationId: String(row.locationId),
locationZip: "",
assignedTo: row.dispatcherName,
dueDate: row.dueDate,
createdDate: addDaysIso(todayIso(), -7),
createdDateFull: new Date().toISOString(),
description: [
row.pocNotes && `POC notes: ${row.pocNotes}`,
row.pocName && `POC: ${row.pocName} · ${row.pocPhone}`,
row.apptTime && row.apptTime !== "—" && `Appt: ${row.apptTime}`,
row.company && `Vendor: ${row.company}`,
]
.filter(Boolean)
.join("\n"),
priority: row.priority,
status:
row.status === "Completed" ? "Done" : row.status === "Incomplete" ? "Open" : "In Progress",
po: "",
tt: "",
problem: row.pm,
trade: "",
subTrade: "",
vendorNTE: "",
scheduledDate: row.scheduledOn,
completedDate: row.status === "Completed" ? todayIso() : "",
source: row.type,
comments: [],
auditLog: [],
dispatches: [],
};
}
export function getMockWorkOrderLocations(): LocationOption[] {
return MOCK_WO_WIZARD_LOCATIONS;
}
export function getMockWorkOrderVendors(): VendorDropdownItem[] {
return MOCK_WO_WIZARD_VENDORS;
}
export function getMockPmTypes(): string[] {
return MOCK_WO_WIZARD_SERVICES.map((s) => s.value).filter(Boolean);
}

View file

@ -0,0 +1,158 @@
import type { Location } from "@/domain/locations/types/location";
import type { DropdownOption } from "@/domain/settings/dropdown-options/types/dropdown-option";
import type {
LocationOption,
VendorDropdownItem,
WorkOrderDetail,
WorkOrderFormValues,
WorkOrderUser,
} from "@/domain/work-orders/types/work-order";
export const MOCK_WO_WIZARD_USERS: WorkOrderUser[] = [
{ id: "mock-user-1", name: "Alex Rivera" },
{ id: "mock-user-2", name: "Jordan Kim" },
{ id: "mock-user-3", name: "Sam Patel" },
];
export const MOCK_WO_WIZARD_LOCATIONS: LocationOption[] = [
{ id: "mock-loc-1", name: "CHI-1042 — Chicago Loop" },
{ id: "mock-loc-2", name: "IND-2208 — Indianapolis North" },
{ id: "mock-loc-3", name: "MIL-3315 — Milwaukee West" },
];
const MOCK_LOCATION_DETAILS: Record<string, Location> = {
"mock-loc-1": {
id: "mock-loc-1",
name: "CHI-1042 — Chicago Loop",
address: "233 S Wacker Dr",
city: "Chicago",
state: "IL",
zipCode: "60606",
phone: "(312) 555-0142",
contact: "Maria Gonzalez",
contactEmail: "maria.gonzalez@example.com",
status: "Active",
},
"mock-loc-2": {
id: "mock-loc-2",
name: "IND-2208 — Indianapolis North",
address: "8702 Keystone Crossing",
city: "Indianapolis",
state: "IN",
zipCode: "46240",
phone: "(317) 555-2208",
contact: "Chris Bennett",
contactEmail: "chris.bennett@example.com",
status: "Active",
},
"mock-loc-3": {
id: "mock-loc-3",
name: "MIL-3315 — Milwaukee West",
address: "411 E Wisconsin Ave",
city: "Milwaukee",
state: "WI",
zipCode: "53202",
phone: "(414) 555-3315",
contact: "Taylor Brooks",
contactEmail: "taylor.brooks@example.com",
status: "Active",
},
};
export const MOCK_WO_WIZARD_SERVICES: DropdownOption[] = [
{
id: "mock-svc-1",
category: "Problem",
value: "HVAC — Quarterly PM",
parentValue: null,
sortOrder: 1,
isActive: true,
},
{
id: "mock-svc-2",
category: "Problem",
value: "Plumbing — Leak repair",
parentValue: null,
sortOrder: 2,
isActive: true,
},
{
id: "mock-svc-3",
category: "Problem",
value: "Electrical — Panel inspection",
parentValue: null,
sortOrder: 3,
isActive: true,
},
{
id: "mock-svc-4",
category: "Problem",
value: "Roof — Membrane patch",
parentValue: null,
sortOrder: 4,
isActive: true,
},
];
export const MOCK_WO_WIZARD_VENDORS: VendorDropdownItem[] = [
{
id: "mock-vendor-1",
companyName: "Midwest HVAC Co.",
tradeSpecialties: "HVAC",
address: "1200 Industrial Pkwy, Chicago, IL 60632",
distanceMiles: 4.2,
},
{
id: "mock-vendor-2",
companyName: "Great Lakes Plumbing",
tradeSpecialties: "Plumbing",
address: "455 Water St, Milwaukee, WI 53202",
distanceMiles: 8.7,
},
{
id: "mock-vendor-3",
companyName: "SparkLine Electric",
tradeSpecialties: "Electrical",
address: "88 Volt Ave, Indianapolis, IN 46225",
distanceMiles: 12.1,
},
];
export function getMockWoWizardLocationDetail(locationId: string): Location | undefined {
return MOCK_LOCATION_DETAILS[locationId];
}
export function buildMockWorkOrderDetail(values: WorkOrderFormValues): WorkOrderDetail {
const id = `mock-wo-${Date.now()}`;
const now = new Date().toISOString();
return {
id,
internalWONumber: values.internalWONumber || id,
workOrderNumber: values.workOrderNumber || "MOCK-0001",
title: values.title || "Mock Work Order",
location:
MOCK_WO_WIZARD_LOCATIONS.find((l) => String(l.id) === values.locationId)?.name ?? "Mock Site",
locationId: values.locationId,
locationZip: getMockWoWizardLocationDetail(values.locationId)?.zipCode ?? "",
assignedTo: values.assignedTo,
dueDate: values.dueDate,
createdDate: values.createdDate,
createdDateFull: now,
description: values.description,
priority: values.priority,
status: values.status,
po: values.po,
tt: values.tt,
problem: values.problem,
trade: values.trade,
subTrade: values.subTrade,
vendorNTE: values.vendorNTE,
scheduledDate: values.scheduledDate,
completedDate: values.completedDate,
source: values.source,
comments: [],
auditLog: [],
dispatches: [],
};
}

View file

@ -0,0 +1,42 @@
import type { ElementType } from "react";
import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
export interface CompDocData {
notes: string;
generatedAt?: string;
}
export interface DocFields {
street: string;
city: string;
state: string;
zip: string;
dateCompleted: string;
amazonRep: string;
seaRep: string;
}
export type DocState = "draft" | "generated" | "not-required";
export interface CompletionDocStateConfig {
pillLabel: string;
pillBg: string;
pillText: string;
cardBg: string;
cardBorder: string;
cardIconBg: string;
iconColor: string;
cardTitle: string;
icon: ElementType;
}
export interface CompDocSiteInfo {
street: string;
city: string;
state: string;
zip: string;
label: string;
fullAddress: string;
}
export type CompDocSaveStatus = Exclude<CompletionDocStatus, "">;

View file

@ -0,0 +1,80 @@
export interface WorkOrderDispatchSummary {
id: string | number;
dispatchNumber: string;
poNumber: string;
vendorName: string;
nteAmount: number | null;
status: string;
dispatchedAt: string;
}
export interface DispatchChecklistItem {
id: string | number;
itemText: string;
isCompleted: boolean;
completedBy: string | null;
completedAt: string | null;
}
export interface DispatchSignoff {
id: string | number;
signoffType: string;
name: string;
signature: string;
signatureMethod: string;
signedAt: string;
}
export interface DispatchComment {
id: string | number;
createdDate: string;
commenttext: string;
userName: string;
commenter: string | null;
}
export interface DispatchWorkOrderRef {
id: string | number;
woNumber: string;
}
export interface DispatchDetail {
id: string | number;
dispatchNumber: string;
poNumber: string;
vendorName: string;
vendorEmail: string;
woNumber: string;
status: string;
nteAmount: number | null;
scheduledDate: string;
completedDate: string;
description: string;
dispatchedAt: string;
acknowledgedAt: string;
verifiedAt: string;
verifiedBy: string;
checklistItems: DispatchChecklistItem[];
signoffs: DispatchSignoff[];
comments: DispatchComment[];
workOrders: DispatchWorkOrderRef[];
}
export interface DispatchToVendorPayload {
workOrderIds: (string | number)[];
vendorIds: (string | number)[];
nteAmount: number | null;
scheduledDate: string | null;
description: string;
taskListTemplateId: number | null;
customChecklistItems: string[] | null;
}
export interface UpdateDispatchPayload {
id: string | number;
status: string;
nteAmount: number | null;
scheduledDate: string | null;
completedDate: string | null;
description: string;
}

View file

@ -0,0 +1,74 @@
import { ASSIGNEE_FILTER_UNASSIGNED } from "./work-order-list";
import {
ALL_WIZARD_STATUSES,
WO_TYPE_META,
WO_TYPES,
type WizardWOStatus,
type WOType,
} from "./work-order-wizard";
export { ASSIGNEE_FILTER_UNASSIGNED };
export type DateRangeKey =
| "this-week"
| "last-week"
| "this-month"
| "last-3-months"
| "next-week"
| "next-month"
| "custom";
export type CompletionDocFilter = "No" | "Yes" | "NN";
export interface WorkOrderAdvancedFilters {
dateRange: DateRangeKey;
customFrom: string;
customTo: string;
sites: Set<string>;
types: Set<WOType>;
dispatchers: Set<string>;
statuses: Set<WizardWOStatus>;
pmTypes: Set<string>;
vendorTechs: Set<string>;
docs: Set<CompletionDocFilter>;
}
export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = {
dateRange: "this-week",
customFrom: "",
customTo: "",
sites: new Set(),
types: new Set(),
dispatchers: new Set(),
statuses: new Set(),
pmTypes: new Set(),
vendorTechs: new Set(),
docs: new Set(),
};
export const DATE_RANGE_OPTIONS: { key: DateRangeKey; label: string }[] = [
{ key: "this-week", label: "This week" },
{ key: "last-week", label: "Last week" },
{ key: "this-month", label: "This month" },
{ key: "last-3-months", label: "Last 3 months" },
{ key: "next-week", label: "Next week" },
{ key: "next-month", label: "Next month" },
{ key: "custom", label: "Custom" },
];
export const DOC_FILTER_LABELS: Record<CompletionDocFilter, string> = {
No: "Pending",
Yes: "Uploaded",
NN: "N/N",
};
export const COMPLETION_DOC_OPTIONS: CompletionDocFilter[] = ["No", "Yes", "NN"];
export const WO_TYPE_DESCRIPTIONS: Record<WOType, string> = Object.fromEntries(
WO_TYPES.map((type) => [type, WO_TYPE_META[type].desc]),
) as Record<WOType, string>;
export { ALL_WIZARD_STATUSES, WO_TYPES };
export type AdvancedFilterSetKey =
"sites" | "types" | "dispatchers" | "statuses" | "pmTypes" | "vendorTechs" | "docs";

View file

@ -0,0 +1,48 @@
export const WORK_ORDER_STATUS_OPTIONS = [
"Open",
"In Progress",
"On Hold",
"Done",
"Cancelled",
] as const;
export const WORK_ORDER_PRIORITY_OPTIONS = ["Sev 1", "Sev 2", "Sev 3", "Sev 4", "Sev 5"] as const;
export const DISPATCH_STATUS_OPTIONS = [
"Sent",
"Acknowledged",
"In Progress",
"Completed",
"Verified",
"Cancelled",
] as const;
export type WorkOrderStatus = (typeof WORK_ORDER_STATUS_OPTIONS)[number];
export type WorkOrderPriority = (typeof WORK_ORDER_PRIORITY_OPTIONS)[number];
export type DispatchStatus = (typeof DISPATCH_STATUS_OPTIONS)[number];
export type WorkOrderCommentType = "customer" | "internal";
export interface LocationOption {
id: string | number;
name: string;
}
export interface EmployeeOption {
id: string | number;
name: string;
}
export interface VendorDropdownItem {
id: string | number;
companyName: string;
tradeSpecialties: string;
address: string;
distanceMiles: number | null;
}
export interface TaskListTemplate {
id: string | number;
name: string;
items: { id?: string | number; itemText?: string }[];
}

View file

@ -0,0 +1,97 @@
import type { WorkOrderCommentType, WorkOrderPriority, WorkOrderStatus } from "./work-order-common";
import type { WorkOrderDispatchSummary } from "./dispatch";
export interface WorkOrderComment {
id: string | number;
createdDate: string;
commenttext: string;
commentType: WorkOrderCommentType | string;
userName: string;
commenter: string | null;
recordType: string | null;
}
export interface WorkOrderAuditEntry {
id: string | number;
fieldName: string;
oldValue: string;
newValue: string;
action: string;
createdAt: string;
userName: string;
}
export interface WorkOrderDetail {
id: string | number;
internalWONumber: string;
workOrderNumber: string;
title: string;
location: string;
locationId: string;
locationZip: string;
assignedTo: string;
dueDate: string;
createdDate: string;
createdDateFull: string;
description: string;
priority: WorkOrderPriority | string;
status: WorkOrderStatus | string;
po: string;
tt: string;
problem: string;
trade: string;
subTrade: string;
vendorNTE: string;
scheduledDate: string;
completedDate: string;
source: string;
comments: WorkOrderComment[];
auditLog: WorkOrderAuditEntry[];
dispatches: WorkOrderDispatchSummary[];
}
export interface WorkOrderFormValues {
internalWONumber: string;
workOrderNumber: string;
title: string;
locationId: string;
priority: string;
status: WorkOrderStatus;
createdDate: string;
dueDate: string;
scheduledDate: string;
completedDate: string;
assignedTo: string;
description: string;
po: string;
tt: string;
problem: string;
trade: string;
subTrade: string;
vendorNTE: string;
source: string;
}
export function createDefaultWorkOrderFormValues(): WorkOrderFormValues {
return {
internalWONumber: "",
workOrderNumber: "",
title: "",
locationId: "",
priority: "",
status: "Open",
createdDate: new Date().toISOString().split("T")[0] ?? "",
dueDate: "",
scheduledDate: "",
completedDate: "",
assignedTo: "",
description: "",
po: "",
tt: "",
problem: "",
trade: "",
subTrade: "",
vendorNTE: "",
source: "",
};
}

View file

@ -0,0 +1,9 @@
import type { WOType } from "./work-order-wizard";
export type WorkOrderTypeFilter = WOType | "All";
export interface WorkOrderBarFilters {
weekMonday: string;
dispatcherIds: Set<string>;
type: WorkOrderTypeFilter;
}

View file

@ -0,0 +1,61 @@
import type { WorkOrderPriority, WorkOrderStatus } from "./work-order-common";
import type { WorkOrderTableRow } from "./work-order-table-row";
export type SortDirection = "asc" | "desc" | "";
export type WorkOrdersSortableColumn = "number" | "title";
export const ASSIGNEE_FILTER_UNASSIGNED = "__unassigned" as const;
export interface WorkOrderListItem {
id: string | number;
number: string;
title: string;
location: string;
priority: WorkOrderPriority | string;
status: WorkOrderStatus | string;
createdDate: string;
dueDate: string;
assignedTo: string;
lastUpdated: string;
}
export interface WorkOrdersListParams {
search?: string;
page?: number;
pageSize?: number;
sortBy?: WorkOrdersSortableColumn | string;
sortDir?: SortDirection;
status?: string;
assignee?: string;
locationId?: string | number;
/** Phase 2: filter bar week range (Monday ISO). */
dateFrom?: string;
/** Phase 2: filter bar week range (Friday ISO). */
dateTo?: string;
/** Phase 2: filter bar WO type. */
type?: string;
/** Phase 2: filter bar dispatcher ids (format TBD by API contract). */
assigneeIds?: string;
}
export interface WorkOrderUser {
id: string | number;
name: string;
}
export interface WorkOrdersListResult {
items: WorkOrderTableRow[];
totalCount: number;
page: number;
pageSize: number;
}
export interface SelectedWorkOrderRef {
id: string | number;
number: string;
title: string;
}
/** Max page size when loading WOs at a location for dispatch multi-select. */
export const DISPATCH_LOCATION_WO_PAGE_SIZE = 50;

View file

@ -0,0 +1,24 @@
export type FileCategory = "image" | "video" | "pdf" | "doc" | "other";
export type MediaCategory =
"Photo" | "Video" | "Receipt" | "Quote" | "Invoice" | "Report" | "Other";
export interface MediaFile {
id: string;
name: string;
fileType: FileCategory;
url: string;
category: MediaCategory;
}
export const MEDIA_CATEGORIES: MediaCategory[] = [
"Photo",
"Video",
"Receipt",
"Quote",
"Invoice",
"Report",
"Other",
];
export const EMPTY_MEDIA: MediaFile[] = [];

View file

@ -0,0 +1,99 @@
import type { WizardWOStatus, WOType } from "./work-order-wizard";
export type CompletionDocStatus = "Yes" | "No" | "NN" | "";
export const WORK_ORDERS_TABLE_PAGE_SIZE = 500;
export interface WorkOrderTableRow {
id: string | number;
woNumber: string;
site: string;
type: WOType | "";
dispatcherId: string;
dispatcherName: string;
scheduledOn: string;
targetWeek: string;
dayGroup: string;
dayLabel: string;
location: string;
locationId: string | number;
pm: string;
company: string;
tech: string;
techPhone: string;
apptStart: string;
apptEnd: string;
apptTime: string;
status: WizardWOStatus | "";
docStatus: CompletionDocStatus;
pocName: string;
pocPhone: string;
pocNotes: string;
dueDate: string;
rescheduleCount: number;
carriedOver: number;
originalWeek: string;
originalDate: string;
isPastDue: boolean;
scheduleWeekOnly: boolean;
mediaCount: number;
title: string;
priority: string;
}
export interface WorkOrderTableDraft {
site: string;
woNumber: string;
type: WOType | "";
dispatcherId: string;
scheduledOn: string;
schedNotes: string;
dueDate: string;
location: string;
locationId: string | number;
pm: string;
company: string;
tech: string;
techPhone: string;
apptStart: string;
apptEnd: string;
status: WizardWOStatus | "";
pocName: string;
pocPhone: string;
pocNotes: string;
scheduleWeekOnly: boolean;
targetWeek: string;
}
export const EMPTY_TABLE_DRAFT: WorkOrderTableDraft = {
site: "",
woNumber: "",
type: "",
dispatcherId: "",
scheduledOn: "",
schedNotes: "",
dueDate: "",
location: "",
locationId: "",
pm: "",
company: "",
tech: "",
techPhone: "",
apptStart: "",
apptEnd: "",
status: "Incomplete",
pocName: "",
pocPhone: "",
pocNotes: "",
scheduleWeekOnly: false,
targetWeek: "",
};
export interface WorkOrderDayGroup {
key: string;
label: string;
date: string;
items: WorkOrderTableRow[];
}
export const UNSCHEDULED_DAY_KEY = "__unscheduled__" as const;

View file

@ -0,0 +1,149 @@
export const WO_TYPES = ["PM", "Reactive", "Emergency", "Add-On", "Overdue"] as const;
export type WOType = (typeof WO_TYPES)[number];
export const ALL_WIZARD_STATUSES = [
"Incomplete",
"Pending",
"Scheduled",
"En Route",
"On Site",
"In Progress",
"Completed",
"Rescheduled",
"Pending Quote",
] as const;
export type WizardWOStatus = (typeof ALL_WIZARD_STATUSES)[number];
export interface WorkOrderWizardDraft {
locationId: string;
locationName: string;
woNumber: string;
type: WOType | "";
assigneeId: string;
assigneeName: string;
scheduledOn: string;
dueDate: string;
pm: string;
vendorId: string;
vendorName: string;
techPhone: string;
vendorNotes: string;
apptStart: string;
apptEnd: string;
status: WizardWOStatus | "";
pocName: string;
pocPhone: string;
pocNotes: string;
scheduleWeekOnly: boolean;
}
export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
locationId: "",
locationName: "",
woNumber: "",
type: "",
assigneeId: "",
assigneeName: "",
scheduledOn: "",
dueDate: "",
pm: "",
vendorId: "",
vendorName: "",
techPhone: "",
vendorNotes: "",
apptStart: "",
apptEnd: "",
status: "Incomplete",
pocName: "",
pocPhone: "",
pocNotes: "",
scheduleWeekOnly: false,
};
export const WIZARD_STEPS: { title: string; nav: string; subtitle: string }[] = [
{
title: "Type & schedule",
nav: "What and when",
subtitle: "Set the work-order type and when it should happen.",
},
{
title: "Location & service",
nav: "Where and what work",
subtitle: "Pick the site and the service to be performed.",
},
{
title: "Vendor & time",
nav: "Who and the window",
subtitle: "Assign a vendor crew and the appointment window.",
},
];
export const WO_TYPE_META: Record<WOType, { desc: string; bg: string; fg: string }> = {
PM: {
desc: "Scheduled preventive maintenance",
bg: "var(--type-pm-bg)",
fg: "var(--type-pm-text)",
},
Reactive: {
desc: "Tenant-reported repair",
bg: "var(--type-reactive-bg)",
fg: "var(--type-reactive-text)",
},
Emergency: {
desc: "Urgent same-day dispatch",
bg: "var(--type-emergency-bg)",
fg: "var(--type-emergency-text)",
},
"Add-On": {
desc: "Extra scope on an existing visit",
bg: "var(--type-addon-bg)",
fg: "var(--type-addon-text)",
},
Overdue: {
desc: "No client response — due date passed",
bg: "var(--type-overdue-bg)",
fg: "var(--type-overdue-text)",
},
};
export const WIZARD_STATUS_DOTS: Record<WizardWOStatus, string> = {
Incomplete: "#9CA3AF",
Pending: "#D97706",
Scheduled: "#3B82F6",
"En Route": "#0EA5E9",
"On Site": "#10B981",
"In Progress": "#2563EB",
Completed: "#16A34A",
Rescheduled: "#F59E0B",
"Pending Quote": "#9333EA",
};
export const TIME_SLOTS: string[] = (() => {
const out: string[] = [];
for (let m = 5 * 60; m <= 22 * 60; m += 30) {
const h = Math.floor(m / 60);
const mm = m % 60;
out.push(`${String(h).padStart(2, "0")}:${String(mm).padStart(2, "0")}`);
}
return out;
})();
export function canContinueWizardStep1(draft: WorkOrderWizardDraft): boolean {
return Boolean(draft.type);
}
export function canCreateWorkOrderFromWizard(draft: WorkOrderWizardDraft): boolean {
return Boolean(draft.locationId);
}
export function isWizardStepValid(step: number, draft: WorkOrderWizardDraft): boolean {
if (step === 1) {
return canContinueWizardStep1(draft);
}
if (step === 2) {
return Boolean(draft.locationId);
}
return true;
}

View file

@ -1,270 +1,61 @@
export interface WorkOrderListItem {
id: string | number;
number: string;
title: string;
location: string;
priority: string;
status: string;
createdDate: string;
dueDate: string;
assignedTo: string;
lastUpdated: string;
}
export type {
DispatchChecklistItem,
DispatchComment,
DispatchDetail,
DispatchSignoff,
DispatchToVendorPayload,
DispatchWorkOrderRef,
UpdateDispatchPayload,
WorkOrderDispatchSummary,
} from "./dispatch";
export interface WorkOrdersListParams {
search?: string;
page?: number;
pageSize?: number;
sortBy?: string;
sortDir?: string;
status?: string;
assignee?: string;
locationId?: string | number;
}
export type {
DispatchStatus,
EmployeeOption,
LocationOption,
TaskListTemplate,
VendorDropdownItem,
WorkOrderCommentType,
WorkOrderPriority,
WorkOrderStatus,
} from "./work-order-common";
export interface WorkOrderUser {
id: string | number;
name: string;
}
export {
DISPATCH_STATUS_OPTIONS,
WORK_ORDER_PRIORITY_OPTIONS,
WORK_ORDER_STATUS_OPTIONS,
} from "./work-order-common";
export interface WorkOrdersListResult {
items: WorkOrderListItem[];
totalCount: number;
page: number;
pageSize: number;
}
export type {
WorkOrderAuditEntry,
WorkOrderComment,
WorkOrderDetail,
WorkOrderFormValues,
} from "./work-order-detail";
export interface WorkOrderComment {
id: string | number;
createdDate: string;
commenttext: string;
commentType: string;
userName: string;
commenter: string | null;
recordType: string | null;
}
export { createDefaultWorkOrderFormValues } from "./work-order-detail";
export interface WorkOrderAuditEntry {
id: string | number;
fieldName: string;
oldValue: string;
newValue: string;
action: string;
createdAt: string;
userName: string;
}
export type {
SelectedWorkOrderRef,
SortDirection,
WorkOrderListItem,
WorkOrdersListParams,
WorkOrdersListResult,
WorkOrderUser,
WorkOrdersSortableColumn,
} from "./work-order-list";
export interface WorkOrderDispatchSummary {
id: string | number;
dispatchNumber: string;
poNumber: string;
vendorName: string;
nteAmount: number | null;
status: string;
dispatchedAt: string;
}
export { ASSIGNEE_FILTER_UNASSIGNED, DISPATCH_LOCATION_WO_PAGE_SIZE } from "./work-order-list";
export interface WorkOrderDetail {
id: string | number;
internalWONumber: string;
workOrderNumber: string;
title: string;
location: string;
locationId: string;
locationZip: string;
assignedTo: string;
dueDate: string;
createdDate: string;
createdDateFull: string;
description: string;
priority: string;
status: string;
po: string;
tt: string;
problem: string;
trade: string;
subTrade: string;
vendorNTE: string;
scheduledDate: string;
completedDate: string;
source: string;
comments: WorkOrderComment[];
auditLog: WorkOrderAuditEntry[];
dispatches: WorkOrderDispatchSummary[];
}
export type {
CompletionDocStatus,
WorkOrderDayGroup,
WorkOrderTableDraft,
WorkOrderTableRow,
} from "./work-order-table-row";
export interface WorkOrderFormValues {
internalWONumber: string;
workOrderNumber: string;
title: string;
locationId: string;
priority: string;
status: string;
createdDate: string;
dueDate: string;
scheduledDate: string;
completedDate: string;
assignedTo: string;
description: string;
po: string;
tt: string;
problem: string;
trade: string;
subTrade: string;
vendorNTE: string;
source: string;
}
export interface LocationOption {
id: string | number;
name: string;
}
export interface EmployeeOption {
id: string | number;
name: string;
}
export interface VendorDropdownItem {
id: string | number;
companyName: string;
tradeSpecialties: string;
address: string;
distanceMiles: number | null;
}
export interface TaskListTemplate {
id: string | number;
name: string;
items: { id?: string | number; itemText?: string }[];
}
export interface DispatchChecklistItem {
id: string | number;
itemText: string;
isCompleted: boolean;
completedBy: string | null;
completedAt: string | null;
}
export interface DispatchSignoff {
id: string | number;
signoffType: string;
name: string;
signature: string;
signatureMethod: string;
signedAt: string;
}
export interface DispatchComment {
id: string | number;
createdDate: string;
commenttext: string;
userName: string;
commenter: string | null;
}
export interface DispatchWorkOrderRef {
id: string | number;
woNumber: string;
}
export interface DispatchDetail {
id: string | number;
dispatchNumber: string;
poNumber: string;
vendorName: string;
vendorEmail: string;
woNumber: string;
status: string;
nteAmount: number | null;
scheduledDate: string;
completedDate: string;
description: string;
dispatchedAt: string;
acknowledgedAt: string;
verifiedAt: string;
verifiedBy: string;
checklistItems: DispatchChecklistItem[];
signoffs: DispatchSignoff[];
comments: DispatchComment[];
workOrders: DispatchWorkOrderRef[];
}
export interface DispatchToVendorPayload {
workOrderIds: (string | number)[];
vendorIds: (string | number)[];
nteAmount: number | null;
scheduledDate: string | null;
description: string;
taskListTemplateId: number | null;
customChecklistItems: string[] | null;
}
export interface UpdateDispatchPayload {
id: string | number;
status: string;
nteAmount: number | null;
scheduledDate: string | null;
completedDate: string | null;
description: string;
}
export interface UpliftRequest {
id: string | number;
status: string;
currentNTE: number | null;
requestedNTE: number;
delta: number;
vendorReason: string;
requestedAt: string;
requestedByVendorName: string;
decidedAt: string;
decidedByName: string;
decisionNote: string;
requiredTier: number;
canDecide: boolean;
}
export const WORK_ORDER_STATUS_OPTIONS = [
"Open",
"In Progress",
"On Hold",
"Done",
"Cancelled",
] as const;
export const WORK_ORDER_PRIORITY_OPTIONS = ["Sev 1", "Sev 2", "Sev 3", "Sev 4", "Sev 5"] as const;
export const DISPATCH_STATUS_OPTIONS = [
"Sent",
"Acknowledged",
"In Progress",
"Completed",
"Verified",
"Cancelled",
] as const;
export function createDefaultWorkOrderFormValues(): WorkOrderFormValues {
return {
internalWONumber: "",
workOrderNumber: "",
title: "",
locationId: "",
priority: "",
status: "Open",
createdDate: new Date().toISOString().split("T")[0] ?? "",
dueDate: "",
scheduledDate: "",
completedDate: "",
assignedTo: "",
description: "",
po: "",
tt: "",
problem: "",
trade: "",
subTrade: "",
vendorNTE: "",
source: "",
};
}
export {
EMPTY_TABLE_DRAFT,
UNSCHEDULED_DAY_KEY,
WORK_ORDERS_TABLE_PAGE_SIZE,
} from "./work-order-table-row";

View file

@ -0,0 +1,35 @@
import { API_PATHS } from "@/api/api-paths";
import { apiGet } from "@/api/api";
import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
import { mapEmployeeOptions } from "@/domain/work-orders/mappers/work-order-mapper";
import type { EmployeeOption } from "@/domain/work-orders/types/work-order";
async function fetchEmployeesFromList(): Promise<EmployeeOption[]> {
const data = await apiGet<unknown>(API_PATHS.employee.getList, {
searchParams: { page: 1, pageSize: 1000 },
});
const paginated = handlePaginatedResponse<unknown>(data);
const items = paginated.items.map((item) => {
const record = (typeof item === "object" && item !== null ? item : {}) as Record<
string,
unknown
>;
const firstName = String(record.firstName ?? record.FirstName ?? "");
const lastName = String(record.lastName ?? record.LastName ?? "");
return {
id: record.id ?? record.Id,
name: `${firstName} ${lastName}`.trim(),
};
});
return mapEmployeeOptions(items);
}
export async function fetchWorkOrderEmployees(): Promise<EmployeeOption[]> {
try {
const data = await apiGet<unknown>(API_PATHS.employee.dropdown);
const items = handleApiResponse<unknown[]>(data);
return Array.isArray(items) ? mapEmployeeOptions(items) : [];
} catch {
return fetchEmployeesFromList();
}
}

View file

@ -0,0 +1,35 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { toast } from "react-toastify";
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
import { shouldUseMockWoWizardCreate } from "@/domain/work-orders/mocks/is-wo-wizard-mock-enabled";
import { buildMockWorkOrderDetail } from "@/domain/work-orders/mocks/wo-wizard-mock-data";
import type { WorkOrderFormValues } from "@/domain/work-orders/types/work-order-detail";
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
import { queryKeys } from "@/infra/query-key/query-key";
async function createWorkOrderFromWizard(values: WorkOrderFormValues): Promise<WorkOrderDetail> {
if (shouldUseMockWoWizardCreate(values.locationId)) {
await new Promise((resolve) => setTimeout(resolve, 400));
return buildMockWorkOrderDetail(values);
}
return workOrdersApi.create(values);
}
export function useCreateWorkOrderFromWizard(
onSuccess?: () => void,
): UseMutationResult<WorkOrderDetail, Error, WorkOrderFormValues> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createWorkOrderFromWizard,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
toast.success("Work Order created successfully!");
onSuccess?.();
},
onError: (error: Error) => {
toast.error(error.message || "Failed to create work order");
},
});
}

View file

@ -1,5 +1,6 @@
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
import { fetchWorkOrderEmployees } from "@/domain/work-orders/use-cases/fetch-work-order-employees";
import type {
DispatchDetail,
EmployeeOption,
@ -32,7 +33,7 @@ export function useWorkOrderLocations(): UseQueryResult<LocationOption[], Error>
export function useWorkOrderEmployeesDropdown(): UseQueryResult<EmployeeOption[], Error> {
return useQuery({
queryKey: [...queryKeys.employees.all, "dropdown"] as const,
queryFn: () => workOrdersApi.fetchEmployeesDropdown(),
queryFn: () => fetchWorkOrderEmployees(),
staleTime: 5 * 60 * 1000,
});
}

View file

@ -20,32 +20,32 @@ function invalidateWorkOrders(queryClient: ReturnType<typeof useQueryClient>) {
interface AddWorkOrderCommentInput {
text: string;
commentType: string;
commentType: "customer" | "internal";
}
interface ChangeWorkOrderAssignmentInput {
userId: string | number;
userName: string;
}
interface AddDispatchCommentInput {
export interface AddDispatchCommentInput {
text: string;
sendEmail: boolean;
}
interface UpdateChecklistItemInput {
export interface UpdateChecklistItemInput {
itemId: string | number;
isCompleted: boolean;
completedBy: string;
}
interface AddDispatchSignoffInput {
export interface AddDispatchSignoffInput {
signoffType: string;
name: string;
signature: string;
signatureMethod: string;
}
interface ChangeWorkOrderAssignmentInput {
userId: string | number;
userName: string;
}
export function useCreateWorkOrder(): UseMutationResult<
WorkOrderDetail,
Error,

View file

@ -0,0 +1,154 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "react-toastify";
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
import {
mapApiStatusToWizard,
mapWizardStatusToApi,
} from "@/domain/work-orders/mappers/wizard-draft-mapper";
import {
mapWorkOrderDetailToFormValues,
mapWorkOrderTableRow,
} from "@/domain/work-orders/mappers/work-order-mapper";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
import { enrichTableRow } from "@/domain/work-orders/utils/table-filter-utils";
import { queryKeys } from "@/infra/query-key/query-key";
export type WorkOrderTablePatch = Partial<WorkOrderTableRow>;
async function loadFormAndUpdate(
id: string | number,
patch: (
values: ReturnType<typeof mapWorkOrderDetailToFormValues>,
) => ReturnType<typeof mapWorkOrderDetailToFormValues>,
) {
const detail = await workOrdersApi.getById(id);
const values = mapWorkOrderDetailToFormValues(detail);
const next = patch(values);
await workOrdersApi.update(id, next);
return detail;
}
export function useWorkOrderTableMutations(
onPatch: (id: string | number, patch: WorkOrderTablePatch) => void,
) {
const queryClient = useQueryClient();
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
};
const patchMutation = useMutation({
mutationFn: async ({
id,
patch,
useFullUpdate,
}: {
id: string | number;
patch: WorkOrderTablePatch;
useFullUpdate?: boolean;
}) => {
if (patch.status !== undefined && !useFullUpdate) {
const apiStatus = mapWizardStatusToApi(patch.status as WizardWOStatus);
await workOrdersApi.changeStatus(id, apiStatus);
return patch;
}
if (patch.dispatcherId !== undefined && !useFullUpdate) {
await workOrdersApi.changeAssignment(id, patch.dispatcherId || "");
return {
...patch,
dispatcherName: patch.dispatcherName ?? "",
};
}
await loadFormAndUpdate(id, (values) => ({
...values,
workOrderNumber: patch.woNumber ?? values.workOrderNumber,
internalWONumber: patch.woNumber ?? values.internalWONumber,
locationId: patch.locationId != null ? String(patch.locationId) : values.locationId,
dueDate: patch.dueDate ?? values.dueDate,
scheduledDate: patch.scheduledOn ?? values.scheduledDate,
assignedTo: patch.dispatcherName ?? values.assignedTo,
problem: patch.pm ?? values.problem,
source: patch.type ?? values.source,
priority: values.priority,
status:
patch.status !== undefined
? mapWizardStatusToApi(patch.status as WizardWOStatus)
: values.status,
description: [
values.description,
patch.pocNotes ? `POC notes: ${patch.pocNotes}` : "",
patch.pocName || patch.pocPhone
? `POC: ${[patch.pocName, patch.pocPhone].filter(Boolean).join(" · ")}`
: "",
]
.filter(Boolean)
.join("\n"),
}));
return patch;
},
onMutate: async ({ id, patch }) => {
onPatch(id, patch);
},
onSuccess: () => {
invalidate();
},
onError: (error: Error) => {
toast.error(error.message || "Failed to update work order");
invalidate();
},
});
const patchField = (id: string | number, patch: WorkOrderTablePatch, useFullUpdate = false) => {
patchMutation.mutate({ id, patch, useFullUpdate });
};
return {
patchField,
isPending: patchMutation.isPending,
};
}
export function tableRowFromDetail(detail: Awaited<ReturnType<typeof workOrdersApi.getById>>) {
return enrichTableRow(
mapWorkOrderTableRow({
...detail,
status: mapApiStatusToWizard(detail.status),
scheduledOn: detail.scheduledDate,
woNumber: detail.internalWONumber || detail.workOrderNumber,
dispatcherName: detail.assignedTo,
pm: detail.problem,
type: detail.source,
}),
);
}
export function maybeAutoSchedulePatch(row: WorkOrderTableRow): WorkOrderTablePatch {
const hasDate = !!row.scheduledOn;
const hasDispatcher = !!row.dispatcherId;
if (hasDate && hasDispatcher && row.status === "Incomplete") {
return { status: "Scheduled" };
}
return {};
}
export function applySchedulePatch(
scheduledOn: string,
scheduleWeekOnly: boolean,
): WorkOrderTablePatch {
if (scheduleWeekOnly) {
return {
scheduledOn: "",
targetWeek: scheduledOn,
scheduleWeekOnly: true,
...deriveDayFields(""),
};
}
return {
scheduledOn,
scheduleWeekOnly: false,
targetWeek: "",
...deriveDayFields(scheduledOn),
};
}

View file

@ -11,7 +11,7 @@ export function useWorkOrdersList(
params: WorkOrdersListParams,
): UseQueryResult<WorkOrdersListResult, Error> {
return useQuery({
queryKey: queryKeys.workOrders.list(params as Record<string, unknown>),
queryKey: queryKeys.workOrders.list(params),
queryFn: () => workOrdersApi.getList(params),
placeholderData: keepPreviousData,
});
@ -24,3 +24,16 @@ export function useWorkOrderUsers(): UseQueryResult<WorkOrderUser[], Error> {
staleTime: 5 * 60 * 1000,
});
}
export function useWorkOrdersByLocation(
locationId: string | number | undefined,
search = "",
enabled = true,
): UseQueryResult<WorkOrdersListResult, Error> {
return useQuery({
queryKey: queryKeys.workOrders.byLocation(locationId ?? "", search),
queryFn: () => workOrdersApi.getListByLocation(locationId!, search),
enabled: enabled && locationId !== undefined && locationId !== "",
staleTime: 30 * 1000,
});
}

View file

@ -0,0 +1,240 @@
import {
ASSIGNEE_FILTER_UNASSIGNED,
DATE_RANGE_OPTIONS,
DOC_FILTER_LABELS,
EMPTY_ADVANCED_FILTERS,
type CompletionDocFilter,
type DateRangeKey,
type WorkOrderAdvancedFilters,
} from "@/domain/work-orders/types/work-order-advanced-filters";
import {
MONTH_NAMES,
addDaysIso,
mondayOf,
pad2,
todayIso,
} from "@/domain/work-orders/utils/wizard-date-utils";
export interface AdvancedFilterChip {
key: string;
group: string;
value?: string;
count?: number;
accent?: boolean;
clear: (filters: WorkOrderAdvancedFilters) => WorkOrderAdvancedFilters;
}
function cloneFilters(filters: WorkOrderAdvancedFilters): WorkOrderAdvancedFilters {
return {
...filters,
sites: new Set(filters.sites),
types: new Set(filters.types),
dispatchers: new Set(filters.dispatchers),
statuses: new Set(filters.statuses),
pmTypes: new Set(filters.pmTypes),
vendorTechs: new Set(filters.vendorTechs),
docs: new Set(filters.docs),
};
}
export function countAdvancedFilters(filters: WorkOrderAdvancedFilters): number {
let count = 0;
if (filters.dateRange !== "this-week") count += 1;
if (filters.sites.size) count += 1;
if (filters.types.size) count += 1;
if (filters.dispatchers.size) count += 1;
if (filters.statuses.size) count += 1;
if (filters.pmTypes.size) count += 1;
if (filters.vendorTechs.size) count += 1;
if (filters.docs.size) count += 1;
return count;
}
function fmtChipDate(iso: string): string {
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return "";
const [, m, d] = iso.split("-").map(Number);
return `${MONTH_NAMES[m - 1].slice(0, 3)} ${d}`;
}
function monthStart(y: number, m: number): string {
return `${y}-${pad2(m + 1)}-01`;
}
function monthEnd(y: number, m: number): string {
return `${y}-${pad2(m + 1)}-${pad2(new Date(y, m + 1, 0).getDate())}`;
}
export function dateRangeFor(filters: WorkOrderAdvancedFilters): [string, string] {
const today = todayIso();
const ty = Number(today.slice(0, 4));
const tm = Number(today.slice(5, 7)) - 1;
const thisWeekMonday = mondayOf(today);
const lastWeekMonday = addDaysIso(thisWeekMonday, -7);
switch (filters.dateRange) {
case "this-week":
return [thisWeekMonday, addDaysIso(thisWeekMonday, 4)];
case "last-week":
return [lastWeekMonday, addDaysIso(lastWeekMonday, 4)];
case "this-month":
return [monthStart(ty, tm), monthEnd(ty, tm)];
case "last-3-months": {
const d = new Date(ty, tm - 2, 1);
return [monthStart(d.getFullYear(), d.getMonth()), monthEnd(ty, tm)];
}
case "next-week":
return [today, addDaysIso(today, 7)];
case "next-month":
return [today, addDaysIso(today, 30)];
case "custom":
return [filters.customFrom || "0000-01-01", filters.customTo || "9999-12-31"];
}
}
function setChip(
chips: AdvancedFilterChip[],
key: string,
group: string,
set: Set<string>,
nameOf: (value: string) => string,
clear: (filters: WorkOrderAdvancedFilters) => WorkOrderAdvancedFilters,
) {
if (!set.size) return;
if (set.size <= 2) {
chips.push({ key, group, value: [...set].map(nameOf).join(", "), clear });
return;
}
chips.push({ key, group, count: set.size, clear });
}
export function buildAdvancedFilterChips(
filters: WorkOrderAdvancedFilters,
options?: {
siteNameOf?: (id: string) => string;
dispatcherNameOf?: (id: string) => string;
},
): AdvancedFilterChip[] {
const chips: AdvancedFilterChip[] = [];
const siteNameOf = options?.siteNameOf ?? ((v) => v);
const dispatcherNameOf = options?.dispatcherNameOf ?? ((v) => v);
if (filters.dateRange !== "this-week") {
const value =
filters.dateRange === "custom"
? `${fmtChipDate(filters.customFrom) || "Any"} – ${fmtChipDate(filters.customTo) || "Any"}`
: (DATE_RANGE_OPTIONS.find((o) => o.key === filters.dateRange)?.label ?? "Custom");
chips.push({
key: "date",
group: "Date",
value,
clear: (current) => ({
...current,
dateRange: "this-week",
customFrom: "",
customTo: "",
}),
});
}
setChip(chips, "sites", "Site", filters.sites, siteNameOf, (current) => ({
...current,
sites: new Set(),
}));
setChip(
chips,
"types",
"Type of WO",
filters.types as Set<string>,
(v) => v,
(current) => ({ ...current, types: new Set() }),
);
const realDispatchers = new Set(
[...filters.dispatchers].filter((id) => id !== ASSIGNEE_FILTER_UNASSIGNED),
);
setChip(chips, "disp", "Assigned to", realDispatchers, dispatcherNameOf, (current) => ({
...current,
dispatchers: new Set(
[...current.dispatchers].filter((id) => id === ASSIGNEE_FILTER_UNASSIGNED),
),
}));
if (filters.dispatchers.has(ASSIGNEE_FILTER_UNASSIGNED)) {
chips.push({
key: "disp-unassigned",
group: "Assigned to",
value: "Unassigned",
accent: true,
clear: (current) => {
const next = new Set(current.dispatchers);
next.delete(ASSIGNEE_FILTER_UNASSIGNED);
return { ...current, dispatchers: next };
},
});
}
setChip(
chips,
"status",
"Status",
filters.statuses as Set<string>,
(v) => v,
(current) => ({ ...current, statuses: new Set() }),
);
setChip(
chips,
"pm",
"PM type",
filters.pmTypes,
(v) => v,
(current) => ({ ...current, pmTypes: new Set() }),
);
setChip(
chips,
"vendor",
"Vendor / tech",
filters.vendorTechs,
(v) => v,
(current) => ({ ...current, vendorTechs: new Set() }),
);
setChip(
chips,
"doc",
"Comp doc",
filters.docs as Set<string>,
(v) => DOC_FILTER_LABELS[v as CompletionDocFilter] ?? v,
(current) => ({ ...current, docs: new Set() }),
);
return chips;
}
export function clearAdvancedFilterGroup(
filters: WorkOrderAdvancedFilters,
chipKey: string,
): WorkOrderAdvancedFilters | null {
const chips = buildAdvancedFilterChips(filters);
const chip = chips.find((c) => c.key === chipKey);
if (!chip) return filters;
const next = chip.clear(cloneFilters(filters));
return countAdvancedFilters(next) > 0 ? next : null;
}
export function getInitialAdvancedFilters(
applied: WorkOrderAdvancedFilters | null,
): WorkOrderAdvancedFilters {
if (!applied) return cloneFilters(EMPTY_ADVANCED_FILTERS);
return cloneFilters(applied);
}
export function cloneAdvancedFilters(filters: WorkOrderAdvancedFilters): WorkOrderAdvancedFilters {
return cloneFilters(filters);
}
export function isDefaultDateRange(dateRange: DateRangeKey): boolean {
return dateRange === "this-week";
}

View file

@ -0,0 +1,119 @@
import type { CompDocData, DocFields } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { procedureFor, SAFETY_BULLETS } from "@/domain/work-orders/utils/completion-doc-procedures";
import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
export const SEAHAVEN_HEADER = {
name: "Seahaven Industries, Inc.",
address: "710 Koehler Avenue, Ronkonkoma, NY 11779",
phone: "(631) 776-5102",
fax: "(631) 677-3934",
email: "work-orders@seahavenind.com",
};
function esc(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
export function buildDocHtml(
row: Pick<WorkOrderTableRow, "woNumber" | "site" | "pm" | "tech" | "company">,
data: CompDocData,
fields: DocFields,
): string {
const cityStateZip =
[fields.city, fields.state].filter(Boolean).join(", ") + (fields.zip ? ` ${fields.zip}` : "");
const proc = procedureFor(row.pm);
const completedBlock = `<h3 style="margin:16px 0 4px;">Completion Details:</h3>
<p style="margin:2px 0;"><b>Date Work Completed:</b> ${esc(fields.dateCompleted || "—")}</p>
<p style="margin:2px 0;"><b>Amazon Representative:</b> ${esc(fields.amazonRep || "—")}</p>
<p style="margin:2px 0;"><b>Seahaven Representative:</b> ${esc(fields.seaRep || "—")}</p>`;
const procHtml = proc.steps
.map((st) => {
const sub = st.sub
? `<ol type="a" style="margin:4px 0 0 0;padding-left:22px;">${st.sub.map((x) => `<li style="margin:2px 0;">${esc(x)}</li>`).join("")}</ol>`
: "";
return `<li style="margin:6px 0;">${esc(st.text)}${sub}</li>`;
})
.join("");
const safety = SAFETY_BULLETS.map((b) => `<li style="margin:4px 0;">${esc(b)}</li>`).join("");
const notesBlock = data.notes
? `<h3 style="margin:16px 0 4px;">PM Comments:</h3><p style="margin:0;">${esc(data.notes)}</p>`
: "";
return `<!doctype html><html><head><meta charset="utf-8"><title>WO ${esc(row.woNumber)} — Completion</title>
<style>
@page { margin: 28px; }
* { box-sizing: border-box; }
body { font-family: 'DM Sans', system-ui, sans-serif; color: #161E38; font-size: 13px; line-height: 1.5; margin: 0; }
.band-top { height: 14px; background: linear-gradient(100deg,#27AAE1,#1C75BC); }
.band-bottom { height: 26px; margin-top: 28px; background: linear-gradient(100deg,#1C75BC,#27AAE1); clip-path: polygon(0 40%,100% 0,100% 100%,0 100%); }
.wrap { padding: 0 36px; }
.head { display: flex; justify-content: space-between; align-items: flex-start; padding: 20px 0 12px; }
.brand { font-weight: 800; font-size: 22px; letter-spacing: .04em; color: #1C75BC; }
.brand small { display:block; font-size: 9px; letter-spacing: .22em; color: #58595B; font-weight:600; }
.co { text-align: right; font-size: 12px; color: #161E38; }
.co b { color:#161E38; }
.rule { height: 3px; background: linear-gradient(90deg,#27AAE1,#1C75BC); border-radius: 2px; }
h1 { font-size: 14px; letter-spacing: .04em; margin: 18px 0 4px; }
.wo { color: #1C75BC; font-weight: 700; }
.muted { color: #58595B; }
ol { padding-left: 22px; }
</style></head>
<body>
<div class="band-top"></div>
<div class="wrap">
<div class="head">
<div class="brand">SEAHAVEN<small>INDUSTRIES</small></div>
<div class="co">
<div><b>${esc(SEAHAVEN_HEADER.name)}</b></div>
<div>${esc(SEAHAVEN_HEADER.address)}</div>
<div>${esc(SEAHAVEN_HEADER.phone)}</div>
<div>${esc(SEAHAVEN_HEADER.fax)}</div>
<div>${esc(SEAHAVEN_HEADER.email)}</div>
</div>
</div>
<div class="rule"></div>
<h1>${esc(serviceTitle(row).toUpperCase())}</h1>
<p style="margin:2px 0;" class="wo">WO# ${esc(row.woNumber)}</p>
<p style="margin:14px 0 0;">Site ID — ${esc(row.site)}</p>
<p style="margin:2px 0;">Amazon.com Services LLC ${esc(row.site)}</p>
<p style="margin:2px 0;" class="muted">${esc(fields.street)},</p>
<p style="margin:2px 0;" class="muted">${esc(cityStateZip)}</p>
<p style="margin:14px 0 4px;"><b>Vendor / Technician:</b> ${esc(row.tech || "—")}${row.company ? ` &middot; ${esc(row.company)}` : ""}</p>
<ul style="padding-left:20px;">${safety}</ul>
${completedBlock}
<h3 style="margin:16px 0 4px;">${esc(proc.heading)}:</h3>
<p style="margin:0 0 8px;">${esc(proc.intro)}</p>
<ol>${procHtml}</ol>
${notesBlock}
</div>
<div class="band-bottom"></div>
</body></html>`;
}
export function printDoc(html: string): void {
const iframe = document.createElement("iframe");
iframe.style.position = "fixed";
iframe.style.right = "0";
iframe.style.bottom = "0";
iframe.style.width = "0";
iframe.style.height = "0";
iframe.style.border = "0";
document.body.appendChild(iframe);
const idoc = iframe.contentWindow?.document;
if (!idoc) {
document.body.removeChild(iframe);
return;
}
idoc.open();
idoc.write(html);
idoc.close();
const win = iframe.contentWindow!;
setTimeout(() => {
win.focus();
win.print();
setTimeout(() => {
if (iframe.parentNode) document.body.removeChild(iframe);
}, 1200);
}, 300);
}

View file

@ -0,0 +1,99 @@
export interface ProcStep {
text: string;
sub?: string[];
}
export interface Procedure {
heading: string;
intro: string;
steps: ProcStep[];
}
export const SAFETY_BULLETS = [
"Use required PPE (gloves, goggles, hearing protection, arc flash equip. if needed).",
"Always follow OSHA, LOTO, FTHA, NFPA, and Amazon Policies before placing hands on equipment.",
'If any other issues are found not part of this PM\'s tasks please create a new "Work from PM" work order to make the correction.',
];
const BACKFLOW_PROCEDURE: Procedure = {
heading: "Backflow Procedure",
intro:
"Each backflow should be tested and if it passes inspection, the documentation must be submitted to the local municipality (i.e., city or county). No backflow PM is sufficient without at least testing the backflow.",
steps: [
{
text: "Contact base building coordinator/site POC, schedule backflow testing",
},
{ text: "Inform Facility Manager of scheduled test date" },
{
text: "Meet with base building coordinator/site POC upon arrival at site, confirming backflows will now be tested",
},
{
text: "Visually inspect backflows and take photos of units",
sub: [
"Document backflow location in facility",
'Document size and type of backflow (i.e., 1" RPZ backflow)',
],
},
{
text: "Perform backflow test",
sub: [
"If backflow test fails, disassemble backflow as needed and determine reason for failure",
"Make repairs to backflow",
"Retest backflow and ensure appropriate pressure",
],
},
{
text: "File backflow test and certification paperwork with site POC and local municipality",
},
{
text: 'If any failed backflow cannot be repaired immediately, notify the site POC and schedule tentative follow-up date for return, ensure a new PO for the follow up work. Issue a new "Work from PM" work order.',
sub: [
"Ensure equipment is functioning appropriately and safely before returning to operations",
"Ensure equipment and work area is clean prior to leaving the work site",
"If any deficiencies are discovered and unable to repair immediately, schedule follow up date with vendor and issue a new PO for the follow-up work. Issue a new \"work from PM' work order.",
"Provide adequate lead time for parts/scheduling",
],
},
],
};
const GENERIC_PROCEDURE: Procedure = {
heading: "Service Procedure",
intro:
"Perform the scheduled preventive maintenance per the manufacturer specification and Amazon site requirements. Document all findings and submit the completion paperwork to the site POC.",
steps: [
{
text: "Contact base building coordinator/site POC and schedule the service visit",
},
{ text: "Inform Facility Manager of the scheduled date" },
{
text: "Visually inspect the equipment and take photos of units",
sub: [
"Document equipment location in facility",
"Document make, model and condition of the asset",
],
},
{
text: "Perform the required preventive maintenance tasks for this asset",
sub: [
"If a deficiency is found, determine the corrective action and parts required",
"Make repairs where possible and re-test to confirm normal operation",
],
},
{
text: "Ensure equipment is functioning appropriately and the work area is clean prior to leaving the site",
},
{
text: "File the completion paperwork with the site POC and Seahaven",
sub: [
'If any deficiencies are discovered and unable to repair immediately, schedule a follow-up date with vendor and issue a new PO. Issue a new "Work from PM" work order.',
],
},
],
};
export function procedureFor(pm: string): Procedure {
return pm === "Backflow Preventers" || pm === "Domestic Backflow"
? BACKFLOW_PROCEDURE
: GENERIC_PROCEDURE;
}

View file

@ -0,0 +1,31 @@
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
const SERVICE_PHRASE: Record<string, string> = {
"Scrubber Pit": "Scrubber Pit Augering",
"Backflow Preventers": "Backflow Preventer Testing",
"Domestic Backflow": "Domestic Backflow Testing",
"Tank / Water Heater": "Tank / Water Heater Inspection",
"Pressure Reducing Valve": "Pressure Reducing Valve Service",
"Pumping Systems": "Pumping System Maintenance",
"HVAC Inspection": "HVAC Inspection",
Electrical: "Electrical Inspection",
Plumbing: "Plumbing Service",
"Door / Hardware": "Door / Hardware Service",
"Floor Drain Backing Up": "Floor Drain Augering",
"Parking Lot Lighting": "Parking Lot Lighting Service",
"Mens Restroom Camera Line": "Restroom Camera Line Service",
"Assess Window Leaks": "Window Leak Assessment",
};
const FREQ_CYCLE = ["13 Week", "26 Week", "52 Week"];
function hashNum(s: string, mod: number): number {
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
return h % mod;
}
export function serviceTitle(row: Pick<WorkOrderTableRow, "pm" | "woNumber">): string {
const phrase = SERVICE_PHRASE[row.pm] ?? row.pm;
return `Amer-Building ${phrase} — ${FREQ_CYCLE[hashNum(row.woNumber, FREQ_CYCLE.length)]}`;
}

View file

@ -0,0 +1,31 @@
import type { Location } from "@/domain/locations/types/location";
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
function buildLabel(city?: string, state?: string): string {
return [city, state].filter(Boolean).join(", ");
}
export function resolveCompDocSite(
row: Pick<WorkOrderTableRow, "site" | "location">,
location?: Location | null,
): CompDocSiteInfo {
if (location?.address || location?.city) {
const city = location.city ?? "";
const state = location.state ?? "";
const zip = location.zipCode ?? "";
const label = buildLabel(city, state);
const street = location.address ?? "";
const fullAddress = [street, label, zip].filter(Boolean).join(", ") || "—";
return { street, city, state, zip, label, fullAddress };
}
const locationParts = row.location.split(",").map((s) => s.trim());
const city = locationParts[0] ?? "";
const state = locationParts[1] ?? "";
const label = buildLabel(city, state);
const street = row.location && !label ? row.location : "";
const fullAddress = [street, label].filter(Boolean).join(", ") || "—";
return { street, city, state, zip: "", label, fullAddress };
}

View file

@ -0,0 +1,80 @@
import { CheckCircle2, FileText, XCircle } from "lucide-react";
import type { CompletionDocStateConfig, DocState } from "@/domain/work-orders/types/completion-doc";
import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
const MONTH_NAMES = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
function pad2(n: number): string {
return String(n).padStart(2, "0");
}
export function docStateOf(status: CompletionDocStatus): DocState {
return status === "Yes" ? "generated" : status === "NN" ? "not-required" : "draft";
}
export function fmtDocNow(): string {
const d = new Date();
let h = d.getHours();
const ap = h >= 12 ? "PM" : "AM";
h = h % 12 || 12;
return `${MONTH_NAMES[d.getMonth()].slice(0, 3)} ${d.getDate()}, ${d.getFullYear()} · ${h}:${pad2(d.getMinutes())} ${ap}`;
}
export const DOC_STATE_CFG: Record<DocState, CompletionDocStateConfig> = {
draft: {
pillLabel: "Draft — not generated",
pillBg: "var(--color-bg-muted)",
pillText: "var(--muted-foreground)",
cardBg: "var(--color-bg-muted)",
cardBorder: "var(--color-border)",
cardIconBg: "var(--color-bg-surface)",
iconColor: "var(--muted-foreground)",
cardTitle: "Draft — not generated",
icon: FileText,
},
generated: {
pillLabel: "Generated",
pillBg: "var(--doc-yes-bg)",
pillText: "var(--color-success)",
cardBg: "var(--doc-yes-bg)",
cardBorder: "#bbf7d0",
cardIconBg: "var(--color-bg-surface)",
iconColor: "var(--color-success)",
cardTitle: "Generated",
icon: CheckCircle2,
},
"not-required": {
pillLabel: "Not required",
pillBg: "var(--indicator-co-1-bg)",
pillText: "var(--warning)",
cardBg: "var(--indicator-co-1-bg)",
cardBorder: "#fde68a",
cardIconBg: "var(--color-bg-surface)",
iconColor: "var(--warning)",
cardTitle: "Not required",
icon: XCircle,
},
};
export function docStatusCardDescription(state: DocState, generatedAt: string): string {
if (state === "draft") {
return "This document hasn't been generated yet. Add any extra details, then generate it — or mark it as not required.";
}
if (state === "generated") {
return `Ready to download or share. Generated ${generatedAt}.`;
}
return "No document is required for this work order. You can undo this anytime.";
}

View file

@ -0,0 +1,68 @@
import { addDaysIso, mondayOf, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
export interface WorkWeek {
mondayIso: string;
label: string;
live: boolean;
}
export function currentWeekMonday(): string {
return mondayOf(todayIso());
}
export function weekRange(mondayIso: string): [string, string] {
return [mondayIso, addDaysIso(mondayIso, 4)];
}
export function compactWeekLabel(mondayIso: string): string {
if (!/^\d{4}-\d{2}-\d{2}$/.test(mondayIso)) {
return "";
}
const fridayIso = addDaysIso(mondayIso, 4);
const [, sm, sd] = mondayIso.split("-").map(Number);
const [, em, ed] = fridayIso.split("-").map(Number);
return `${sm}/${sd}–${em}/${ed}`;
}
export function isLiveWeek(mondayIso: string): boolean {
return mondayIso === currentWeekMonday();
}
export function isFutureWeek(mondayIso: string): boolean {
return mondayIso > currentWeekMonday();
}
export function nextWeekMonday(): string {
return addDaysIso(currentWeekMonday(), 7);
}
function toWorkWeek(mondayIso: string): WorkWeek {
return {
mondayIso,
label: compactWeekLabel(mondayIso),
live: isLiveWeek(mondayIso),
};
}
export function buildWeekWindow(anchorMonday: string, before = 4, after = 4): WorkWeek[] {
const weeks: WorkWeek[] = [];
const liveMonday = currentWeekMonday();
const upcomingMonday = nextWeekMonday();
for (let i = -before; i <= after; i += 1) {
const mondayIso = addDaysIso(anchorMonday, i * 7);
if (isFutureWeek(mondayIso) && mondayIso !== upcomingMonday) {
continue;
}
weeks.push(toWorkWeek(mondayIso));
}
for (const requiredMonday of [liveMonday, upcomingMonday]) {
if (!weeks.some((week) => week.mondayIso === requiredMonday)) {
weeks.push(toWorkWeek(requiredMonday));
}
}
weeks.sort((a, b) => a.mondayIso.localeCompare(b.mondayIso));
return weeks;
}

View file

@ -0,0 +1,46 @@
import { addDaysIso, pad2 } from "@/domain/work-orders/utils/wizard-date-utils";
export const WEEKDAY_LABELS = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"] as const;
export function deriveDayFields(rawDate: string): {
dayGroup: string;
dayLabel: string;
} {
const dayGroup = rawDate ? rawDate.replace(/^\d{4}-(\d{2})-(\d{2})$/, "$1/$2") : "";
const dayLabel = rawDate
? (() => {
const d = new Date(`${rawDate}T12:00:00`);
return `${WEEKDAY_LABELS[d.getDay()]} · ${dayGroup}`;
})()
: "";
return { dayGroup, dayLabel };
}
export function buildWeekSkeleton(weekMonday: string): {
key: string;
label: string;
date: string;
items: never[];
}[] {
const base = new Date(`${weekMonday}T12:00:00`);
return Array.from({ length: 5 }, (_, i) => {
const d = new Date(base);
d.setDate(base.getDate() + i);
const key = `${pad2(d.getMonth() + 1)}/${pad2(d.getDate())}`;
const iso = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
return {
key,
label: `${WEEKDAY_LABELS[d.getDay()]} · ${key}`,
date: iso,
items: [],
};
});
}
export function isDateInWeek(iso: string, weekMonday: string): boolean {
if (!iso || !/^\d{4}-\d{2}-\d{2}$/.test(iso)) {
return false;
}
const friday = addDaysIso(weekMonday, 4);
return iso >= weekMonday && iso <= friday;
}

View file

@ -0,0 +1,166 @@
import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list";
import type { WorkOrderBarFilters } from "@/domain/work-orders/types/work-order-list-filters";
import type {
WorkOrderDayGroup,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import { dateRangeFor } from "@/domain/work-orders/utils/advanced-filters-utils";
import { isDateInWeek } from "@/domain/work-orders/utils/table-day-utils";
import { weekRange } from "@/domain/work-orders/utils/list-week-utils";
const TERMINAL_STATUSES = new Set(["Completed", "Canceled", "Done", "Cancelled"]);
export function computeIsPastDue(row: WorkOrderTableRow): boolean {
if (!row.dueDate || TERMINAL_STATUSES.has(row.status)) {
return false;
}
const today = new Date().toISOString().slice(0, 10);
return row.dueDate < today;
}
export function enrichTableRow(row: WorkOrderTableRow): WorkOrderTableRow {
return {
...row,
isPastDue: computeIsPastDue(row),
};
}
export function matchesAdvancedFilters(
row: WorkOrderTableRow,
filters: WorkOrderAdvancedFilters,
): boolean {
const [from, to] = dateRangeFor(filters);
if (row.scheduledOn && (row.scheduledOn < from || row.scheduledOn > to)) {
return false;
}
if (filters.sites.size && !filters.sites.has(row.site)) {
return false;
}
if (filters.types.size && !filters.types.has(row.type as never)) {
return false;
}
if (filters.dispatchers.size) {
const unassignedMatch =
filters.dispatchers.has(ASSIGNEE_FILTER_UNASSIGNED) && !row.dispatcherId;
const idMatch = !!row.dispatcherId && filters.dispatchers.has(row.dispatcherId);
if (!unassignedMatch && !idMatch) {
return false;
}
}
if (filters.statuses.size && (!row.status || !filters.statuses.has(row.status as never))) {
return false;
}
if (filters.pmTypes.size && !filters.pmTypes.has(row.pm)) {
return false;
}
if (filters.vendorTechs.size && !filters.vendorTechs.has(row.tech)) {
return false;
}
if (filters.docs.size && !filters.docs.has(row.docStatus as never)) {
return false;
}
return true;
}
function rowMatchesSearch(row: WorkOrderTableRow, query: string): boolean {
const q = query.trim().toLowerCase();
if (!q) {
return true;
}
const haystack = [
row.site,
row.location,
row.woNumber,
row.type,
row.dispatcherName,
row.scheduledOn,
row.dueDate,
row.pm,
row.company,
row.tech,
row.status,
row.title,
]
.join(" ")
.toLowerCase();
return haystack.includes(q);
}
export function filterByBarAndSearch(
rows: WorkOrderTableRow[],
bar: WorkOrderBarFilters,
search: string,
): WorkOrderTableRow[] {
const [weekFrom, weekTo] = weekRange(bar.weekMonday);
return rows.filter((row) => {
if (!rowMatchesSearch(row, search)) {
return false;
}
if (bar.type !== "All" && row.type !== bar.type) {
return false;
}
if (bar.dispatcherIds.size > 0) {
const unassignedMatch =
bar.dispatcherIds.has(ASSIGNEE_FILTER_UNASSIGNED) && !row.dispatcherId;
const idMatch = !!row.dispatcherId && bar.dispatcherIds.has(String(row.dispatcherId));
if (!unassignedMatch && !idMatch) {
return false;
}
}
if (row.scheduledOn) {
if (row.scheduledOn < weekFrom || row.scheduledOn > weekTo) {
return false;
}
} else if (row.targetWeek) {
if (!isDateInWeek(row.targetWeek, bar.weekMonday)) {
return false;
}
}
return true;
});
}
export function splitScheduledUnscheduled(rows: WorkOrderTableRow[]): {
scheduled: WorkOrderTableRow[];
unscheduled: WorkOrderTableRow[];
} {
const scheduled: WorkOrderTableRow[] = [];
const unscheduled: WorkOrderTableRow[] = [];
for (const row of rows) {
if (!row.scheduledOn) {
unscheduled.push(row);
} else {
scheduled.push(row);
}
}
return { scheduled, unscheduled };
}
export function buildDayGroups(scheduled: WorkOrderTableRow[]): WorkOrderDayGroup[] {
const groups: WorkOrderDayGroup[] = [];
for (const row of scheduled) {
const existing = groups.find((group) => group.key === row.dayGroup);
if (existing) {
existing.items.push(row);
} else {
groups.push({
key: row.dayGroup,
label: row.dayLabel,
date: row.scheduledOn,
items: [row],
});
}
}
groups.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
return groups;
}

View file

@ -0,0 +1,148 @@
export const MONTH_NAMES = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
export function pad2(n: number): string {
return String(n).padStart(2, "0");
}
export function todayIso(): string {
const d = new Date();
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
}
export function fmtDate(iso: string): string {
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) {
return "";
}
const [y, m, d] = iso.split("-").map(Number);
return `${MONTH_NAMES[m - 1].slice(0, 3)} ${d}, ${y}`;
}
export function addDaysIso(iso: string, days: number): string {
const d = new Date(`${iso}T12:00:00`);
d.setDate(d.getDate() + days);
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
}
export function mondayOf(iso: string): string {
const d = new Date(`${iso}T12:00:00`);
const dow = d.getDay();
d.setDate(d.getDate() + (dow === 0 ? -6 : 1 - dow));
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
}
export function weeksOfMonth(y: number, m: number): string[] {
const firstIso = `${y}-${pad2(m)}-01`;
const lastIso = `${y}-${pad2(m)}-${pad2(new Date(y, m, 0).getDate())}`;
const out: string[] = [];
for (let mon = mondayOf(firstIso); mon <= lastIso; mon = addDaysIso(mon, 7)) {
if (addDaysIso(mon, 4) >= firstIso) {
out.push(mon);
}
}
return out;
}
export function weekRangeLabel(mondayIso: string): string {
if (!/^\d{4}-\d{2}-\d{2}$/.test(mondayIso)) {
return "";
}
const fri = addDaysIso(mondayIso, 4);
const [, sm, sd] = mondayIso.split("-").map(Number);
const [, em, ed] = fri.split("-").map(Number);
return `${MONTH_NAMES[sm - 1].slice(0, 3)} ${sd} – ${MONTH_NAMES[em - 1].slice(0, 3)} ${ed}`;
}
export function fmtWeekRange(startIso: string): string {
if (!/^\d{4}-\d{2}-\d{2}$/.test(startIso)) {
return fmtDate(startIso);
}
return weekRangeLabel(startIso);
}
export function fmt12(t: string): string {
const [h, m] = t.split(":").map(Number);
const ampm = h < 12 ? "AM" : "PM";
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
return `${h12}:${String(m).padStart(2, "0")} ${ampm}`;
}
export function calcDuration(s: string, e: string): string {
const [sh, sm] = s.split(":").map(Number);
const [eh, em] = e.split(":").map(Number);
const mins = eh * 60 + em - (sh * 60 + sm);
if (mins <= 0) {
return "";
}
const h = Math.floor(mins / 60);
const m = mins % 60;
if (h && m) {
return `${h}h ${m}m`;
}
if (h) {
return `${h}h`;
}
return `${m}m`;
}
export function formatApptRange(start: string, end: string): string {
if (!start) {
return "—";
}
return end ? `${fmt12(start)} – ${fmt12(end)}` : fmt12(start);
}
export const TIME_SLOTS: string[] = (() => {
const out: string[] = [];
for (let m = 5 * 60; m <= 22 * 60; m += 30) {
const h = Math.floor(m / 60);
const mm = m % 60;
out.push(`${String(h).padStart(2, "0")}:${String(mm).padStart(2, "0")}`);
}
return out;
})();
export function parseApptTime(appt: string): { start: string; end: string } {
const m = appt.match(/(\d{1,2}):(\d{2})\s*[–-]\s*(\d{1,2}):(\d{2})/);
if (m) {
return {
start: `${m[1].padStart(2, "0")}:${m[2]}`,
end: `${m[3].padStart(2, "0")}:${m[4]}`,
};
}
const single = appt.match(/(\d{1,2}):(\d{2})\s*(AM|PM)/i);
if (single) {
let h = Number(single[1]) % 12;
if (/pm/i.test(single[3])) {
h += 12;
}
return { start: `${pad2(h)}:${single[2]}`, end: "" };
}
return { start: "", end: "" };
}
export function formatApptTimeValue(start: string, end: string): string {
if (!start) {
return "";
}
return end ? `${start} – ${end}` : start;
}
export function fmtWeekOf(mondayIso: string): string {
if (!mondayIso) {
return "";
}
return `Week of ${weekRangeLabel(mondayIso)}`;
}

View file

@ -0,0 +1,31 @@
import type {
FileCategory,
MediaCategory,
MediaFile,
} from "@/domain/work-orders/types/work-order-media";
let mediaIdCounter = 0;
export function classifyUpload(f: File): {
fileType: FileCategory;
category: MediaCategory;
} {
const t = f.type;
if (t.startsWith("image/")) return { fileType: "image", category: "Photo" };
if (t.startsWith("video/")) return { fileType: "video", category: "Video" };
if (t === "application/pdf" || /\.pdf$/i.test(f.name)) {
return { fileType: "pdf", category: "Report" };
}
return { fileType: "other", category: "Other" };
}
export function makeMediaFile(f: File): MediaFile {
const { fileType, category } = classifyUpload(f);
return {
id: `m-${Date.now()}-${mediaIdCounter++}`,
name: f.name,
fileType,
url: URL.createObjectURL(f),
category,
};
}

View file

@ -0,0 +1,11 @@
import { useCallback, useState } from "react";
export function useMenuAnchor() {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const open = Boolean(anchorEl);
const setAnchor = useCallback((element: HTMLElement) => setAnchorEl(element), []);
const close = useCallback(() => setAnchorEl(null), []);
return { anchorEl, open, setAnchor, close };
}

View file

@ -1,3 +1,5 @@
import type { WorkOrdersListParams } from "@/domain/work-orders/types/work-order-list";
export const queryKeys = {
auth: {
all: ["auth"] as const,
@ -5,8 +7,10 @@ export const queryKeys = {
},
workOrders: {
all: ["workOrders"] as const,
list: (params?: Record<string, unknown>) =>
list: (params?: WorkOrdersListParams) =>
[...queryKeys.workOrders.all, "list", params ?? {}] as const,
byLocation: (locationId: string | number, search = "") =>
[...queryKeys.workOrders.all, "byLocation", locationId, search] as const,
detail: (id: string | number) => [...queryKeys.workOrders.all, "detail", id] as const,
dispatch: (id: string | number) => [...queryKeys.workOrders.all, "dispatch", id] as const,
vendors: (trade?: string, siteZip?: string) =>

View file

@ -0,0 +1,36 @@
import type { AuthUser } from "@/domain/auth/types/auth-user";
import { env } from "@/lib/env";
const BYPASS_DISABLED_KEY = "auth-bypass-disabled";
export function isAuthBypassEnabled(): boolean {
if (!env.isDev || !env.authBypass) {
return false;
}
if (typeof window !== "undefined" && sessionStorage.getItem(BYPASS_DISABLED_KEY)) {
return false;
}
return true;
}
export function getBypassAuthUser(): AuthUser {
return {
token: "dev-bypass-token",
expiration: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(),
email: "dev-bypass@seahaven.test",
userRoles: "Admin",
phoneNumber: "",
fullname: "Dev Bypass User",
id: "dev-bypass",
};
}
export function disableAuthBypassForSession(): void {
if (typeof window === "undefined") {
return;
}
sessionStorage.setItem(BYPASS_DISABLED_KEY, "1");
}

View file

@ -1,4 +1,5 @@
export const env = {
apiUrl: import.meta.env.VITE_API_URL?.trim() || "/api",
isDev: import.meta.env.DEV,
authBypass: import.meta.env.VITE_AUTH_BYPASS === "true",
} as const;

View file

@ -99,6 +99,19 @@ export function formatDateTime(value: string): string {
return `${datePart} ${timePart} ${tz ?? ""}`.trim();
}
export function getWaitTimeTextClass(color: "green" | "yellow" | "red" | ""): string | undefined {
if (color === "green") {
return "text-green-600";
}
if (color === "yellow") {
return "text-yellow-600";
}
if (color === "red") {
return "text-red-600";
}
return undefined;
}
export function toDateInputValue(value: string | null | undefined): string {
if (!value) {
return "";

2
src/vite-env.d.ts vendored
View file

@ -3,6 +3,8 @@
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_API_TARGET: string;
readonly VITE_AUTH_BYPASS?: string;
readonly VITE_WO_TABLE_MOCK?: string;
}
interface ImportMeta {