Merge branch 'dev' into 408-feature-implement-paidunpaid-breaks---client-app-frontend-development

This commit is contained in:
Achintha Isuru
2026-02-17 16:35:18 -05:00
32 changed files with 770 additions and 272 deletions

59
.github/workflows/web-quality.yml vendored Normal file
View File

@@ -0,0 +1,59 @@
name: Web Quality
on:
pull_request:
branches:
- dev
- main
push:
branches:
- dev
- main
jobs:
web-quality:
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/web
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
run_install: false
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
cache-dependency-path: apps/web/pnpm-lock.yaml
- name: Setup Firebase CLI
working-directory: .
run: npm install -g firebase-tools
- name: Generate Data Connect SDK
working-directory: .
run: |
cp backend/dataconnect/dataconnect.dev.yaml backend/dataconnect/dataconnect.yaml
firebase dataconnect:sdk:generate --non-interactive
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm lint
- name: Typecheck
run: pnpm typecheck
- name: Test
run: pnpm test
- name: Build
run: pnpm build

View File

@@ -28,6 +28,7 @@ help:
@echo " make web-dev Start local web frontend dev server" @echo " make web-dev Start local web frontend dev server"
@echo " make web-build [ENV=dev] Build web frontend for production (dev/staging)" @echo " make web-build [ENV=dev] Build web frontend for production (dev/staging)"
@echo " make web-lint Run linter for web frontend" @echo " make web-lint Run linter for web frontend"
@echo " make web-test Run tests for web frontend"
@echo " make web-preview Preview web frontend build" @echo " make web-preview Preview web frontend build"
@echo " make web-deploy [ENV=dev] Build and deploy web app (dev/staging)" @echo " make web-deploy [ENV=dev] Build and deploy web app (dev/staging)"
@echo "" @echo ""
@@ -50,6 +51,8 @@ help:
@echo " make mobile-client-build PLATFORM=apk Build client app (apk/ipa/etc)" @echo " make mobile-client-build PLATFORM=apk Build client app (apk/ipa/etc)"
@echo " make mobile-staff-dev-android [DEVICE=android] Run staff app (Android)" @echo " make mobile-staff-dev-android [DEVICE=android] Run staff app (Android)"
@echo " make mobile-staff-build PLATFORM=apk Build staff app (apk/ipa/etc)" @echo " make mobile-staff-build PLATFORM=apk Build staff app (apk/ipa/etc)"
@echo " make mobile-analyze Run flutter analyze for client+staff"
@echo " make mobile-test Run flutter test for client+staff"
@echo " make mobile-hot-reload Hot reload running Flutter app" @echo " make mobile-hot-reload Hot reload running Flutter app"
@echo " make mobile-hot-restart Hot restart running Flutter app" @echo " make mobile-hot-restart Hot restart running Flutter app"
@echo "" @echo ""

View File

@@ -71,6 +71,7 @@ make mobile-staff-dev-android DEVICE=<device_id>
- **Bootstrap**: `melos bootstrap` (Installs all dependencies) - **Bootstrap**: `melos bootstrap` (Installs all dependencies)
- **Generate All**: `melos run gen:all` (Localization + Code Generation) - **Generate All**: `melos run gen:all` (Localization + Code Generation)
- **Analyze**: `melos run analyze:all` - **Analyze**: `melos run analyze:all`
- **Test**: `melos run test:all`
- **Help**: `melos run info` (Shows all available custom scripts) - **Help**: `melos run info` (Shows all available custom scripts)
## 🏗 Coding Principles ## 🏗 Coding Principles

View File

@@ -0,0 +1,7 @@
import 'package:flutter_test/flutter_test.dart';
void main() {
test('client smoke test', () {
expect(2 + 2, 4);
});
}

View File

@@ -0,0 +1,7 @@
import 'package:flutter_test/flutter_test.dart';
void main() {
test('staff smoke test', () {
expect(2 + 2, 4);
});
}

View File

@@ -78,6 +78,18 @@ melos:
packageFilters: packageFilters:
dependsOn: build_runner dependsOn: build_runner
analyze:all:
run: |
melos exec --scope="krowwithus_client" -- "flutter analyze"
melos exec --scope="krowwithus_staff" -- "flutter analyze"
description: "Run flutter analyze for both client and staff apps."
test:all:
run: |
melos exec --scope="krowwithus_client" -- "flutter test"
melos exec --scope="krowwithus_staff" -- "flutter test"
description: "Run flutter tests for both client and staff apps."
build:client: build:client:
run: | run: |
melos run gen:l10n --filter="core_localization" melos run gen:l10n --filter="core_localization"

View File

@@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config' import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([ export default defineConfig([
globalIgnores(['dist']), globalIgnores(['dist', 'src/dataconnect-generated/**']),
{ {
files: ['**/*.{ts,tsx}'], files: ['**/*.{ts,tsx}'],
extends: [ extends: [
@@ -19,5 +19,24 @@ export default defineConfig([
ecmaVersion: 2020, ecmaVersion: 2020,
globals: globals.browser, globals: globals.browser,
}, },
rules: {
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
ignoreRestSiblings: true,
},
],
'@typescript-eslint/no-empty-object-type': 'warn',
'@typescript-eslint/no-require-imports': 'warn',
'react-refresh/only-export-components': 'warn',
'react-hooks/exhaustive-deps': 'warn',
'react-hooks/set-state-in-effect': 'warn',
'react-hooks/purity': 'warn',
'react-hooks/preserve-manual-memoization': 'warn',
},
}, },
]) ])

View File

@@ -5,8 +5,11 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "vite build",
"typecheck": "tsc -b",
"lint": "eslint .", "lint": "eslint .",
"test": "vitest run",
"test:watch": "vitest",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
@@ -31,7 +34,6 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"dataconnect-generated": "link:C:/Users/dell/AppData/Local/pnpm/store/5/node_modules/src/dataconnect-generated",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"firebase": "^12.8.0", "firebase": "^12.8.0",
"framer-motion": "^12.29.2", "framer-motion": "^12.29.2",
@@ -67,6 +69,7 @@
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.46.4", "typescript-eslint": "^8.46.4",
"vite": "^7.2.4" "vite": "^7.2.4",
"vitest": "^3.2.4"
} }
} }

321
apps/web/pnpm-lock.yaml generated
View File

