mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
Compare commits
10 commits
042b9db2ac
...
8813cb2e75
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8813cb2e75 | ||
|
|
a55beb903c | ||
|
|
31e4fb1907 | ||
|
|
579af60278 | ||
|
|
ec20c693d3 | ||
|
|
4beefba719 | ||
|
|
df8f3553e8 | ||
|
|
e13bc4c1a2 | ||
|
|
a3e8a533b1 | ||
|
|
01939cbb70 |
87 changed files with 4598 additions and 438 deletions
108
public/seahaven-logo.svg
Normal file
108
public/seahaven-logo.svg
Normal 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 |
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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("") },
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
22
src/components/common/comment-timeline-mappers.ts
Normal file
22
src/components/common/comment-timeline-mappers.ts
Normal 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);
|
||||
}
|
||||
54
src/components/common/comment-timeline.tsx
Normal file
54
src/components/common/comment-timeline.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
src/components/common/detail-field.tsx
Normal file
36
src/components/common/detail-field.tsx
Normal 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>;
|
||||
}
|
||||
|
|
@ -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,
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
51
src/components/ui/applied-filter-chip.tsx
Normal file
51
src/components/ui/applied-filter-chip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
src/components/ui/applied-filters-bar.tsx
Normal file
36
src/components/ui/applied-filters-bar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
src/components/ui/checkbox-indicator.tsx
Normal file
33
src/components/ui/checkbox-indicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
src/components/ui/checkbox-list-item.tsx
Normal file
38
src/components/ui/checkbox-list-item.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
src/components/ui/checkbox-menu-item.tsx
Normal file
50
src/components/ui/checkbox-menu-item.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
src/components/ui/collapsible-section.tsx
Normal file
50
src/components/ui/collapsible-section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/components/ui/count-badge.tsx
Normal file
23
src/components/ui/count-badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
src/components/ui/date-input.tsx
Normal file
40
src/components/ui/date-input.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
src/components/ui/dialog-blur-backdrop.ts
Normal file
12
src/components/ui/dialog-blur-backdrop.ts
Normal 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;
|
||||
50
src/components/ui/dot-toggle-pill.tsx
Normal file
50
src/components/ui/dot-toggle-pill.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
src/components/ui/filter-popover.tsx
Normal file
51
src/components/ui/filter-popover.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
src/components/ui/icon-close-button.tsx
Normal file
35
src/components/ui/icon-close-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
src/components/ui/menu-anchor-button.tsx
Normal file
32
src/components/ui/menu-anchor-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
src/components/ui/nav-arrow-button.tsx
Normal file
30
src/components/ui/nav-arrow-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
src/components/ui/nav-pill-button.tsx
Normal file
34
src/components/ui/nav-pill-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
96
src/components/ui/resource-list-table.tsx
Normal file
96
src/components/ui/resource-list-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
src/components/ui/search-input.tsx
Normal file
53
src/components/ui/search-input.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
src/components/ui/segment-control.tsx
Normal file
49
src/components/ui/segment-control.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
src/components/ui/select-all-action.tsx
Normal file
31
src/components/ui/select-all-action.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
src/components/ui/side-sheet.tsx
Normal file
42
src/components/ui/side-sheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
src/components/ui/status-chip-filter.tsx
Normal file
42
src/components/ui/status-chip-filter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
src/components/ui/table-state-rows.tsx
Normal file
31
src/components/ui/table-state-rows.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
src/components/ui/toggle-card.tsx
Normal file
40
src/components/ui/toggle-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
src/components/ui/toggle-pill.tsx
Normal file
35
src/components/ui/toggle-pill.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
src/components/ui/toggle-segment.tsx
Normal file
35
src/components/ui/toggle-segment.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
src/components/ui/toolbar-button.tsx
Normal file
55
src/components/ui/toolbar-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
122
src/domain/work-orders/mappers/wizard-draft-mapper.ts
Normal file
122
src/domain/work-orders/mappers/wizard-draft-mapper.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
45
src/domain/work-orders/mocks/dispatcher-filter-mock-data.ts
Normal file
45
src/domain/work-orders/mocks/dispatcher-filter-mock-data.ts
Normal 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))
|
||||
);
|
||||
}
|
||||
10
src/domain/work-orders/mocks/is-wo-table-mock-enabled.ts
Normal file
10
src/domain/work-orders/mocks/is-wo-table-mock-enabled.ts
Normal 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-");
|
||||
}
|
||||
10
src/domain/work-orders/mocks/is-wo-wizard-mock-enabled.ts
Normal file
10
src/domain/work-orders/mocks/is-wo-wizard-mock-enabled.ts
Normal 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-");
|
||||
}
|
||||
521
src/domain/work-orders/mocks/wo-table-mock-data.ts
Normal file
521
src/domain/work-orders/mocks/wo-table-mock-data.ts
Normal 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);
|
||||
}
|
||||
158
src/domain/work-orders/mocks/wo-wizard-mock-data.ts
Normal file
158
src/domain/work-orders/mocks/wo-wizard-mock-data.ts
Normal 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: [],
|
||||
};
|
||||
}
|
||||
42
src/domain/work-orders/types/completion-doc.ts
Normal file
42
src/domain/work-orders/types/completion-doc.ts
Normal 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, "">;
|
||||
80
src/domain/work-orders/types/dispatch.ts
Normal file
80
src/domain/work-orders/types/dispatch.ts
Normal 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;
|
||||
}
|
||||
74
src/domain/work-orders/types/work-order-advanced-filters.ts
Normal file
74
src/domain/work-orders/types/work-order-advanced-filters.ts
Normal 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";
|
||||
48
src/domain/work-orders/types/work-order-common.ts
Normal file
48
src/domain/work-orders/types/work-order-common.ts
Normal 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 }[];
|
||||
}
|
||||
97
src/domain/work-orders/types/work-order-detail.ts
Normal file
97
src/domain/work-orders/types/work-order-detail.ts
Normal 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: "",
|
||||
};
|
||||
}
|
||||
9
src/domain/work-orders/types/work-order-list-filters.ts
Normal file
9
src/domain/work-orders/types/work-order-list-filters.ts
Normal 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;
|
||||
}
|
||||
61
src/domain/work-orders/types/work-order-list.ts
Normal file
61
src/domain/work-orders/types/work-order-list.ts
Normal 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;
|
||||
24
src/domain/work-orders/types/work-order-media.ts
Normal file
24
src/domain/work-orders/types/work-order-media.ts
Normal 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[] = [];
|
||||
99
src/domain/work-orders/types/work-order-table-row.ts
Normal file
99
src/domain/work-orders/types/work-order-table-row.ts
Normal 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;
|
||||
149
src/domain/work-orders/types/work-order-wizard.ts
Normal file
149
src/domain/work-orders/types/work-order-wizard.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
240
src/domain/work-orders/utils/advanced-filters-utils.ts
Normal file
240
src/domain/work-orders/utils/advanced-filters-utils.ts
Normal 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";
|
||||
}
|
||||
119
src/domain/work-orders/utils/completion-doc-html.ts
Normal file
119
src/domain/work-orders/utils/completion-doc-html.ts
Normal 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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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 ? ` · ${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);
|
||||
}
|
||||
99
src/domain/work-orders/utils/completion-doc-procedures.ts
Normal file
99
src/domain/work-orders/utils/completion-doc-procedures.ts
Normal 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;
|
||||
}
|
||||
31
src/domain/work-orders/utils/completion-doc-service-title.ts
Normal file
31
src/domain/work-orders/utils/completion-doc-service-title.ts
Normal 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)]}`;
|
||||
}
|
||||
31
src/domain/work-orders/utils/completion-doc-site.ts
Normal file
31
src/domain/work-orders/utils/completion-doc-site.ts
Normal 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 };
|
||||
}
|
||||
80
src/domain/work-orders/utils/completion-doc-state.ts
Normal file
80
src/domain/work-orders/utils/completion-doc-state.ts
Normal 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.";
|
||||
}
|
||||
68
src/domain/work-orders/utils/list-week-utils.ts
Normal file
68
src/domain/work-orders/utils/list-week-utils.ts
Normal 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;
|
||||
}
|
||||
46
src/domain/work-orders/utils/table-day-utils.ts
Normal file
46
src/domain/work-orders/utils/table-day-utils.ts
Normal 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;
|
||||
}
|
||||
166
src/domain/work-orders/utils/table-filter-utils.ts
Normal file
166
src/domain/work-orders/utils/table-filter-utils.ts
Normal 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;
|
||||
}
|
||||
148
src/domain/work-orders/utils/wizard-date-utils.ts
Normal file
148
src/domain/work-orders/utils/wizard-date-utils.ts
Normal 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)}`;
|
||||
}
|
||||
31
src/domain/work-orders/utils/work-order-media-utils.ts
Normal file
31
src/domain/work-orders/utils/work-order-media-utils.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
11
src/hooks/use-menu-anchor.ts
Normal file
11
src/hooks/use-menu-anchor.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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) =>
|
||||
|
|
|
|||
36
src/lib/auth/auth-bypass.ts
Normal file
36
src/lib/auth/auth-bypass.ts
Normal 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");
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
2
src/vite-env.d.ts
vendored
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue