Merge pull request #35 from Sea-Haven-Industries/feature/wo-shared-ui

feat(ui): add shared board UI primitives and plumbing
This commit is contained in:
Alexandre Brandizzi 2026-07-21 14:03:58 -03:00 • committed by GitHub
commit ec20c693d3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 1450 additions and 124 deletions

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

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

After

Width:  |  Height:  |  Size: 10 KiB

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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