@@ -5,7 +5,6 @@ settings:
excludeLinksFromLockfile: false excludeLinksFromLockfile: false
overrides: overrides:
dataconnect-generated: link:../../../../../AppData/Local/pnpm/global/5/node_modules/src/dataconnect-generated
'@dataconnect/generated': link:src/dataconnect-generated '@dataconnect/generated': link:src/dataconnect-generated
importers: importers:
@@ -75,9 +74,6 @@ importers:
cmdk: cmdk:
specifier: ^1.1.1 specifier: ^1.1.1
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
dataconnect-generated:
specifier: link:../../../../../AppData/Local/pnpm/global/5/node_modules/src/dataconnect-generated
version: link:../../../../../AppData/Local/pnpm/global/5/node_modules/src/dataconnect-generated
date-fns: date-fns:
specifier: ^4.1.0 specifier: ^4.1.0
version: 4.1.0 version: 4.1.0
@@ -181,6 +177,9 @@ importers:
vite: vite:
specifier: ^7.2.4 specifier: ^7.2.4
version: 7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2) version: 7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)
vitest:
specifier: ^3.2.4
version: 3.2.4(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)
packages: packages:
@@ -1787,6 +1786,9 @@ packages:
'@types/babel__traverse@7.28.0': '@types/babel__traverse@7.28.0':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/d3-array@3.2.2': '@types/d3-array@3.2.2':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
@@ -1814,6 +1816,9 @@ packages:
'@types/d3-timer@3.0.2': '@types/d3-timer@3.0.2':
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/estree@1.0.8': '@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -1899,6 +1904,35 @@ packages:
peerDependencies: peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
'@vitest/expect@3.2.4':
resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==}
'@vitest/mocker@3.2.4':
resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==}
peerDependencies:
msw: ^2.4.9
vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/pretty-format@3.2.4':
resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==}
'@vitest/runner@3.2.4':
resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==}
'@vitest/snapshot@3.2.4':
resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==}
'@vitest/spy@3.2.4':
resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==}
'@vitest/utils@3.2.4':
resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==}
acorn-jsx@5.3.2: acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies: peerDependencies:
@@ -1927,6 +1961,10 @@ packages:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'} engines: {node: '>=10'}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
asynckit@0.4.0: asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
@@ -1958,6 +1996,10 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true hasBin: true
cac@6.7.14:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
call-bind-apply-helpers@1.0.2: call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -1969,10 +2011,18 @@ packages:
caniuse-lite@1.0.30001766: caniuse-lite@1.0.30001766:
resolution: {integrity: sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==} resolution: {integrity: sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==}
chai@5.3.3:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
engines: {node: '>=18'}
chalk@4.1.2: chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'} engines: {node: '>=10'}
check-error@2.1.3:
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
engines: {node: '>= 16'}
class-variance-authority@0.7.1: class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
@@ -2089,6 +2139,10 @@ packages:
decimal.js-light@2.5.1: decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
deep-eql@5.0.2:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'}
deep-is@0.1.4: deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
@@ -2125,6 +2179,9 @@ packages:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
es-module-lexer@1.7.0:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
es-object-atoms@1.1.1: es-object-atoms@1.1.1:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2198,6 +2255,9 @@ packages:
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
engines: {node: '>=4.0'} engines: {node: '>=4.0'}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
esutils@2.0.3: esutils@2.0.3:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -2205,6 +2265,10 @@ packages:
eventemitter3@5.0.4: eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
fast-deep-equal@3.1.3: fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -2415,6 +2479,9 @@ packages:
js-tokens@4.0.0: js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
js-tokens@9.0.1:
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
js-yaml@4.1.1: js-yaml@4.1.1:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
hasBin: true hasBin: true
@@ -2528,6 +2595,9 @@ packages:
long@5.3.2: long@5.3.2:
resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
lru-cache@5.1.1: lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
@@ -2611,6 +2681,13 @@ packages:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'} engines: {node: '>=8'}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pathval@2.0.1:
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
engines: {node: '>= 14.16'}
picocolors@1.1.1: picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -2827,10 +2904,19 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'} engines: {node: '>=8'}
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
source-map-js@1.2.1: source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
string-width@4.2.3: string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -2843,6 +2929,9 @@ packages:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'} engines: {node: '>=8'}
strip-literal@3.1.0:
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
supports-color@7.2.0: supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -2868,10 +2957,28 @@ packages:
tiny-invariant@1.3.3: tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
tinyexec@0.3.2:
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
tinyglobby@0.2.15: tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
tinypool@1.1.1:
resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
engines: {node: ^18.0.0 || >=20.0.0}
tinyrainbow@2.0.0:
resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
engines: {node: '>=14.0.0'}
tinyspy@4.0.4:
resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
engines: {node: '>=14.0.0'}
tr46@0.0.3: tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
@@ -2944,6 +3051,11 @@ packages:
victory-vendor@37.3.6: victory-vendor@37.3.6:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
vite-node@3.2.4:
resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
vite@7.3.1: vite@7.3.1:
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@@ -2984,6 +3096,34 @@ packages:
yaml: yaml:
optional: true optional: true
vitest@3.2.4:
resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@types/debug': ^4.1.12
'@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
'@vitest/browser': 3.2.4
'@vitest/ui': 3.2.4
happy-dom: '*'
jsdom: '*'
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@types/debug':
optional: true
'@types/node':
optional: true
'@vitest/browser':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
void-elements@3.1.0: void-elements@3.1.0:
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -3010,6 +3150,11 @@ packages:
engines: {node: '>= 8'} engines: {node: '>= 8'}
hasBin: true hasBin: true
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
word-wrap@1.2.5: word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -4698,6 +4843,11 @@ snapshots:
dependencies: dependencies:
'@babel/types': 7.28.6 '@babel/types': 7.28.6
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/d3-array@3.2.2': {} '@types/d3-array@3.2.2': {}
'@types/d3-color@3.1.3': {} '@types/d3-color@3.1.3': {}
@@ -4722,6 +4872,8 @@ snapshots:
'@types/d3-timer@3.0.2': {} '@types/d3-timer@3.0.2': {}
'@types/deep-eql@4.0.2': {}
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
@@ -4843,6 +4995,48 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@vitest/expect@3.2.4':
dependencies:
'@types/chai': 5.2.3
'@vitest/spy': 3.2.4
'@vitest/utils': 3.2.4
chai: 5.3.3
tinyrainbow: 2.0.0
'@vitest/mocker@3.2.4(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2))':
dependencies:
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)
'@vitest/pretty-format@3.2.4':
dependencies:
tinyrainbow: 2.0.0
'@vitest/runner@3.2.4':
dependencies:
'@vitest/utils': 3.2.4
pathe: 2.0.3
strip-literal: 3.1.0
'@vitest/snapshot@3.2.4':
dependencies:
'@vitest/pretty-format': 3.2.4
magic-string: 0.30.21
pathe: 2.0.3
'@vitest/spy@3.2.4':
dependencies:
tinyspy: 4.0.4
'@vitest/utils@3.2.4':
dependencies:
'@vitest/pretty-format': 3.2.4
loupe: 3.2.1
tinyrainbow: 2.0.0
acorn-jsx@5.3.2(acorn@8.15.0): acorn-jsx@5.3.2(acorn@8.15.0):
dependencies: dependencies:
acorn: 8.15.0 acorn: 8.15.0
@@ -4868,6 +5062,8 @@ snapshots:
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
assertion-error@2.0.1: {}
asynckit@0.4.0: {} asynckit@0.4.0: {}
autoprefixer@10.4.23(postcss@8.5.6): autoprefixer@10.4.23(postcss@8.5.6):
@@ -4908,6 +5104,8 @@ snapshots:
node-releases: 2.0.27 node-releases: 2.0.27
update-browserslist-db: 1.2.3(browserslist@4.28.1) update-browserslist-db: 1.2.3(browserslist@4.28.1)
cac@6.7.14: {}
call-bind-apply-helpers@1.0.2: call-bind-apply-helpers@1.0.2:
dependencies: dependencies:
es-errors: 1.3.0 es-errors: 1.3.0
@@ -4917,11 +5115,21 @@ snapshots:
caniuse-lite@1.0.30001766: {} caniuse-lite@1.0.30001766: {}
chai@5.3.3:
dependencies:
assertion-error: 2.0.1
check-error: 2.1.3
deep-eql: 5.0.2
loupe: 3.2.1
pathval: 2.0.1
chalk@4.1.2: chalk@4.1.2:
dependencies: dependencies:
ansi-styles: 4.3.0 ansi-styles: 4.3.0
supports-color: 7.2.0 supports-color: 7.2.0
check-error@2.1.3: {}
class-variance-authority@0.7.1: class-variance-authority@0.7.1:
dependencies: dependencies:
clsx: 2.1.1 clsx: 2.1.1
@@ -5030,6 +5238,8 @@ snapshots:
decimal.js-light@2.5.1: {} decimal.js-light@2.5.1: {}
deep-eql@5.0.2: {}
deep-is@0.1.4: {} deep-is@0.1.4: {}
delayed-stream@1.0.0: {} delayed-stream@1.0.0: {}
@@ -5057,6 +5267,8 @@ snapshots:
es-errors@1.3.0: {} es-errors@1.3.0: {}
es-module-lexer@1.7.0: {}
es-object-atoms@1.1.1: es-object-atoms@1.1.1:
dependencies: dependencies:
es-errors: 1.3.0 es-errors: 1.3.0
@@ -5184,10 +5396,16 @@ snapshots:
estraverse@5.3.0: {} estraverse@5.3.0: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.8
esutils@2.0.3: {} esutils@2.0.3: {}
eventemitter3@5.0.4: {} eventemitter3@5.0.4: {}
expect-type@1.3.0: {}
fast-deep-equal@3.1.3: {} fast-deep-equal@3.1.3: {}
fast-json-stable-stringify@2.1.0: {} fast-json-stable-stringify@2.1.0: {}
@@ -5386,6 +5604,8 @@ snapshots:
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
js-tokens@9.0.1: {}
js-yaml@4.1.1: js-yaml@4.1.1:
dependencies: dependencies:
argparse: 2.0.1 argparse: 2.0.1
@@ -5468,6 +5688,8 @@ snapshots:
long@5.3.2: {} long@5.3.2: {}
loupe@3.2.1: {}
lru-cache@5.1.1: lru-cache@5.1.1:
dependencies: dependencies:
yallist: 3.1.1 yallist: 3.1.1
@@ -5539,6 +5761,10 @@ snapshots:
path-key@3.1.1: {} path-key@3.1.1: {}
pathe@2.0.3: {}
pathval@2.0.1: {}
picocolors@1.1.1: {} picocolors@1.1.1: {}
picomatch@4.0.3: {} picomatch@4.0.3: {}
@@ -5807,8 +6033,14 @@ snapshots:
shebang-regex@3.0.0: {} shebang-regex@3.0.0: {}
siginfo@2.0.0: {}
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
stackback@0.0.2: {}
std-env@3.10.0: {}
string-width@4.2.3: string-width@4.2.3:
dependencies: dependencies:
emoji-regex: 8.0.0 emoji-regex: 8.0.0
@@ -5821,6 +6053,10 @@ snapshots:
strip-json-comments@3.1.1: {} strip-json-comments@3.1.1: {}
strip-literal@3.1.0:
dependencies:
js-tokens: 9.0.1
supports-color@7.2.0: supports-color@7.2.0:
dependencies: dependencies:
has-flag: 4.0.0 has-flag: 4.0.0
@@ -5839,11 +6075,21 @@ snapshots:
tiny-invariant@1.3.3: {} tiny-invariant@1.3.3: {}
tinybench@2.9.0: {}
tinyexec@0.3.2: {}
tinyglobby@0.2.15: tinyglobby@0.2.15:
dependencies: dependencies:
fdir: 6.5.0(picomatch@4.0.3) fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3 picomatch: 4.0.3
tinypool@1.1.1: {}
tinyrainbow@2.0.0: {}
tinyspy@4.0.4: {}
tr46@0.0.3: {} tr46@0.0.3: {}
ts-api-utils@2.4.0(typescript@5.9.3): ts-api-utils@2.4.0(typescript@5.9.3):
@@ -5919,6 +6165,27 @@ snapshots:
d3-time: 3.1.0 d3-time: 3.1.0
d3-timer: 3.0.1 d3-timer: 3.0.1
vite-node@3.2.4(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2):
dependencies:
cac: 6.7.14
debug: 4.4.3
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)
transitivePeerDependencies:
- '@types/node'
- jiti
- less
- lightningcss
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
- tsx
- yaml
vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2): vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2):
dependencies: dependencies:
esbuild: 0.27.2 esbuild: 0.27.2
@@ -5933,6 +6200,47 @@ snapshots:
jiti: 2.6.1 jiti: 2.6.1
lightningcss: 1.30.2 lightningcss: 1.30.2
vitest@3.2.4(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
'@vitest/spy': 3.2.4
'@vitest/utils': 3.2.4
chai: 5.3.3
debug: 4.4.3
expect-type: 1.3.0
magic-string: 0.30.21
pathe: 2.0.3
picomatch: 4.0.3
std-env: 3.10.0
tinybench: 2.9.0
tinyexec: 0.3.2
tinyglobby: 0.2.15
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)
vite-node: 3.2.4(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.10.9
transitivePeerDependencies:
- jiti
- less
- lightningcss
- msw
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
- tsx
- yaml
void-elements@3.1.0: {} void-elements@3.1.0: {}
web-vitals@4.2.4: {} web-vitals@4.2.4: {}
@@ -5956,6 +6264,11 @@ snapshots:
dependencies: dependencies:
isexe: 2.0.0 isexe: 2.0.0
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
word-wrap@1.2.5: {} word-wrap@1.2.5: {}
wrap-ansi@7.0.0: wrap-ansi@7.0.0:

View File

@@ -1,3 +1,2 @@
overrides: overrides:
'@dataconnect/generated': link:src/dataconnect-generated '@dataconnect/generated': link:src/dataconnect-generated
dataconnect-generated: link:../../../../../AppData/Local/pnpm/global/5/node_modules/src/dataconnect-generated

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { getDashboardPath, normalizeRole } from './roleUtils'
describe('roleUtils', () => {
it('normalizes lowercase and uppercase roles', () => {
expect(normalizeRole('admin')).toBe('admin')
expect(normalizeRole('CLIENT')).toBe('client')
expect(normalizeRole('Vendor')).toBe('vendor')
})
it('returns null for unsupported roles', () => {
expect(normalizeRole('super_admin')).toBeNull()
expect(normalizeRole('')).toBeNull()
})
it('maps known roles to the proper dashboard path', () => {
expect(getDashboardPath('admin')).toBe('/dashboard/admin')
expect(getDashboardPath('CLIENT')).toBe('/dashboard/client')
expect(getDashboardPath('vendor')).toBe('/dashboard/vendor')
})
it('falls back to client dashboard for unknown roles', () => {
expect(getDashboardPath('unknown')).toBe('/dashboard/client')
})
})

View File

@@ -0,0 +1,30 @@
export type Role = 'admin' | 'client' | 'vendor'
export type RawRole = Role | Uppercase<Role> | string
const DEFAULT_DASHBOARD_PATH = '/dashboard/client'
const ROLE_TO_DASHBOARD: Record<Role, string> = {
admin: '/dashboard/admin',
client: '/dashboard/client',
vendor: '/dashboard/vendor',
}
export const normalizeRole = (role: RawRole): Role | null => {
const normalizedRole = role.toLowerCase()
if (normalizedRole === 'admin' || normalizedRole === 'client' || normalizedRole === 'vendor') {
return normalizedRole
}
return null
}
export const getDashboardPath = (role: RawRole): string => {
const normalizedRole = normalizeRole(role)
if (!normalizedRole) {
return DEFAULT_DASHBOARD_PATH
}
return ROLE_TO_DASHBOARD[normalizedRole]
}

View File

@@ -56,7 +56,7 @@ export default function AddClient() {
const { mutateAsync: createBusiness, isPending: isCreatingBusiness } = useCreateBusiness(dataConnect); const { mutateAsync: createBusiness, isPending: isCreatingBusiness } = useCreateBusiness(dataConnect);
const { mutateAsync: createHub, isPending: isCreatingHub } = useCreateTeamHub(dataConnect); const { mutateAsync: createHub, isPending: isCreatingHub } = useCreateTeamHub(dataConnect);
const { mutateAsync: createTeam, isPending: isCreatingTeam } = useCreateTeam(dataConnect); const { mutateAsync: createTeam } = useCreateTeam(dataConnect);
const handleChange = (field: string, value: any) => { const handleChange = (field: string, value: any) => {
setFormData(prev => ({ ...prev, [field]: value })); setFormData(prev => ({ ...prev, [field]: value }));

View File

@@ -28,9 +28,7 @@ import {
useCreateCustomRateCard, useCreateCustomRateCard,
useUpdateCustomRateCard, useUpdateCustomRateCard,
useDeleteCustomRateCard, useDeleteCustomRateCard,
useCreateVendorRate,
useUpdateVendorRate, useUpdateVendorRate,
useDeleteVendorRate,
useGetVendorByUserId useGetVendorByUserId
} from "@/dataconnect-generated/react"; } from "@/dataconnect-generated/react";
import RateCardModal from "./components/RateCardModal"; import RateCardModal from "./components/RateCardModal";
@@ -111,9 +109,7 @@ function VendorCompanyPricebookView({
const { mutate: updateCustomRateCard } = useUpdateCustomRateCard(); const { mutate: updateCustomRateCard } = useUpdateCustomRateCard();
const { mutate: deleteCustomRateCard } = useDeleteCustomRateCard(); const { mutate: deleteCustomRateCard } = useDeleteCustomRateCard();
const { mutate: createVendorRate } = useCreateVendorRate();
const { mutate: updateVendorRate } = useUpdateVendorRate(); const { mutate: updateVendorRate } = useUpdateVendorRate();
const { mutate: deleteVendorRate } = useDeleteVendorRate();
const handleUpdateVendorRate = (vars: any) => { const handleUpdateVendorRate = (vars: any) => {
updateVendorRate(vars, { updateVendorRate(vars, {
@@ -146,8 +142,13 @@ function VendorCompanyPricebookView({
}, [vendorRates, vendorName]); }, [vendorRates, vendorName]);
const CATEGORIES = useMemo(() => { const CATEGORIES = useMemo(() => {
const cats = new Set(vendorRates.map(r => r.category).filter(Boolean)); const categories = vendorRates.reduce<string[]>((acc, r) => {
return Array.from(cats); if (r.category) {
acc.push(String(r.category));
}
return acc;
}, []);
return Array.from(new Set(categories));
}, [vendorRates]); }, [vendorRates]);
const handleSaveRateCard = (cardData: any) => { const handleSaveRateCard = (cardData: any) => {
@@ -233,7 +234,6 @@ function VendorCompanyPricebookView({
return rates.map((r: any) => { return rates.map((r: any) => {
const parsed = parseRoleName(r.roleName || ""); const parsed = parseRoleName(r.roleName || "");
const position = parsed.position;
// Apply discount if it's a custom rate card // Apply discount if it's a custom rate card
const proposedRate = r.clientRate * (1 - discount / 100); const proposedRate = r.clientRate * (1 - discount / 100);
@@ -470,6 +470,9 @@ function VendorCompanyPricebookView({
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{RATE_CARDS.map((tab) => { {RATE_CARDS.map((tab) => {
const cardData = customRateCards.find((c) => c.name === tab); const cardData = customRateCards.find((c) => c.name === tab);
if (!cardData) {
return null;
}
const isRenaming = renamingCard === tab; const isRenaming = renamingCard === tab;
if (isRenaming) { if (isRenaming) {

View File

@@ -6,10 +6,8 @@ import { InvoiceStatus } from "@/dataconnect-generated";
import { useGetInvoiceById, useUpdateInvoice, useListRecentPaymentsByInvoiceId } from "@/dataconnect-generated/react"; import { useGetInvoiceById, useUpdateInvoice, useListRecentPaymentsByInvoiceId } from "@/dataconnect-generated/react";
import { dataConnect } from "@/features/auth/firebase"; import { dataConnect } from "@/features/auth/firebase";
import DashboardLayout from "@/features/layouts/DashboardLayout"; import DashboardLayout from "@/features/layouts/DashboardLayout";
import type { RootState } from "@/store/store";
import { format, parseISO } from "date-fns"; import { format, parseISO } from "date-fns";
import { ArrowLeft, Download, Mail, CheckCircle, FileText, User, Calendar, MapPin, DollarSign } from "lucide-react"; import { ArrowLeft, Download, Mail, CheckCircle, FileText, User, Calendar, MapPin, DollarSign } from "lucide-react";
import { useSelector } from "react-redux";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
const statusConfig: Record<string, { label: string; className: string }> = { const statusConfig: Record<string, { label: string; className: string }> = {
@@ -25,14 +23,13 @@ const statusConfig: Record<string, { label: string; className: string }> = {
export default function InvoiceDetail() { export default function InvoiceDetail() {
const navigate = useNavigate(); const navigate = useNavigate();
const { id: invoiceId } = useParams<{ id: string }>(); const { id: invoiceId } = useParams<{ id: string }>();
const { user } = useSelector((state: RootState) => state.auth);
// Fetch Invoice Data // Fetch Invoice Data
const { data: invoiceData, isLoading: loadingInvoice } = useGetInvoiceById(dataConnect, { id: invoiceId! }); const { data: invoiceData, isLoading: loadingInvoice } = useGetInvoiceById(dataConnect, { id: invoiceId! });
const invoice = invoiceData?.invoice; const invoice = invoiceData?.invoice;
// Fetch Payment History // Fetch Payment History
const { data: paymentsData, isLoading: loadingPayments } = useListRecentPaymentsByInvoiceId(dataConnect, { invoiceId: invoiceId! }); const { data: paymentsData } = useListRecentPaymentsByInvoiceId(dataConnect, { invoiceId: invoiceId! });
const payments = paymentsData?.recentPayments || []; const payments = paymentsData?.recentPayments || [];
// Mutations // Mutations

View File

@@ -15,12 +15,9 @@ import { useToast } from "@/common/components/ui/use-toast";
import { InvoiceStatus, InovicePaymentTerms } from "@/dataconnect-generated"; import { InvoiceStatus, InovicePaymentTerms } from "@/dataconnect-generated";
import { import {
useCreateInvoice, useCreateInvoice,
useCreateInvoiceTemplate,
useDeleteInvoiceTemplate,
useGetInvoiceById, useGetInvoiceById,
useListBusinesses, useListBusinesses,
useListInvoices, useListInvoices,
useListInvoiceTemplates,
useListOrders, useListOrders,
useListStaff, useListStaff,
useListVendorRates, useListVendorRates,
@@ -71,9 +68,6 @@ export default function InvoiceEditor() {
const { data: staffData } = useListStaff(dataConnect); const { data: staffData } = useListStaff(dataConnect);
const staffDirectory = staffData?.staffs || []; const staffDirectory = staffData?.staffs || [];
const { data: templatesData, refetch: refetchTemplates } = useListInvoiceTemplates(dataConnect);
const templates = templatesData?.invoiceTemplates || [];
const { data: currentInvoiceData } = useGetInvoiceById(dataConnect, { id: effectiveInvoiceId || "" }, { enabled: isEdit && !!effectiveInvoiceId }); const { data: currentInvoiceData } = useGetInvoiceById(dataConnect, { id: effectiveInvoiceId || "" }, { enabled: isEdit && !!effectiveInvoiceId });
const existingInvoice = currentInvoiceData?.invoice; const existingInvoice = currentInvoiceData?.invoice;
@@ -176,7 +170,7 @@ export default function InvoiceEditor() {
phone: existingInvoice.business?.phone || "", phone: existingInvoice.business?.phone || "",
email: existingInvoice.business?.email || "", email: existingInvoice.business?.email || "",
address: existingInvoice.business?.address || "", address: existingInvoice.business?.address || "",
manager_name: existingInvoice.business?.contactName || "", manager_name: existingInvoice.managerName || "",
hub_name: existingInvoice.hub || "", hub_name: existingInvoice.hub || "",
vendor_id: existingInvoice.vendorNumber || "" vendor_id: existingInvoice.vendorNumber || ""
}, },
@@ -198,21 +192,6 @@ export default function InvoiceEditor() {
const selectedBusiness = businesses.find(b => b.id === selectedClientId); const selectedBusiness = businesses.find(b => b.id === selectedClientId);
if (!selectedBusiness || !position) return 0; if (!selectedBusiness || !position) return 0;
const businessName = selectedBusiness.businessName || "";
const extractCompanyName = (name: string) => {
if (!name) return '';
return name.split(/\s*[-]\s*/)[0].trim();
};
const mainCompanyName = extractCompanyName(businessName);
// Logic similar to MVP
const clientSpecificRate = vendorRates.find(rate =>
rate.roleName?.toLowerCase() === position.toLowerCase() &&
rate.client_name === businessName // This field might be different in Data Connect, checking listVendorRates output
);
if (clientSpecificRate) return clientSpecificRate.clientRate || 0;
const defaultRate = vendorRates.find(rate => const defaultRate = vendorRates.find(rate =>
rate.roleName?.toLowerCase() === position.toLowerCase() rate.roleName?.toLowerCase() === position.toLowerCase()
); );
@@ -314,157 +293,6 @@ export default function InvoiceEditor() {
}); });
}; };
const handleDuplicateInvoice = (invoice: any) => {
const newStaffEntries = (invoice.roles || []).map((entry: any) => ({
...entry,
date: format(new Date(), 'MM/dd/yyyy')
}));
const newInvoiceNumber = generateInvoiceNumber(invoice.business?.businessName || '', invoices);
setFormData({
invoice_number: newInvoiceNumber,
event_id: "",
event_name: invoice.order?.eventName || "",
invoice_date: format(new Date(), 'yyyy-MM-dd'),
due_date: format(addDays(new Date(), 45), 'yyyy-MM-dd'),
payment_terms: invoice.paymentTerms || "NET_45",
hub: invoice.hub || "",
manager: invoice.managerName || "",
vendor_id: invoice.vendorNumber || "",
department: invoice.order?.deparment || "",
po_reference: invoice.order?.poReference || "",
from_company: {
name: invoice.vendor?.companyName || formData.from_company.name,
address: invoice.vendor?.address || formData.from_company.address,
phone: invoice.vendor?.phone || formData.from_company.phone,
email: invoice.vendor?.email || formData.from_company.email,
},
to_company: {
name: invoice.business?.businessName || "",
phone: invoice.business?.phone || "",
email: invoice.business?.email || "",
address: invoice.business?.address || "",
manager_name: invoice.business?.contactName || "",
hub_name: invoice.hub || "",
vendor_id: invoice.vendorNumber || ""
},
staff_entries: newStaffEntries,
charges: invoice.charges || [],
other_charges: invoice.otherCharges || 0,
notes: invoice.notes || "",
});
if (invoice.businessId) {
setSelectedClientId(invoice.businessId);
}
toast({
title: "✅ Invoice Duplicated",
description: `Copied from ${invoice.invoiceNumber} - update dates and details as needed`,
});
};
const { mutate: createTemplate } = useCreateInvoiceTemplate(dataConnect);
const { mutate: deleteTemplate } = useDeleteInvoiceTemplate(dataConnect);
const handleUseTemplate = (template: any) => {
const newStaffEntries = (template.roles || []).map((entry: any) => ({
...entry,
name: "",
date: format(new Date(), 'MM/dd/yyyy'),
worked_hours: 0,
regular_hours: 0,
ot_hours: 0,
dt_hours: 0,
regular_value: 0,
ot_value: 0,
dt_value: 0,
total: 0
}));
const newInvoiceNumber = generateInvoiceNumber(template.business?.businessName || '', invoices);
setFormData((prev: any) => ({
...prev,
invoice_number: newInvoiceNumber,
invoice_date: format(new Date(), 'yyyy-MM-dd'),
due_date: format(addDays(new Date(), 45), 'yyyy-MM-dd'),
payment_terms: template.paymentTerms || "NET_45",
hub: template.hub || "",
department: "",
po_reference: template.order?.poReference || "",
from_company: {
name: template.vendor?.companyName || prev.from_company.name,
address: template.vendor?.address || prev.from_company.address,
phone: template.vendor?.phone || prev.from_company.phone,
email: template.vendor?.email || prev.from_company.email,
},
to_company: {
name: template.business?.businessName || "",
phone: template.business?.phone || "",
email: template.business?.email || "",
address: template.business?.address || "",
manager_name: template.business?.contactName || "",
hub_name: template.hub || "",
vendor_id: template.vendorNumber || ""
},
staff_entries: newStaffEntries,
charges: template.charges || [],
notes: template.notes || "",
}));
if (template.businessId) {
setSelectedClientId(template.businessId);
}
toast({
title: "✅ Template Applied",
description: `Applied "${template.name}" - fill in staff names and times`,
});
};
const handleSaveTemplate = async (templateName: string) => {
const selectedBusiness = businesses.find(b => b.id === selectedClientId);
await createTemplate({
name: templateName,
ownerId: "00000000-0000-0000-0000-000000000000", // placeholder, usually from auth
businessId: selectedClientId || undefined,
vendorId: "00000000-0000-0000-0000-000000000000", // placeholder
paymentTerms: formData.payment_terms as InovicePaymentTerms,
invoiceNumber: formData.invoice_number,
issueDate: new Date(formData.invoice_date).toISOString(),
dueDate: new Date(formData.due_date).toISOString(),
hub: formData.hub,
managerName: formData.manager,
roles: formData.staff_entries,
charges: formData.charges,
otherCharges: parseFloat(formData.other_charges) || 0,
subtotal: totals.subtotal,
amount: totals.grandTotal,
notes: formData.notes,
staffCount: formData.staff_entries.length,
chargesCount: formData.charges.length,
});
refetchTemplates();
toast({
title: "✅ Template Saved",
description: `"${templateName}" can now be reused for future invoices`,
});
};
const handleDeleteTemplate = async (templateId: string) => {
await deleteTemplate({ id: templateId });
refetchTemplates();
toast({
title: "Template Deleted",
description: "Template has been removed",
});
};
const parseTimeToMinutes = (timeStr: string) => { const parseTimeToMinutes = (timeStr: string) => {
if (!timeStr || timeStr === "hh:mm") return null; if (!timeStr || timeStr === "hh:mm") return null;
const match = timeStr.match(/(\d{1,2}):(\d{2})\s*(AM|PM)/i); const match = timeStr.match(/(\d{1,2}):(\d{2})\s*(AM|PM)/i);
@@ -747,11 +575,11 @@ export default function InvoiceEditor() {
<h3 className="font-bold text-red-800">Disputed Items Highlighted</h3> <h3 className="font-bold text-red-800">Disputed Items Highlighted</h3>
<p className="text-sm text-red-700 mt-1"> <p className="text-sm text-red-700 mt-1">
{disputedIndices.length} line item(s) have been disputed by the client. {disputedIndices.length} line item(s) have been disputed by the client.
<strong> Reason: </strong>{existingInvoice.dispute_reason || "Not specified"} <strong> Reason: </strong>{existingInvoice.disputeReason || "Not specified"}
</p> </p>
{existingInvoice.dispute_details && ( {existingInvoice.disputeDetails && (
<p className="text-sm text-red-600 mt-2 bg-white p-2 rounded border border-red-200"> <p className="text-sm text-red-600 mt-2 bg-white p-2 rounded border border-red-200">
"{existingInvoice.dispute_details}" "{existingInvoice.disputeDetails}"
</p> </p>
)} )}
<p className="text-xs text-red-600 mt-2"> <p className="text-xs text-red-600 mt-2">
@@ -775,7 +603,7 @@ export default function InvoiceEditor() {
<p className="text-xs text-muted-text">Quickly fill invoice from a completed event's shifts</p> <p className="text-xs text-muted-text">Quickly fill invoice from a completed event's shifts</p>
</div> </div>
</div> </div>
<Select onValueChange={(val) => { <Select onValueChange={(val: string) => {
const event = events.find(e => e.id === val); const event = events.find(e => e.id === val);
if (event) handleImportFromEvent(event); if (event) handleImportFromEvent(event);
}}> }}>

View File

@@ -59,7 +59,6 @@ export default function InvoiceList() {
// If user is client, they should see their invoices. If admin, they see all. // If user is client, they should see their invoices. If admin, they see all.
const userRole = user?.userRole?.toUpperCase(); const userRole = user?.userRole?.toUpperCase();
const isClient = userRole === "CLIENT"; const isClient = userRole === "CLIENT";
const isVendor = userRole === "VENDOR";
if (isClient && inv.businessId !== user?.uid) return false; if (isClient && inv.businessId !== user?.uid) return false;
// In a real scenario, we'd match vendorId for vendor users // In a real scenario, we'd match vendorId for vendor users

View File

@@ -10,9 +10,48 @@ import OrderReductionAlert from "./components/OrderReductionAlert";
import EventFormWizard from "./components/EventFormWizard"; import EventFormWizard from "./components/EventFormWizard";
import { useToast } from "@/common/components/ui/use-toast"; import { useToast } from "@/common/components/ui/use-toast";
import { useGetOrderById, useUpdateOrder, useListStaff } from "@/dataconnect-generated/react"; import { useGetOrderById, useUpdateOrder, useListStaff } from "@/dataconnect-generated/react";
import type { UpdateOrderVariables } from "@/dataconnect-generated";
import { dataConnect } from "@/features/auth/firebase"; import { dataConnect } from "@/features/auth/firebase";
import type { RootState } from "@/store/store"; import type { RootState } from "@/store/store";
const asRecord = (value: unknown): Record<string, unknown> | null => {
if (typeof value === "object" && value !== null) {
return value as Record<string, unknown>;
}
return null;
};
const getTeamHubId = (value: unknown): string | null => {
const record = asRecord(value);
if (!record) return null;
const directValue = record.teamHubId;
if (typeof directValue === "string" && directValue.length > 0) {
return directValue;
}
const teamHub = asRecord(record.teamHub);
const nestedValue = teamHub?.id;
if (typeof nestedValue === "string" && nestedValue.length > 0) {
return nestedValue;
}
return null;
};
const getStringField = (value: unknown, keys: string[]): string | null => {
const record = asRecord(value);
if (!record) return null;
for (const key of keys) {
const fieldValue = record[key];
if (typeof fieldValue === "string" && fieldValue.length > 0) {
return fieldValue;
}
}
return null;
};
export default function EditOrder() { export default function EditOrder() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -22,7 +61,7 @@ export default function EditOrder() {
const { user } = useSelector((state: RootState) => state.auth); const { user } = useSelector((state: RootState) => state.auth);
const [showReductionAlert, setShowReductionAlert] = useState(false); const [showReductionAlert, setShowReductionAlert] = useState(false);
const [pendingUpdate, setPendingUpdate] = useState<any>(null); const [pendingUpdate, setPendingUpdate] = useState<UpdateOrderVariables | null>(null);
const [originalRequested, setOriginalRequested] = useState(0); const [originalRequested, setOriginalRequested] = useState(0);
const { data: orderData, isLoading: isOrderLoading } = useGetOrderById( const { data: orderData, isLoading: isOrderLoading } = useGetOrderById(
@@ -86,11 +125,37 @@ export default function EditOrder() {
const assignedStaff = Array.isArray(event?.assignedStaff) ? event!.assignedStaff : []; const assignedStaff = Array.isArray(event?.assignedStaff) ? event!.assignedStaff : [];
const assignedCount = assignedStaff.length; const assignedCount = assignedStaff.length;
const isVendor = user?.userRole === 'vendor' || (user as any)?.role === 'vendor'; const isVendor = user?.userRole?.toLowerCase() === 'vendor';
if (!eventId) return;
const teamHubId = getTeamHubId(eventData) ?? getTeamHubId(event);
if (!teamHubId) {
toast({
title: "Missing Team Hub",
description: "Cannot update order because no team hub is associated.",
variant: "destructive",
});
return;
}
const updatePayload: UpdateOrderVariables = {
id: eventId,
teamHubId,
eventName: eventData.event_name,
date: eventData.date,
startDate: eventData.startDate || eventData.date,
endDate: eventData.endDate,
notes: eventData.notes,
shifts: eventData.shifts,
requested: totalRequested,
total: eventData.total,
poReference: eventData.po_reference,
};
// If client is reducing headcount and vendor has already assigned staff // If client is reducing headcount and vendor has already assigned staff
if (!isVendor && totalRequested < originalRequested && assignedCount > totalRequested) { if (!isVendor && totalRequested < originalRequested && assignedCount > totalRequested) {
setPendingUpdate({ ...eventData, requested: totalRequested }); setPendingUpdate(updatePayload);
setShowReductionAlert(true); setShowReductionAlert(true);
toast({ toast({
@@ -100,52 +165,46 @@ export default function EditOrder() {
return; return;
} }
if (eventId) { updateOrderMutation.mutate(updatePayload);
// Normal update
updateOrderMutation.mutate({
id: eventId,
eventName: eventData.event_name,
date: eventData.date,
startDate: eventData.startDate || eventData.date,
endDate: eventData.endDate,
notes: eventData.notes,
shifts: eventData.shifts,
requested: totalRequested,
total: eventData.total,
poReference: eventData.po_reference,
});
}
}; };
const handleAutoUnassign = async () => { const handleAutoUnassign = async () => {
if (!pendingUpdate || !event || !eventId) return; if (!pendingUpdate || !event || !eventId) return;
const assignedStaff = Array.isArray(event.assignedStaff) ? (event.assignedStaff as any[]) : []; const assignedStaff = Array.isArray(event.assignedStaff) ? event.assignedStaff : [];
const excessCount = assignedStaff.length - pendingUpdate.requested; const requestedCount = pendingUpdate.requested ?? 0;
const excessCount = Math.max(0, assignedStaff.length - requestedCount);
// Calculate reliability scores for assigned staff // Calculate reliability scores for assigned staff
const staffWithScores = assignedStaff.map(assigned => { const staffWithScores = assignedStaff.map((assigned) => {
const staffInfo = allStaff.find(s => s.id === assigned.staff_id || s.id === assigned.staffId); const staffId = getStringField(assigned, ["staff_id", "staffId"]);
const staffName = getStringField(assigned, ["staffName", "staff_name"]);
const role = getStringField(assigned, ["role"]) ?? "";
const staffInfo = staffId ? allStaff.find((s) => s.id === staffId) : undefined;
return { return {
...assigned, staffId,
staffName,
role,
reliability: staffInfo?.averageRating ? staffInfo.averageRating * 20 : 50, // Convert 0-5 to 0-100 reliability: staffInfo?.averageRating ? staffInfo.averageRating * 20 : 50, // Convert 0-5 to 0-100
}; };
}); });
// Sort by reliability (lowest first) // Sort by reliability (lowest first)
staffWithScores.sort((a: any, b: any) => a.reliability - b.reliability); staffWithScores.sort((a, b) => a.reliability - b.reliability);
// Remove lowest reliability staff // Remove lowest reliability staff
const staffToKeep = staffWithScores.slice(excessCount); const staffToKeep = staffWithScores.slice(excessCount);
const assignedStaffPayload = staffToKeep
.filter((s) => typeof s.staffId === "string" && s.staffId.length > 0)
.map((s) => ({
staffId: s.staffId as string,
staffName: s.staffName ?? "Unknown",
role: s.role,
}));
await updateOrderMutation.mutateAsync({ await updateOrderMutation.mutateAsync({
id: eventId,
...pendingUpdate, ...pendingUpdate,
assignedStaff: staffToKeep.map((s: any) => ({ assignedStaff: assignedStaffPayload,
staffId: s.staffId || s.staff_id,
staffName: s.staffName || s.staff_name,
role: s.role
}))
}); });
setShowReductionAlert(false); setShowReductionAlert(false);
@@ -203,14 +262,16 @@ export default function EditOrder() {
<div className="mb-6"> <div className="mb-6">
<OrderReductionAlert <OrderReductionAlert
originalRequested={originalRequested} originalRequested={originalRequested}
newRequested={pendingUpdate.requested} newRequested={pendingUpdate.requested ?? 0}
currentAssigned={Array.isArray(event.assignedStaff) ? event.assignedStaff.length : 0} currentAssigned={Array.isArray(event.assignedStaff) ? event.assignedStaff.length : 0}
onAutoUnassign={handleAutoUnassign} onAutoUnassign={handleAutoUnassign}
onManualUnassign={handleManualUnassign} onManualUnassign={handleManualUnassign}
lowReliabilityStaff={(Array.isArray(event.assignedStaff) ? event.assignedStaff : []).map((assigned: any) => { lowReliabilityStaff={(Array.isArray(event.assignedStaff) ? event.assignedStaff : []).map((assigned) => {
const staffInfo = allStaff.find(s => s.id === assigned.staffId || s.id === assigned.staff_id); const staffId = getStringField(assigned, ["staffId", "staff_id"]);
const staffName = getStringField(assigned, ["staffName", "staff_name"]) ?? "Unknown";
const staffInfo = staffId ? allStaff.find((s) => s.id === staffId) : undefined;
return { return {
name: assigned.staffName || assigned.staff_name, name: staffName,
reliability: staffInfo?.averageRating ? staffInfo.averageRating * 20 : 50 reliability: staffInfo?.averageRating ? staffInfo.averageRating * 20 : 50
}; };
}).sort((a, b) => a.reliability - b.reliability)} }).sort((a, b) => a.reliability - b.reliability)}
@@ -219,10 +280,10 @@ export default function EditOrder() {
)} )}
<EventFormWizard <EventFormWizard
event={event as any} event={event}
onSubmit={handleSubmit} onSubmit={handleSubmit}
isSubmitting={updateOrderMutation.isPending} isSubmitting={updateOrderMutation.isPending}
currentUser={user as any} currentUser={user}
onCancel={() => navigate(createPageUrl("Events"))} onCancel={() => navigate(createPageUrl("Events"))}
/> />
</div> </div>

View File

@@ -9,7 +9,7 @@ import { Button } from "@/common/components/ui/button";
import { Badge } from "@/common/components/ui/badge"; import { Badge } from "@/common/components/ui/badge";
import DashboardLayout from "@/features/layouts/DashboardLayout"; import DashboardLayout from "@/features/layouts/DashboardLayout";
import { useGetOrderById, useUpdateOrder, useListShiftRolesByBusinessAndOrder } from "@/dataconnect-generated/react"; import { useGetOrderById, useUpdateOrder, useListShiftRolesByBusinessAndOrder } from "@/dataconnect-generated/react";
import { OrderStatus } from "@/dataconnect-generated"; import { OrderStatus, type UpdateOrderVariables } from "@/dataconnect-generated";
import { dataConnect } from "@/features/auth/firebase"; import { dataConnect } from "@/features/auth/firebase";
import { useToast } from "@/common/components/ui/use-toast"; import { useToast } from "@/common/components/ui/use-toast";
import AssignStaffModal from "./components/AssignStaffModal"; import AssignStaffModal from "./components/AssignStaffModal";
@@ -37,6 +37,31 @@ const safeFormatDateTime = (value?: string | null): string => {
} }
}; };
const resolveTeamHubId = (value: unknown): string | null => {
if (typeof value !== "object" || value === null) {
return null;
}
const record = value as Record<string, unknown>;
const directValue = record.teamHubId;
if (typeof directValue === "string" && directValue.length > 0) {
return directValue;
}
const teamHub = record.teamHub;
if (typeof teamHub !== "object" || teamHub === null) {
return null;
}
const teamHubRecord = teamHub as Record<string, unknown>;
const nestedValue = teamHubRecord.id;
if (typeof nestedValue === "string" && nestedValue.length > 0) {
return nestedValue;
}
return null;
};
const getStatusBadge = (status: OrderStatus) => { const getStatusBadge = (status: OrderStatus) => {
switch (status) { switch (status) {
case OrderStatus.FULLY_STAFFED: case OrderStatus.FULLY_STAFFED:
@@ -105,7 +130,6 @@ export default function OrderDetail() {
// Fetch real shift roles to get IDs and accurate counts // Fetch real shift roles to get IDs and accurate counts
const { const {
data: shiftRolesData, data: shiftRolesData,
isLoading: isLoadingShifts,
refetch: refetchShifts refetch: refetchShifts
} = useListShiftRolesByBusinessAndOrder( } = useListShiftRolesByBusinessAndOrder(
dataConnect, dataConnect,
@@ -142,10 +166,22 @@ export default function OrderDetail() {
const handleCancel = () => { const handleCancel = () => {
if (!order || !id || !canModify) return; if (!order || !id || !canModify) return;
cancelMutation.mutate({ const teamHubId = resolveTeamHubId(order);
if (!teamHubId) {
toast({
title: "Missing Team Hub",
description: "Cannot cancel this order because team hub information is missing.",
variant: "destructive",
});
return;
}
const payload: UpdateOrderVariables = {
id, id,
teamHubId,
status: OrderStatus.CANCELLED, status: OrderStatus.CANCELLED,
}); };
cancelMutation.mutate(payload);
}; };
const handleEdit = () => { const handleEdit = () => {
@@ -163,7 +199,7 @@ export default function OrderDetail() {
if (shiftRolesData?.shiftRoles && shiftRolesData.shiftRoles.length > 0) { if (shiftRolesData?.shiftRoles && shiftRolesData.shiftRoles.length > 0) {
return shiftRolesData.shiftRoles; return shiftRolesData.shiftRoles;
} }
return Array.isArray(order?.shifts) ? (order!.shifts as any[]) : []; return Array.isArray(order?.shifts) ? order.shifts : [];
}, [shiftRolesData, order?.shifts]); }, [shiftRolesData, order?.shifts]);
const totalRequested = order?.requested ?? 0; const totalRequested = order?.requested ?? 0;

View File

@@ -13,21 +13,19 @@ import DashboardLayout from "@/features/layouts/DashboardLayout";
import { import {
Search, Search,
Calendar, Calendar,
Filter,
ArrowRight, ArrowRight,
Clock, Clock,
CheckCircle, CheckCircle,
AlertTriangle, AlertTriangle,
XCircle,
FileText FileText
} from "lucide-react"; } from "lucide-react";
import React, { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import type { RootState } from "@/store/store"; import type { RootState } from "@/store/store";
import { useListOrders, useListBusinesses } from "@/dataconnect-generated/react"; import { useListOrders, useListBusinesses } from "@/dataconnect-generated/react";
import { dataConnect } from "@/features/auth/firebase"; import { dataConnect } from "@/features/auth/firebase";
import { format, isWithinInterval, parseISO, startOfDay, endOfDay } from "date-fns"; import { format, isWithinInterval, startOfDay, endOfDay } from "date-fns";
import { OrderStatus } from "@/dataconnect-generated"; import { OrderStatus } from "@/dataconnect-generated";
export default function OrderList() { export default function OrderList() {

View File

@@ -1,6 +1,5 @@
import React, { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import { Search, Check, X, UserPlus, Star, Clock, AlertTriangle } from "lucide-react"; import { Search, Check, Star, Clock, AlertTriangle } from "lucide-react";
import { format, isSameDay, parseISO } from "date-fns";
import { import {
Dialog, Dialog,
@@ -20,7 +19,6 @@ import {
useCreateAssignment, useCreateAssignment,
useUpdateShiftRole, useUpdateShiftRole,
useListAssignments, useListAssignments,
useListStaffAvailabilitiesByDay
} from "@/dataconnect-generated/react"; } from "@/dataconnect-generated/react";
import { dataConnect } from "@/features/auth/firebase"; import { dataConnect } from "@/features/auth/firebase";
import { AssignmentStatus } from "@/dataconnect-generated"; import { AssignmentStatus } from "@/dataconnect-generated";
@@ -38,7 +36,6 @@ export default function AssignStaffModal({ isOpen, onClose, shift, onSuccess }:
const [selectedStaff, setSelectedStaff] = useState<any>(null); const [selectedStaff, setSelectedStaff] = useState<any>(null);
const vendorId = shift.shift?.order?.vendorId || shift.order?.vendorId; const vendorId = shift.shift?.order?.vendorId || shift.order?.vendorId;
const shiftDate = shift.shift?.date || shift.date;
const shiftStartTime = shift.startTime || shift.start; const shiftStartTime = shift.startTime || shift.start;
const shiftEndTime = shift.endTime || shift.end; const shiftEndTime = shift.endTime || shift.end;

View File

@@ -46,7 +46,7 @@ import { Tabs, TabsList, TabsTrigger } from "@/common/components/ui/tabs";
import DashboardLayout from "@/features/layouts/DashboardLayout"; import DashboardLayout from "@/features/layouts/DashboardLayout";
import { useListOrders, useUpdateOrder } from "@/dataconnect-generated/react"; import { useListOrders, useUpdateOrder } from "@/dataconnect-generated/react";
import { dataConnect } from "@/features/auth/firebase"; import { dataConnect } from "@/features/auth/firebase";
import { OrderStatus } from "@/dataconnect-generated"; import { OrderStatus, type UpdateOrderVariables } from "@/dataconnect-generated";
import { useToast } from "@/common/components/ui/use-toast"; import { useToast } from "@/common/components/ui/use-toast";
/** /**
@@ -83,6 +83,35 @@ const safeParseDate = (dateString: any) => {
} }
}; };
const isViewMode = (value: string): value is "day" | "week" | "month" => {
return value === "day" || value === "week" || value === "month";
};
const resolveTeamHubId = (value: unknown): string | null => {
if (typeof value !== "object" || value === null) {
return null;
}
const record = value as Record<string, unknown>;
const directValue = record.teamHubId;
if (typeof directValue === "string" && directValue.length > 0) {
return directValue;
}
const teamHub = record.teamHub;
if (typeof teamHub !== "object" || teamHub === null) {
return null;
}
const teamHubRecord = teamHub as Record<string, unknown>;
const nestedValue = teamHubRecord.id;
if (typeof nestedValue === "string" && nestedValue.length > 0) {
return nestedValue;
}
return null;
};
export default function Schedule() { export default function Schedule() {
const navigate = useNavigate(); const navigate = useNavigate();
const { toast } = useToast(); const { toast } = useToast();
@@ -204,10 +233,22 @@ export default function Schedule() {
const confirmReschedule = () => { const confirmReschedule = () => {
if (rescheduleData) { if (rescheduleData) {
updateOrderMutation.mutate({ const teamHubId = resolveTeamHubId(rescheduleData.order);
if (!teamHubId) {
toast({
title: "Missing Team Hub",
description: "Cannot reschedule this order because team hub information is missing.",
variant: "destructive",
});
return;
}
const payload: UpdateOrderVariables = {
id: rescheduleData.order.id, id: rescheduleData.order.id,
date: rescheduleData.newDate.toISOString() teamHubId,
}); date: rescheduleData.newDate.toISOString(),
};
updateOrderMutation.mutate(payload);
} }
}; };
@@ -287,7 +328,15 @@ export default function Schedule() {
</h2> </h2>
</div> </div>
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as any)} className="w-full md:w-auto"> <Tabs
value={viewMode}
onValueChange={(value) => {
if (isViewMode(value)) {
setViewMode(value);
}
}}
className="w-full md:w-auto"
>
<TabsList className="grid grid-cols-3 w-full md:w-[240px]"> <TabsList className="grid grid-cols-3 w-full md:w-[240px]">
<TabsTrigger value="day" className="text-xs font-bold uppercase tracking-wider">Day</TabsTrigger> <TabsTrigger value="day" className="text-xs font-bold uppercase tracking-wider">Day</TabsTrigger>
<TabsTrigger value="week" className="text-xs font-bold uppercase tracking-wider">Week</TabsTrigger> <TabsTrigger value="week" className="text-xs font-bold uppercase tracking-wider">Week</TabsTrigger>

View File

@@ -39,7 +39,7 @@ export default function TaskBoard() {
const [conditionalColoring, setConditionalColoring] = useState(true); const [conditionalColoring, setConditionalColoring] = useState(true);
// Queries // Queries
const { data: shiftsData, isLoading: shiftsLoading } = useListShifts(dataConnect); const { data: shiftsData } = useListShifts(dataConnect);
const { data: clientsData } = useListBusinesses(dataConnect); const { data: clientsData } = useListBusinesses(dataConnect);
const shifts = useMemo(() => shiftsData?.shifts || [], [shiftsData]); const shifts = useMemo(() => shiftsData?.shifts || [], [shiftsData]);

View File

@@ -31,7 +31,7 @@ export default function TaskCard({
provided, provided,
onClick, onClick,
itemHeight = "normal", itemHeight = "normal",
conditionalColoring = true conditionalColoring: _conditionalColoring = true
}: TaskCardProps) { }: TaskCardProps) {
const heightClasses = { const heightClasses = {
compact: "p-3", compact: "p-3",

View File

@@ -164,7 +164,8 @@ export default function AddStaff() {
<Checkbox <Checkbox
id={skill} id={skill}
checked={field.value?.includes(skill)} checked={field.value?.includes(skill)}
onCheckedChange={(checked) => { onChange={(e) => {
const checked = e.target.checked;
const updatedSkills = checked const updatedSkills = checked
? [...(field.value || []), skill] ? [...(field.value || []), skill]
: field.value?.filter((s: string) => s !== skill); : field.value?.filter((s: string) => s !== skill);

View File

@@ -18,7 +18,7 @@ const ITEMS_PER_PAGE = 10;
function StaffActiveStatus({ staffId }: { staffId: string }) { function StaffActiveStatus({ staffId }: { staffId: string }) {
const { data: staffDetail, isLoading } = useGetStaffById(dataConnect, { id: staffId }); const { data: staffDetail, isLoading } = useGetStaffById(dataConnect, { id: staffId });
const getLastActiveText = (lastActive?: string) => { const getLastActiveText = (lastActive?: string | null) => {
if (!lastActive) return 'Never'; if (!lastActive) return 'Never';
try { try {
const date = new Date(lastActive); const date = new Date(lastActive);

View File

@@ -74,7 +74,7 @@ export default function EmployeeCard({ staff }: EmployeeCardProps) {
const coveragePercentage = staff.shift_coverage_percentage || 0; const coveragePercentage = staff.shift_coverage_percentage || 0;
const cancellationCount = staff.cancellation_count || 0; const cancellationCount = staff.cancellation_count || 0;
const noShowCount = staff.no_show_count || 0; const noShowCount = staff.no_show_count || 0;
const rating = staff.rating || 0; const rating = staff.averageRating || 0;
const reliabilityScore = staff.reliability_score || 0; const reliabilityScore = staff.reliability_score || 0;
const reliability = getReliabilityColor(reliabilityScore); const reliability = getReliabilityColor(reliabilityScore);

View File

@@ -107,7 +107,7 @@ export default function DocumentVault() {
const filteredStaff = useMemo(() => { const filteredStaff = useMemo(() => {
let result = [...staff]; let result = [...staff];
if (isVendor && user?.uid) { if (isVendor && user?.uid) {
result = result.filter(s => s.ownerId === user.uid || s.createdBy === user.email); result = result.filter(s => s.ownerId === user.uid);
} }
if (searchTerm) { if (searchTerm) {
result = result.filter(s => result = result.filter(s =>
@@ -472,7 +472,7 @@ export default function DocumentVault() {
<thead> <thead>
<tr className="border-b border-gray-100"> <tr className="border-b border-gray-100">
<th className="text-left py-6 px-8 font-bold text-gray-400 text-[10px] uppercase tracking-wider min-w-[280px]">Employees</th> <th className="text-left py-6 px-8 font-bold text-gray-400 text-[10px] uppercase tracking-wider min-w-[280px]">Employees</th>
{availableDocTypes.map((type, idx) => ( {availableDocTypes.map((type) => (
<th key={type.documentType} className="p-4 min-w-[160px]"> <th key={type.documentType} className="p-4 min-w-[160px]">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<span className="font-bold text-gray-600 text-[10px] uppercase tracking-wider">{type.name}</span> <span className="font-bold text-gray-600 text-[10px] uppercase tracking-wider">{type.name}</span>

View File

@@ -1,5 +1,6 @@
import { getFirestore, doc, getDoc } from "firebase/firestore"; import { getFirestore, doc, getDoc } from "firebase/firestore";
import { app } from "../features/auth/firebase"; import { app } from "../features/auth/firebase";
import { getDashboardPath as getDashboardPathFromRole } from "../features/auth/roleUtils";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - generated dataconnect types may not be resolvable in this context // @ts-ignore - generated dataconnect types may not be resolvable in this context
import { getUserById } from "@/dataconnect-generated"; import { getUserById } from "@/dataconnect-generated";
@@ -68,11 +69,5 @@ export const fetchUserData = async (uid: string): Promise<UserData | null> => {
* @returns The appropriate dashboard path * @returns The appropriate dashboard path
*/ */
export const getDashboardPath = (userRole: string): string => { export const getDashboardPath = (userRole: string): string => {
const roleMap: Record<string, string> = { return getDashboardPathFromRole(userRole);
admin: "/dashboard/admin",
client: "/dashboard/client",
vendor: "/dashboard/vendor",
};
return roleMap[userRole.toLowerCase()] || "/dashboard/client";
}; };

11
apps/web/vitest.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.test.ts'],
coverage: {
enabled: false,
},
},
})

View File

@@ -68,7 +68,48 @@ distribute-ios-script: &distribute-ios-script
--groups "$FIREBASE_TESTER_GROUPS" \ --groups "$FIREBASE_TESTER_GROUPS" \
--token $FIREBASE_TOKEN --token $FIREBASE_TOKEN
# Reusable script for web quality checks
web-quality-script: &web-quality-script
name: ✅ Web Quality Checks
script: |
npm install -g pnpm
cd apps/web
pnpm install --frozen-lockfile
pnpm lint
pnpm test
pnpm build
# Reusable script for mobile quality checks
mobile-quality-script: &mobile-quality-script
name: ✅ Mobile Quality Checks
script: |
dart pub global activate melos
export PATH="$PATH":"$HOME/.pub-cache/bin"
make mobile-install
make mobile-analyze
make mobile-test
workflows: workflows:
# =================================================================================
# Quality workflow (Web + Mobile)
# =================================================================================
quality-gates-dev:
name: ✅ Quality Gates (Dev)
working_directory: .
instance_type: mac_mini_m2
max_build_duration: 60
environment:
flutter: stable
xcode: latest
cocoapods: default
groups:
- client_app_dev_credentials
vars:
ENV: dev
scripts:
- *web-quality-script
- *mobile-quality-script
# ================================================================================= # =================================================================================
# Base workflow for client_app # Base workflow for client_app
# ================================================================================= # =================================================================================

View File

@@ -1,6 +1,6 @@
# --- Web App Development --- # --- Web App Development ---
.PHONY: web-install web-info web-dev web-build web-lint web-preview web-deploy .PHONY: web-install web-info web-dev web-build web-lint web-test web-preview web-deploy
WEB_DIR := apps/web WEB_DIR := apps/web
@@ -30,6 +30,10 @@ web-lint:
@echo "--> Linting web frontend..." @echo "--> Linting web frontend..."
@cd $(WEB_DIR) && pnpm lint @cd $(WEB_DIR) && pnpm lint
web-test:
@echo "--> Running web frontend tests..."
@cd $(WEB_DIR) && pnpm test
web-preview: web-preview:
@echo "--> Previewing web frontend build..." @echo "--> Previewing web frontend build..."
@cd $(WEB_DIR) && pnpm preview @cd $(WEB_DIR) && pnpm preview