mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 06:53:12 +00:00
feat(ui): add shared board UI primitives and plumbing
This commit is contained in:
parent
c68d47af7b
commit
01939cbb70
48 changed files with 1472 additions and 143 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 |
|
|
@ -1,10 +1,11 @@
|
|||
import ky, { type KyInstance, type Options } from "ky";
|
||||
import ky, { type HTTPError, type KyInstance, type Options } from "ky";
|
||||
import { env } from "@/lib/env";
|
||||
import { resolveApiPrefixUrl } from "@/lib/resolve-api-prefix-url";
|
||||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { mapHttpStatusToMessage, normalizeApiRequestError } from "@/api/api-error";
|
||||
import { createApiErrorFromResponse, mapHttpStatusToMessage } 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 {
|
||||
|
|
@ -21,24 +22,37 @@ function isAuthLoginRequest(request: Request): boolean {
|
|||
}
|
||||
|
||||
export const api: KyInstance = ky.create({
|
||||
prefix: resolveApiPrefixUrl(env.apiUrl),
|
||||
prefixUrl: resolveApiPrefixUrl(env.apiUrl),
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
hooks: {
|
||||
beforeRequest: [
|
||||
({ request }) => {
|
||||
(request) => {
|
||||
const token = getAccessToken();
|
||||
if (token) {
|
||||
request.headers.set("Authorization", token);
|
||||
}
|
||||
},
|
||||
],
|
||||
beforeError: [({ error }) => normalizeApiRequestError(error)],
|
||||
beforeError: [
|
||||
async (error: HTTPError) => {
|
||||
const { response } = error;
|
||||
|
||||
if (!response) {
|
||||
error.message = "No response from server. Please check your connection.";
|
||||
return error;
|
||||
}
|
||||
|
||||
const apiError = await createApiErrorFromResponse(response);
|
||||
error.message = apiError.message;
|
||||
return error;
|
||||
},
|
||||
],
|
||||
afterResponse: [
|
||||
({ request, response }) => {
|
||||
if (response.status !== 401 || isAuthLoginRequest(request)) {
|
||||
async (request, _options, response) => {
|
||||
if (response.status !== 401 || isAuthLoginRequest(request) || isAuthBypassEnabled()) {
|
||||
return response;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from "react";
|
||||
import { Outlet } from "react-router";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Box } from "@mui/material";
|
||||
|
||||
import { ProtectedRoute } from "@/components/auth/protected-route";
|
||||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, type ReactNode } from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
|
|
@ -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" display="block">
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
|
|
@ -113,14 +107,7 @@ export default function VendorPoViewPage() {
|
|||
|
||||
return (
|
||||
<Box className="mx-auto max-w-5xl p-4">
|
||||
<Stack
|
||||
direction="row"
|
||||
className="mb-4"
|
||||
sx={{
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" className="mb-4">
|
||||
<Button variant="text" onClick={() => navigate(-1)}>
|
||||
← Back
|
||||
</Button>
|
||||
|
|
@ -128,47 +115,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} 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 +153,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 +175,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 +211,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 +251,19 @@ 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",
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" 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 +307,7 @@ export default function VendorPoViewPage() {
|
|||
)}
|
||||
</Paper>
|
||||
</Stack>
|
||||
|
||||
<Dialog open={Boolean(upliftAction)} onClose={() => setUpliftAction(null)}>
|
||||
<DialogTitle>
|
||||
{upliftAction?.type === "approve" ? "Approve Uplift" : "Deny Uplift"}
|
||||
|
|
|
|||
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" justifyContent="space-between" className="mb-1">
|
||||
<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" 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";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import LogoutOutlinedIcon from "@mui/icons-material/LogoutOutlined";
|
||||
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
|
||||
import {
|
||||
|
|
@ -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" 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,51 @@ 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)}
|
||||
sx={{
|
||||
alignItems: { xs: "stretch", md: "center" },
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
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,
|
||||
)}
|
||||
>
|
||||
<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} alignItems="center" flexWrap="nowrap">
|
||||
{actions}
|
||||
</Stack>
|
||||
)}
|
||||
|
|
|
|||
95
src/components/ui/resource-list-table.tsx
Normal file
95
src/components/ui/resource-list-table.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
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";
|
||||
import type { SortDirection } from "@/domain/work-orders/types/work-order-list";
|
||||
|
||||
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} flexWrap="wrap" useFlexGap>
|
||||
<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;
|
||||
|
|
|
|||
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