From 29942281e7748e2ccf66c0cd2c533857ab5fa5e4 Mon Sep 17 00:00:00 2001 From: Laljanibasha Shaik <98688990+laljohnny@users.noreply.github.com> Date: Fri, 21 Nov 2025 12:02:19 +0530 Subject: [PATCH 01/25] Add Node.js CI workflow This workflow sets up a CI pipeline for Node.js applications, installing dependencies, building the source code, and running tests across multiple Node.js versions. --- .github/workflows/node.js.yml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/node.js.yml diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml new file mode 100644 index 000000000..093a9544b --- /dev/null +++ b/.github/workflows/node.js.yml @@ -0,0 +1,31 @@ +# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs + +name: Node.js CI + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +jobs: + build: + + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18.x, 20.x, 22.x] + # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ + + steps: + - uses: actions/checkout@v4 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + - run: npm ci + - run: npm run build --if-present + - run: npm test From 67192802de4f07090d97facb4836925e3d503678 Mon Sep 17 00:00:00 2001 From: Laljanibasha Shaik <98688990+laljohnny@users.noreply.github.com> Date: Fri, 21 Nov 2025 12:04:22 +0530 Subject: [PATCH 02/25] Remove npm test from Node.js workflow Remove npm test step from workflow --- .github/workflows/node.js.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 093a9544b..87e625382 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -28,4 +28,4 @@ jobs: cache: 'npm' - run: npm ci - run: npm run build --if-present - - run: npm test + From a0234718546aaac338b5093d41f02452ece64fa4 Mon Sep 17 00:00:00 2001 From: Laljanibasha Shaik <98688990+laljohnny@users.noreply.github.com> Date: Fri, 21 Nov 2025 12:09:17 +0530 Subject: [PATCH 03/25] Update node.js.yml --- .github/workflows/node.js.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 87e625382..bcea99bb2 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -16,7 +16,7 @@ jobs: strategy: matrix: - node-version: [18.x, 20.x, 22.x] + node-version: [22.x] # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ steps: From 032d3f3badfeb09151866ad0ffec7c413b3b4159 Mon Sep 17 00:00:00 2001 From: Laljanibasha Shaik <98688990+laljohnny@users.noreply.github.com> Date: Mon, 24 Nov 2025 15:02:15 +0530 Subject: [PATCH 04/25] Update node.js.yml --- .github/workflows/node.js.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index bcea99bb2..e24bd5732 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -6,8 +6,12 @@ name: Node.js CI on: push: branches: [ "master" ] + tags: + - "v*" pull_request: branches: [ "master" ] + tags: + - "v*" jobs: build: From 050b9df316599b927dc431077c2b2ffed061aa68 Mon Sep 17 00:00:00 2001 From: Laljanibasha Shaik <98688990+laljohnny@users.noreply.github.com> Date: Mon, 24 Nov 2025 16:56:21 +0530 Subject: [PATCH 05/25] Add artifact upload step to CI workflow --- .github/workflows/node.js.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index e24bd5732..87685f68b 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -33,3 +33,7 @@ jobs: - run: npm ci - run: npm run build --if-present + - uses: actions/upload-artifact@v4 + with: + name: my-build-output + path: ./dist/ From 9169b58bdc789f8777d2e7c25416196c77ccde84 Mon Sep 17 00:00:00 2001 From: Laljanibasha Shaik <98688990+laljohnny@users.noreply.github.com> Date: Tue, 25 Nov 2025 13:16:32 +0530 Subject: [PATCH 06/25] Add Trivy security scan and report upload steps --- .github/workflows/node.js.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 87685f68b..b55eda52c 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -37,3 +37,22 @@ jobs: with: name: my-build-output path: ./dist/ + - name: Aqua Security Trivy Scan + uses: aquasecurity/trivy-action@0.33.1 + with: + scan-type: fs + scan-ref: . + severity: HIGH,CRITICAL + ignore-unfixed: true + vuln-type: os,library + exit-code: 1 + format: text + output: trivy-report.text + hide-progress: true + + - name: Upload Trivy Report + uses: actions/upload-artifact@v4 + with: + name: trivy-report + path: trivy-report.json + From 0d5d3d18de789bf393a13208c4c829273912e6da Mon Sep 17 00:00:00 2001 From: Laljanibasha Shaik <98688990+laljohnny@users.noreply.github.com> Date: Tue, 25 Nov 2025 13:18:10 +0530 Subject: [PATCH 07/25] Change Trivy report format to CSV and output type --- .github/workflows/node.js.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index b55eda52c..4c525544e 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -46,8 +46,8 @@ jobs: ignore-unfixed: true vuln-type: os,library exit-code: 1 - format: text - output: trivy-report.text + format: table + output: trivy-report.csv hide-progress: true - name: Upload Trivy Report From 302f87e875f26666c9f0f55eed57b08caab9c6b3 Mon Sep 17 00:00:00 2001 From: Laljanibasha Shaik <98688990+laljohnny@users.noreply.github.com> Date: Tue, 25 Nov 2025 13:19:23 +0530 Subject: [PATCH 08/25] Change Trivy report format from JSON to CSV --- .github/workflows/node.js.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 4c525544e..040dd367b 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -54,5 +54,5 @@ jobs: uses: actions/upload-artifact@v4 with: name: trivy-report - path: trivy-report.json + path: trivy-report.csv From 4146e8f21eeeefc77d6657085f2f592fb0b9870c Mon Sep 17 00:00:00 2001 From: Laljanibasha Shaik <98688990+laljohnny@users.noreply.github.com> Date: Tue, 25 Nov 2025 13:27:43 +0530 Subject: [PATCH 09/25] Change Trivy exit code and report output format --- .github/workflows/node.js.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 040dd367b..9ee184a1f 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -45,14 +45,14 @@ jobs: severity: HIGH,CRITICAL ignore-unfixed: true vuln-type: os,library - exit-code: 1 + exit-code: 0 format: table - output: trivy-report.csv + output: trivy-report.txt hide-progress: true - name: Upload Trivy Report uses: actions/upload-artifact@v4 with: name: trivy-report - path: trivy-report.csv + path: trivy-report.txt From f9dd47cf1b5643915035e834674a03ca3cebc012 Mon Sep 17 00:00:00 2001 From: Laljanibasha Shaik <98688990+laljohnny@users.noreply.github.com> Date: Tue, 25 Nov 2025 13:31:07 +0530 Subject: [PATCH 10/25] Change artifact upload path to current directory --- .github/workflows/node.js.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 9ee184a1f..1916ccdca 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -36,7 +36,7 @@ jobs: - uses: actions/upload-artifact@v4 with: name: my-build-output - path: ./dist/ + path: . - name: Aqua Security Trivy Scan uses: aquasecurity/trivy-action@0.33.1 with: From 32a9971055eee79a2e0b2686df38d140421629b0 Mon Sep 17 00:00:00 2001 From: Laljanibasha Shaik <98688990+laljohnny@users.noreply.github.com> Date: Tue, 25 Nov 2025 14:38:53 +0530 Subject: [PATCH 11/25] Remove conditional build step in workflow --- .github/workflows/node.js.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 1916ccdca..c1102ee6c 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -31,12 +31,13 @@ jobs: node-version: ${{ matrix.node-version }} cache: 'npm' - run: npm ci - - run: npm run build --if-present + - run: npm run build - uses: actions/upload-artifact@v4 with: name: my-build-output path: . + - name: Aqua Security Trivy Scan uses: aquasecurity/trivy-action@0.33.1 with: From 5bd8a494c3c06afa5de7ca0d3462bd9697b31f44 Mon Sep 17 00:00:00 2001 From: Laljanibasha Shaik <98688990+laljohnny@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:44:20 +0530 Subject: [PATCH 12/25] Update greeting message in index.js --- index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.js b/index.js index 54e5fef1f..c6ee820d1 100644 --- a/index.js +++ b/index.js @@ -3,7 +3,7 @@ const port = process.env.PORT || 3000; const server = http.createServer((req, res) => { res.statusCode = 200; - const msg = 'Hello Node!\n' + const msg = 'Hello Node Application!\n' res.end(msg); }); From d85461f22a914ca2bd6410802596806a6c8fe48c Mon Sep 17 00:00:00 2001 From: Laljanibasha Shaik <98688990+laljohnny@users.noreply.github.com> Date: Mon, 8 Dec 2025 17:38:43 +0530 Subject: [PATCH 13/25] Delete package.json --- package.json | 20 -------------------- 1 file changed, 20 deletions(-) delete mode 100644 package.json diff --git a/package.json b/package.json deleted file mode 100644 index b0d12dfc6..000000000 --- a/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "node-hello", - "version": "1.0.0", - "description": "", - "main": "index.js", - "scripts": { - "start": "node index.js" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/johnpapa/node-hello.git" - }, - "keywords": [], - "author": "", - "license": "ISC", - "bugs": { - "url": "https://github.com/johnpapa/node-hello/issues" - }, - "homepage": "https://github.com/johnpapa/node-hello#readme" -} From 44ff8a09b24e15b72f0a7f40d7e3c4fd644338fc Mon Sep 17 00:00:00 2001 From: Laljanibasha Shaik <98688990+laljohnny@users.noreply.github.com> Date: Mon, 8 Dec 2025 17:38:59 +0530 Subject: [PATCH 14/25] Delete package-lock.json --- package-lock.json | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 package-lock.json diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 61b5dcb84..000000000 --- a/package-lock.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "papa-node-hello-000", - "version": "1.0.0", - "lockfileVersion": 1 -} From 12e36d6a260c1b9bce5175b4357e2467cf92509f Mon Sep 17 00:00:00 2001 From: Laljanibasha Shaik <98688990+laljohnny@users.noreply.github.com> Date: Mon, 8 Dec 2025 17:39:18 +0530 Subject: [PATCH 15/25] Delete index.js --- index.js | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 index.js diff --git a/index.js b/index.js deleted file mode 100644 index c6ee820d1..000000000 --- a/index.js +++ /dev/null @@ -1,12 +0,0 @@ -const http = require('http'); -const port = process.env.PORT || 3000; - -const server = http.createServer((req, res) => { - res.statusCode = 200; - const msg = 'Hello Node Application!\n' - res.end(msg); -}); - -server.listen(port, () => { - console.log(`Server running on http://localhost:${port}/`); -}); From 334cdc0ae8b79ab6b915e1a6205a9afe217745c4 Mon Sep 17 00:00:00 2001 From: Laljanibasha Shaik <98688990+laljohnny@users.noreply.github.com> Date: Mon, 8 Dec 2025 17:39:33 +0530 Subject: [PATCH 16/25] Delete .prettierrc --- .prettierrc | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 .prettierrc diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 3f8d2998f..000000000 --- a/.prettierrc +++ /dev/null @@ -1,8 +0,0 @@ -{ - "bracketSpacing": true, - "printWidth": 80, - "singleQuote": true, - "tabWidth": 2, - "trailingComma": "all", - "useTabs": false -} From 50acbce6e19c7ba88ed43ceed3e9a62521049cc5 Mon Sep 17 00:00:00 2001 From: Laljanibasha Shaik <98688990+laljohnny@users.noreply.github.com> Date: Mon, 8 Dec 2025 17:39:47 +0530 Subject: [PATCH 17/25] Delete .gitignore --- .gitignore | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .gitignore diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 496ee2ca6..000000000 --- a/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.DS_Store \ No newline at end of file From 895a546134a20e6ede43ce1c591f24c9ae7796d9 Mon Sep 17 00:00:00 2001 From: Laljanibasha Shaik <98688990+laljohnny@users.noreply.github.com> Date: Mon, 8 Dec 2025 17:40:10 +0530 Subject: [PATCH 18/25] Delete .vscode directory --- .vscode/settings.json | 24 ------------------------ 1 file changed, 24 deletions(-) delete mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 33717dc11..000000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "workbench.colorCustomizations": { - "activityBar.activeBackground": "#dd894d", - "activityBar.activeBorder": "#aff0ca", - "activityBar.background": "#dd894d", - "activityBar.foreground": "#15202b", - "activityBar.inactiveForeground": "#15202b99", - "activityBarBadge.background": "#aff0ca", - "activityBarBadge.foreground": "#15202b", - "editorGroup.border": "#dd894d", - "panel.border": "#dd894d", - "sideBar.border": "#dd894d", - "statusBar.background": "#cf6d28", - "statusBar.border": "#cf6d28", - "statusBar.foreground": "#15202b", - "statusBarItem.hoverBackground": "#a45620", - "titleBar.activeBackground": "#cf6d28", - "titleBar.activeForeground": "#15202b", - "titleBar.border": "#cf6d28", - "titleBar.inactiveBackground": "#cf6d2899", - "titleBar.inactiveForeground": "#15202b99" - }, - "peacock.color": "#cf6d28" -} \ No newline at end of file From 6d93ead90997997b427409a5aae5985b6a9724e0 Mon Sep 17 00:00:00 2001 From: Laljanibasha Shaik <98688990+laljohnny@users.noreply.github.com> Date: Mon, 8 Dec 2025 17:40:51 +0530 Subject: [PATCH 19/25] Add files via upload --- .gitignore | 25 ++++++++++++++++++ README.md | 47 +++++++++++++++++++++++++++++---- package.json | 72 +++++++++++++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 26 +++++++++++++++++++ 4 files changed, 165 insertions(+), 5 deletions(-) create mode 100644 .gitignore create mode 100644 package.json create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..0baaf0cc9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* +react-build.tar.gz +package-lock.json diff --git a/README.md b/README.md index b84b3924e..b87cb0044 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,46 @@ -# Node Hello World +# Getting Started with Create React App -Simple node.js app that servers "hello world" +This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). -Great for testing simple deployments to the cloud +## Available Scripts -## Run It +In the project directory, you can run: -`npm start` +### `npm start` + +Runs the app in the development mode.\ +Open [http://localhost:3000](http://localhost:3000) to view it in the browser. + +The page will reload if you make edits.\ +You will also see any lint errors in the console. + +### `npm test` + +Launches the test runner in the interactive watch mode.\ +See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. + +### `npm run build` + +Builds the app for production to the `build` folder.\ +It correctly bundles React in production mode and optimizes the build for the best performance. + +The build is minified and the filenames include the hashes.\ +Your app is ready to be deployed! + +See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. + +### `npm run eject` + +**Note: this is a one-way operation. Once you `eject`, you can’t go back!** + +If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. + +Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. + +You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. + +## Learn More + +You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). + +To learn React, check out the [React documentation](https://reactjs.org/). diff --git a/package.json b/package.json new file mode 100644 index 000000000..e15804ef5 --- /dev/null +++ b/package.json @@ -0,0 +1,72 @@ +{ + "name": "poc-criticalasset", + "version": "0.1.0", + "private": true, + "dependencies": { + "@apollo/client": "^4.0.9", + "@chakra-ui/icons": "^2.2.4", + "@chakra-ui/react": "^2.10.9", + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@hello-pangea/dnd": "^18.0.1", + "@hookform/resolvers": "^5.2.2", + "@mui/icons-material": "^5.18.0", + "@mui/material": "^5.18.0", + "@mui/x-data-grid": "^6.17.0", + "@mui/x-date-pickers": "^5.0.19", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.7.0", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^13.5.0", + "@types/jest": "^27.5.2", + "@types/node": "^16.18.126", + "@types/react": "^19.1.10", + "@types/react-dom": "^19.1.7", + "axios": "^1.11.0", + "bootstrap": "^5.3.8", + "dayjs": "^1.11.13", + "flowise-embed-react": "^3.0.5", + "graphql": "^16.12.0", + "lucide-react": "^0.542.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-hook-form": "^7.62.0", + "react-icons": "^5.5.0", + "react-router-dom": "^6.26.1", + "react-scripts": "5.0.1", + "recharts": "^2.15.4", + "@types/react-color": "^3.0.13", + "react-color": "^2.19.3", + "tinycolor2": "^1.6.0", + "web-vitals": "^2.1.4", + "yup": "^1.7.0" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "@types/recharts": "^1.8.29", + "typescript": "^4.9.5" + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..cf722c24e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + }, + "include": [ + "src" + ] +} From 419796d5d7a98c9dab1d308b1df92c5680170d56 Mon Sep 17 00:00:00 2001 From: Laljani Basha Shaik Date: Mon, 8 Dec 2025 17:45:44 +0530 Subject: [PATCH 20/25] add-file --- public/executive_team_json_payloads.json | 643 +++++++++ public/favicon.ico | Bin 0 -> 1150 bytes public/favicon1.ico | Bin 0 -> 1150 bytes public/images/alert.svg | 1 + public/images/asset.svg | 1 + public/images/bot.svg | 15 + public/images/chat-bot-1.svg | 1 + public/images/chat-bot.svg | 1 + public/images/emptyData.svg | 1 + public/images/error.svg | 1 + public/images/floor.svg | 3 + public/images/gear.svg | 1 + public/images/google.svg | 1 + public/images/location.svg | 1 + public/images/logo-white.png | Bin 0 -> 25361 bytes public/images/robot.svg | 1 + public/images/room.svg | 26 + public/images/trend.svg | 1 + public/images/user.svg | 5 + public/images/whitelogo.svg | 31 + public/images/zone.svg | 2 + public/index.html | 49 + public/manifest.json | 25 + public/property_manager_json_payloads.json | 366 +++++ public/robots.txt | 3 + src/App.css | 0 src/App.test.tsx | 9 + src/App.tsx | 389 +++++ src/components/AssetPerformance.tsx | 375 +++++ src/components/Cards/ActionItemsCard.tsx | 68 + .../Cards/ContractorActivityCard.tsx | 70 + src/components/Cards/FinancialPulseCard.tsx | 72 + src/components/Cards/KeyPerformanceCard.tsx | 47 + src/components/Cards/TenantImpactCard.tsx | 70 + src/components/CenterModal.tsx | 27 + src/components/Chatbot.tsx | 223 +++ src/components/CrisisCheck.tsx | 288 ++++ src/components/DashboardCard.tsx | 53 + src/components/GreetingBar.tsx | 73 + src/components/Layout.tsx | 229 +++ src/components/MainMenu.tsx | 635 +++++++++ src/components/MiniSidebar.tsx | 184 +++ src/components/ModalContent/AssetModal.tsx | 150 ++ src/components/ModalContent/FloorModal.tsx | 141 ++ src/components/ModalContent/LocationModal.tsx | 235 +++ src/components/ModalContent/RoomModal.tsx | 110 ++ src/components/ModalContent/ZoneModal.tsx | 127 ++ src/components/PageWithTitle.tsx | 19 + src/components/ProtectedRoute.tsx | 18 + src/components/PublicRoute.tsx | 19 + src/components/RightSideModal.tsx | 99 ++ src/components/RoleProtectedRoute.tsx | 34 + src/components/SideNav.tsx | 202 +++ src/components/aiChatbox.tsx | 159 +++ src/components/api.tsx | 39 + src/components/assetIcons/AssetIcon.tsx | 104 ++ src/components/assetIcons/AssetIconField.tsx | 127 ++ src/components/assetIcons/ColorPicker.tsx | 120 ++ src/components/assetIcons/IconPicker.tsx | 170 +++ src/components/assetIcons/TypePicker.tsx | 75 + src/components/assetIcons/index.ts | 6 + src/components/icons/add.tsx | 13 + src/components/icons/addCircle.tsx | 11 + src/components/icons/addMultiple.tsx | 10 + src/components/icons/address.tsx | 25 + src/components/icons/admin.tsx | 16 + src/components/icons/ai.tsx | 13 + src/components/icons/americanExpressCard.tsx | 28 + src/components/icons/asset.tsx | 11 + src/components/icons/assetCategories.tsx | 15 + src/components/icons/assets.tsx | 10 + src/components/icons/assets/ahu1.tsx | 24 + src/components/icons/assets/ahu2.tsx | 28 + .../icons/assets/airAdmittanceValve.tsx | 42 + src/components/icons/assets/airCompressor.tsx | 26 + .../icons/assets/backflowPreventer.tsx | 26 + src/components/icons/assets/battery.tsx | 30 + .../icons/assets/batteryInverter.tsx | 36 + src/components/icons/assets/cabinet.tsx | 30 + src/components/icons/assets/cableTray.tsx | 32 + .../icons/assets/carbonDioxideSensor.tsx | 38 + .../icons/assets/carbonMonoxideDetector.tsx | 28 + src/components/icons/assets/cardReader.tsx | 30 + src/components/icons/assets/ceilingTiles.tsx | 28 + .../icons/assets/chillerAirCooled.tsx | 34 + .../icons/assets/chillerWaterCooled.tsx | 34 + src/components/icons/assets/clock.tsx | 36 + src/components/icons/assets/computer.tsx | 38 + src/components/icons/assets/controlDamper.tsx | 52 + src/components/icons/assets/coolingTower.tsx | 38 + .../icons/assets/dataReceptacle.tsx | 32 + src/components/icons/assets/defibrillator.tsx | 30 + .../icons/assets/defibrillator2.tsx | 30 + src/components/icons/assets/dehumidifier.tsx | 42 + .../icons/assets/desktopComputer.tsx | 40 + src/components/icons/assets/dimmingSwitch.tsx | 43 + .../icons/assets/disconnectSwitch.tsx | 34 + .../icons/assets/disconnectSwitch2.tsx | 28 + src/components/icons/assets/disposal.tsx | 61 + src/components/icons/assets/door.tsx | 32 + src/components/icons/assets/doorContact.tsx | 45 + .../icons/assets/drinkingFountain.tsx | 26 + .../icons/assets/duckworkSheetmetal.tsx | 26 + .../icons/assets/ductworkInsulation.tsx | 30 + src/components/icons/assets/electrical.tsx | 26 + .../icons/assets/electricalPanel.tsx | 34 + src/components/icons/assets/elevator1.tsx | 30 + src/components/icons/assets/elevator2.tsx | 30 + src/components/icons/assets/eolResistor.tsx | 26 + src/components/icons/assets/eolResistor2.tsx | 26 + src/components/icons/assets/escalator.tsx | 38 + src/components/icons/assets/exhaustFan.tsx | 30 + src/components/icons/assets/expansionTank.tsx | 29 + src/components/icons/assets/eyeWash.tsx | 30 + src/components/icons/assets/fanCoilUnit.tsx | 38 + src/components/icons/assets/faucet.tsx | 30 + src/components/icons/assets/fence.tsx | 26 + src/components/icons/assets/fireAlarm.tsx | 39 + .../icons/assets/fireAlarmCommunicator.tsx | 46 + .../icons/assets/fireExtinguisher1.tsx | 26 + .../icons/assets/fireExtinguisher2.tsx | 26 + src/components/icons/assets/fireJBox.tsx | 42 + src/components/icons/assets/fireSafety.tsx | 27 + src/components/icons/assets/floorDrain.tsx | 40 + src/components/icons/assets/floorSink.tsx | 26 + src/components/icons/assets/fuse.tsx | 26 + src/components/icons/assets/gasMeter.tsx | 34 + src/components/icons/assets/generator.tsx | 30 + src/components/icons/assets/greaseTrap.tsx | 38 + .../icons/assets/groundingCabinet.tsx | 30 + src/components/icons/assets/gutter.tsx | 29 + .../icons/assets/heaterBaseboard.tsx | 26 + .../icons/assets/heaterElectric.tsx | 26 + .../icons/assets/heaterInfrared.tsx | 30 + src/components/icons/assets/hoseBibb.tsx | 26 + src/components/icons/assets/hubDrain.tsx | 26 + src/components/icons/assets/humidifier.tsx | 30 + .../icons/assets/humiditySensor.tsx | 42 + src/components/icons/assets/hvac.tsx | 34 + src/components/icons/assets/index.ts | 317 +++++ .../icons/assets/initiatingDevice1.tsx | 62 + .../icons/assets/initiatingDevice2.tsx | 61 + .../icons/assets/irrigationSystem.tsx | 26 + .../icons/assets/irrigationZoneValves.tsx | 26 + src/components/icons/assets/jBox.tsx | 29 + src/components/icons/assets/jBox2.tsx | 34 + src/components/icons/assets/kitchenHood.tsx | 26 + src/components/icons/assets/landscape.tsx | 28 + src/components/icons/assets/laptop.tsx | 26 + .../icons/assets/lcdAnnunciator.tsx | 62 + .../icons/assets/lightEmergency1.tsx | 26 + .../icons/assets/lightEmergency2.tsx | 34 + src/components/icons/assets/lightExit.tsx | 32 + src/components/icons/assets/lightFixture.tsx | 26 + .../icons/assets/lightFluorescent.tsx | 27 + src/components/icons/assets/lightLed.tsx | 30 + src/components/icons/assets/lightSwitch.tsx | 34 + .../icons/assets/mainDistributionPanel.tsx | 94 ++ src/components/icons/assets/mechanical.tsx | 37 + src/components/icons/assets/meter.tsx | 28 + src/components/icons/assets/mopSink.tsx | 26 + .../icons/assets/motionDetector.tsx | 34 + src/components/icons/assets/motor.tsx | 48 + src/components/icons/assets/mowerLawn.tsx | 30 + src/components/icons/assets/mowerRiding.tsx | 30 + src/components/icons/assets/mri1.tsx | 30 + .../icons/assets/notificationAppliance1.tsx | 56 + .../icons/assets/notificationAppliance2.tsx | 41 + .../icons/assets/occupancySensor.tsx | 50 + src/components/icons/assets/other1.tsx | 30 + src/components/icons/assets/other2.tsx | 50 + src/components/icons/assets/parkingLight.tsx | 28 + src/components/icons/assets/photoSensor.tsx | 35 + src/components/icons/assets/pipeHanger.tsx | 56 + src/components/icons/assets/plug.tsx | 34 + src/components/icons/assets/plumbing.tsx | 34 + .../icons/assets/portableGenerator.tsx | 39 + src/components/icons/assets/printer.tsx | 34 + src/components/icons/assets/projector.tsx | 30 + src/components/icons/assets/pumps.tsx | 27 + .../icons/assets/refrigerantCopperPiping.tsx | 28 + src/components/icons/assets/register.tsx | 68 + src/components/icons/assets/rooftopUnit.tsx | 36 + src/components/icons/assets/room.tsx | 34 + src/components/icons/assets/security.tsx | 30 + .../icons/assets/securityCamera.tsx | 26 + .../icons/assets/securityControlPanel.tsx | 30 + src/components/icons/assets/serverRack.tsx | 30 + src/components/icons/assets/showerTub.tsx | 28 + src/components/icons/assets/sink.tsx | 26 + src/components/icons/assets/smartPhone.tsx | 34 + src/components/icons/assets/solarPanel.tsx | 32 + .../icons/assets/solarPanelBackupBaattery.tsx | 28 + .../icons/assets/solarPanelInverter.tsx | 28 + src/components/icons/assets/splitDxUnit.tsx | 30 + src/components/icons/assets/sprinkler.tsx | 26 + src/components/icons/assets/subMeter.tsx | 61 + src/components/icons/assets/tablet.tsx | 30 + .../icons/assets/temperatureSensor.tsx | 34 + src/components/icons/assets/thermostat.tsx | 144 ++ .../icons/assets/thermostaticMixingValve.tsx | 26 + src/components/icons/assets/toilet.tsx | 42 + .../icons/assets/transferSwitch.tsx | 26 + src/components/icons/assets/transformer1.tsx | 36 + src/components/icons/assets/transformer2.tsx | 39 + src/components/icons/assets/transformer3.tsx | 48 + .../icons/assets/transmissionTower.tsx | 28 + src/components/icons/assets/transponder.tsx | 42 + src/components/icons/assets/urinal.tsx | 34 + src/components/icons/assets/valve.tsx | 32 + src/components/icons/assets/vavBox.tsx | 42 + src/components/icons/assets/vrfIndoorUnit.tsx | 34 + .../icons/assets/vrfOutdoorHeatPump.tsx | 26 + src/components/icons/assets/wallHydrant.tsx | 51 + src/components/icons/assets/waterBoiler.tsx | 29 + .../icons/assets/waterFlowStation.tsx | 26 + src/components/icons/assets/waterHeater.tsx | 34 + src/components/icons/assets/waterMeter.tsx | 28 + .../icons/assets/waterSourceHeatPump.tsx | 31 + src/components/icons/assets/window.tsx | 24 + .../icons/assets/wirelessRouter.tsx | 38 + .../icons/assets/wiringElectrical.tsx | 26 + .../icons/assets/wiringLowVoltage.tsx | 30 + src/components/icons/assets/xray1.tsx | 44 + src/components/icons/assets/xray2.tsx | 24 + src/components/icons/authentication.tsx | 15 + src/components/icons/bankAccount.tsx | 16 + src/components/icons/bell.tsx | 14 + src/components/icons/billing.tsx | 14 + src/components/icons/briefcase.tsx | 17 + src/components/icons/call.tsx | 32 + src/components/icons/callHover.tsx | 32 + src/components/icons/close.tsx | 31 + src/components/icons/commonCreditCard.tsx | 20 + src/components/icons/crown.tsx | 20 + src/components/icons/cup.tsx | 27 + src/components/icons/dashboard.tsx | 21 + src/components/icons/download.tsx | 14 + src/components/icons/edit.tsx | 14 + src/components/icons/exclamation.tsx | 20 + src/components/icons/feedback.tsx | 30 + src/components/icons/feedbackHover.tsx | 29 + src/components/icons/files.tsx | 15 + src/components/icons/flag.tsx | 16 + src/components/icons/floor.tsx | 10 + src/components/icons/folder.tsx | 49 + src/components/icons/help.tsx | 14 + src/components/icons/invite.tsx | 14 + src/components/icons/listView.tsx | 9 + src/components/icons/location.tsx | 10 + src/components/icons/locations.tsx | 10 + src/components/icons/lock.tsx | 14 + src/components/icons/logo.tsx | 83 ++ src/components/icons/logoDarkBg.tsx | 74 + src/components/icons/logoIcon.tsx | 19 + src/components/icons/logoIconDarkBg.tsx | 19 + src/components/icons/logoSquare.tsx | 33 + src/components/icons/logoSquareDarkBg.tsx | 23 + src/components/icons/manager.tsx | 16 + src/components/icons/masterCard.tsx | 32 + src/components/icons/menu.tsx | 19 + src/components/icons/moreV.tsx | 14 + src/components/icons/mySettings.tsx | 14 + src/components/icons/newFolder.tsx | 77 + src/components/icons/noCircle.tsx | 20 + src/components/icons/noResource.tsx | 49 + src/components/icons/offlineIcon.tsx | 9 + src/components/icons/onBoard.tsx | 13 + src/components/icons/pageNotFound.tsx | 98 ++ src/components/icons/phone.tsx | 14 + src/components/icons/planIcon.tsx | 31 + src/components/icons/resend.tsx | 17 + src/components/icons/rocket.tsx | 14 + src/components/icons/rooms.tsx | 10 + src/components/icons/search.tsx | 16 + src/components/icons/searchArticles.tsx | 42 + src/components/icons/searchArticlesHover.tsx | 42 + src/components/icons/settings.tsx | 10 + src/components/icons/signOut.tsx | 14 + src/components/icons/starShip.tsx | 16 + src/components/icons/submitTicket.tsx | 43 + src/components/icons/submitTicketHover.tsx | 43 + src/components/icons/supportAgent.tsx | 36 + src/components/icons/supportAgentHover.tsx | 32 + src/components/icons/team.tsx | 10 + src/components/icons/tickets.tsx | 36 + src/components/icons/ticketsHover.tsx | 36 + src/components/icons/tilesView.tsx | 13 + src/components/icons/toggleNav.tsx | 47 + src/components/icons/unlimited.tsx | 20 + src/components/icons/user.tsx | 20 + src/components/icons/visa.tsx | 45 + src/components/icons/yesCircle.tsx | 22 + src/components/icons/zone.tsx | 10 + src/components/toastService.tsx | 29 + src/context/AuthContext.tsx | 68 + src/context/LoaderContext.tsx | 35 + src/global.d.ts | 7 + src/graphql/apolloClient.ts | 96 ++ src/graphql/mutations.ts | 1107 +++++++++++++++ src/graphql/queries.ts | 886 ++++++++++++ src/index.css | 498 +++++++ src/index.tsx | 20 + src/logo.svg | 1 + src/pages/Forget-Password.tsx | 256 ++++ src/pages/Login.tsx | 306 ++++ src/pages/Reset-password.tsx | 346 +++++ src/pages/Signup.tsx | 500 +++++++ .../accept-invitation/AcceptInvitation.tsx | 357 +++++ src/pages/ai-addons/AiAddonModal.tsx | 314 ++++ src/pages/ai-addons/AiAddons.tsx | 257 ++++ src/pages/company/Company.tsx | 449 ++++++ src/pages/company/CompanyModal.tsx | 461 ++++++ src/pages/company/EditCompanyModal.tsx | 455 ++++++ src/pages/dashboard/Dashboard.tsx | 157 ++ src/pages/dashboard/ExecutiveDashboard.tsx | 66 + src/pages/dashboard/MainDashboard.tsx | 216 +++ src/pages/dashboard/Property_Manager.json | 394 ++++++ src/pages/dashboard/WorkOrders.tsx | 458 ++++++ .../AIDrivenStrategicInsightsPanel.tsx | 576 ++++++++ .../FinancialHealth.tsx | 1079 ++++++++++++++ .../OperationalEfficiencyPanel.tsx | 351 +++++ .../StrategicKPIPerformancePanel.tsx | 459 ++++++ .../SustainabilityESGAccordion.tsx | 190 +++ src/pages/gallery/Gallery.tsx | 631 +++++++++ src/pages/gallery/index.ts | 2 + src/pages/master-data/MasterData.tsx | 223 +++ src/pages/master-data/MasterDataModal.tsx | 288 ++++ .../assetCategory/AssetCategory.tsx | 295 ++++ .../assetCategory/AssetCategoryModal.tsx | 304 ++++ src/pages/masterData/assetType/AssetType.tsx | 343 +++++ .../masterData/assetType/AssetTypeModal.tsx | 355 +++++ .../masterData/assetfields/AssetFields.tsx | 336 +++++ .../assetfields/AssetFieldsForm.tsx | 480 +++++++ .../assetpartfields/AssetPartFields.tsx | 336 +++++ .../assetpartfields/AssetPartFieldsForm.tsx | 478 +++++++ .../masterData/assetparts/AssetPartModal.tsx | 259 ++++ .../masterData/assetparts/AssetParts.tsx | 357 +++++ .../assignmentType/AssignmentType.tsx | 349 +++++ .../assignmentType/AssignmentTypeModal.tsx | 238 ++++ .../masterData/manufacturer/Manufacturer.tsx | 354 +++++ .../manufacturer/ManufacturerModal.tsx | 289 ++++ .../masterData/serviceType/ServiceType.tsx | 366 +++++ .../serviceType/ServiceTypeModal.tsx | 258 ++++ .../serviecCategory/ServiceCategory.tsx | 281 ++++ .../serviecCategory/ServiceCategoryModal.tsx | 181 +++ src/pages/masterData/vendor/Vendor.tsx | 336 +++++ src/pages/masterData/vendor/VendorModal.tsx | 357 +++++ .../workOrderType/WorkOrderModal1.tsx | 11 + .../workOrderType/WorkOrderType.tsx | 326 +++++ .../workOrderType/WorkOrderTypeModal.tsx | 179 +++ .../workorderStage/WorkOrderStage.tsx | 268 ++++ .../workorderStage/WorkOrderStageModal.tsx | 171 +++ src/pages/onBoard/Onboard.tsx | 1204 ++++++++++++++++ src/pages/onBoard/onboardData.js | 1257 +++++++++++++++++ src/pages/partners/EditPartnerModal.tsx | 219 +++ src/pages/partners/PartnerModal.tsx | 388 +++++ src/pages/partners/Partners.tsx | 498 +++++++ src/pages/plans/PlanForm.tsx | 422 ++++++ src/pages/plans/Plans.tsx | 438 ++++++ src/pages/settings/Settings.tsx | 312 ++++ .../subscription/SubscriptionOverview.tsx | 370 +++++ .../subscription/SubscriptionPlanDialog.tsx | 444 ++++++ src/pages/subscription/subscriptionPlans.ts | 357 +++++ src/pages/team/Team.tsx | 1153 +++++++++++++++ src/pages/ticketing/TicketForm.tsx | 410 ++++++ src/pages/ticketing/TicketingBoard.tsx | 949 +++++++++++++ src/pages/ticketing/index.ts | 2 + src/react-app-env.d.ts | 1 + src/reportWebVitals.ts | 15 + src/setupTests.ts | 5 + src/themes/colors.ts | 22 + src/themes/muiTheme.ts | 362 +++++ src/themes/theme.tsx | 108 ++ src/tinycolor2.d.ts | 13 + 375 files changed, 41520 insertions(+) create mode 100644 public/executive_team_json_payloads.json create mode 100644 public/favicon.ico create mode 100644 public/favicon1.ico create mode 100644 public/images/alert.svg create mode 100644 public/images/asset.svg create mode 100644 public/images/bot.svg create mode 100644 public/images/chat-bot-1.svg create mode 100644 public/images/chat-bot.svg create mode 100644 public/images/emptyData.svg create mode 100644 public/images/error.svg create mode 100644 public/images/floor.svg create mode 100644 public/images/gear.svg create mode 100644 public/images/google.svg create mode 100644 public/images/location.svg create mode 100644 public/images/logo-white.png create mode 100644 public/images/robot.svg create mode 100644 public/images/room.svg create mode 100644 public/images/trend.svg create mode 100644 public/images/user.svg create mode 100644 public/images/whitelogo.svg create mode 100644 public/images/zone.svg create mode 100644 public/index.html create mode 100644 public/manifest.json create mode 100644 public/property_manager_json_payloads.json create mode 100644 public/robots.txt create mode 100644 src/App.css create mode 100644 src/App.test.tsx create mode 100644 src/App.tsx create mode 100644 src/components/AssetPerformance.tsx create mode 100644 src/components/Cards/ActionItemsCard.tsx create mode 100644 src/components/Cards/ContractorActivityCard.tsx create mode 100644 src/components/Cards/FinancialPulseCard.tsx create mode 100644 src/components/Cards/KeyPerformanceCard.tsx create mode 100644 src/components/Cards/TenantImpactCard.tsx create mode 100644 src/components/CenterModal.tsx create mode 100644 src/components/Chatbot.tsx create mode 100644 src/components/CrisisCheck.tsx create mode 100644 src/components/DashboardCard.tsx create mode 100644 src/components/GreetingBar.tsx create mode 100644 src/components/Layout.tsx create mode 100644 src/components/MainMenu.tsx create mode 100644 src/components/MiniSidebar.tsx create mode 100644 src/components/ModalContent/AssetModal.tsx create mode 100644 src/components/ModalContent/FloorModal.tsx create mode 100644 src/components/ModalContent/LocationModal.tsx create mode 100644 src/components/ModalContent/RoomModal.tsx create mode 100644 src/components/ModalContent/ZoneModal.tsx create mode 100644 src/components/PageWithTitle.tsx create mode 100644 src/components/ProtectedRoute.tsx create mode 100644 src/components/PublicRoute.tsx create mode 100644 src/components/RightSideModal.tsx create mode 100644 src/components/RoleProtectedRoute.tsx create mode 100644 src/components/SideNav.tsx create mode 100644 src/components/aiChatbox.tsx create mode 100644 src/components/api.tsx create mode 100644 src/components/assetIcons/AssetIcon.tsx create mode 100644 src/components/assetIcons/AssetIconField.tsx create mode 100644 src/components/assetIcons/ColorPicker.tsx create mode 100644 src/components/assetIcons/IconPicker.tsx create mode 100644 src/components/assetIcons/TypePicker.tsx create mode 100644 src/components/assetIcons/index.ts create mode 100644 src/components/icons/add.tsx create mode 100644 src/components/icons/addCircle.tsx create mode 100644 src/components/icons/addMultiple.tsx create mode 100644 src/components/icons/address.tsx create mode 100644 src/components/icons/admin.tsx create mode 100644 src/components/icons/ai.tsx create mode 100644 src/components/icons/americanExpressCard.tsx create mode 100644 src/components/icons/asset.tsx create mode 100644 src/components/icons/assetCategories.tsx create mode 100644 src/components/icons/assets.tsx create mode 100644 src/components/icons/assets/ahu1.tsx create mode 100644 src/components/icons/assets/ahu2.tsx create mode 100644 src/components/icons/assets/airAdmittanceValve.tsx create mode 100644 src/components/icons/assets/airCompressor.tsx create mode 100644 src/components/icons/assets/backflowPreventer.tsx create mode 100644 src/components/icons/assets/battery.tsx create mode 100644 src/components/icons/assets/batteryInverter.tsx create mode 100644 src/components/icons/assets/cabinet.tsx create mode 100644 src/components/icons/assets/cableTray.tsx create mode 100644 src/components/icons/assets/carbonDioxideSensor.tsx create mode 100644 src/components/icons/assets/carbonMonoxideDetector.tsx create mode 100644 src/components/icons/assets/cardReader.tsx create mode 100644 src/components/icons/assets/ceilingTiles.tsx create mode 100644 src/components/icons/assets/chillerAirCooled.tsx create mode 100644 src/components/icons/assets/chillerWaterCooled.tsx create mode 100644 src/components/icons/assets/clock.tsx create mode 100644 src/components/icons/assets/computer.tsx create mode 100644 src/components/icons/assets/controlDamper.tsx create mode 100644 src/components/icons/assets/coolingTower.tsx create mode 100644 src/components/icons/assets/dataReceptacle.tsx create mode 100644 src/components/icons/assets/defibrillator.tsx create mode 100644 src/components/icons/assets/defibrillator2.tsx create mode 100644 src/components/icons/assets/dehumidifier.tsx create mode 100644 src/components/icons/assets/desktopComputer.tsx create mode 100644 src/components/icons/assets/dimmingSwitch.tsx create mode 100644 src/components/icons/assets/disconnectSwitch.tsx create mode 100644 src/components/icons/assets/disconnectSwitch2.tsx create mode 100644 src/components/icons/assets/disposal.tsx create mode 100644 src/components/icons/assets/door.tsx create mode 100644 src/components/icons/assets/doorContact.tsx create mode 100644 src/components/icons/assets/drinkingFountain.tsx create mode 100644 src/components/icons/assets/duckworkSheetmetal.tsx create mode 100644 src/components/icons/assets/ductworkInsulation.tsx create mode 100644 src/components/icons/assets/electrical.tsx create mode 100644 src/components/icons/assets/electricalPanel.tsx create mode 100644 src/components/icons/assets/elevator1.tsx create mode 100644 src/components/icons/assets/elevator2.tsx create mode 100644 src/components/icons/assets/eolResistor.tsx create mode 100644 src/components/icons/assets/eolResistor2.tsx create mode 100644 src/components/icons/assets/escalator.tsx create mode 100644 src/components/icons/assets/exhaustFan.tsx create mode 100644 src/components/icons/assets/expansionTank.tsx create mode 100644 src/components/icons/assets/eyeWash.tsx create mode 100644 src/components/icons/assets/fanCoilUnit.tsx create mode 100644 src/components/icons/assets/faucet.tsx create mode 100644 src/components/icons/assets/fence.tsx create mode 100644 src/components/icons/assets/fireAlarm.tsx create mode 100644 src/components/icons/assets/fireAlarmCommunicator.tsx create mode 100644 src/components/icons/assets/fireExtinguisher1.tsx create mode 100644 src/components/icons/assets/fireExtinguisher2.tsx create mode 100644 src/components/icons/assets/fireJBox.tsx create mode 100644 src/components/icons/assets/fireSafety.tsx create mode 100644 src/components/icons/assets/floorDrain.tsx create mode 100644 src/components/icons/assets/floorSink.tsx create mode 100644 src/components/icons/assets/fuse.tsx create mode 100644 src/components/icons/assets/gasMeter.tsx create mode 100644 src/components/icons/assets/generator.tsx create mode 100644 src/components/icons/assets/greaseTrap.tsx create mode 100644 src/components/icons/assets/groundingCabinet.tsx create mode 100644 src/components/icons/assets/gutter.tsx create mode 100644 src/components/icons/assets/heaterBaseboard.tsx create mode 100644 src/components/icons/assets/heaterElectric.tsx create mode 100644 src/components/icons/assets/heaterInfrared.tsx create mode 100644 src/components/icons/assets/hoseBibb.tsx create mode 100644 src/components/icons/assets/hubDrain.tsx create mode 100644 src/components/icons/assets/humidifier.tsx create mode 100644 src/components/icons/assets/humiditySensor.tsx create mode 100644 src/components/icons/assets/hvac.tsx create mode 100644 src/components/icons/assets/index.ts create mode 100644 src/components/icons/assets/initiatingDevice1.tsx create mode 100644 src/components/icons/assets/initiatingDevice2.tsx create mode 100644 src/components/icons/assets/irrigationSystem.tsx create mode 100644 src/components/icons/assets/irrigationZoneValves.tsx create mode 100644 src/components/icons/assets/jBox.tsx create mode 100644 src/components/icons/assets/jBox2.tsx create mode 100644 src/components/icons/assets/kitchenHood.tsx create mode 100644 src/components/icons/assets/landscape.tsx create mode 100644 src/components/icons/assets/laptop.tsx create mode 100644 src/components/icons/assets/lcdAnnunciator.tsx create mode 100644 src/components/icons/assets/lightEmergency1.tsx create mode 100644 src/components/icons/assets/lightEmergency2.tsx create mode 100644 src/components/icons/assets/lightExit.tsx create mode 100644 src/components/icons/assets/lightFixture.tsx create mode 100644 src/components/icons/assets/lightFluorescent.tsx create mode 100644 src/components/icons/assets/lightLed.tsx create mode 100644 src/components/icons/assets/lightSwitch.tsx create mode 100644 src/components/icons/assets/mainDistributionPanel.tsx create mode 100644 src/components/icons/assets/mechanical.tsx create mode 100644 src/components/icons/assets/meter.tsx create mode 100644 src/components/icons/assets/mopSink.tsx create mode 100644 src/components/icons/assets/motionDetector.tsx create mode 100644 src/components/icons/assets/motor.tsx create mode 100644 src/components/icons/assets/mowerLawn.tsx create mode 100644 src/components/icons/assets/mowerRiding.tsx create mode 100644 src/components/icons/assets/mri1.tsx create mode 100644 src/components/icons/assets/notificationAppliance1.tsx create mode 100644 src/components/icons/assets/notificationAppliance2.tsx create mode 100644 src/components/icons/assets/occupancySensor.tsx create mode 100644 src/components/icons/assets/other1.tsx create mode 100644 src/components/icons/assets/other2.tsx create mode 100644 src/components/icons/assets/parkingLight.tsx create mode 100644 src/components/icons/assets/photoSensor.tsx create mode 100644 src/components/icons/assets/pipeHanger.tsx create mode 100644 src/components/icons/assets/plug.tsx create mode 100644 src/components/icons/assets/plumbing.tsx create mode 100644 src/components/icons/assets/portableGenerator.tsx create mode 100644 src/components/icons/assets/printer.tsx create mode 100644 src/components/icons/assets/projector.tsx create mode 100644 src/components/icons/assets/pumps.tsx create mode 100644 src/components/icons/assets/refrigerantCopperPiping.tsx create mode 100644 src/components/icons/assets/register.tsx create mode 100644 src/components/icons/assets/rooftopUnit.tsx create mode 100644 src/components/icons/assets/room.tsx create mode 100644 src/components/icons/assets/security.tsx create mode 100644 src/components/icons/assets/securityCamera.tsx create mode 100644 src/components/icons/assets/securityControlPanel.tsx create mode 100644 src/components/icons/assets/serverRack.tsx create mode 100644 src/components/icons/assets/showerTub.tsx create mode 100644 src/components/icons/assets/sink.tsx create mode 100644 src/components/icons/assets/smartPhone.tsx create mode 100644 src/components/icons/assets/solarPanel.tsx create mode 100644 src/components/icons/assets/solarPanelBackupBaattery.tsx create mode 100644 src/components/icons/assets/solarPanelInverter.tsx create mode 100644 src/components/icons/assets/splitDxUnit.tsx create mode 100644 src/components/icons/assets/sprinkler.tsx create mode 100644 src/components/icons/assets/subMeter.tsx create mode 100644 src/components/icons/assets/tablet.tsx create mode 100644 src/components/icons/assets/temperatureSensor.tsx create mode 100644 src/components/icons/assets/thermostat.tsx create mode 100644 src/components/icons/assets/thermostaticMixingValve.tsx create mode 100644 src/components/icons/assets/toilet.tsx create mode 100644 src/components/icons/assets/transferSwitch.tsx create mode 100644 src/components/icons/assets/transformer1.tsx create mode 100644 src/components/icons/assets/transformer2.tsx create mode 100644 src/components/icons/assets/transformer3.tsx create mode 100644 src/components/icons/assets/transmissionTower.tsx create mode 100644 src/components/icons/assets/transponder.tsx create mode 100644 src/components/icons/assets/urinal.tsx create mode 100644 src/components/icons/assets/valve.tsx create mode 100644 src/components/icons/assets/vavBox.tsx create mode 100644 src/components/icons/assets/vrfIndoorUnit.tsx create mode 100644 src/components/icons/assets/vrfOutdoorHeatPump.tsx create mode 100644 src/components/icons/assets/wallHydrant.tsx create mode 100644 src/components/icons/assets/waterBoiler.tsx create mode 100644 src/components/icons/assets/waterFlowStation.tsx create mode 100644 src/components/icons/assets/waterHeater.tsx create mode 100644 src/components/icons/assets/waterMeter.tsx create mode 100644 src/components/icons/assets/waterSourceHeatPump.tsx create mode 100644 src/components/icons/assets/window.tsx create mode 100644 src/components/icons/assets/wirelessRouter.tsx create mode 100644 src/components/icons/assets/wiringElectrical.tsx create mode 100644 src/components/icons/assets/wiringLowVoltage.tsx create mode 100644 src/components/icons/assets/xray1.tsx create mode 100644 src/components/icons/assets/xray2.tsx create mode 100644 src/components/icons/authentication.tsx create mode 100644 src/components/icons/bankAccount.tsx create mode 100644 src/components/icons/bell.tsx create mode 100644 src/components/icons/billing.tsx create mode 100644 src/components/icons/briefcase.tsx create mode 100644 src/components/icons/call.tsx create mode 100644 src/components/icons/callHover.tsx create mode 100644 src/components/icons/close.tsx create mode 100644 src/components/icons/commonCreditCard.tsx create mode 100644 src/components/icons/crown.tsx create mode 100644 src/components/icons/cup.tsx create mode 100644 src/components/icons/dashboard.tsx create mode 100644 src/components/icons/download.tsx create mode 100644 src/components/icons/edit.tsx create mode 100644 src/components/icons/exclamation.tsx create mode 100644 src/components/icons/feedback.tsx create mode 100644 src/components/icons/feedbackHover.tsx create mode 100644 src/components/icons/files.tsx create mode 100644 src/components/icons/flag.tsx create mode 100644 src/components/icons/floor.tsx create mode 100644 src/components/icons/folder.tsx create mode 100644 src/components/icons/help.tsx create mode 100644 src/components/icons/invite.tsx create mode 100644 src/components/icons/listView.tsx create mode 100644 src/components/icons/location.tsx create mode 100644 src/components/icons/locations.tsx create mode 100644 src/components/icons/lock.tsx create mode 100644 src/components/icons/logo.tsx create mode 100644 src/components/icons/logoDarkBg.tsx create mode 100644 src/components/icons/logoIcon.tsx create mode 100644 src/components/icons/logoIconDarkBg.tsx create mode 100644 src/components/icons/logoSquare.tsx create mode 100644 src/components/icons/logoSquareDarkBg.tsx create mode 100644 src/components/icons/manager.tsx create mode 100644 src/components/icons/masterCard.tsx create mode 100644 src/components/icons/menu.tsx create mode 100644 src/components/icons/moreV.tsx create mode 100644 src/components/icons/mySettings.tsx create mode 100644 src/components/icons/newFolder.tsx create mode 100644 src/components/icons/noCircle.tsx create mode 100644 src/components/icons/noResource.tsx create mode 100644 src/components/icons/offlineIcon.tsx create mode 100644 src/components/icons/onBoard.tsx create mode 100644 src/components/icons/pageNotFound.tsx create mode 100644 src/components/icons/phone.tsx create mode 100644 src/components/icons/planIcon.tsx create mode 100644 src/components/icons/resend.tsx create mode 100644 src/components/icons/rocket.tsx create mode 100644 src/components/icons/rooms.tsx create mode 100644 src/components/icons/search.tsx create mode 100644 src/components/icons/searchArticles.tsx create mode 100644 src/components/icons/searchArticlesHover.tsx create mode 100644 src/components/icons/settings.tsx create mode 100644 src/components/icons/signOut.tsx create mode 100644 src/components/icons/starShip.tsx create mode 100644 src/components/icons/submitTicket.tsx create mode 100644 src/components/icons/submitTicketHover.tsx create mode 100644 src/components/icons/supportAgent.tsx create mode 100644 src/components/icons/supportAgentHover.tsx create mode 100644 src/components/icons/team.tsx create mode 100644 src/components/icons/tickets.tsx create mode 100644 src/components/icons/ticketsHover.tsx create mode 100644 src/components/icons/tilesView.tsx create mode 100644 src/components/icons/toggleNav.tsx create mode 100644 src/components/icons/unlimited.tsx create mode 100644 src/components/icons/user.tsx create mode 100644 src/components/icons/visa.tsx create mode 100644 src/components/icons/yesCircle.tsx create mode 100644 src/components/icons/zone.tsx create mode 100644 src/components/toastService.tsx create mode 100644 src/context/AuthContext.tsx create mode 100644 src/context/LoaderContext.tsx create mode 100644 src/global.d.ts create mode 100644 src/graphql/apolloClient.ts create mode 100644 src/graphql/mutations.ts create mode 100644 src/graphql/queries.ts create mode 100644 src/index.css create mode 100644 src/index.tsx create mode 100644 src/logo.svg create mode 100644 src/pages/Forget-Password.tsx create mode 100644 src/pages/Login.tsx create mode 100644 src/pages/Reset-password.tsx create mode 100644 src/pages/Signup.tsx create mode 100644 src/pages/accept-invitation/AcceptInvitation.tsx create mode 100644 src/pages/ai-addons/AiAddonModal.tsx create mode 100644 src/pages/ai-addons/AiAddons.tsx create mode 100644 src/pages/company/Company.tsx create mode 100644 src/pages/company/CompanyModal.tsx create mode 100644 src/pages/company/EditCompanyModal.tsx create mode 100644 src/pages/dashboard/Dashboard.tsx create mode 100644 src/pages/dashboard/ExecutiveDashboard.tsx create mode 100644 src/pages/dashboard/MainDashboard.tsx create mode 100644 src/pages/dashboard/Property_Manager.json create mode 100644 src/pages/dashboard/WorkOrders.tsx create mode 100644 src/pages/dashboard/excutive-dashboard-components/AIDrivenStrategicInsightsPanel.tsx create mode 100644 src/pages/dashboard/excutive-dashboard-components/FinancialHealth.tsx create mode 100644 src/pages/dashboard/excutive-dashboard-components/OperationalEfficiencyPanel.tsx create mode 100644 src/pages/dashboard/excutive-dashboard-components/StrategicKPIPerformancePanel.tsx create mode 100644 src/pages/dashboard/excutive-dashboard-components/SustainabilityESGAccordion.tsx create mode 100644 src/pages/gallery/Gallery.tsx create mode 100644 src/pages/gallery/index.ts create mode 100644 src/pages/master-data/MasterData.tsx create mode 100644 src/pages/master-data/MasterDataModal.tsx create mode 100644 src/pages/masterData/assetCategory/AssetCategory.tsx create mode 100644 src/pages/masterData/assetCategory/AssetCategoryModal.tsx create mode 100644 src/pages/masterData/assetType/AssetType.tsx create mode 100644 src/pages/masterData/assetType/AssetTypeModal.tsx create mode 100644 src/pages/masterData/assetfields/AssetFields.tsx create mode 100644 src/pages/masterData/assetfields/AssetFieldsForm.tsx create mode 100644 src/pages/masterData/assetpartfields/AssetPartFields.tsx create mode 100644 src/pages/masterData/assetpartfields/AssetPartFieldsForm.tsx create mode 100644 src/pages/masterData/assetparts/AssetPartModal.tsx create mode 100644 src/pages/masterData/assetparts/AssetParts.tsx create mode 100644 src/pages/masterData/assignmentType/AssignmentType.tsx create mode 100644 src/pages/masterData/assignmentType/AssignmentTypeModal.tsx create mode 100644 src/pages/masterData/manufacturer/Manufacturer.tsx create mode 100644 src/pages/masterData/manufacturer/ManufacturerModal.tsx create mode 100644 src/pages/masterData/serviceType/ServiceType.tsx create mode 100644 src/pages/masterData/serviceType/ServiceTypeModal.tsx create mode 100644 src/pages/masterData/serviecCategory/ServiceCategory.tsx create mode 100644 src/pages/masterData/serviecCategory/ServiceCategoryModal.tsx create mode 100644 src/pages/masterData/vendor/Vendor.tsx create mode 100644 src/pages/masterData/vendor/VendorModal.tsx create mode 100644 src/pages/masterData/workOrderType/WorkOrderModal1.tsx create mode 100644 src/pages/masterData/workOrderType/WorkOrderType.tsx create mode 100644 src/pages/masterData/workOrderType/WorkOrderTypeModal.tsx create mode 100644 src/pages/masterData/workorderStage/WorkOrderStage.tsx create mode 100644 src/pages/masterData/workorderStage/WorkOrderStageModal.tsx create mode 100644 src/pages/onBoard/Onboard.tsx create mode 100644 src/pages/onBoard/onboardData.js create mode 100644 src/pages/partners/EditPartnerModal.tsx create mode 100644 src/pages/partners/PartnerModal.tsx create mode 100644 src/pages/partners/Partners.tsx create mode 100644 src/pages/plans/PlanForm.tsx create mode 100644 src/pages/plans/Plans.tsx create mode 100644 src/pages/settings/Settings.tsx create mode 100644 src/pages/subscription/SubscriptionOverview.tsx create mode 100644 src/pages/subscription/SubscriptionPlanDialog.tsx create mode 100644 src/pages/subscription/subscriptionPlans.ts create mode 100644 src/pages/team/Team.tsx create mode 100644 src/pages/ticketing/TicketForm.tsx create mode 100644 src/pages/ticketing/TicketingBoard.tsx create mode 100644 src/pages/ticketing/index.ts create mode 100644 src/react-app-env.d.ts create mode 100644 src/reportWebVitals.ts create mode 100644 src/setupTests.ts create mode 100644 src/themes/colors.ts create mode 100644 src/themes/muiTheme.ts create mode 100644 src/themes/theme.tsx create mode 100644 src/tinycolor2.d.ts diff --git a/public/executive_team_json_payloads.json b/public/executive_team_json_payloads.json new file mode 100644 index 000000000..91d456c4a --- /dev/null +++ b/public/executive_team_json_payloads.json @@ -0,0 +1,643 @@ +{ + "executive_dashboard": { + "user_context": { + "user_id": "USR-exec-001", + "tenant_id": "tenant_001", + "portfolio_scope": "ALL_PROPERTIES", + "timestamp": "2025-08-11T07:00:00Z", + "timezone": "America/New_York", + "reporting_period": "2025-08" + }, + + "panel_1_financial_health": { + "panel_id": "portfolio_financial_health_risk", + "title": "Portfolio Financial Health & Risk Exposure", + "priority": 1, + "refresh_interval_seconds": 3600, + "data": { + "real_time_performance": { + "portfolio_noi": { + "current": 2400000.00, + "budget": 2210000.00, + "variance_pct": 8.6, + "trend": "POSITIVE" + }, + "total_revenue": { + "current": 3800000.00, + "budget": 3650000.00, + "variance_pct": 4.1, + "trend": "POSITIVE" + }, + "operating_expenses": { + "current": 1400000.00, + "budget": 1440000.00, + "variance_pct": -2.8, + "trend": "FAVORABLE" + }, + "roi_percentage": 12.3, + "occupancy_rate_pct": 94.2, + "occupancy_change_pct": 2.1, + "rent_per_sqft": 45.50, + "market_performance_vs_benchmark_pct": 8.5 + }, + "budget_variance_analysis": { + "maintenance_costs": { + "actual": 485000.00, + "budget": 430000.00, + "variance_pct": 12.8, + "variance_amount": 55000.00, + "status": "OVER_BUDGET", + "explanation": "Emergency HVAC repairs and elevator maintenance" + }, + "energy_costs": { + "actual": 325000.00, + "budget": 298000.00, + "variance_pct": 9.1, + "variance_amount": 27000.00, + "status": "OVER_BUDGET", + "explanation": "Aging HVAC systems reducing efficiency" + }, + "capex_utilization": { + "utilized": 1650000.00, + "allocated": 2540000.00, + "utilization_pct": 65.0, + "remaining": 890000.00, + "status": "UNDER_UTILIZED" + }, + "vendor_costs": { + "actual": 275000.00, + "budget": 260000.00, + "variance_pct": 5.8, + "variance_amount": 15000.00, + "status": "SLIGHTLY_OVER" + } + }, + "risk_assessment": { + "portfolio_risk_score": 25, + "risk_category": "LOW", + "compliance_percentage": 94.0, + "active_violations": 0, + "audit_findings": 2, + "insurance_claims_ytd": 1, + "debt_service_coverage_ratio": 1.8, + "loan_to_value_ratio": 65.5, + "tenant_payment_risk_score": 15, + "market_risk_score": 35, + "lease_expiry_risk_12m_pct": 23.5 + }, + "investment_performance": { + "portfolio_value": 47200000.00, + "value_appreciation_ytd_pct": 3.8, + "cash_flow_monthly": 420000.00, + "cash_reserves": 2100000.00, + "days_cash_on_hand": 180, + "liquidity_ratio": 2.5, + "market_position": "TOP_QUARTILE", + "esg_score": 78, + "sustainability_investment": 245000.00 + }, + "ai_financial_insights": { + "cost_optimization_opportunities": [ + { + "opportunity": "LED lighting upgrade across portfolio", + "estimated_savings_annual": 156000.00, + "implementation_cost": 320000.00, + "roi_years": 2.1, + "confidence": 92 + }, + { + "opportunity": "HVAC system modernization", + "estimated_savings_annual": 285000.00, + "implementation_cost": 850000.00, + "roi_years": 3.0, + "confidence": 88 + } + ], + "risk_predictions": [ + { + "risk_type": "Maintenance Cost Escalation", + "probability_pct": 67, + "impact_estimate": 125000.00, + "mitigation": "Accelerate preventive maintenance program" + }, + { + "risk_type": "Energy Cost Volatility", + "probability_pct": 45, + "impact_estimate": 85000.00, + "mitigation": "Lock in energy contracts, improve efficiency" + } + ], + "market_opportunities": [ + { + "opportunity": "Rent optimization in Building A", + "revenue_potential_annual": 180000.00, + "market_rent_gap_pct": -5.2, + "implementation_timeline": "Q4 2025" + } + ] + } + } + }, + + "panel_2_strategic_kpis": { + "panel_id": "strategic_kpi_performance_market", + "title": "Strategic KPI Performance & Market Position", + "priority": 2, + "refresh_interval_seconds": 86400, + "data": { + "operational_efficiency": { + "portfolio_wide_metrics": { + "operating_expense_ratio": 36.8, + "industry_benchmark": 42.1, + "performance_vs_benchmark_pct": -12.6, + "rank_vs_peers": "TOP_DECILE" + }, + "maintenance_efficiency": { + "cost_per_sqft": 4.85, + "industry_average": 5.40, + "efficiency_score": 90, + "preventive_vs_reactive_ratio": 3.2 + }, + "energy_performance": { + "energy_cost_per_sqft": 3.25, + "portfolio_energy_efficiency": 87.5, + "carbon_footprint_reduction_ytd_pct": 8.3, + "energy_star_score_avg": 82 + } + }, + "tenant_satisfaction": { + "overall_satisfaction_score": 4.2, + "response_rate_pct": 78.5, + "net_promoter_score": 45, + "retention_rate_pct": 89.2, + "satisfaction_trend": "IMPROVING", + "key_satisfaction_drivers": [ + {"factor": "Maintenance Response Time", "score": 4.5}, + {"factor": "Building Cleanliness", "score": 4.3}, + {"factor": "HVAC Comfort", "score": 3.8}, + {"factor": "Security", "score": 4.4} + ] + }, + "market_positioning": { + "market_share_pct": 12.8, + "competitive_ranking": 3, + "rent_premium_vs_market_pct": 8.5, + "occupancy_vs_market": { + "portfolio_occupancy": 94.2, + "market_average": 87.6, + "advantage_pct": 6.6 + }, + "asset_quality_score": 88, + "brand_recognition_score": 72 + }, + "growth_metrics": { + "revenue_growth_ytd_pct": 6.8, + "noi_growth_ytd_pct": 8.6, + "same_store_growth_pct": 4.2, + "new_lease_spreads_pct": 12.3, + "renewal_spreads_pct": 4.7, + "development_pipeline_value": 15600000.00 + }, + "board_presentation_metrics": { + "key_highlights": [ + "Portfolio NOI exceeds budget by 8.6% YTD", + "Occupancy rate 6.6% above market average", + "Operating efficiency in top decile vs peers", + "Tenant satisfaction improved to 4.2/5.0" + ], + "areas_of_focus": [ + "HVAC system modernization program", + "Lease renewal negotiations for 2026", + "ESG initiative expansion", + "Market expansion opportunities" + ], + "upcoming_milestones": [ + {"milestone": "Q3 Board Meeting", "date": "2025-09-15"}, + {"milestone": "Annual Investor Conference", "date": "2025-10-22"}, + {"milestone": "Sustainability Report Publication", "date": "2025-11-01"} + ] + } + } + }, + + "panel_3_ai_insights": { + "panel_id": "ai_strategic_insights_predictive", + "title": "AI-Driven Strategic Insights & Predictive Analytics", + "priority": 3, + "refresh_interval_seconds": 86400, + "data": { + "investment_optimization": { + "portfolio_optimization_score": 84, + "ai_recommendations": [ + { + "recommendation_id": "AI-REC-001", + "type": "CAPITAL_ALLOCATION", + "title": "Accelerate HVAC Modernization Program", + "description": "AI analysis indicates 40% of portfolio HVAC systems underperforming, causing 15% energy inefficiency", + "financial_impact": { + "investment_required": 2400000.00, + "annual_savings": 780000.00, + "payback_period_years": 3.1, + "npv_10_year": 3250000.00, + "irr_pct": 28.5 + }, + "urgency": "HIGH", + "confidence_score": 91, + "implementation_timeline": "Q4 2025 - Q2 2026" + }, + { + "recommendation_id": "AI-REC-002", + "type": "ASSET_OPTIMIZATION", + "title": "Building A Repositioning Strategy", + "description": "Market analysis suggests 25% rent premium achievable with amenity upgrades", + "financial_impact": { + "investment_required": 1200000.00, + "annual_revenue_increase": 450000.00, + "payback_period_years": 2.7, + "asset_value_increase": 3600000.00 + }, + "urgency": "MEDIUM", + "confidence_score": 87, + "implementation_timeline": "Q1 2026" + } + ], + "predictive_maintenance_roi": { + "total_savings_potential": 1250000.00, + "prevention_vs_reactive_cost_ratio": 4.2, + "assets_flagged_for_intervention": 12, + "emergency_repairs_prevented_ytd": 8 + } + }, + "market_intelligence": { + "market_trend_analysis": { + "rental_rate_forecast_12m_pct": 4.2, + "occupancy_forecast_12m_pct": 91.5, + "cap_rate_trend": "COMPRESSING", + "new_supply_impact": "MODERATE", + "demand_drivers": ["Tech sector growth", "Return-to-office trend", "Infrastructure investment"] + }, + "competitive_intelligence": { + "new_competitors": 2, + "market_share_at_risk_pct": 3.2, + "competitive_advantages": [ + "Superior maintenance response times", + "Energy efficiency leadership", + "Prime location portfolio" + ], + "threats": [ + "New Class A development nearby", + "Aggressive competitor pricing" + ] + }, + "expansion_opportunities": [ + { + "market": "Austin Tech Corridor", + "opportunity_score": 88, + "investment_required": 25000000.00, + "projected_roi_pct": 16.8, + "risk_level": "MEDIUM" + }, + { + "market": "Miami Financial District", + "opportunity_score": 76, + "investment_required": 18500000.00, + "projected_roi_pct": 14.2, + "risk_level": "MEDIUM_HIGH" + } + ] + }, + "risk_analytics": { + "portfolio_risk_modeling": { + "value_at_risk_95_confidence": 2850000.00, + "stress_test_scenarios": [ + { + "scenario": "Economic Recession", + "probability_pct": 25, + "noi_impact_pct": -15.2, + "occupancy_impact_pct": -8.5, + "mitigation_strategies": ["Flexible lease terms", "Cost reduction program"] + }, + { + "scenario": "Interest Rate Shock", + "probability_pct": 35, + "noi_impact_pct": -5.8, + "refinancing_impact": 285000.00, + "mitigation_strategies": ["Fixed rate conversions", "Debt reduction"] + }, + { + "scenario": "Major Tenant Default", + "probability_pct": 15, + "noi_impact_pct": -12.3, + "revenue_at_risk": 1250000.00, + "mitigation_strategies": ["Tenant diversification", "Credit enhancement"] + } + ] + }, + "early_warning_indicators": [ + { + "indicator": "Tenant Payment Delays", + "current_value": 2.3, + "threshold": 5.0, + "status": "NORMAL", + "trend": "STABLE" + }, + { + "indicator": "Market Rent Divergence", + "current_value": -5.2, + "threshold": -10.0, + "status": "MONITOR", + "trend": "IMPROVING" + }, + { + "indicator": "Maintenance Cost Escalation", + "current_value": 12.8, + "threshold": 15.0, + "status": "CAUTION", + "trend": "INCREASING" + } + ] + }, + "ai_executive_summary": { + "daily_executive_brief": "Portfolio performing 8.6% above NOI budget. AI recommends accelerating HVAC modernization for $780K annual savings. Market conditions favorable for rent optimization in Q4. Monitor maintenance cost escalation approaching threshold.", + "strategic_priorities": [ + { + "priority": "Implement AI-recommended HVAC modernization", + "timeline": "Q4 2025", + "expected_impact": "$780K annual savings", + "confidence": 91 + }, + { + "priority": "Execute Building A repositioning strategy", + "timeline": "Q1 2026", + "expected_impact": "$450K annual revenue increase", + "confidence": 87 + }, + { + "priority": "Evaluate Austin market expansion", + "timeline": "Q2 2026", + "expected_impact": "16.8% projected ROI", + "confidence": 78 + } + ], + "board_talking_points": [ + "Portfolio NOI growth 8.6% demonstrates operational excellence", + "AI-driven maintenance preventing $1.25M in emergency repairs", + "Top quartile performance vs peer group validates strategy", + "Market expansion opportunities identified in Austin and Miami" + ] + } + } + }, + + "panel_4_operational_efficiency": { + "panel_id": "operational_efficiency_resource_optimization", + "title": "Operational Efficiency & Resource Optimization", + "priority": 4, + "refresh_interval_seconds": 86400, + "data": { + "portfolio_performance": { + "total_properties": 8, + "total_sqft": 2850000, + "total_assets_managed": 342, + "properties_at_target_performance": 6, + "properties_underperforming": 2, + "avg_asset_health_score": 84.2, + "operational_efficiency_score": 91 + }, + "resource_utilization": { + "maintenance_team_efficiency": { + "total_technicians": 24, + "avg_utilization_pct": 78.5, + "productivity_score": 87, + "response_time_avg_hours": 2.3, + "sla_compliance_pct": 94.5 + }, + "vendor_performance": { + "total_vendors": 15, + "avg_performance_rating": 4.2, + "cost_efficiency_score": 82, + "vendors_exceeding_expectations": 9, + "vendors_requiring_improvement": 2, + "contract_renewals_upcoming": 4 + }, + "technology_adoption": { + "iot_sensors_deployed": 847, + "automation_coverage_pct": 68, + "ai_utilization_score": 76, + "digital_transformation_progress": 83 + } + }, + "cost_optimization": { + "energy_management": { + "total_energy_consumption_kwh": 2450000, + "energy_efficiency_improvement_ytd_pct": 8.3, + "energy_cost_reduction_ytd": 125000.00, + "renewable_energy_pct": 35, + "carbon_footprint_reduction_pct": 12.1 + }, + "maintenance_optimization": { + "preventive_maintenance_ratio": 76, + "emergency_repairs_reduction_pct": 23, + "maintenance_cost_per_sqft": 4.85, + "asset_uptime_pct": 98.2, + "warranty_claim_recovery": 45000.00 + }, + "procurement_efficiency": { + "vendor_consolidation_savings": 85000.00, + "bulk_purchasing_savings": 32000.00, + "contract_optimization_savings": 67000.00, + "total_procurement_savings_ytd": 184000.00 + } + }, + "performance_benchmarking": { + "industry_comparisons": { + "operating_expense_ratio": { + "portfolio": 36.8, + "industry_median": 42.1, + "percentile_rank": 85 + }, + "tenant_satisfaction": { + "portfolio": 4.2, + "industry_median": 3.7, + "percentile_rank": 78 + }, + "energy_efficiency": { + "portfolio": 87.5, + "industry_median": 79.2, + "percentile_rank": 82 + }, + "maintenance_response_time": { + "portfolio_hours": 2.3, + "industry_median_hours": 4.1, + "percentile_rank": 89 + } + }, + "improvement_opportunities": [ + { + "area": "Digital Automation", + "current_score": 68, + "target_score": 85, + "investment_required": 450000.00, + "annual_savings_potential": 180000.00 + }, + { + "area": "Predictive Analytics", + "current_score": 76, + "target_score": 90, + "investment_required": 320000.00, + "annual_savings_potential": 240000.00 + } + ] + }, + "operational_insights": { + "efficiency_trends": [ + "Maintenance team productivity increased 12% YTD", + "Emergency repairs down 23% due to predictive maintenance", + "Energy efficiency improvements saving $125K annually", + "Vendor performance ratings improved across 80% of contracts" + ], + "optimization_recommendations": [ + { + "recommendation": "Expand IoT sensor deployment to remaining 15% of assets", + "impact": "Improve predictive maintenance coverage", + "investment": 125000.00, + "timeline": "Q4 2025" + }, + { + "recommendation": "Implement automated work order routing", + "impact": "Reduce response times by 30%", + "investment": 85000.00, + "timeline": "Q1 2026" + } + ] + } + } + }, + + "panel_5_sustainability_esg": { + "panel_id": "sustainability_esg_performance", + "title": "Sustainability & ESG Performance Metrics", + "priority": 5, + "refresh_interval_seconds": 86400, + "data": { + "environmental_performance": { + "energy_metrics": { + "total_energy_consumption_kwh": 2450000, + "renewable_energy_pct": 35, + "energy_intensity_kwh_per_sqft": 86.0, + "energy_reduction_ytd_pct": 8.3, + "carbon_emissions_metric_tons": 1245, + "carbon_intensity_reduction_pct": 12.1, + "energy_star_portfolio_score": 82 + }, + "water_management": { + "total_water_consumption_gallons": 8450000, + "water_efficiency_improvement_pct": 6.2, + "water_recycling_pct": 15, + "leak_detection_saves_gallons": 125000 + }, + "waste_management": { + "waste_diversion_rate_pct": 78, + "recycling_rate_pct": 65, + "composting_rate_pct": 13, + "landfill_waste_reduction_pct": 22 + } + }, + "social_responsibility": { + "tenant_wellness": { + "air_quality_score": 89, + "wellness_amenities_count": 12, + "tenant_health_program_participation_pct": 43, + "wellness_certification_properties": 3 + }, + "community_impact": { + "local_vendor_pct": 68, + "community_investment_annual": 125000.00, + "volunteer_hours_ytd": 450, + "educational_partnerships": 4 + }, + "diversity_inclusion": { + "minority_vendor_pct": 32, + "women_owned_vendor_pct": 28, + "diversity_training_completion_pct": 95, + "supplier_diversity_score": 78 + } + }, + "governance_compliance": { + "board_governance": { + "board_diversity_pct": 40, + "independent_directors_pct": 75, + "esg_expertise_on_board": true, + "annual_esg_reporting": true + }, + "compliance_metrics": { + "regulatory_compliance_score": 96, + "audit_findings_resolved_pct": 100, + "certifications_maintained": 8, + "training_compliance_pct": 98 + }, + "transparency": { + "sustainability_report_published": true, + "esg_data_third_party_verified": true, + "stakeholder_engagement_sessions": 6, + "public_commitments_on_track": 4 + } + }, + "esg_ratings_benchmarks": { + "external_ratings": { + "gresb_score": 78, + "gresb_peer_ranking": "GREEN_STAR", + "msci_esg_rating": "A", + "sustainalytics_score": 22.1, + "esg_risk_rating": "MEDIUM" + }, + "certification_status": { + "leed_certified_properties": 5, + "energy_star_certified_properties": 6, + "boma_best_certified_properties": 3, + "well_certified_properties": 2 + }, + "investor_alignment": { + "esg_focused_investors_pct": 45, + "green_financing_utilized": 850000.00, + "sustainability_linked_loans": 2, + "green_bond_eligible_assets_pct": 60 + } + }, + "sustainability_roadmap": { + "net_zero_target": "2035", + "carbon_reduction_target_2030_pct": 50, + "renewable_energy_target_2028_pct": 75, + "key_initiatives": [ + { + "initiative": "Solar Panel Installation Program", + "timeline": "2025-2027", + "investment": 2400000.00, + "carbon_reduction_tons": 890, + "roi_years": 8.5 + }, + { + "initiative": "Building Automation Upgrade", + "timeline": "2025-2026", + "investment": 1200000.00, + "energy_savings_pct": 15, + "roi_years": 6.2 + }, + { + "initiative": "Electric Vehicle Infrastructure", + "timeline": "2026", + "investment": 450000.00, + "tenant_satisfaction_impact": "HIGH" + } + ], + "progress_tracking": { + "initiatives_on_track": 8, + "initiatives_ahead_of_schedule": 2, + "initiatives_requiring_attention": 1, + "overall_progress_pct": 78 + } + } + } + } + } +} \ No newline at end of file diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..969767068e12ef94714f2e15712ab5f9b2062189 GIT binary patch literal 1150 zcmb7E&rcIk5MC4i08XAg`losGWc1|0n^U#o6whTL4R`_&euweBP5@Ye8)xT0w?iK55e*?Fe|SK@)3G2_|_tf zwHf$ilOp{`XQ+2z>fjp-FbaNoU9yg(9?!l_Qh52zAY$XM+)0Q@``wfip@;WVb2CYUl2Zv%p*QUEa1TUu%+B~)g z76UIng^{@fv3HJYuy%5>o!8ef%%o(F-syqFUHYDpodV0=mo*7J2WEB>yt0Hpi#K7_ z@=1I(3v>IG)C0=RD9*unH%oQ+4S&$3{S&a&Y1niJQD1%T3cO$YNqoIX_iw@aQh>fP z8Rr%{Cgo&B_i2HpuR?!$L*}W1Rqgq19)tPo5IvVd{jmr$=hBd!tG&MsMz!|) z$1%ot*WeuNNZmcGQO;9TpHX>Y>%oK52Q$}pQ7vbVfEkAM%9ZHy

xasgIrI zQr%hh0sP*N$Ti|GqrR1%%ij6Jlf))+Ao2Sh;`S_Y`~?DYsO(TSIZvbcA%2Qk5xfnl vc7l^8afT5GeB!y3v3#O~)J%@g?_P^U#o6whTL4R`_&euweBP5@Ye8)xT0w?iK55e*?Fe|SK@)3G2_|_tf zwHf$ilOp{`XQ+2z>fjp-FbaNoU9yg(9?!l_Qh52zAY$XM+)0Q@``wfip@;WVb2CYUl2Zv%p*QUEa1TUu%+B~)g z76UIng^{@fv3HJYuy%5>o!8ef%%o(F-syqFUHYDpodV0=mo*7J2WEB>yt0Hpi#K7_ z@=1I(3v>IG)C0=RD9*unH%oQ+4S&$3{S&a&Y1niJQD1%T3cO$YNqoIX_iw@aQh>fP z8Rr%{Cgo&B_i2HpuR?!$L*}W1Rqgq19)tPo5IvVd{jmr$=hBd!tG&MsMz!|) z$1%ot*WeuNNZmcGQO;9TpHX>Y>%oK52Q$}pQ7vbVfEkAM%9ZHy

xasgIrI zQr%hh0sP*N$Ti|GqrR1%%ij6Jlf))+Ao2Sh;`S_Y`~?DYsO(TSIZvbcA%2Qk5xfnl vc7l^8afT5GeB!y3v3#O~)J%@g?_P \ No newline at end of file diff --git a/public/images/asset.svg b/public/images/asset.svg new file mode 100644 index 000000000..733955f5b --- /dev/null +++ b/public/images/asset.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/bot.svg b/public/images/bot.svg new file mode 100644 index 000000000..e8c749dd8 --- /dev/null +++ b/public/images/bot.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/images/chat-bot-1.svg b/public/images/chat-bot-1.svg new file mode 100644 index 000000000..50d870a8f --- /dev/null +++ b/public/images/chat-bot-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/chat-bot.svg b/public/images/chat-bot.svg new file mode 100644 index 000000000..5e5e20806 --- /dev/null +++ b/public/images/chat-bot.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/emptyData.svg b/public/images/emptyData.svg new file mode 100644 index 000000000..c8ecd9ff2 --- /dev/null +++ b/public/images/emptyData.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/error.svg b/public/images/error.svg new file mode 100644 index 000000000..600c402f8 --- /dev/null +++ b/public/images/error.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/floor.svg b/public/images/floor.svg new file mode 100644 index 000000000..4e1b9a59a --- /dev/null +++ b/public/images/floor.svg @@ -0,0 +1,3 @@ + + \ No newline at end of file diff --git a/public/images/gear.svg b/public/images/gear.svg new file mode 100644 index 000000000..6fd849af3 --- /dev/null +++ b/public/images/gear.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/google.svg b/public/images/google.svg new file mode 100644 index 000000000..c5307b0a5 --- /dev/null +++ b/public/images/google.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/location.svg b/public/images/location.svg new file mode 100644 index 000000000..fe890130c --- /dev/null +++ b/public/images/location.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/logo-white.png b/public/images/logo-white.png new file mode 100644 index 0000000000000000000000000000000000000000..f6bb4bdf0abc86bd33265b3ffddd74c818dbe4dd GIT binary patch literal 25361 zcmYhj1yof1*EUQyf=YLYFhfgs8!+V1k`jVQ3p#Y8Akr-j0}LffjlfXSASI!6mxM$2 zcgEZQ^S!ecELqEQ&hPBJ_TJZ?cRE^+$VnJTu&}VmpFCF8!@|OY0{*^Au6GkaRirBwV zDw8>@=)5hsM=45mp|}~^ZmhIIO~N4?7KC;SdqejDAKY@dZ}Ub?RVOY}VqU~aCNuiV zW~X_wcCYs2Iv^ntmhCftapK=Fd1AJ0Dlw~?KI^kxG0!07u`|kBc zRyZl`wd2}A`p|oNk+pSP!dsUpKZ@J`eRP+}SmiGx$BMOYu11SAESpoT&d{sdYlE35 zOE$4`$J+r{=l)M!BH#Y^DVflD_rT|H!=2Z#+*mL)QIyD%fSf_p%q8)E{~=|!`D8B8 zy!OTTefk93YM0rsJBjx-GuZ1jRsZ|wlS<6{9Z2ZDLm!T53(hsTFUve&>b2?qzU|1vkfkA4J&o z)kPp7xy^{IVFat`r}LqOw$QzlZsmWsSmEchc~8mZ9L~aD)pC7a_wFvY7OV8D4T_cO ze}cFTj^o3H?INnCul z`oCNM&?VA~I#e_HVm~+yyv0?gro`-zJDFWKEn=>7OjDae8;|{Cne4Iw7mp1@0U_k4iG|OD2VY%P&%%YkbN@?`B*c@`r&Ny?3P9 zPONdegSawRAHMsi;Ezg{NseRiEAKJ5W}}^CJb|XIQI$pWW@Y2~`+E|mOf}W`dQX?n%j}t$Iv*Yw#`ptuII2;#C{4y9 zd+PH4pAUuNS?-FtC?cbNX^!$kx`s2cDpTdTj^|umoCMyB{n>SSc93dC>`-~B^3Mt^ z8O2{t@rb@nRtn+QcTc12tY_mY7xO*-jqeb_8#~{1Fr;wP9nbmbLo(yPqvaE{C1nGq zCf8^`m{D){#tIkeAXHSWZors!et_;5Y=IsB{#l0h8*REi-=21Bj-X(@R{6gpB7{Ya z{0~PSakuQZlUDV#wmt;x90b`c)qBffy*2cKS?1WXIYSDHtOImVyHWhWVK{fuqw#+> zD?+44BVcyQO;0%``D$aB9zxjuLdv6&dfex9F*2~$_hdH|ZRTv{Gqy2O(A^vJ&(3to ztx9bmx&r8zGNY=wAEiYfYOTMHh2WE>tZo*6_FVstTe43tVm~mn>iy5! zN!H9%u`FD8#WQguJRCLS8_1TmsaDf|EFq18a6HgS+<<-vFB6Zr8x;N9JR9K zuefm1rd?aEa#p=F@dmxB^=@bE^f@IFjLW8-cj$!w=LtQYC91H?Kiz?euhq4Uj3y(E zS0B36_wIh(u4J{|ifu9!pQy5I!S#Rf@B@?7@x)WttCSh>e@@^UVnXG+OvXzwE7 zT(^FdYWF%rrORDvUgKM z0|;6W$Q9D(2+=fP2-nO!o^A^zoag_)=Y4ojC%klxNjXn$^*J*E*(mYnm*;{Ft0_+j zdUo{KEB-8ZYqyceqcf(B>s@A#AF;q}?;v7UBL1nIhnufj1_+$A^)G*-vzgu(OZlH4 zCa?CVRx!$W{}x>F?#;?miM+O&Yl3N@`HILkibL+#emwu@Qu9uldAMxIn`t^IurILk3vW4@?G34s~Y4Sk}8D zzn`>4Hl}G>;p)^d*O!1!XL$Z;L#;I_+n~(Q`4^CntfgADJbzDBYh$w403PUvHIY6L zc7^!06{u$d^&70l?;Dm<{&%68;!q4GH-qp%9#+n{<2+LlGXh?7&h@j$+JdOlC0`hk9*RMwOTBe2`3cSc5YIK`naAe<7H#tKg2(tqA8=uV1rvC$U8E)Q&3`jR2>%e#R_*Qs#K3XpX}GpvG(-Nv1_GyW1ZNk zWBqhh`pu5ZnGcv^j~vj2F0OSE3)zR&TnX}&VRKCCwohhxK^F!#R;SmDSp5=j-*7H-5rGj>Sr+$b5j9lXuGqiZ>FED+8!Kk#ZPm*cmSb@&q$22JCoWP@tvPt#=1mSB^2hRDz>J{pr=_sL^U_6AvauA7k>vJ9Qc)bAH@J;gdvr zARQ{nD_3{LFrH{2Z|B3?^(oKjK~bUJ5@z@y>m~_I1fjNln(VPLoF@Wr!7o0HBbVjo z*U##C4(f}dzVU6I!P33!NH&4HvB!S$=s0PQyzFo2K&PRGe{r6!^WO;No7etvZzGRgfl(mck*mJ{N>6%*G4uWx*q=F_v9z^828Bx| zKu=QkL)lj!FZF1>0fQ1WiBZO{1(H2VTmI>25D>ciCGLp5bEGSCE}Ic78cXw`4nB!s zP?5i^WYdYi00gV|r*ZzuCHOLuhfGdy#rN&!c62ZLXF>!yqqsUMwggmDria557qd|1HDy;VF*RS$PLn*dCXiBk!vT@&+ZlB^~?5n8W zokV0P%U@LPivWTudO`|az#v5{knNd8zUW3RU{x9Qt0L6l)+AykPoRG1Yz^@j#c#MY z{}8(&D8xei_RX!+0DxLlO$=QaNEb1p;Ws^8pLS{7$ypJ9%TWW@$~xCSxxVUN{X!LK ztu87$CZc(ygZxB|+vweR^<BxZgo>6Q$`0QX*~3^z$ASf}BZ9wvjv~lA{bMsbImeXWdJ8 zBzxVzoQHO6-4z6E^JFD-PkpvM>E`6d0_A()n;KZ~=b-CWr5_-r#}h(apD>WuOd!4e z{Al}RC)L7dQ%qmDcfIo=w1eu)%9axs#S};F^bYTRv(oX@5irl$wP$NK_8xh>FS_k@ zk;_d0v&>QQuf3eBM=0rgCy!KB=~u|13(K(z*M67?Kb2ee#}tEN>zpPlanba>`A@9X zc@&X6RiT19)HWg1TEt+bbX@OaNH2WAw?il#P{?WJ4-n+%r}eU&z5~zACE6YFGU(g4 zBs_QEctGYu4ISz&{>WK#90am{8xY0VS)&IsDB)=VTt#9GUY&=QXQ8sJ9>oLqi@g|L zCSm90H$|AfaV-I@eA!dal2<8VieF#aa;l9dR%SOeXYD3y0wl0 z!XrS`JS9o_o-(9l;dKj2sd{P_{;VH@`>Z5Cw|w=ruFJg>&0Yv=fnKg_!i<70bMN!{ z@9h+}hbqhkudXkle|o@oqGN0{T^`c6&Ha<@rjp zAmW2{RHdW5b{&>+3O+!YZCCo6{O>t!mQEhp44q! zmdcK#)?i#hAK|M|4bbF6r=B==&WUo9gSw_kjO>(h;u9|TUWKJ!cVvSP;ZvgEistI zFQNUoZY5QHf(rE}Js)MRa1bdCpK-+GjNifF{6|bUR*Afom0VTrezZ02WV~sIDThDl z6-iSTTK5_5bvFY^?Zq-s-A!VO1;>@1c$;cDSrxRHwE+bbA{O<_s{gh9R;)xao`GF9 z=XzkgDs}u!Dz7FHRPkU==zSqPJ_pwbbN1MGvVzWmGJg!JWN&fgKlmXJA@*BloImX@ z(nv-5?9ND?={eDT^aRBggYh=wpAy~9mSFRO#9qRp1NI#u1>;mwxEaHX8_ zq|dcKDYVrqH{aj&tsj>HX{Ej#MCs-ZOv?^bbT9kuV+Tmuvl`UJ?A{y_gW1tXbpYB# z_x>n_<}CeS-K)`w2`M!}XaC#+3RyQyo-f#SX6~SZo?Bkb@t9P{+q+kSv)lx02)8v! zpsh2R?PZ?U>92NtRaSVZyQD;%xWSs_>H5A6ENcc$xG z1m~ZVYQEqG@Q$WSW?v*5u7OHgMj|2316e^h1DD=xJCzYG;%mEN8gA#a5EfY&n0=t8 z4C#@{kU;~-L%98YDcVYXDTvbi9hkhG%E42ZnW}0A13f&fh7y5+1gU%V?tr#<^NNyO zOYGUq>t{Uru?|1~Y`IZTNI2>00?=jEvn^Ug@=9HibU3I|<7zt!N;e}!trIAeOjbP( zUh^rScPkBj2afNZ_Ldo&0Em? z(^r3AXRlIU45HL>2PTje#Ff5UefzfKv$Q)Qjx3HE3-^`6`wutA0*7?eD3nS0y!fh` z`482U#PlY7^&^GCmDJ_Q^tWQHNp9P5Dg3?9Pv6tBlNIDEl8lAEWloOKcA?dW6(a?Y zVrAa!TUCx*37CIJCt98z~rJ!@z3Ukxgr0L1* z>v`=@X4Oqy;=@Ve`tK4hEC793$1M@0YF2Us`7$Z_vZ+z8Q^;;TxXL1NeZ&z{y(YeW zjN6e!RidXpks! zb5BBRQbmmthp)Vjv#KD5LjH7tsy&vE+ho`WN*@o${zNel zfR4I^!ry*Q$a!sqpn>Nqyptug%mr1-T5xbo(!p)VG9=xX4x6BXR!3!3EW)fX~=hn- zSsDaw#Sn1^RsQ+^o`s{D9V+-q|I5E;tpM$qpf)YROH zbA5|bW?1n{)MKTm+M}aVpC8gj`vCUdP6WFT0H<uP% z!FRF&^Pi-{-_{62Kv-kCXz_>Pb3o16?Vit};?fAh6f4gCDg4OPAJg@&^Sb4u%O2|5 zNi{;9#o3^&-NX=o&bK|L{t1y%M*8KFa+U<3!M6YR{%94J7FTup_msK?zym5>x3uGR z>;P%YR3BD#9a34cE1K3gjN~X>pDqur9(@{_oxReHFZ%EWVVssA^AX&xpZ|(4tTng6 zp(si!vd=G_zpwl-tVLjnH3XI8HpFmtF8wS`c@hBfGHL2`=S;b1&11KA3WB&cCrXCp zgRzp90Kygtx9dEoKNjC=NdkO2_vo7HM_Zn+0;Yp7qt*B8u{pV&^0I4m5Ph17bwV_* zvf&h}L?3X>a=j%78H_qVn|@d26FRy0ydTBu`RDqp(aFMDD{RTHV?B18y-IgKbepc} zR+-7-m8OLf_|1eH|7fM(a^4qzmRmbIZ>oFyb zWX`WC;x*GjvGp*Wdy_5GPas@2ZnIz!y7=`)b%RQ5f<*LGwDLatjRY;_{hsA2MzY7k zacZ^0z1o!)Nt3Pk<-e%NB5up-3){8{FT;qGxnLgz7Nlxq-kF}4&|prj zRJM-lQ@G{sSFs`#%C&8|Z>mJnX2;T5XEm<5*y_P@WjOlxj`b zErUqkpzZK|)n$vRc9d@Lf~P~!ayy&(^3OgFqI`sL+Z|cghDmf&4Vzh;r$P8IfSj^v z2yV~~FU^1o^%tXSQ#Es28-M-G14WmH7nGuGY|=2aZDmCBqYq4Hwm`UQ*|2TZ4;Si8 zGD{`Ey_?hWI|EyPYoWL4;ZADpOb)4|WrMrrfTa(qKP+xqbGbjr992(1aud4M-wcX2 zR49^#7)J-SH?~ET%C~u`Uzz_TzHj{G(tnZ|EouJej~N%4+lo7FxRO*|MeUCVV9*Lo z|2Dx=)-9a`woi&~Bb!$ExNc;sGR(Q(h?RNf%Y@um{ol;FRrFnw8Ra3p4XfF+*R4tZ zg97Oi`oXE!lQ%wt<>zD7YHIjwRIO^;wO0$6$UE0DhLh^o@t342m*V@ z={#h+ce1HGO6zhrU-B$c+eIcu5ZC)`j=?JJS#KCFg4dtg8s7gcAfW#lSV6tQmajc^ zn?vYJU6t-ktg~3q>0t?1s5E2|WQA5T5MvKxR$;2ec-yl!;bC}Hs=HK2y zRYQ!~r4Qgc!VqCuV(2-WPTOcRt$i32#UZ`Ltc(!kc18V4C16p!EKZVk zE|T1m;5WKitW}6h$p9C}b0B=rIYT;(=k|}Pr*~jNl|A!2s1Z7cyfB3(iZB+mn1IzM z=DFA*%3BViXFH-XI{0G>qKI_qCo+6-i1Vh!*O6VKpa@U|P%Y+$x8^+_Cpa&i@f?y< zI0m`3j(#`ZGG-~`FCP_g{#7%7{z}VQJ`$7>TKMTRSgHmO8qil+9zQt}zEA5i*wSSg zcz;tgM-5qZ4>+FAn}v=3X7?ur3}tr*UNIKqz7N`r)GG^qn45TWzt_gSb6rTyDd_e% zsS;-m_9QNhoG4Kgu6M@at&@72V8l*bmH(;9BJ1v>usZAGPYQc$$u8mJ(0Vl4gITAR}}Q zT@^J2Vp%0gI^M6dU?PwQv}S<5q{0FLPyW9 z&W~i!$I!FW<+&lFssOJ&BW=b^k@{>tc`dXC z&jCGlS!mJ47q9IJf3$47G#$L;u#c#tRc$x*cklM5vPtL@;H;vA?jDv;R(zeSCIwvr7U`BBBfhP*nBW?m_D@ejU z2Z3mkSa3KG(4D87X$}m0aWt5#6dKVlk(yT+9R5r2=zbjS7xfin;UBwJf%&JnF+iCX zr*|PZ671b*z9HCPSGm7Ndbfn?{EoB6?ld(kP+DQX{AD>`i?B8$+cJNlT!i0XTm=8l zuGl)RU&7->p@*Y}oBS{wgu#_sD9OGIF{in%Tw~O{(fq~S$;13d-9UvepS=ET8E7I; z4tL`%M$UL^@t1SF0^aG1hoyL>MYE6dYG1NziX3$*k*pwqsGj=%d3L_{5eQ@*^>aSk z^(*2v$-FdcjdvF zFGk(g**b?$c8Zv}97~DDDvK%`T(Let_7s4W)N>!_whM6)z#Z8NhfAi0(J`)m%dEGR zdpxVd4FVydzna!(+JCU?7vwHFvp5Jmt=^t%{{z?DCRX8!pLtBLe%{Ynrbm)MTl4=4 zlN_2sSBOU$K~-AAvpG++XLVlIL{kaW;7c}X`NC7(ijQ`=Nqk=RoP5nao4n!6xI;-r zUbQ9J5R-poCeHtuc%?bp%Wv*kM^cTW{2c_D&L7>pV7VG0e)!`O^b*h;dG=kZA6D;H zffnzQd$Yel4<%2s%gLHhRQ@T)@sKYR`=V}~^6n4~lwr_x}ZBN{7<7C1cT56t> zXX(I9R3uZvbT5%~&UZ4VuNKJaM7YNS zEz^pZ@#riD!nb0)*f+&}j{3!@H3aC1aq=??sMw14HjhSa3csjC%7tTo`B2`(9!e{U z9QyeX^6p?Y)zac=)0;niT4ZlYH=f$Tdw#H{0#Q)`Vfr@2rV~AYsf*0AW62@-rkDwU zR@RrbR-#`!-fN#FqwZHZW6Zzc%&gC!D}Jvc81TG|Zc_ub6b*Z?C|?iD%2O&JvIGv^ zwQ?|UUrEPV2r-1t^AK@qRjt2UB6c%%gP8qc~r=1@t|H0 zGqIlHMOEF$rR5Jzbp{+mTrq2G#=E@l{9MUJ`5~JQT=;O>+G%IMk&eBe+W`ZCyjQ1w zC0fE`m2TwMLF=FjsY=+OS=hmjcjFJiqu?5w0gc^mYRh)23Z<8G5pkx0)D0K+ABNkeUq7hi575T|o-x z{SWaV>}?2Py9hirWtT7p4`0UtB)e=iO5lmx;J|S?A-$So#c$4&<)|_z^CUyA$MH%9 zhFH2a#g7a##!`en#W0IXg)DdbL(2Giq_cRV*4P@~UwrV5rzv;9^D(t933J-mnsTqG z${8rZHb6r*7#>p;y59F?Xb@4!3;6ieoyz(;^N}^XOsA*L;+~=aq|YZ#T*u3 z;@#^}kX86I?e=uaMZx9D(UH=q<=mhZA#VS`P%waU4`|(|S(BA;3G#1W^c^kdpM?JW zK!U7lf+*g;C>j&{a_F|(X5Ko(JwD$%6*=Q-DBewnry-Dyl!UJvf%53(BRy^edafFl zmgH%}+jK`41gr9)^fe6eG;)>YH3To{c)l`B{c6u;#@#Ae=W;DlPMd-Aswp=&eHtoJ z)ZzRwLZX5z8M{x#l3J5WT8}(g1U(T~Fr8$aPQK{7xO>sa4zy&82-@)>_|jfmKT^>x z)5`b+@Ub5M3z9M8PY*QbNZv~Vgazs7@vs4pZiA6Y+P}k#)w8MAnySAf2B2R74MZ+# zdFp=7YCRJ?zMn{2e|O*@Bu;gewMCJd;CI4Au7EaOQ7OsEL*A*V#mkeA>DL||xg3H< z?CNZr^lsFAcER(H1geafVoK#bSSuTv=3$PS6mG%}?-^9PpSg~o&re5}Vp^#l$2!4Oaq_q!W%JyI* z&aD)^u44ohg6&W-?$zb#pP`jA+u%|aA~-}$-Jr&87nUL&0gR4zFtIrLZ!<^YHz{mD zAo(i%>+Z@Cf8utVaYkXAsV)#agdiZTU0w-xtL}0hKPK2HWi8{ETL2N+%1t(P9sAH2 z{tjC!&3A3iZ)1oAEL!$-{)!6~oSd-)45ooz zV)yFVJtZ;16f*H-o@6OYJc|t&=TLVShHO!A-nCB2#TGlu#flmjf7gVv6AG;!-7DC` zJ?0^UMkQYKscg%KrI#9QSr%5u$_A^`w>>Mz1Gv%Ox5771ilqB|~h4vg)xt3>*UFrQLUG0$7o^STm`BCWRV9{4>xh z00rf3a3cD`$RrK%l!y$MaEdTx*KO5Yk|K2j$%mxXf>w_|jz)QbnrN@5W+aE)J3 z-=!(hA4f&fg=3J@q&F2&aTmPmF7wG+5g>+8Be&TCERE|v|AwV!U_FKyp`voO5{T(g zLBCRsYYlt@X=X3OJ0lo#;33tCsJ4z8smIYWD~gEO-QA6)e5_xtUPMD_YYn~=Qc-y*{j3T(_H5 z*^$TSImGEkU~5?#LTvRZ zMrUPf+$@}8@~n2|MpN75U9GF0>-^e3h} zt91n`41Yld$xE1=pvjI`3MC*J2ZoK#pW2&bc1gdDz$Ou}((eL23mit_)$k8^ysx_#q`= zJU-JLe4}K>%7#jm`?!SK_G<@bK#2}I(TE73;#|8{-1}9#jb%7wUGj{Ay$@kfCf@6m z-pLs&`+#^le!5aR>3zSaSLt2LwJnH+Wvbr>;qA5UAUWdyY&~w;qiEK}7n%(@u91%= zgDzR`^Tc-G4=Qrxjy%GyDVA_xer?!rLd2&NykOIlKTdd$!i+mimu_K6`i$zc1Nqu& zfb`(e!NQhjrPr0yT{uv*Sh?lYZ*C$z*DEW`km$j$5HZ~!?OTsA8Irfk+VC$&Z|048 z&;N5MvlDEdzDaD~I~8q=9Z9cB5DC*B_|&&B{%NvxCb33X#=G!k`S4M-UhihA9^i(3 z*C$3c$z;RX;Jd@p0mGMNP1{O&PIR^ehvVs#CcKui0GuI`HQ>~!Tn~A>l7Ba|$sX2xbA8cGi=hL6t7h&# z$}ZCYFrv5lcbO*`2^zO?4|HSuGKjqO{9vsvJ!rGF;?ZLexv5K}LHRg+JhJJO*GEaK zC8;l_QO`Rq-+*1$unM0HT?=*+Ee4&j+)*Wl6DQ9;Ro$i^zXQ3eToh|Rlq(bymj^PN z;fw&C)r6>1BQ2kro13%dSA$iOZ!FY^Pk8sh>W)4>K3e>iD8E_e*E7Q90+Wmyi+u;L z!UIRVG%PHQ_Q`tuy)xDojQ39GL_? zAe?3hOr~(iel$+lx=2uuo&h{V9;`xH=@6k~8vN{TLy4kInjVDoN?+@vlD{=I_7@HG zr%comA_c$3r&kt;Qy#tl|Ib0bW-jJcQ}lD#z{&CXFxi(rn)0^=)HM1P(p<#ytd+Pt zNQ%$vOBO;9_kyaSNLhlXbPJgl;M$UmPs0oVX*TKa7_>@_2H+#@5@N>n0O^M7Eawih zyx-|>3}IN*ejqL9WP5UN8ohu}bngt6M`^N_K7cthkKS-)#>e6wi<;v4M1VG_s+tqw z9$t3JRLQ|L-5;+}NsOUTT{09-5i})L!w! z$MIE9LJ?`*tbvyjz;yg7Sk^Enuj-vFZR(V3D>Ka59JHkyV1CCxpP%9c&K@DJj;C+V ziU2wZc``Tp$Pv?UJ=_HP!!OSfZ?rwF@Z`1Ff%4_w5i^F0Ftd32;fpt}86G?SK|xJj zS2{2Mosqs}QrIo=!i&rJXW?B1w6WQUMLN+*!G~ay1sD$lRhZ-4x>mbLO1*l~2LlBM?=%0qG%h9RhzUsiY0os45a6!d;mSvnf$!8hLVh$I5q!rGVp5B0C3^q$km_E zbIg4(6e+a7Bc=;{BN%wr|1^zY<${V0_FG_4qke{Um9wgX*UoSAdmrGq8E~=<|0W4d zC<+n^sNhZ^2i>oW9quU%{2G6;O=u}Dn5MvpXm9)|xOTuoBJ%NKJQHcycti)sfdB||B6R%v=Tcwm6I`-ULKe%1Zg*6!|g2^bnU z457sy1vsm#j>eliJI!H;j~EgJ_~vQreF$-GabFnmT{!2|8L$Sh3fH^3+6%nF8wV3T zU#XKR4ZB~%6er16RmSU~AiwY4nhgix`mpZBEY+?@*_J@93vvAq zJvOg2FQU!w_Bd@t&BRjpF?}l5Wf~TNJjwIgQQ9qY6y1BRCiT}rqK;dTUZ&6LUeY!6 zZe3q>pJgu zPJP0X>VzUEDtl}JHChSq%asZOrfWclXK(s>ceihW*G^tw!Y9!8Lg>3U_i1D0sb?{q*%R-r`1Z1NB!vm#>)Vs6-YZ2oub$ww%89Z`tz`8>d|z4>b05s~F_0Te_Sc5Tv(n@OMXn%+S_ zAiC|%|A}sd0VBF%7)0~Bxl{r(d}B>% z8fn$(O`^{Hy%uVQS6F)sisi4av&c5T9gligfqzlTBq*b=#F~Ji=z{fP9t2#Tf}V7L zq=J8x^PNyj8(qh+v1wA&x(->9tc-tGS?Wzvyc;XzZ;%*&vC}iPb_st8|CY%p2=Q(_|o%vXyf7;PUW{{K3)j%X6&Yd zap$95L1F}SC8AINp_D|%Y~NJ^GmPIn{^C#YfBELbUXUG-NApUx>3 zBSHW(Qpg<+;~`t_VKQZ#mOzeKSL*YU4!z#f{9i8hq^XB?>{SJS9p0-oTh(cQZQ5#X z(zgKl&G#y zlCiLMYKAN#6NDRi0S*Yp^6MqT$WlPx333U#vC3O9Up*HA!ak&L`@uTIYjBQG(izdk z0~5K)dS2EumXAmbRqWDyK8nE%A4KU7G~Irm<=-d-Ob z_`zf1EW(l-JJsFEzpe-L)hsI=$mjZ)f`08E%{drNoatoU!FzcBy|=vaEuM(2qANQ+ z;J818)XFY%5V|J1cp=n->SUQw(O|{Z2ybl;tj@?RFEQZcQZur>28XL3cAl zh61}$L>zBYT0$YXM#Z7_M0*|7GmJ=9P1BrifRSdWPbX?%-X0`s8hZHETtUCg!64FV z+9NQ*-mPyA)Jq2s%k{y%QWEM(xj;WA#cl<5AHMf358+tjDN~C!s&%TzcYO|`Bp4SF z=->frZhzfj(luaJz!+4YnaXVq@D@Q)rN19v;m?CkqibZ>Br}4e0IUQg)nPvZAh3;O z*tZLHTBHoUV99EtweMYc47Ys$CHIlb{Ov12Pj)is%vEMo9}d#rqu)O6DWNGf*h9e} zat@4UGXDhoUc;z4*?gyrs6wbHsddCF?as#L=B8%mtue%G9FY2Lm_pi_`3b>HortCr^=bzuq{!c?)Evl?KAiC#mv z$C3uJYqEaw+B>E4I)=F8Leo}QExu7&S*k_3GnJ%v+KGJ!jnfHZfyFCb=I%gQK_0Xj znEOJ-;zW9sWNjs2OEJ5ERNR`x!QeLj@P^^;7pNQGDf4!Yf@9%tl& zZkmV66BvD;@q;k}1XgzW2b)Iy{3O` z16-X484(V%^A8x-36fN5F|&^{-{B*(!^Bq0E8*n!&N@*mA;F<(d}NMjgM8Xodq#m* zArId=`l3uvKtYZWc|7oG6CM;nDfvoTXM_Z*cr}yIp7cD&-y38hf+3NmeGhJ{_lqIt z6MpX~gwnoJvg-l(QyzqmOqe8x$D*y2eYx(@TK6ejb|_pb09@-+S73Fh+QOxy3p31E5veffu@IO?9Zbw`eAFZ zKL6D`n4h)XOk%l`dYYqNCi0`{pK~d4_5>cn7g|FqE;H0d)=|fnC7Xc_#>w|LoI|lW z2<*3TOWunTY}S?i2n#2yKt*UX_(NZIQ1KLK>v0!fE*#vCEglPw>RRUvDz)(l)cP3) z!iGer%e>xwb|(GQLC2{bHyKR!$kgFpnb;BGxL}&8{)8A0Fheh0=J3}Y5c%=C^2rbb zFiZ+6vtk$Y19=}63B7X^+P*Z?41LVqlD1m+H%l%BX&w2kqL>br`Yot;W4fM8MLE9N zzC@+%vxRwq+X2XdR?-|5JQDplcv3j5E?j6N1w6CvZKjX@);t}X%MR9 zR?&UGC~zh@z$J=qX^5QO@af|?+W|jG5B@#3| zfC!tO%m?0_4rU$*21j4B*%xK#;e^Y&`IRuW^+}^o58Blgh+g!`?loDHAl_yNoNspi z#eDP6Z7@7L4EvV9soZn)3jsh5qvN4U-(8BLr_5yy`VzP?pw~SGe{sJ1g4h9R{vRA< zOStfOa_~j(l^B&#=rwqTanL3yN~wO&ec;EJ!gyI0Io(&3(gO*_?Kg6C07 z0(ivbyUEJed*qaPW7gR1$P&tel5*Z}g>sV(ABgVg6J~=%;io|}+8GZJP^_VF?_~9q zqGB2Pk{%oP(hV4wp&J*un+<>g=@nOp zla3?|s4V&bB)oIb`ES)Mr~h6JU`?PUWqQl6LjxUzg4$Yfp`LecMntOGU<-03-tN~F zK6hw=_y+$Q-m~aZ_zT`U@FfoPin;lGyF))eR~x!y z)q}6!R4Au{maB9STPN@&AFr~6wkI{T%r%bo2EWj)!~HCaHvDaS7A<}pOcKv$K%!<% znL=~ClI-=H5>({#uv`+lJ*dFb-OPu1KF^a<{F_8~ET2HBM&W~)^x z!I{_vhOW8HLTnekv^%5iqH(~MfHG>9B!#={L8or0YwW_5sR@8yfkn45t(>PRLiqj6 z1q^Da?_J}-#^lxS=L(r0U;VXbI(QKMJsoWL*ZU`_v`9IOUUibO8*Ll6)Nk7le+Yhk zsNnFh1iiH__(9;@Pk(ieM}l3`R(_j^-$F!b*DJ^Opk-0|IJ^;k#r2rXPc*bd*57P2 z?fLABpcO@U@KXi=$pYM*46bV!YT)9YhFkoj?3Us63ftCJ@)O(($Yq(Q7Tp{6-=D~Z z&^>z2&=urEd@_P_Q+BHFD!gtXO*xHCLx#Fjg4tPoC$w&XZaMBKwVXzdleZ|1vvSb)!Y^Fm&}rGx%C4U98$HPW<);Rg(j~^VHvI~ zQIjaY-=|8|0=8w`v;ao`$Cl?q*&S#xaiLXGXv^rXImSjJK*M>0SGCUw+L71nDbpU9 zd~87fNh711xl4s8vs?CKw$Ad~Tga|}&^CE6>*rgi z*poDSYRPeB%jyaB?GK%N3=P}FVlPI%Mu_KyYGnAQ7w&s9T`s`B-O#)qvI$fGyg?)k zbzDpkXe3s42 zP2ZN9{i%sh!(*klqj^K7I~`Pmqt{b{OVrT9_O^#9ZLn+*nMo13tr+2#C!h77I?R6w zY7#&{5_ub)DE2C$k7?neWI$2v%b$AQ_~19ASQ1V^Zv^-n1Gk|p8Ey$j=W&EVrr%z% z9zL+2V*w}!iC#(6{$5S4i-QTjOmHmR?Xjfh73u*$33=Tps zVbL6?d8Dy-vIf5-5n$)_wk}%!$z^{tg0G_SxG%Kp2X&6!&Uhg#hgaa_Id{}d11}8FdK)sj<->()BlsS= zs)hYmzvzWPe`{T@0m#HiMtw=+CP@p#t1|*@g)T;bI|{KYqlLJFbnrFSjt>t%0@C{P zw~c~X=EiJ(sl{Xaht>@H5{%2CP3-|r*Sn!=)4M+3dYEmK56hJd+NrE^pAGgZ*m@LH z_FSDRda`k!6t$fM%e1q=2I>%gt{Gp!CUX&08D;1CX^Hde*g)Vxhk<@A24N2Q&@=8s zoBrZLGWw&3ry}Pfi84;BziNJh_4x<&N3Q*<%EEBoH(RbO3Ff zl;OeYLK=T#x{`Y@^0Y~OCd^QNCKmEenC`6YZp)YLkB8-LpR79`_MV&$;DKKR_9DJ= z$m!ifI{#JvDzzUke z77<{-BsI1FS7$Ws1vm&p!Pj(YDk*$U4lE(y?WbzP1T^ha6u;NL&}KqX{5{kWw#=zK z<)BPYWpA@CGu9c$p4_eN*3k}BG}Fu)7hr`GLTVS$zH@daWs08Ej4z+=Qiz5M=Gaw` z3|H2nf0B<0F{$L{pZ&flDQDx#MSjj8oeXaVg6|-pUwb&FBv(SZJ~cif)HGc6W`w({ zQ>1`XCb>xuYn8_13>E*XvxBO2p&F-|<45{9=;Mu;B0yIGu|kF)i0v=$bYQP zvFVf^fE5Hnl7jXO4BeC9UgHlSBjumolHzSC%S^pxpptN=B!!(Dh}MF+w$ew23*j|( zgdpskoW<>cdTw&Jej@1mi4xs{E@nBOLnw?)e0b6Z_`--Ghj1(wC+og3pt)W9$Rd#s z(-Zz-CNsXB3K&|l4_sAvEI<_1Y8bLJS^xw1jy`ju>|EwsqYW;H#+@QZIKR)e=C zp7{+6;#nZy-4{-x8|8Ls`BIXxnFziAucz~lr}};WIF3DXGNKg6&aq`>&$1(qJ&t50 ztYDd#0k9e7g}_^N#*1zQL;LSA&>pi2b^1?QTf#PjGT4IU5wLL~OqdEGE*F zKAims^q=Edz?uZP>_0rX;BIzbMAd`()$OG(-mx^yO%hyUd|3JhJdj<1q#LLYwJaO2fcF-Suki$xg#V1)4|HFwjE0 zzO$|OGE1ee;Vy0$_!TU)*K26R-2CR&ot`3Wj2ze!$-~wvgGE$7^e^NrD*8`C8@aF)cDoV9L;p^8uwuN8&O((; ze!!|U^Z|;U0_z6^xgE+=8{2Il;@E(5l-*Mab;e1aQDCn1CEI8x2lmXv?GH2(xQ9~k z?%Ti4;-)UH@-`3}s|~Tql0@05TZKa$w}D!8@8S**g$G>pO1VxI1pA1O?og4i^ZHa7 z{8B>n2<>?dWN*8d^2@|GN_Rd#NyBaG=npqYQ5jVPENQXVBlSExJmwCMpK>bA?{DF~ zJxYQl)!Ra*jgu%Lm7c~847ZvUsRuzd>vi_LN()0KS75ii(Z>_ycy{)HvIDD~mgsZU zn6F5bRC>Qsm9X(SZT3fo!lj!f75;XY6bhUF?jV3GT$UA6ZpKE7Dshl|8-Sgzi zmcgV-3clf~+7Dom*hv(v97*=%?XG46?hT>j7pF)8C$8_lD!Ko6m}ldNZEKZrB7&qd zEm@Xxv9(mr(YMQ=(7knh(?HL7h*KJ)Vf=%sSvQG9GQ_mw?B{Xz=BwVjwobVjvYo1a zQyRWyG*jf2GLQ&(BJ!jXXk3&XTPEq5TfLo_ASTcvzy)A{@jO_`5(@CTxn~5LztY_* zhzO}uc~h$dR`hy~ab?N0YpX5W1&_`toJ+^`IqARqj(IGcv(m*jaVbi)a(P?m{eE>w zfBhiYs%qhehC(vUfb=e^`$YTf;<<9;+~Ti_qr{Ho8zN7pE0#2vvE;cvW>_>;r1y#r zJF6OuGj~@hTnm3dn18o*ah0n20$yWj;4*hhDT>YvANKR{e_t)iOQhz0jXonTcN`tA zkpz^eP{=E4JF^pfh9WLIPW4&OIe5*oR>D_8`BS!Z7nYaR)SU5go@$SULsomfGkR&| zvbb3ecRE?!nsgU;8}#q_Vd!~!cJ26^lroNK7a4dCM1F${4rYb&KbS=Q2_L*K8T+H1 zH*Ra0!Rb6ga{c0^#0(|toRwRJ^3KU_zA(mzIC({kW~g6g*&W&#c1r4YV`h!0=MWc4(c#mr6^NJ7MwKFxb%4%O$i4_Y9U;T!*8}s?C(a7t^ zwEBK3Wa{+05bxMUTjIPV10|-bkuhH`xUHD7z8|Q&{1ayTMl4 z6GYXwe{J`kilbdQrKYZ)uogQogG%PSG7nj8$BJ6DC!zIy#$n=TbBxm|z-jV>;|--- zdeRiksk)h3{fFdkxKq2f`uS$24(iD1?$D(O>3<9ll?~ow-LgNLiw1=hG8It`()+03 zN+SyIb!n0V$Emx4O3kT|C%|6|$V9I{eQES+=GDvK0$nkS%HvA@y^+z;fEI)P=)YK& z@@WW}KtFoYKPr#1DA||EgnSx)=5anSN8Sp4FMq7U>v~$Eu8%Uag#+Xir7z`o#+InS;X4f@b#d4Nzy3A*>x~dp!awvM^X8kj z-|CQ|r1NO3k!k$u@A?6W&DtGL2J?(szWXX}c6(b(%C>&HR~UHT0ct^DkGFetgk1X; zo2DH64EYn>W$xxw$a%Rq*>My&`~N5+Bsr6I0aSVM@AQHd(IbV!7Q>^{X+|%28mizG9k)EOVA)Na2pdmArF7ytTYG^Ol@|jlmC%Z(rLJZ(EG)@DTiV-ubbfZ zxq_i>IGl!W8lQP2SrPr2(QCTgMz=t1(;({=x%L$3HW9-de{rJJbS;hW>jd~eS0iU?K!p*T-Gym8BmPu+Hl0FXUPv#9{(&IV!{3KJ= z{%Pq-P_0Fcid&9p(#&-0oyI|gCHU+y7j1xJBjY}o(BIX2LsraluZKU)$o9p53(x#D zypQ1lQf=MMrsUzOwYL2bUtvy~BRYcL<6H7C?$#?ji2Y7_t>Cf^F#zS;bb;7<;5`ve z|3eUIEw_BX=UjdjWDR|x{!!krVF#(FgytV5v>agFrc|g~&$*A3n^||ic{bJKF+D~y zSqMJZBsLcn+)Gn+`o}SPb$hX@IRbi;1Mj+;#Eoj!{4~DdrRN+@aGHmNod9Omcg3%w zKQ?#{N2JgT{y#2=?|GaCm7$3G(f)d57R;9mR94!OO}m>bq=0|>yZu=^h!Yk(!4h(> zu(k5eG<*Y944$NOnTn5{{jc1=f4Zj*(xOGqBnjt-%wetM-$l2izP`g;&>i*ltrSH+ zs_u~t4s{z;*wHKB+Ad4$Qk%yNdd(Hx;eI&fxjxzscU}Bcwc^m~uj@#%04T8ozwb^* zyC;@KpPBvpYFTJc`^sfLnjcN3)6uRes*i=t*~Kl=*=&kvO0N_ghW_u%T#Z7eLKgYZ z-GwraKJph;P&M17Ji})3gYwFL4itW~i5F4;_NwhqCye>-TQ-SS*xza8yBddztj&_i z9>l_KbAdbB3Ii(a(eC#L?WL{Og8GiH#lR+S+Ula=*;m-o<^B7iU$xLg@)#~u1HGB? z#i-_JH9>2G2PmY17CU^m6~gQ|%8Tv+l9@+$GW+7mkgxv@8z0OaQ+g7^27Q{HGACWV z0Td_&KzuT9zYH(`>T^`|)n`(o*+CZwzpV0~)uGz6AI^bI9huJD4T94+DIj=1{OE98 zT{-fwc;XN&?$9|e!JT|QlLEDHJs)+(dC^H$ni^Hm(h&Z$^OKNNMZ?U!5Bh~~i)NgG zy4rlUYaIVhsf*O^Kh8ArP|6uETbUqGlUW7z%w>4#p-pz52v6u)sSj>A8u#+wLk;^h z^IPR=paq-^`-jHI_wvIe$aqu2ye(;sXK$19#)WxvQqY4DfbKQ--f19yTK!7ev~hqe zB9Oj~t98j_Q5~|c2j`9Cg984S`T@0<4PEADw&rCf;lRXi?)1*fg~3qZ$+v z6GhLu)7C^Acfp?r75(c>up01pEloO&R#=qRrVmg}MFJ=TMRd}H8$vuN5tl8(=NWnV z@Nm18v>E&~Ugz-;&%s60bG*!BZ@z`^sp+_0%GAQ{t#m}HSpMv-f4>Hhi#5N$Y%bG4 z>7u=SHbmJg#@(Ovb?|5&uY<`HOt;LnQqFo+1kYYLnSf*33E`rZoNdBO0bGr+ShcN? zWa~j~$)GSZ2fM26?v;F%;8GQ|UGA>=HY(ColLWDcG{1?c?j5bGtK;|nVeN1T2^A^x z+k?!4zl0kzsKPJLw&TbEX04U%gB_Sb`%Acb=ca8?)PfXdCB9*M4+(yJ`SpW}>Ajd$5!DILdQkRi~NFajMwl4 z*Spr;9*7^<2Av{r^Up&kh=njFb{|47w96aVC&J~((!7?Fqg%qfvsy$Ho4T$%Z22DB z@T1<$S*!NHhW*JWxt=IX7{W~#P{l<*t(KE}+!~#{lQ3G@S+CcoOZ<~Cx}(tF zw=(7*=BA6RwI_Tw!qj=|J3HLVq6LXa|V)( zLLW=!7Io&dAHi7E;LNNxzMZA4`ua($9TtT|Wi23p+wSzR zx_GW8D*-(^0o9slF><}D{I(nVNsG#SpDn@&sJ_BGNnze*m_38MWVUitE(@ok-_$nn ztSO#THJeTPtO(-QX9()L?ePm_iamf(8fpsc(?$!YA!z5{!>0H}q8Gwz*BCv;x)Vzt zIYcHe!l|EYz@ki+${;!vbElGpB5%wkHcpC{qxC?;{tBy9xYl5vphr{k(RS$cH(vd1 zs_My^qx70gQk<0jJd5y+Up7~<_gaN+^>20p&?)IIK?C8VWSmt37m!F#yv8civrTFG zAZ8W6b=gP3z@9cso?Qe^yh*%$T6~cGw{AGx6u1ioD&eAMb7A zL`=D<{xTAgSE+LzJm^?0V;bWxBcM&ioESLVYUUuB&-czsAGz35wsh4(Y2l>L3`%JR|y!b*q=$T zlrw-i>4>TL%*Qw0us)u?Bc>s(1hG*W{hQfN5<_99YM-ZtId#h4UE|iGjH3e_*}Wwo z%WLXmZLEV(l&&?qvEL(r6g20@Zr~Lmy#btfiZsq$ag#c43vsjz-n%Fq< z`e9GT>bwE1tLix|B9?HgkN5q=Sw!7$f{}Xi8fhtJD{7=I4p5X93y@yK3)*iJ_OO2E zTqZfJ%wiC{xNF*$SOTi4P{hl=C*zb1!TW+}-ds)!u)e3W1f+KUIKakvN&xZSJ`WJz zAGWJ3$O1y3A^eGN(nPf(t=S;0!b(;?(J3;Vt5mTl^6p>4r`hA)l{dN%M!jxQcVUJ- zs~JwR-~_Lhsb9yk`ScJIy5s2(1D7*umS}ikXWwKc+&OLEO6$AXdE<8B{y61}=pW zNm7@53ZR6boQ{Qpi4p%g99bMO`2NCeKi7{g0x~^xRu>4y%KBU-^H@{yOvQL1-`sT5 zfK0{C_ZQ_v*Q_<#c@a*+-KsR0E=qw%p)(_ewJisyP6oaQbAv~*n>oWMPEtycBJUKI z8Q8=5%jk6Puh13p=~yNS93WQjk7pMny^?8h{yT?o6%I-sJw7Z({=@XU594ENTga>WEf{vVX{m=x z7k)FvHkWV<=(hT(aVbcFIHsFTl#u95!mB)-~y}*fIz0a*FHWg ziJua@GMQZqA*LODjuZCsiW%hDA!tCF;a^5L^*9+P&nd+7-|)2O$a**+bmA222kE-Y z`iK@2%(k?_#HL|6o58Cz%<#P-q)~FdvVe*@QT+Skxy#G^a8v+=3_Q^eD{N%qowTT% zA-^e9ZF|XQGQOo#Sk(TlAK9qK&!;lMq}c}~K3B}lch-U@JSp}EG`tL@RGUy&X{GlX zfc;4pKl}j+GcAMmjxT7&LLCY;&EHSw)-a_)S;(QWDWIJDKC>GAD!Jgifom0R(3AVXFdzH<%5E2i?~lH6!6m%KIg1KJLpp~-3c!SiwL|(q)2ioC zN&jaZpHn7C;zd8*s&F|zpSb3jnDIugI@eK`CJI%>s&Tx@pVPje#dyc`8CdxSc*VhB z^P|EXtZf?&5fbUiFD2>bneXozjnU{V3nQG)rq>u|NEwUE8A%OPqdYjl_PKG3l66cZJ-KzqjHwHpDRqPy>E`bb9b>6PrIIrczD&jXhkt@si!Sus&1IQmw} z;kz6~`c56ptm)H7m#9T9&GJ0TL}E#a^fDB|A5={y!Wk0X6_D^mMjbp@@PznNeWnEeu?AzhZ^4_{w3166axIq(V}5-GdD%YhoTqu(Pz;@&Cp(YL zfU5q4>@%^UWy}N{$LBo(Mq_j=QBa)xEHOo(YK9-={WT>9xhZRqJ;|EjRyD}<~Ymh{Uu3)oz3fIeX`>u7>l-G&N(rx{7 z3vpa4$nE5+&WVTh&uP7ZiF|ot7>H3$u{_h5P2t7u+~h>cn-;|sl>AC9N}&U6=m^*lO()SET#ae;{JW=TWGb<%_5N}$=Rl2rt=`A4`iry&bb9cHAM z$w}4%CrVPrMu08UviiT}*=aPHR2iesN>mpxm}pElqPl92GMlMCUZ8#W4NR|vjZ1zF z!P6eZ%$d@q>+GW`gwh#)iMU?T3Kk(BCw+285m3HRPS`X)u&Ec?9^tWQE&N#Zk9hqw z#Y@fWwS{8gk&aW%y-MtPvspC?PDCH^M&hD$h9!F@Tt(o7%y$Zbyl+xMvKp{21W(%^ z+|z2pmWn!>W7&SjwN4X(vi_-I_cB2AdGwNLnDaE#EnixZPRjEw5taj%6aSsz(A2V* zWXVfr-pMb{Kspyw@aWN_^-gMGCl8hb86fMP_P4C_kZ!8kf9{=vS!xU_c6RG{g33c? zNgdRt<8 literal 0 HcmV?d00001 diff --git a/public/images/robot.svg b/public/images/robot.svg new file mode 100644 index 000000000..d8937058b --- /dev/null +++ b/public/images/robot.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/room.svg b/public/images/room.svg new file mode 100644 index 000000000..83efddc4b --- /dev/null +++ b/public/images/room.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/trend.svg b/public/images/trend.svg new file mode 100644 index 000000000..67c6f9913 --- /dev/null +++ b/public/images/trend.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/user.svg b/public/images/user.svg new file mode 100644 index 000000000..c46ff8b46 --- /dev/null +++ b/public/images/user.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/whitelogo.svg b/public/images/whitelogo.svg new file mode 100644 index 000000000..0a2f25fb5 --- /dev/null +++ b/public/images/whitelogo.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/zone.svg b/public/images/zone.svg new file mode 100644 index 000000000..4ede24365 --- /dev/null +++ b/public/images/zone.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/public/index.html b/public/index.html new file mode 100644 index 000000000..c3dd7c707 --- /dev/null +++ b/public/index.html @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + Critical Asset + + + +

+ + + diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 000000000..080d6c77a --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,25 @@ +{ + "short_name": "React App", + "name": "Create React App Sample", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/public/property_manager_json_payloads.json b/public/property_manager_json_payloads.json new file mode 100644 index 000000000..8aebf1db1 --- /dev/null +++ b/public/property_manager_json_payloads.json @@ -0,0 +1,366 @@ +{ + "property_manager_dashboard": { + "user_context": { + "user_id": "USR-pm-001", + "tenant_id": "tenant_001", + "property_ids": ["PROP-DOWNTOWN-01", "PROP-RIVERSIDE-02"], + "timestamp": "2025-08-11T07:00:00Z", + "timezone": "America/New_York" + }, + + "panel_1_critical_alerts": { + "panel_id": "critical_alerts_emergency_status", + "title": "Critical Alerts & Emergency Status", + "priority": 1, + "refresh_interval_seconds": 30, + "data": { + "summary": { + "total_active_alerts": 3, + "emergency_count": 1, + "critical_count": 1, + "security_count": 1, + "system_failure_count": 0 + }, + "alerts": [ + { + "alert_id": "ALT-2025-08-11-001", + "property_id": "PROP-DOWNTOWN-01", + "property_name": "Downtown Office Complex", + "timestamp": "2025-08-11T06:45:23Z", + "alert_type": "EMERGENCY", + "severity_level": 1, + "title": "HVAC System Complete Failure - Building A", + "description": "Main HVAC unit offline, temperature rising rapidly, 200+ tenants affected", + "affected_areas": ["Floor 1-5", "Conference Rooms", "Main Lobby"], + "estimated_impact": "High - 200 tenants affected, potential revenue loss $15K/day", + "status": "IN_PROGRESS", + "assigned_to": "John Smith", + "assigned_to_phone": "+1-555-1001", + "eta_resolution": "2025-08-11T10:00:00Z", + "escalation_level": 2, + "action_required": "Emergency HVAC vendor contacted, backup systems activated, tenant notifications sent", + "data_source": "IoT_SENSORS", + "contact_info": { + "vendor": "HVAC Solutions Inc", + "phone": "+1-555-0123", + "technician": "Bob Wilson", + "eta": "09:30" + }, + "time_since_alert": "75 minutes", + "urgency_indicator": "IMMEDIATE" + }, + { + "alert_id": "ALT-2025-08-11-002", + "property_id": "PROP-DOWNTOWN-01", + "timestamp": "2025-08-11T07:15:45Z", + "alert_type": "SECURITY", + "severity_level": 3, + "title": "Security Camera Offline - Floor 3", + "description": "Main security camera in lobby area offline for 45 minutes", + "affected_areas": ["Floor 3 Main Lobby"], + "estimated_impact": "Medium - Security monitoring gap, no immediate tenant impact", + "status": "ACKNOWLEDGED", + "assigned_to": "Security Team", + "eta_resolution": "2025-08-11T14:00:00Z", + "escalation_level": 1, + "action_required": "Security vendor notified, temporary coverage arranged", + "data_source": "SECURITY_SYSTEM", + "contact_info": { + "vendor": "SecureWatch Pro", + "phone": "+1-555-0789" + }, + "time_since_alert": "45 minutes", + "urgency_indicator": "MONITOR" + } + ], + "emergency_readiness": { + "backup_systems_status": "ONLINE", + "emergency_contacts_available": true, + "vendor_response_confirmed": "2_OF_3_CONFIRMED", + "evacuation_procedures_ready": true + } + } + }, + + "panel_2_work_orders": { + "panel_id": "work_order_pipeline_resources", + "title": "Today's Work Order Pipeline & Resource Allocation", + "priority": 2, + "refresh_interval_seconds": 300, + "data": { + "summary": { + "total_work_orders_today": 8, + "overdue_count": 1, + "in_progress_count": 3, + "scheduled_count": 4, + "technician_utilization_pct": 75, + "budget_utilized_today": 3200.00, + "budget_planned_today": 3850.00 + }, + "work_orders": [ + { + "work_order_id": "WO-2025-08-11-0001", + "property_id": "PROP-DOWNTOWN-01", + "title": "Emergency Elevator Repair - Building A Main", + "priority": "EMERGENCY", + "category": "EMERGENCY", + "status": "OVERDUE", + "scheduled_date": "2025-08-10", + "scheduled_time": "14:00:00", + "estimated_duration": 240, + "assigned_technician": "Mike Chen (TECH-001)", + "technician_skills": ["ELEVATORS", "Mechanical", "Safety_Certified"], + "technician_status": "AVAILABLE", + "team_size": 2, + "location_detail": "Building A, Main Elevator Bank, Unit 1", + "parts_required": [ + {"part": "Elevator Motor Controller", "qty": 1, "status": "IN_STOCK"}, + {"part": "Safety Cable", "qty": 2, "status": "ORDERED"} + ], + "tools_required": ["Specialized Elevator Tools", "Safety Harness", "Multimeter"], + "vendor_involved": "Elevator Services Pro", + "vendor_eta": "2025-08-11T09:30:00Z", + "tenant_impact": "HIGH", + "access_requirements": "Building manager escort required, tenant notifications sent", + "completion_percentage": 0, + "notes": "Waiting for specialized parts delivery, vendor confirmed 9:30 AM arrival", + "due_date": "2025-08-10T18:00:00Z", + "cost_estimate": 1200.00, + "actual_cost": 0.00, + "sla_status": "BREACHED", + "overdue_hours": 15 + }, + { + "work_order_id": "WO-2025-08-11-0002", + "property_id": "PROP-DOWNTOWN-01", + "title": "HVAC Filter Replacement - Floor 3", + "priority": "MEDIUM", + "category": "PREVENTIVE", + "status": "IN_PROGRESS", + "scheduled_date": "2025-08-11", + "scheduled_time": "09:00:00", + "estimated_duration": 120, + "assigned_technician": "John Smith (TECH-002), Sarah Lee (TECH-003)", + "technician_skills": ["HVAC", "Preventive_Maintenance"], + "team_size": 2, + "location_detail": "Floor 3, Room 315, Main HVAC Unit", + "parts_required": [ + {"part": "HVAC Filter - High Efficiency", "qty": 4, "status": "IN_STOCK"} + ], + "tools_required": ["Screwdriver Set", "Cleaning Supplies"], + "vendor_involved": null, + "tenant_impact": "MINIMAL", + "access_requirements": "Standard access, brief noise during installation", + "completion_percentage": 75, + "notes": "75% complete, final filter installation in progress", + "due_date": "2025-08-11T11:00:00Z", + "cost_estimate": 450.00, + "actual_cost": 425.00, + "eta_completion": "2025-08-11T10:45:00Z" + }, + { + "work_order_id": "WO-2025-08-11-0003", + "property_id": "PROP-DOWNTOWN-01", + "title": "Plumbing Inspection - Floors 1-2", + "priority": "LOW", + "category": "INSPECTION", + "status": "PENDING_PARTS", + "scheduled_date": "2025-08-11", + "scheduled_time": "14:00:00", + "estimated_duration": 180, + "assigned_technician": null, + "technician_skills": ["Plumbing", "Inspection_Certified"], + "team_size": 1, + "location_detail": "Floors 1-2, All Restroom Facilities", + "parts_required": [], + "tools_required": ["Inspection Camera", "Pressure Gauge"], + "vendor_involved": null, + "tenant_impact": "NONE", + "access_requirements": "Coordinate with tenants for restroom access", + "completion_percentage": 0, + "notes": "CONFLICT: All qualified technicians assigned to higher priority work", + "due_date": "2025-08-11T17:00:00Z", + "cost_estimate": 200.00, + "actual_cost": 0.00, + "conflict_reason": "NO_TECHNICIAN_AVAILABLE", + "suggested_reschedule": "2025-08-12T09:00:00Z" + } + ], + "resource_utilization": { + "technicians": { + "total_available": 8, + "currently_assigned": 6, + "utilization_percentage": 75, + "on_break": 1, + "off_duty": 1 + }, + "parts_inventory": { + "total_items_tracked": 150, + "items_in_stock": 142, + "critical_low_items": 2, + "items_on_order": 6 + }, + "vendor_coordination": { + "appointments_scheduled": 3, + "vendors_confirmed": 2, + "vendors_delayed": 1, + "emergency_vendors_on_call": 5 + }, + "budget_tracking": { + "daily_budget_planned": 3850.00, + "actual_spent": 2425.00, + "remaining_budget": 1425.00, + "utilization_percentage": 62.98 + } + } + } + }, + + "panel_3_asset_performance": { + "panel_id": "asset_performance_predictive_maintenance", + "title": "Asset Performance & Predictive Maintenance Intelligence", + "priority": 3, + "refresh_interval_seconds": 3600, + "data": { + "summary": { + "total_assets_monitored": 47, + "healthy_assets": 38, + "warning_assets": 6, + "critical_assets": 3, + "avg_portfolio_health": 84.2, + "ai_predictions_generated": 12, + "potential_cost_savings": 23400.00, + "energy_efficiency_avg": 87.5 + }, + "assets": [ + { + "asset_id": "AST-HVAC-B1-001", + "asset_name": "Main HVAC Unit - Building A", + "property_id": "PROP-DOWNTOWN-01", + "asset_type": "HVAC", + "location_detail": "Building A, Roof Level, Unit 1", + "current_status": "CRITICAL", + "health_score": 45, + "performance_trend": "DECLINING", + "efficiency_rating": 67.5, + "baseline_efficiency": 92.0, + "efficiency_variance": -24.5, + "energy_consumption_current": 285.7, + "energy_consumption_baseline": 220.3, + "energy_variance_pct": 29.7, + "temperature_reading": 78.5, + "pressure_reading": 12.8, + "vibration_level": 3.2, + "runtime_hours_today": 20.5, + "last_maintenance_date": "2025-07-15", + "next_scheduled_maintenance": "2025-09-15", + "predicted_failure_date": "2025-08-25", + "failure_probability": 78.5, + "predicted_failure_type": "Motor Bearing Wear", + "maintenance_priority": "IMMEDIATE", + "cost_impact_prediction": 8500.00, + "preventive_cost_estimate": 1200.00, + "roi_preventive_action": 7.08, + "anomaly_detected": true, + "anomaly_description": "Temperature 15°F above normal, vibration levels elevated for 3 consecutive days", + "anomaly_severity": "CRITICAL", + "recommended_action": "Schedule emergency motor bearing replacement within 48 hours", + "action_urgency": "IMMEDIATE", + "vendor_recommendation": "HVAC Solutions Inc", + "parts_required_prediction": [ + {"part": "Motor Bearing Assembly", "confidence": 90}, + {"part": "Cooling Fan", "confidence": 65} + ], + "ai_confidence_score": 92, + "warranty_status": "ACTIVE", + "compliance_status": "COMPLIANT", + "days_until_failure": 14 + }, + { + "asset_id": "AST-ELEV-001", + "asset_name": "Main Elevator - Building A", + "property_id": "PROP-DOWNTOWN-01", + "asset_type": "ELEVATORS", + "location_detail": "Building A, Main Elevator Bank", + "current_status": "WARNING", + "health_score": 72, + "performance_trend": "DECLINING", + "efficiency_rating": 89.0, + "baseline_efficiency": 95.0, + "energy_consumption_current": 145.2, + "energy_consumption_baseline": 130.0, + "energy_variance_pct": 11.7, + "runtime_hours_today": 16.0, + "last_maintenance_date": "2025-06-01", + "next_scheduled_maintenance": "2025-09-01", + "predicted_failure_date": "2025-11-15", + "failure_probability": 35.2, + "predicted_failure_type": "Cable Tension System", + "maintenance_priority": "MEDIUM", + "cost_impact_prediction": 12000.00, + "preventive_cost_estimate": 800.00, + "roi_preventive_action": 15.0, + "anomaly_detected": true, + "anomaly_description": "Energy consumption 12% above baseline, slight increase in travel time", + "anomaly_severity": "MODERATE", + "recommended_action": "Schedule comprehensive inspection within 4 weeks", + "action_urgency": "THIS_MONTH", + "vendor_recommendation": "Elevator Services Pro", + "ai_confidence_score": 78, + "warranty_status": "ACTIVE", + "compliance_status": "DUE_INSPECTION", + "days_until_failure": 96 + }, + { + "asset_id": "AST-HVAC-B2-002", + "asset_name": "Secondary HVAC - Building A", + "property_id": "PROP-DOWNTOWN-01", + "asset_type": "HVAC", + "location_detail": "Building A, Floor 10, Unit 2", + "current_status": "HEALTHY", + "health_score": 94, + "performance_trend": "IMPROVING", + "efficiency_rating": 96.0, + "baseline_efficiency": 92.0, + "energy_consumption_current": 195.8, + "energy_consumption_baseline": 210.0, + "energy_variance_pct": -6.8, + "temperature_reading": 68.2, + "runtime_hours_today": 18.0, + "last_maintenance_date": "2025-07-01", + "next_scheduled_maintenance": "2025-12-01", + "predicted_failure_date": "2026-06-15", + "failure_probability": 8.5, + "maintenance_priority": "LOW", + "cost_impact_prediction": 4500.00, + "preventive_cost_estimate": 400.00, + "roi_preventive_action": 11.25, + "anomaly_detected": false, + "recommended_action": "Continue standard monitoring, no intervention needed", + "action_urgency": "NEXT_QUARTER", + "ai_confidence_score": 95, + "warranty_status": "ACTIVE", + "compliance_status": "COMPLIANT", + "days_until_failure": 308 + } + ], + "portfolio_intelligence": { + "total_assets": 47, + "ai_predictions": 12, + "cost_savings_potential": 23400.00, + "energy_efficiency_trend": "IMPROVING", + "compliance_percentage": 94.0, + "inspections_due_30_days": 3, + "predictive_maintenance_opportunities": 8, + "emergency_repairs_prevented": 2 + }, + "ai_insights": { + "top_recommendation": "Prioritize HVAC-B1-001 motor bearing replacement to prevent $8,500 emergency repair", + "cost_optimization": "Implementing all AI recommendations could save $23,400 in emergency repairs", + "efficiency_trend": "Portfolio energy efficiency improved 2.3% vs last month", + "risk_assessment": "3 assets require immediate attention, 6 assets trending toward issues" + } + } + } + } +} \ No newline at end of file diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 000000000..e9e57dc4d --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/src/App.css b/src/App.css new file mode 100644 index 000000000..e69de29bb diff --git a/src/App.test.tsx b/src/App.test.tsx new file mode 100644 index 000000000..2a68616d9 --- /dev/null +++ b/src/App.test.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import App from './App'; + +test('renders learn react link', () => { + render(); + const linkElement = screen.getByText(/learn react/i); + expect(linkElement).toBeInTheDocument(); +}); diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 000000000..25c182e19 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,389 @@ +import { useMemo } from "react"; +import { ChakraProvider } from "@chakra-ui/react"; +import { + StyledEngineProvider, + ThemeProvider as MuiThemeProvider, +} from "@mui/material"; +import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; +import { ApolloProvider } from "@apollo/client/react"; +import apolloClient from "./graphql/apolloClient"; +import Login from "./pages/Login"; +import Layout from "./components/Layout"; +import { AuthProvider } from "./context/AuthContext"; +import Signup from "./pages/Signup"; +import ForgetPassword from "./pages/Forget-Password"; +import ResetPassword from "./pages/Reset-password"; +import AcceptInvitation from "./pages/accept-invitation/AcceptInvitation"; +import { ProtectedRoute } from "./components/ProtectedRoute"; +import { RoleProtectedRoute } from "./components/RoleProtectedRoute"; +import Dashboard from "./pages/dashboard/Dashboard"; +import MainDashboard from "./pages/dashboard/MainDashboard"; +import Onboard from "./pages/onBoard/Onboard"; +import TicketingBoard from "./pages/ticketing"; +import { getRoleTheme } from "./themes/theme"; +import { LoaderProvider } from "./context/LoaderContext"; +import createCache from "@emotion/cache"; +import { CacheProvider } from "@emotion/react"; +import PageWithTitle from "./components/PageWithTitle"; +import muiTheme from "./themes/muiTheme"; +import Gallery from "./pages/gallery"; +import SubscriptionOverview from "./pages/subscription/SubscriptionOverview"; +import Team from "./pages/team/Team"; +import Partners from "./pages/partners/Partners"; +import Company from "./pages/company/Company"; +import { PublicRoute } from "./components/PublicRoute"; +import AiAddons from "./pages/ai-addons/AiAddons"; +import MasterData from "./pages/master-data/MasterData"; +import AssetCategory from "./pages/masterData/assetCategory/AssetCategory"; +import AssetType from "./pages/masterData/assetType/AssetType"; +import AssetFields from "./pages/masterData/assetfields/AssetFields"; +import AssetPartFields from "./pages/masterData/assetpartfields/AssetPartFields"; +import AssetParts from "./pages/masterData/assetparts/AssetParts"; +import AssignmentType from "./pages/masterData/assignmentType/AssignmentType"; +import Manufacturer from "./pages/masterData/manufacturer/Manufacturer"; +import ServiceType from "./pages/masterData/serviceType/ServiceType"; +import ServiceCategory from "./pages/masterData/serviecCategory/ServiceCategory"; +import Vendor from "./pages/masterData/vendor/Vendor"; +import WorkOrderType from "./pages/masterData/workOrderType/WorkOrderType"; +import WorkOrderStage from "./pages/masterData/workorderStage/WorkOrderStage"; +import Plans from "./pages/plans/Plans"; +// import PlanForm from "./pages/plans/PlanForm"; +import SettingsPage from "./pages/settings/Settings"; +const muiCache = createCache({ key: "mui", prepend: true }); +const chakraCache = createCache({ key: "chakra" }); + +function App() { + const chakraTheme = getRoleTheme("user"); // or 'user', 'manager', etc. based on your role logic + const memoizedMuiTheme = useMemo(() => muiTheme, []); + + return ( + + + + + + + + + + + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + } + /> + + + + } + > + + + + } + /> + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + {/* + + + + + } + /> */} + + + + + + } + /> + + + + + + } + /> + {/* master data routes */} + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + {/* } /> */} + + } + /> + + + + + + + + + + + ); +} + +export default App; diff --git a/src/components/AssetPerformance.tsx b/src/components/AssetPerformance.tsx new file mode 100644 index 000000000..f593e71a0 --- /dev/null +++ b/src/components/AssetPerformance.tsx @@ -0,0 +1,375 @@ +import { InfoOutlineIcon } from "@chakra-ui/icons"; +import { + Box, + Text, + SimpleGrid, + Accordion, + AccordionItem, + AccordionButton, + AccordionPanel, + AccordionIcon, + Badge, + Progress, + Divider, + CardHeader, + Card, + CardBody, + Flex, + Heading, + Image, + IconButton, + Menu, + MenuButton, + MenuItem, + MenuList +} from "@chakra-ui/react"; +import { useState } from "react"; + +type AssetSummary = { + total_assets_monitored: number; + healthy_assets: number; + warning_assets: number; + critical_assets: number; + avg_portfolio_health: number; + potential_cost_savings: number; +}; + +type Asset = { + asset_id: string; + asset_name: string; + current_status: "CRITICAL" | "WARNING" | "HEALTHY" | string; + health_score: number; + efficiency_rating: number; + performance_trend: "DECLINING" | "IMPROVING" | "STABLE" | string; + failure_probability: number; + predicted_failure_date: string; + predicted_failure_type?: string; // ✅ optional now + anomaly_detected?: boolean; + anomaly_description?: string; + recommended_action?: string; + + // Allow extra fields from backend without errors + [key: string]: any; +}; + +type PortfolioIntelligence = { + compliance_percentage: number; + inspections_due_30_days: number; + predictive_maintenance_opportunities: number; +}; + +type AIInsights = { + top_recommendation: string; + cost_optimization: string; + efficiency_trend: string; + risk_assessment: string; +}; + +type AssetPanel = { + panel_id: string; + title: string; + priority: number; + refresh_interval_seconds: number; + data: { + summary: AssetSummary; + assets: Asset[]; + portfolio_intelligence: PortfolioIntelligence; + ai_insights: AIInsights; + }; +}; + +type AssetPerformanceProps = { + panel: AssetPanel; +}; + +const AssetPerformance = ({ panel }: AssetPerformanceProps) => { + const { summary, assets, portfolio_intelligence, ai_insights } = panel.data; + const [filterAssetData, setFilterAssetData] = useState(panel.data.assets || []); + const filterAsset = (status?: string) => { + if(status == 'ALL'){ + setFilterAssetData(assets) + }else{ + const filters = assets?.filter((data:any) => data.current_status == status) + setFilterAssetData(filters) + } +} + + return ( + <> + + + + + + analytics + + + {panel.title} + + + + + filter_list + } + variant='outline' + /> + + filterAsset('ALL')}> + + Total Assets Monitored + {summary?.total_assets_monitored} + + + filterAsset('CRITICAL')}> + Critical Assets{summary.critical_assets} + filterAsset('WARNING')}> + Warning Assets {summary.warning_assets} + + filterAsset('HEALTHY')}> + Healthy Assets {summary.healthy_assets} + + + + + + + AI Predictions Generated + 12 + + + Avg Portfolio Health + 84.2 + + + Energy Efficiency Avg + 87.5 + + + Potential Cost Savings + 23400 + + {/* + Total Assets Monitored + 47 + */} + + + + {filterAssetData?.map((asset:any) => ( + + + + + + {asset.asset_name} + {asset.current_status} + + + + + + + + + + + + Asset ID + + {asset.asset_id} + + + + Health Score + + {/* */} + {asset.health_score} + + + + Efficiency Rating + + {asset.efficiency_rating}% + + + + Performance Trend + + + {asset.performance_trend} + + + + + Failure Probability + + {/* */} + {asset.failure_probability}% + + + + Predicted Failure + + + + {asset.predicted_failure_type} + + + + + Predicted Failure Date + + + {asset.predicted_failure_date} + + + + + + {/* Anomaly */} + {asset.anomaly_detected && ( + + + + Logo Anomaly Detected: + + {asset.anomaly_description} + + Recommended: {asset.recommended_action} + + + )} + + + ))} + + + {filterAssetData?.length == 0 && ( + No alerts found. + )} + + + + + ); +}; + +export default AssetPerformance; \ No newline at end of file diff --git a/src/components/Cards/ActionItemsCard.tsx b/src/components/Cards/ActionItemsCard.tsx new file mode 100644 index 000000000..8834d88be --- /dev/null +++ b/src/components/Cards/ActionItemsCard.tsx @@ -0,0 +1,68 @@ +import { Box, Flex, Text, Badge, VStack, HStack, Button, Circle } from "@chakra-ui/react"; + +interface ActionItem { + id: number; + title: string; + description: string; + color?: string; // optional, default color if not provided +} + +interface ActionItemsCardProps { + headerTitle?: string; + urgentCount?: number; + items: ActionItem[]; +} + +const ActionItemsCard: React.FC = ({ + headerTitle = "⚠️ Action Items", + urgentCount = 0, + items, +}) => { + return ( + + {/* Header */} + + {headerTitle} + {urgentCount > 0 && ( + + {urgentCount} URGENT + + )} + + + {/* Items */} + + {items.map((item, index) => ( + + + {index + 1} + + + {item.title} + + {item.description} + + + + ))} + + + {/* Footer */} + + + + + + ); +}; + +export default ActionItemsCard; diff --git a/src/components/Cards/ContractorActivityCard.tsx b/src/components/Cards/ContractorActivityCard.tsx new file mode 100644 index 000000000..fa36d11f3 --- /dev/null +++ b/src/components/Cards/ContractorActivityCard.tsx @@ -0,0 +1,70 @@ +import { Box, Flex, Text, Badge, VStack, Button, HStack } from "@chakra-ui/react"; + +interface Contractor { + id: number; + title: string; + description: string; + status: string; + statusColor: string; // Chakra colorScheme +} + +interface ContractorActivityCardProps { + headerTitle?: string; + headerBadge?: { label: string; colorScheme: string }; + contractors: Contractor[]; + footerButtons?: { label: string; variant?: string; colorScheme?: string }[]; +} + +const ContractorActivityCard: React.FC = ({ + headerTitle = "🔧 Today’s Contractor Activity", + headerBadge = { label: "ON SCHEDULE", colorScheme: "green" }, + contractors, + footerButtons = [ + { label: "Track Progress", colorScheme: "blue" }, + { label: "All Contractors", variant: "ghost" }, + ], +}) => { + return ( + + {/* Header */} + + {headerTitle} + + {headerBadge.label} + + + + {/* Contractor List */} + + {contractors.map((contractor) => ( + + + {contractor.title} + + {contractor.description} + + + {contractor.status} + + ))} + + + {/* Footer */} + + {footerButtons.map((btn, i) => ( + + ))} + + + ); +}; + +export default ContractorActivityCard; diff --git a/src/components/Cards/FinancialPulseCard.tsx b/src/components/Cards/FinancialPulseCard.tsx new file mode 100644 index 000000000..8609ddebd --- /dev/null +++ b/src/components/Cards/FinancialPulseCard.tsx @@ -0,0 +1,72 @@ +import { Box, Flex, Text, Badge, Button, VStack } from "@chakra-ui/react"; + +interface FinancialItem { + id: number; + label: string; + value: string; + color: string; // Chakra text color +} + +interface FinancialPulseCardProps { + headerTitle?: string; + headerBadge?: { label: string; bg: string; color: string }; + financialData: FinancialItem[]; + footerButtons?: { label: string; variant?: string; colorScheme?: string }[]; +} + +const FinancialPulseCard: React.FC = ({ + headerTitle = "💰 Financial Pulse", + headerBadge = { label: "OVER BUDGET", bg: "yellow.400", color: "black" }, + financialData, + footerButtons = [ + { label: "Review Overages", colorScheme: "blue" }, + { label: "Full Report", variant: "ghost" }, + ], +}) => { + return ( + + {/* Header */} + + {headerTitle} + + {headerBadge.label} + + + + {/* Financial Data */} + + {financialData.map((item) => ( + + {item.label} + + {item.value} + + + ))} + + + {/* Footer */} + + {footerButtons.map((btn, i) => ( + + ))} + + + ); +}; + +export default FinancialPulseCard; diff --git a/src/components/Cards/KeyPerformanceCard.tsx b/src/components/Cards/KeyPerformanceCard.tsx new file mode 100644 index 000000000..c6bd3e128 --- /dev/null +++ b/src/components/Cards/KeyPerformanceCard.tsx @@ -0,0 +1,47 @@ +import { Box, Flex, Text, Badge, VStack } from "@chakra-ui/react"; + +interface KPIItem { + id: number; + label: string; + value: string; + color: string; // Chakra color for value +} + +interface KeyPerformanceCardProps { + headerTitle?: string; + headerBadge?: { label: string; colorScheme: string }; + kpis: KPIItem[]; +} + +const KeyPerformanceCard: React.FC = ({ + headerTitle = "📈 Key Performance", + headerBadge = { label: "TRENDING UP", colorScheme: "green" }, + kpis, +}) => { + return ( + + {/* Header */} + + {headerTitle} + + {headerBadge.label} + + + + {/* KPIs */} + + {kpis.map((kpi) => ( + + {kpi.label} + + {kpi.value} + + + ))} + + + ); +}; + +export default KeyPerformanceCard; + diff --git a/src/components/Cards/TenantImpactCard.tsx b/src/components/Cards/TenantImpactCard.tsx new file mode 100644 index 000000000..12babb6ef --- /dev/null +++ b/src/components/Cards/TenantImpactCard.tsx @@ -0,0 +1,70 @@ +import { Box, Flex, Text, Badge, Button, VStack, HStack } from "@chakra-ui/react"; + +interface TenantImpact { + id: number; + name: string; + description: string; + impactLevel: string; + impactColor: string; // Chakra Badge colorScheme +} + +interface TenantImpactCardProps { + headerTitle?: string; + headerBadge?: { label: string; colorScheme: string }; + tenants: TenantImpact[]; + footerButtons?: { label: string; variant?: string; colorScheme?: string }[]; +} + +const TenantImpactCard: React.FC = ({ + headerTitle = "Tenant Impact Today", + headerBadge = { label: "5 AFFECTED", colorScheme: "orange" }, + tenants, + footerButtons = [ + { label: "Send Notifications", colorScheme: "blue" }, + { label: "View All", variant: "ghost" }, + ], +}) => { + return ( + + {/* Header */} + + {headerTitle} + + {headerBadge.label} + + + + {/* Tenant List */} + + {tenants.map((tenant) => ( + + + {tenant.name} + + {tenant.description} + + + {tenant.impactLevel} + + ))} + + + {/* Footer */} + + {footerButtons.map((btn, i) => ( + + ))} + + + ); +}; + +export default TenantImpactCard; diff --git a/src/components/CenterModal.tsx b/src/components/CenterModal.tsx new file mode 100644 index 000000000..c1d1b93e7 --- /dev/null +++ b/src/components/CenterModal.tsx @@ -0,0 +1,27 @@ +import { Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalCloseButton } from "@chakra-ui/react"; + +type CenterModalProps = { + isOpen: boolean; + onClose: () => void; + title?: string; + children: React.ReactNode; +}; + +const CenterModal: React.FC = ({ isOpen, onClose, title, children }) => { + return ( + + + + {title} + + {children} + + + ); +}; + +export default CenterModal; \ No newline at end of file diff --git a/src/components/Chatbot.tsx b/src/components/Chatbot.tsx new file mode 100644 index 000000000..63f8b2659 --- /dev/null +++ b/src/components/Chatbot.tsx @@ -0,0 +1,223 @@ +import { useState } from "react"; +import { + Box, + IconButton, + Stack, + TextField, + Typography, + useMediaQuery, + useTheme, +} from "@mui/material"; +import { Send } from "lucide-react"; + +type ChatbotProps = { + onClose?: () => void; + onMobileClose?: () => void; +}; + +type Message = { + id: number; + role: "user" | "assistant"; + text: string; +}; + +const Chatbot: React.FC = ({ onClose, onMobileClose }) => { + const [messages, setMessages] = useState([ + { id: 1, role: "assistant", text: "Hello! How can I help you today?" }, + { id: 2, role: "user", text: "Show me today's work orders." }, + { id: 3, role: "assistant", text: "Here are your assigned work orders." }, + ]); + const [input, setInput] = useState(""); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("md")); + + const handleSend = () => { + if (!input.trim()) return; + setMessages([...messages, { id: Date.now(), role: "user", text: input }]); + setTimeout(() => { + setMessages((prev) => [ + ...prev, + { id: Date.now(), role: "assistant", text: "This is an AI response" }, + ]); + }, 600); + setInput(""); + }; + + const handleClose = () => { + if (onClose) { + onClose(); + } + if (isMobile && onMobileClose) { + onMobileClose(); + } + }; + + return ( + + {/* Header */} + + + Chatbot + + + + close + + + + + {/* Messages */} + + {messages.map((msg) => ( + + + + {msg.role === "assistant" ? "smart_toy" : "person"} + + + + {msg.text} + + + ))} + + + {/* Input */} + { + e.preventDefault(); + handleSend(); + }} + sx={{ + px: 3, + py: 2, + borderTop: "1px solid #333", + display: "flex", + gap: 2, + alignItems: "center", + }} + > + setInput(e.target.value)} + fullWidth + size="small" + sx={{ + "& .MuiOutlinedInput-root": { + bgcolor: "#1f2937", + color: "#fff", + "& fieldset": { + border: "none", + }, + "&:hover fieldset": { + border: "none", + }, + "&.Mui-focused fieldset": { + border: "none", + }, + "& input::placeholder": { + color: "#9ca3af", + opacity: 1, + }, + }, + }} + /> + + + + + + ); +}; + +export default Chatbot; + + diff --git a/src/components/CrisisCheck.tsx b/src/components/CrisisCheck.tsx new file mode 100644 index 000000000..0482a7063 --- /dev/null +++ b/src/components/CrisisCheck.tsx @@ -0,0 +1,288 @@ +import React, { useState } from "react"; +import { + Box, + Flex, + Text, + SimpleGrid, + Tabs, + Tab, + TabList, + TabPanel, + TabPanels, + Card, + CardBody, + CardHeader, + Heading, + IconButton, + Menu, + MenuButton, + MenuItem, + MenuList, + Image +} from "@chakra-ui/react"; +import { AddIcon, EditIcon, ExternalLinkIcon, HamburgerIcon, InfoIcon, RepeatIcon, WarningIcon, WarningTwoIcon } from "@chakra-ui/icons"; +import CenterModal from "./CenterModal"; + + +type CrisisSummary = { + total_active_alerts: number; + emergency_count: number; + critical_count: number; + security_count: number; + system_failure_count: number; +}; + +type CrisisPanel = { + panel_id: string; + title: string; + priority: number; + refresh_interval_seconds: number; + data: { + summary: CrisisSummary; + alerts: any[]; + emergency_readiness: any; + }; +}; + +type CrisisCheckProps = { + panel: CrisisPanel; +}; + +const CrisisCheck: React.FC = ({ panel }) => { + const { summary, alerts } = panel.data; + const [selectedItem, setSelectedItem] = useState(null); + const [isOpen, setIsOpen] = useState(false); + const [filterAlertsData, setFilterAlertsData] = useState(panel.data.alerts || []); + + const getIcon = (type: string) => { + switch (type) { + case "EMERGENCY": + return ; + case "SECURITY": + return ; + case "CRITICAL": + return ; + default: + return null; + } + }; + + const getBg = (type: string) => { + switch (type) { + case "EMERGENCY": + return "red.50"; + case "CRITICAL": + return "blue.50"; + case "SECURITY": + return "orange.50"; + case "SYSTEM_FAILURE": + return "yellow.50"; + default: + return "white"; + } + }; + + const getBorders = (type: string) => { + switch (type) { + case "EMERGENCY": + return "red.400"; + case "CRITICAL": + return "blue.400"; + case "SECURITY": + return "orange.400"; + case "SYSTEM_FAILURE": + return "yellow.400"; + default: + return "gray.200"; + } + }; + console.log(alerts) + // const filterAlerts = (type?: string) => + + // type ? alerts.filter((a: any) => a.alert_type === type) : alerts; + + const filterAlerts = (type?: string) => { + const filterAlerts = (type?: string) =>type ? alerts.filter((a: any) => a.alert_type === type) : alerts; + setFilterAlertsData(filterAlerts(type)); + } + + const renderAlerts = (data: any) => { + + // const mapped = data; + + return data?.length ? ( + + {data?.map((alert: any, index: number) => ( + { + setIsOpen(true); + setSelectedItem(data[index]); + }} + > + {getIcon(alert.alert_type)} + + + {alert.title} + + + {alert.description} + + + {alert.time} + + ))} + + ) : ( + + No alerts found. + + ); + }; + + return ( + <> + + + + + + notifications_active + + {panel.title} + + + + filter_list + } + variant='outline' + /> + + filterAlerts('')}> + + Total Active Alerts + {summary.total_active_alerts} + + + filterAlerts('EMERGENCY')}> + Emergency Alerts{summary.emergency_count} + filterAlerts('CRITICAL')}> + Critical Alerts {summary.critical_count} + + filterAlerts('SECURITY')}> + Security Alerts {summary.security_count} + filterAlerts('SYSTEM_FAILURE')}> + System Failure Alerts {summary.system_failure_count} + + + + + + {renderAlerts(filterAlertsData)} + + + + + {/* Modal for Alert Details */} + setIsOpen(false)} + title={selectedItem?.title || "Alert Details"} + > + + + + Description + {selectedItem?.description} + + + Time + {selectedItem?.time || 'N/A'} + + + Property + {selectedItem?.property_name || selectedItem?.property_id} + + + Affected Areas + {selectedItem?.affected_areas?.join(", ")} + + + + Estimated Impact + {selectedItem?.estimated_impact} + + + Assigned To + {selectedItem?.assigned_to} + + + ETA Resolution + {selectedItem?.eta_resolution} + + + Action Required + {selectedItem?.action_required} + + {selectedItem?.contact_info &&( + + Vendor Contact + {selectedItem.contact_info.vendor} -{" "} + {selectedItem.contact_info.phone}{" "} + {selectedItem.contact_info.technician + ? `(Technician: ${selectedItem.contact_info.technician})` + : ""} + + )} + + Urgency Indicator + {selectedItem?.urgency_indicator} + + + + + + + ); +}; + +export default CrisisCheck; diff --git a/src/components/DashboardCard.tsx b/src/components/DashboardCard.tsx new file mode 100644 index 000000000..7abb7a039 --- /dev/null +++ b/src/components/DashboardCard.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import { Box, Text, Button, Stack } from "@chakra-ui/react"; + +interface DashboardCardProps { + title: string; + children: React.ReactNode; + footer?: React.ReactNode; + status?: string; // optional: for things like HIGH, MEDIUM, LOW +} + +const DashboardCard: React.FC = ({ title, children, footer, status }) => { + return ( + + {/* Header */} + + {title} + {status && ( + + {status} + + )} + + + {/* Content */} + + {children} + + + {/* Footer */} + {footer && {footer}} + + ); +}; + +export default DashboardCard; diff --git a/src/components/GreetingBar.tsx b/src/components/GreetingBar.tsx new file mode 100644 index 000000000..abc19be6a --- /dev/null +++ b/src/components/GreetingBar.tsx @@ -0,0 +1,73 @@ +import React, { useEffect, useState } from "react"; +import { Box, Flex, Heading, Text } from "@chakra-ui/react"; +import { useAuth } from "../context/AuthContext"; + +interface HeaderGreetingProps { + name: string; + buildingInfo: string; // Example: "Metro Plaza Complex • 3 Buildings • 450,000 sq ft" + weather: string; // Optional: for displaying current weather +} + +const HeaderGreeting: React.FC = ({ name, buildingInfo, weather}) => { + const [currentTime, setCurrentTime] = useState(new Date()); + const { user } = useAuth(); + + // Update time every second + useEffect(() => { + const timer = setInterval(() => { + setCurrentTime(new Date()); + }, 1000); + + return () => clearInterval(timer); + }, []); + + // Function to get greeting based on current hour + const getGreeting = () => { + const hour = currentTime.getHours(); + if (hour < 12) return "Good Morning"; + if (hour < 18) return "Good Afternoon"; + return "Good Evening"; + }; + + // Format time like "7:32 AM" + const formatTime = (date: Date) => + date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); + + // Format date like "Tuesday, Dec 17, 2024" + const formatDate = (date: Date) => + date.toLocaleDateString([], { weekday: "long", month: "short", day: "numeric", year: "numeric" }); + + return ( + + {/* Left side */} + + {getGreeting()}, {user?.displayname} + {user?.properties?.roles[0].displayname == 'propertymanager' ? 'Property Manager' : 'Excutive manager'} + {/* {formatDate(currentTime)} */} + + + {/* Right side */} + + + {formatTime(currentTime)} + + {formatDate(currentTime)}{","} {weather || formatDate(currentTime)} + {buildingInfo} + {/* {weather || formatDate(currentTime)} */} + + + + ); +}; + +export default HeaderGreeting; + diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx new file mode 100644 index 000000000..be591f2b9 --- /dev/null +++ b/src/components/Layout.tsx @@ -0,0 +1,229 @@ +// src/components/Layout.tsx +import Box from "@mui/material/Box"; +import IconButton from "@mui/material/IconButton"; +import Typography from "@mui/material/Typography"; +import useMediaQuery from "@mui/material/useMediaQuery"; +import { useTheme } from "@mui/material/styles"; +import { Outlet, useOutletContext } from "react-router-dom"; +import SideNav from "./SideNav"; +import { useState, useEffect, useMemo, useRef } from "react"; +import {PanelLeft } from "lucide-react"; +import { useAuth } from "../context/AuthContext"; +import { useQuery } from "@apollo/client/react"; +import { COMPANIES_QUERY } from "../graphql/queries"; +import { BubbleChat } from 'flowise-embed-react' + +type LayoutOutletContext = { + setHeaderTitle: (title: string) => void; + onMobileClose?: () => void; +}; + +const Layout = () => { + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("lg")); + // On mobile, start with panels closed. On desktop, start with sidebar open + const [sidenavOpen, setSidenavOpen] = useState(true); + const [headerTitle, setHeaderTitle] = useState("My Application"); + const { setTenantList } = useAuth(); + const userJwtToken = localStorage.getItem("accessToken"); + const bubbleTheme = { + button: { + backgroundColor: '#2563eb', + right: 24, + bottom: 24, + size: 48, + iconColor: '#ffffff', + dragAndDrop: true, + autoWindowOpen: { autoOpen: false } + }, + + tooltip: { + showTooltip: false, + tooltipMessage: 'Need help? Chat with us!', + tooltipBackgroundColor: '#1f2937', + tooltipTextColor: '#ffffff', + tooltipFontSize: 14, + }, + + disclaimer: { + title: 'Welcome to Critical Asset Assistant', + message: 'Your intelligent companion for asset management.', + textColor: '#f9fafb', + buttonColor: '#1d4ed8', + buttonText: 'Get Started', + buttonTextColor: '#ffffff', + blurredBackgroundColor: 'rgba(15,23,42,0.85)', + backgroundColor: '#1e293b', + }, + chatWindow: { + showTitle: true, + title: 'Critical Asset Assistant', + + welcomeMessage: 'Hello! How can I help you today?', + backgroundColor: '#ffffff', + + starterPrompts: [ + 'Show me my assets', + 'What are my locations?', + 'List asset categories', + 'Get asset summary' + ], + clearChatOnReload: false, + botMessage: { + backgroundColor: '#f1f5f9', + textColor: '#0f172a', + showAvatar: true, + avatarSrc: '/icons/bot.svg', + }, + + userMessage: { + backgroundColor: '#1d4ed8', + textColor: '#ffffff', + showAvatar: true, + avatarSrc: '/icons/user.svg', + }, + + textInput: { + placeholder: 'Type your message here...', + backgroundColor: '#ffffff', + textColor: '#0f172a', + sendButtonColor: '#1d4ed8', + autoFocus: true, + maxChars: 500, + }, + + footer: { + textColor: 'transparent', + text: '', + company: '', + }, + }, + }; + + // Update state when breakpoint changes + useEffect(() => { + if (isMobile) { + // On mobile, keep sidebar visible but panels closed + setSidenavOpen(false); + } else { + setSidenavOpen(true); + } + }, [isMobile]); + + // Fetch companies using GraphQL + const { data, error, refetch } = useQuery(COMPANIES_QUERY, { + fetchPolicy: 'cache-and-network', // Always fetch from network, but use cache if available + }); + + // Memoize the tenants to prevent unnecessary re-renders + const tenants = useMemo(() => { + if (data && typeof data === 'object' && 'companies' in data && Array.isArray(data.companies)) { + // Map companies to Tenant format + return (data as { companies: any[] }).companies.map((company: any) => ({ + id: company.id, + displayname: company.name || company.displayName || '', + slug: company.id, // Using id as slug, adjust if you have a slug field + ...company, // Include all company data + })); + } + return []; + }, [data]); + + // Track previous tenants to avoid unnecessary updates + const prevTenantsRef = useRef(null); + + useEffect(() => { + // Create a stable string representation to compare + const tenantsKey = JSON.stringify(tenants.map(t => t.id).sort()); + + // Only update if the tenant IDs have actually changed + if (prevTenantsRef.current !== tenantsKey) { + prevTenantsRef.current = tenantsKey; + setTenantList(tenants); + } + }, [tenants, setTenantList]); + + useEffect(() => { + if (error) { + console.error("Failed to fetch company list:", error); + } + }, [error]); + + const handleMobileClose = () => { + if (isMobile) { + setSidenavOpen(false); + } + }; + + const handleToggleSidebar = () => { + setSidenavOpen(!sidenavOpen); + }; + return ( + + + + + + + + + + {headerTitle} + + + + + + + +
+ +
+
+ ); +}; + +export default Layout; + +export const useLayoutContext = () => useOutletContext(); diff --git a/src/components/MainMenu.tsx b/src/components/MainMenu.tsx new file mode 100644 index 000000000..6d00d3626 --- /dev/null +++ b/src/components/MainMenu.tsx @@ -0,0 +1,635 @@ +import React, { Fragment, useEffect, useMemo, useState } from "react"; +import { + Box, + Button, + List, + ListItemButton, + ListItemIcon, + ListItemText, + Collapse, + Icon, + Avatar, + Menu, + MenuItem, + ListSubheader, + Typography, + useMediaQuery, +} from "@mui/material"; +import { Link as RouterLink, useLocation } from "react-router-dom"; +import { useAuth, type Tenant } from "../context/AuthContext"; +import { ExpandLess, ExpandMore } from "@mui/icons-material"; +import { Building2, Search } from "lucide-react"; +import TextField from "@mui/material/TextField"; +import InputAdornment from "@mui/material/InputAdornment"; + +type MainMenuProps = { + onMobileClose?: () => void; +}; + +const MainMenu: React.FC = ({ onMobileClose }) => { + const { user, tenantList } = useAuth(); + const location = useLocation(); + const [openMenus, setOpenMenus] = useState>({}); + const isMobile = useMediaQuery("(max-width: 768px)"); + const inactiveColor = "rgba(255, 255, 255, 0.72)"; + const [selectedTenantId, setSelectedTenantId] = useState( + tenantList && tenantList.length > 0 ? tenantList[0]?.id ?? null : null + ); + const [tenantMenuAnchor, setTenantMenuAnchor] = + useState(null); + const [searchTerm, setSearchTerm] = useState(""); + + // Update selected tenant when tenantList changes + useEffect(() => { + if (tenantList && tenantList.length > 0 && !selectedTenantId) { + setSelectedTenantId(tenantList[0]?.id ?? null); + } + }, [tenantList, selectedTenantId]); + + const selectedTenant = tenantList?.find( + (tenant) => tenant.id === selectedTenantId + ); + + const handleTenantMenuOpen = (event: React.MouseEvent) => { + setTenantMenuAnchor(event.currentTarget); + }; + + const handleTenantMenuClose = () => { + setTenantMenuAnchor(null); + setSearchTerm(""); // Clear search when menu closes + }; + + const handleTenantSelect = (tenant: Tenant) => { + setSelectedTenantId(tenant.id); + handleTenantMenuClose(); + + const authToken = localStorage.getItem("accessToken"); + const id = tenant.id || ""; + // const name = (tenant as any).name || tenant.displayname || ""; + const email = (tenant as any).email || ""; + const phone = (tenant as any).phone || ""; + const companyName = tenant.name || tenant.displayname || ""; + + if (authToken) { + // Navigate to external application with authToken, company name, email, and phone as query parameters + const url = new URL("http://company.criticalasset.com/login"); + url.searchParams.set("id", id); + url.searchParams.set("authToken", authToken); + if (companyName) url.searchParams.set("companyName", companyName); + if (email) url.searchParams.set("email", email); + if (phone) url.searchParams.set("phone", phone); + window.open(url.toString(), "_blank"); + } + }; + + const getInitials = (name?: string | null) => { + if (!name) return "?"; + const parts = name.trim().split(/\s+/); + const initials = parts + .map((part) => part.charAt(0).toUpperCase()) + .join("") + .slice(0, 1); + return initials || "?"; + }; + + // Filter tenants based on search term + const filteredTenants = useMemo(() => { + if (!searchTerm) return tenantList || []; + const term = searchTerm.toLowerCase(); + return (tenantList || []).filter((tenant: any) => { + const name = (tenant.name || tenant.displayname || "").toLowerCase(); + const email = (tenant.email || "").toLowerCase(); + return name.includes(term) || email.includes(term); + }); + }, [tenantList, searchTerm]); + + const handleMenuToggle = (menuKey: string, isActive: boolean) => { + setOpenMenus((prev) => { + // Determine current open state + const currentlyOpen = prev[menuKey] !== undefined + ? prev[menuKey] + : isActive; + + // Toggle the current state + return { + ...prev, + [menuKey]: !currentlyOpen, + }; + }); + }; + + const isMenuOpen = (menuKey: string, isActive: boolean) => { + // If user has explicitly set a state, use that (allows manual override) + if (openMenus[menuKey] !== undefined) { + return openMenus[menuKey]; + } + // Otherwise, auto-open if active + return isActive; + }; + + // Define all navigation items with their allowed roles + const allNavItems: any = [ + // { path: "/onboard", text: "Assets", Icon: "apartment", allowedRoles: ["SUPER_ADMIN", "partner_admin"] }, + { + path: "/dashboard", + text: "Dashboard", + Icon: "dashboard", + allowedRoles: ["SUPER_ADMIN", "partner_admin", "PARTNER_ADMIN", "super_admin"] // Add more roles as needed + }, + { + path: "/ticketing", + text: "Ticketing", + Icon: "view_kanban", + allowedRoles: ["SUPER_ADMIN", "partner_admin", "PARTNER_ADMIN", "super_admin"] // Add more roles as needed + }, + { + path: "/subscription", + text: "Plan & Billing", + Icon: "credit_card", + allowedRoles: ["SUPER_ADMIN", "super_admin", "partner_admin", "PARTNER_ADMIN"] + }, + { + path: "/plans", + text: "Subscription Plans", + Icon: "card_membership", + allowedRoles: ["SUPER_ADMIN", "super_admin"] + }, + { + path: "/ai-addons", + text: "AI Addons", + Icon: "smart_toy", + allowedRoles: ["SUPER_ADMIN", "super_admin", "partner_admin", "PARTNER_ADMIN"] + }, + { + path: "/team", + text: "Team", + Icon: "groups", + allowedRoles: ["SUPER_ADMIN", "super_admin", "partner_admin", "PARTNER_ADMIN"] + }, + // { + // path: "/gallery", + // text: "Gallery", + // Icon: "gallery_thumbnail", + // allowedRoles: ["SUPER_ADMIN","super_admin", "partner_admin"] + // }, + { + path: "/partners", + text: "Partners", + Icon: "people", + allowedRoles: ["SUPER_ADMIN", "super_admin"] + }, + // { + // path: "/master-data", + // text: "Master Data", + // Icon: "database", + // allowedRoles: ["SUPER_ADMIN", "super_admin", "partner_admin", "PARTNER_ADMIN"] + // }, + { + path: "/company", + text: "Company", + Icon: "business", + allowedRoles: ["SUPER_ADMIN", "super_admin", "partner_admin", "PARTNER_ADMIN"] + }, + { + text: "Master Data", + isParent: true, + Icon: "data_table", + children: [ + { path: "/manufacturer", text: "Manufacturer", Icon: "factory" }, + // { path: "/vendor", text: "Vendor", Icon: "storefront" }, + { path: "/assetcategory", text: "Asset Category", Icon: "category" }, + { path: "/assettype", text: "Asset Type", Icon: "warehouse" }, + { path: "/assetparts", text: "Asset Parts", Icon: "build" }, + { path: "/assetpartfields", text: "Asset Part Fields", Icon: "list_alt" }, + { path: "/assetfields", text: "Asset Fields", Icon: "view_list" }, + // { path: "/assignmenttype", text: "Assignment Type", Icon: "assignment" }, + // { path: "/servicecategory", text: "Service Category", Icon: "construction" }, + { path: "/servicetype", text: "Service Type", Icon: "service_toolbox" }, + // { path: "/workordertype", text: "Workorder Type", Icon: "business_center" }, + // { path: "/workorderstage", text: "Workorder Stages", Icon: "timeline" }, + ], + }, + ]; + + // Filter navItems based on user role + const navItems = useMemo(() => { + const userRole = (user as any)?.role; + + // If no role is set, return empty array or all items (adjust based on your needs) + if (!userRole) { + return []; + } + + // Filter items based on allowed roles + return allNavItems.filter((item: any) => { + // If allowedRoles is not defined, show to all (backward compatibility) + if (!item.allowedRoles || item.allowedRoles.length === 0) { + return true; + } + // Check if user's role is in the allowed roles array + return item.allowedRoles.includes(userRole); + }); + }, [user]); + + // Reset menu state when menus become inactive, so they auto-open when active again + useEffect(() => { + setOpenMenus((prev) => { + const updated = { ...prev }; + let hasChanges = false; + + navItems.forEach((nav: any) => { + if (nav.children) { + const menuKey = nav.path || nav.text; + const isActive = nav.children.some((child: any) => + location.pathname.startsWith(child.path) + ); + + // If menu is inactive and has a state set, clear it + if (!isActive && updated[menuKey] !== undefined) { + delete updated[menuKey]; + hasChanges = true; + } + } + }); + + return hasChanges ? updated : prev; + }); + }, [location.pathname]); + + return ( + + + + + + setSearchTerm(e.target.value)} + size="small" + fullWidth + InputProps={{ + startAdornment: ( + + + + ), + }} + sx={{ + "& .MuiOutlinedInput-root": { + bgcolor: "rgba(0,0,0,0.02)", + "& fieldset": { + borderColor: "rgba(0,0,0,0.1)", + }, + }, + }} + /> + + + {filteredTenants.length > 0 ? ( + filteredTenants.map((tenant: any) => ( + handleTenantSelect(tenant)} + selected={tenant.id === selectedTenantId} + sx={{ + gap: 1.5, + alignItems: "flex-start", + py: 1, + px: 2, + "&.Mui-selected": { + bgcolor: "rgba(59,130,246,0.12)", + }, + "&.Mui-selected:hover": { + bgcolor: "rgba(59,130,246,0.18)", + }, + }} + > + + {getInitials(tenant.name || tenant.displayname)} + + + + {tenant.name || tenant.displayname || "--"} + + + {tenant.email || "--"} + + + + )) + ) : ( + + + No companies found + + + )} + + + + + + {navItems.map((nav: any) => { + const isActive = nav.path + ? location.pathname.startsWith(nav.path) + : false; + + if (!nav.isParent) { + return ( + { + if (isMobile && onMobileClose) { + onMobileClose(); + } + }} + sx={{ + my: 0.5, + px: 1.2, + py: 1, + borderLeft: "2px solid", + borderLeftColor: isActive ? "#fff" : "transparent", + bgcolor: isActive ? "#3b82f6" : "transparent", + color: isActive ? "#fff" : inactiveColor, + "&:hover": { + color: "#fff", + bgcolor: "#3b82f6", + borderLeftColor: "#fff", + }, + }} + > + + + {nav.Icon} + + + + + ); + } + + const isParentActive = + nav.children?.some((child: any) => + location.pathname.startsWith(child.path) + ) ?? false; + + const menuKey = nav.path || nav.text; + const menuIsOpen = isMenuOpen(menuKey, isParentActive); + + return ( + + handleMenuToggle(menuKey, isParentActive)} + sx={{ + my: 0.5, + px: 1.2, + py: 1, + borderLeft: "2px solid", + borderLeftColor: isParentActive ? "#fff" : "transparent", + bgcolor: isParentActive ? "#3b82f6" : "transparent", + color: isParentActive ? "#fff" : inactiveColor, + "&:hover": { + color: "#fff", + bgcolor: "#3b82f6", + borderLeftColor: "#fff", + }, + }} + > + + + {nav.Icon} + + + + {menuIsOpen ? ( + + ) : ( + + )} + + + + {nav.children.map((child: any) => { + const isChildActive = location.pathname.startsWith( + child.path + ); + + return ( + { + if (isMobile && onMobileClose) { + onMobileClose(); + } + }} + sx={{ + pl: 4, + py: 1, + borderLeft: "2px solid", + borderLeftColor: isChildActive + ? "#fff" + : "transparent", + bgcolor: isChildActive ? "#3b82f6" : "transparent", + color: isChildActive ? "#fff" : inactiveColor, + "&:hover": { + color: "#fff", + bgcolor: "#3b82f6", + borderLeftColor: "#fff", + }, + }} + > + + + {child.Icon} + + + + + ); + })} + + + + ); + })} + + + + {/* + + */} + + ); +}; + +export default MainMenu; + diff --git a/src/components/MiniSidebar.tsx b/src/components/MiniSidebar.tsx new file mode 100644 index 000000000..34262d4b7 --- /dev/null +++ b/src/components/MiniSidebar.tsx @@ -0,0 +1,184 @@ +import { useState, MouseEvent } from "react"; +import { + Avatar, + Box, + IconButton, + Menu, + MenuItem, + Tooltip, + Typography, +} from "@mui/material"; +import { useAuth } from "../context/AuthContext"; +import LogoIcon from "./icons/logoIconDarkBg"; + +import { Sparkles } from "lucide-react"; +import { Navigation } from "lucide-react"; + + +type MiniSidebarProps = { + activePanel: "menu" | "chat" | null; + isOpen: boolean; + onMenuClick: () => void; + onChatClick: () => void; + zIndex?: number; +}; + +const MiniSidebar: React.FC = ({ + activePanel, + isOpen, + onMenuClick, + onChatClick, + zIndex = 1, +}) => { + const { user, logout } = useAuth(); + const [menuAnchorEl, setMenuAnchorEl] = useState(null); + + const userDisplayName = + user?.userdisplayname || user?.name || user?.email || user?.user || "User"; + const userInitial = userDisplayName?.trim()?.[0]?.toUpperCase() || "U"; + + const handleMenuOpen = (event: MouseEvent) => { + setMenuAnchorEl(event.currentTarget); + }; + + const handleMenuClose = () => { + setMenuAnchorEl(null); + }; + + const handleLogout = () => { + handleMenuClose(); + logout(); + }; + + const isMenuOpen = Boolean(menuAnchorEl); + const isMenuPanelActive = activePanel === "menu" && isOpen; + const isChatPanelActive = activePanel === "chat" && isOpen; + + return ( + + {/* Top Section */} + + + + {/* Menu Icon */} + + + + + + + {/* Chatbot Icon */} + + + + + + + + {/* Bottom Section - User Icon */} + + + + {userInitial} + + + + + + {userDisplayName} + + + window.location.href = '/settings'} sx={{ fontSize: "0.875rem" }}> + Settings + + + Logout + + + + + ); +}; + +export default MiniSidebar; + diff --git a/src/components/ModalContent/AssetModal.tsx b/src/components/ModalContent/AssetModal.tsx new file mode 100644 index 000000000..0ff0c276b --- /dev/null +++ b/src/components/ModalContent/AssetModal.tsx @@ -0,0 +1,150 @@ +import React, { useEffect, useState } from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import MenuItem from "@mui/material/MenuItem"; +import Stack from "@mui/material/Stack"; +import TextField from "@mui/material/TextField"; +import { api } from "../api"; +// import { Box } from "lucide-react"; +type assetModalProps = { + data: any; + setData: React.Dispatch>; + submitData: (data: any, isEdit: boolean) => void; +}; +const initialState = { + name: "", + // description: "", + assettypeid: "", + maintenancestatus: "", +}; +const AssetModal: React.FC = ({data, setData, submitData }) => { + const [formData, setFormData] = useState(initialState); + + const [errors, setErrors] = useState<{ [key: string]: string }>({}); + const [assettype, setAssettype] = useState([]); +useEffect(() => { + if (data && Object.keys(data).length > 0) { + const assetData = { + name: data?.name, + assettypeid: data?.assettypeid, + maintenancestatus: data?.maintenancestatus, + id: data?.id, + } + setFormData(assetData); + } else { + setFormData(initialState); + } + }, [data]); + useEffect(() => { + getAssetType(); + }, []); + const getAssetType=async()=>{ + try { + const res = await api.patch("data/rest", { + query: `ca_asset_type{}`, + }); + if (res?.data) { + setAssettype(res.data.data.ca_asset_type || []); + } + } catch (error: any) { + console.error("Fetch error:", error.message); + } finally { + // setLoading(false); + } + } + + const handleChange = ( + e: React.ChangeEvent + ) => { + const { name, value } = e.target; + setFormData((prev: any) => ({ ...prev, [name]: value })); + + // clear error when user types again + setErrors((prev) => ({ ...prev, [name]: "" })); + }; + + const validate = () => { + const newErrors: { [key: string]: string } = {}; + + Object.entries(formData).forEach(([key, value]: any) => { + if (!value.trim()) { + newErrors[key] = `${key} is required`; + } + }); + + return newErrors; + }; + + const handleSubmit = () => { + const validationErrors = validate(); + if (Object.keys(validationErrors).length > 0) { + setErrors(validationErrors); + return; + } + const isEdit = !!formData.id; + submitData(formData, isEdit); + setFormData(initialState); + console.log("✅ Form submitted:", formData); + // here you can trigger API call + }; + + return ( + + + + + {assettype?.map((asset: any) => ( + + {asset.name} + + ))} + + + + + + + + ); +}; + +export default AssetModal; diff --git a/src/components/ModalContent/FloorModal.tsx b/src/components/ModalContent/FloorModal.tsx new file mode 100644 index 000000000..01dd1b9f5 --- /dev/null +++ b/src/components/ModalContent/FloorModal.tsx @@ -0,0 +1,141 @@ +import React, { useEffect, useState } from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Stack from "@mui/material/Stack"; +import TextField from "@mui/material/TextField"; +type floorModalProps = { + data: any; + setData: React.Dispatch>; + submitData: (data: any, isEdit: boolean) => void; +}; +const initialState = { + name: "", + description: "", + // path: "", + // type: "", + // locationid: "", + // filesize: "", + // createdusing: "", +}; +const FloorModal: React.FC = ({data, setData, submitData }) => { + const [formData, setFormData] = useState(initialState); + const [errors, setErrors] = useState<{ [key: string]: string }>({}); + +useEffect(() => { + if (data && Object.keys(data).length > 0) { + const floorData = { + name: data.name, + description: data.description, + id: data.id, + } + setFormData({...initialState,...floorData}); + } else { + setFormData(initialState); + } + }, [data]); + + + const handleChange = ( + e: React.ChangeEvent + ) => { + const { name, value } = e.target; + setFormData((prev: any) => ({ ...prev, [name]: value })); + + // clear error when user types again + setErrors((prev) => ({ ...prev, [name]: "" })); + }; + + const validate = () => { + const newErrors: { [key: string]: string } = {}; + + Object.entries(formData).forEach(([key, value]: any) => { + if (!value?.trim()) { + newErrors[key] = `${key} is required`; + } + }); + + // special check for filesize + if (formData.filesize && !/^\d+$/.test(formData.filesize)) { + newErrors.filesize = "Filesize must be numeric"; + } + + return newErrors; + }; + + // const handleSubmit = () => { + // const validationErrors = validate(); + // if (Object.keys(validationErrors).length > 0) { + // setErrors(validationErrors); + // return; + // } + // const isEdit = !!formData.id; + // submitData(formData, isEdit); + // setFormData(initialState); + // console.log("✅ Form submitted:", formData); + // // here you can trigger API call + // }; + const handleSubmit = async () => { + + const validationErrors = validate(); + if (Object.keys(validationErrors).length > 0) { + setErrors(validationErrors); + return; + } + + const isEdit = !!formData.id; + + try { + + await submitData(formData, isEdit); + setFormData(initialState); + } catch (err) { + console.error("Error in submitData:", err); + } + + + }; + + return ( + + + + + + + + + + ); +}; + +export default FloorModal; diff --git a/src/components/ModalContent/LocationModal.tsx b/src/components/ModalContent/LocationModal.tsx new file mode 100644 index 000000000..3ed042f65 --- /dev/null +++ b/src/components/ModalContent/LocationModal.tsx @@ -0,0 +1,235 @@ +import React, { useEffect, useState } from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import MenuItem from "@mui/material/MenuItem"; +import Stack from "@mui/material/Stack"; +import TextField from "@mui/material/TextField"; + +type locationModalProps = { + data: any; + setData: React.Dispatch>; + submitData: (data: any, isEdit: boolean) => void; +}; +const initialState = { + name: "", + description: "", + type: "", + address1: "", + address2: "", + city: "", + state: "", + zip: "", + countryalpha2: "", +}; +const LocationModal: React.FC = ({ data, setData, submitData }) => { + + // const [formData, setFormData] = useState({ + // // id: "", + // name: "", + // description: "", + // type: "", + // address1: "", + // address2: "", + // city: "", + // state: "", + // zip: "", + // countryalpha2: "", + // // parentid: "", + // }); + const [formData, setFormData] = useState(initialState); + const [errors, setErrors] = useState<{ [key: string]: string }>({}); + useEffect(() => { + if (data && Object.keys(data).length > 0) { + const locationData = { + name: data?.name, + description: data?.description, + type: data?.location_type, + address1: data?.address?.address1, + address2: data?.address?.address2, + city: data?.address?.city, + state: data?.address?.state, + zip: data?.address?.zip, + countryalpha2: data?.address?.countryalpha2, + id: data?.id, + } + setFormData(locationData); + } else { + setFormData(initialState); + } + }, [data]); + const handleChange = ( + e: React.ChangeEvent + ) => { + const { name, value } = e.target; + setFormData((prev:any) => ({ ...prev, [name]: value })); + + // clear error when user types again + setErrors((prev) => ({ ...prev, [name]: "" })); + }; + + const validate = () => { + const newErrors: { [key: string]: string } = {}; + + Object.entries(formData).forEach(([key, value]:any) => { + // if (key === "id" || key === "parentid") return; + if (!value?.trim()) { + newErrors[key] = `${key} is required`; + } + }); + + // special check for zip + if (formData.zip && !/^\d+$/.test(formData.zip)) { + newErrors.zip = "Zip must be numeric"; + } + + return newErrors; + }; + + const handleSubmit = () => { + const validationErrors = validate(); + if (Object.keys(validationErrors).length > 0) { + setErrors(validationErrors); + return; + } + const isEdit = !!formData.id; + submitData(formData, isEdit); + setFormData(initialState); + // console.log("✅ Form submitted:", formData); + // here you can trigger API call + }; + + return ( + + + + + + + + Building + Floor + Room + Area + Zone + + + + + + + + + + + + + + + + + + + ); +}; + +export default LocationModal; + diff --git a/src/components/ModalContent/RoomModal.tsx b/src/components/ModalContent/RoomModal.tsx new file mode 100644 index 000000000..d89df5ee5 --- /dev/null +++ b/src/components/ModalContent/RoomModal.tsx @@ -0,0 +1,110 @@ +import React, { useEffect, useState } from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Stack from "@mui/material/Stack"; +import TextField from "@mui/material/TextField"; +type roomModalProps = { + data: any; + setData: React.Dispatch>; + submitData: (data: any, isEdit: boolean) => void; +}; +const initialState = { + // id: "", + name: "", + description: "", + // floorid: "", +}; +const RoomModal: React.FC = ({ data, setData, submitData }) => { + const [formData, setFormData] = useState(initialState); + + const [errors, setErrors] = useState<{ [key: string]: string }>({}); + useEffect(() => { + if (data && Object.keys(data).length > 0) { + const roomData = { + name: data.name, + description: data.description, + id: data.id, + } + setFormData({...initialState,...roomData}); + } else { + setFormData(initialState); + } + }, [data]); + + const handleChange = ( + e: React.ChangeEvent + ) => { + const { name, value } = e.target; + setFormData((prev: any) => ({ ...prev, [name]: value })); + + // clear error when user types again + setErrors((prev) => ({ ...prev, [name]: "" })); + }; + + const validate = () => { + const newErrors: { [key: string]: string } = {}; + + Object.entries(formData).forEach(([key, value]: any) => { + if (!value.trim()) { + newErrors[key] = `${key} is required`; + } + }); + + return newErrors; + }; + + const handleSubmit = () => { + const validationErrors = validate(); + if (Object.keys(validationErrors).length > 0) { + setErrors(validationErrors); + return; + } + const isEdit = !!formData.id; + submitData(formData, isEdit); + setFormData(initialState); + console.log("✅ Form submitted:", formData); + // here you can trigger API call + }; + + return ( + + + + + + + + + + ); +}; + +export default RoomModal; diff --git a/src/components/ModalContent/ZoneModal.tsx b/src/components/ModalContent/ZoneModal.tsx new file mode 100644 index 000000000..83c30a327 --- /dev/null +++ b/src/components/ModalContent/ZoneModal.tsx @@ -0,0 +1,127 @@ +import React, { useEffect, useState } from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Stack from "@mui/material/Stack"; +import TextField from "@mui/material/TextField"; +type zoneModalProps = { + data: any; + setData: React.Dispatch>; + submitData: (data: any, isEdit: boolean) => void; +}; +const initialState = { + // id: "", + name: "", + description: "", + // floorid: "", +} +const ZoneModal: React.FC = ({ data, setData, submitData }) => { + const [formData, setFormData] = useState(initialState); +useEffect(() => { + if (data && Object.keys(data).length > 0) { + const locationData = { + name: data.name, + description: data.description, + id: data.id, + } + setFormData(locationData); + } else { + setFormData(initialState); + } + }, [data]); + const [errors, setErrors] = useState<{ [key: string]: string }>({}); + + const handleChange = ( + e: React.ChangeEvent + ) => { + const { name, value } = e.target; + setFormData((prev: any) => ({ ...prev, [name]: value })); + + // clear error when user types again + setErrors((prev) => ({ ...prev, [name]: "" })); + }; + + const validate = () => { + const newErrors: { [key: string]: string } = {}; + + Object.entries(formData).forEach(([key, value]: any) => { + if (!value.trim()) { + newErrors[key] = `${key} is required`; + } + }); + + return newErrors; + }; + + // const handleSubmit = () => { + // const validationErrors = validate(); + // if (Object.keys(validationErrors).length > 0) { + // setErrors(validationErrors); + // return; + // } + // const isEdit = !!formData.id; + // submitData(formData, isEdit); + // console.log("✅ Form submitted:", formData); + // // here you can trigger API call + // }; + const handleSubmit = async () => { + const validationErrors = validate(); + if (Object.keys(validationErrors).length > 0) { + setErrors(validationErrors); + return; + } + + const isEdit = !!formData.id; + + try { + console.log("Submitting data...", formData); + await submitData(formData, isEdit); // ✅ wait for async function + console.log("✅ Data submitted successfully"); + } catch (error) { + console.error("❌ Error submitting data:", error); + } + + setFormData(initialState); // reset form after submission + }; + + return ( + + + + + + + + + + ); +}; + +export default ZoneModal; diff --git a/src/components/PageWithTitle.tsx b/src/components/PageWithTitle.tsx new file mode 100644 index 000000000..037690786 --- /dev/null +++ b/src/components/PageWithTitle.tsx @@ -0,0 +1,19 @@ +import { ReactNode, useEffect } from "react"; +import { useLayoutContext } from "./Layout"; + +interface PageWithTitleProps { + title: string; + children: ReactNode; +} + +const PageWithTitle = ({ title, children }: PageWithTitleProps) => { + const { setHeaderTitle } = useLayoutContext(); + + useEffect(() => { + setHeaderTitle(title); + }, [setHeaderTitle, title]); + + return <>{children}; +}; + +export default PageWithTitle; diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx new file mode 100644 index 000000000..1906e3f98 --- /dev/null +++ b/src/components/ProtectedRoute.tsx @@ -0,0 +1,18 @@ +import { Navigate, useLocation } from "react-router-dom"; +import { useAuth } from "../context/AuthContext"; +import { ReactElement } from "react"; +export const ProtectedRoute = ({ children }: { children: ReactElement }) => { + const { user } = useAuth(); + const storedUser = localStorage.getItem("user"); + const token = localStorage.getItem("accessToken"); + if (!token) { + return ( + + ); + } + return children; +}; \ No newline at end of file diff --git a/src/components/PublicRoute.tsx b/src/components/PublicRoute.tsx new file mode 100644 index 000000000..84b60da4d --- /dev/null +++ b/src/components/PublicRoute.tsx @@ -0,0 +1,19 @@ + import { JSX } from "react"; +import { Navigate, useLocation } from "react-router-dom"; + +export const PublicRoute = ({ children }: { children: JSX.Element }) => { + + const token = localStorage.getItem("accessToken"); + const location = useLocation(); + + if (token) { + const previous = location.state?.from; + return ; + + } + + + return children; +}; + + diff --git a/src/components/RightSideModal.tsx b/src/components/RightSideModal.tsx new file mode 100644 index 000000000..2c793ef61 --- /dev/null +++ b/src/components/RightSideModal.tsx @@ -0,0 +1,99 @@ +import React from "react"; +import Dialog from "@mui/material/Dialog"; +import DialogContent from "@mui/material/DialogContent"; +import DialogTitle from "@mui/material/DialogTitle"; +import IconButton from "@mui/material/IconButton"; +import Slide from "@mui/material/Slide"; +import { TransitionProps } from "@mui/material/transitions"; +import CloseIcon from "@mui/icons-material/Close"; +import Box from "@mui/material/Box"; + +type RightSideModalProps = { + isOpen: boolean; + onClose: () => void; + title?: string; + children: React.ReactNode; +}; + +const Transition = React.forwardRef(function Transition( + props: TransitionProps & { + children: React.ReactElement; + }, + ref: React.Ref +) { + return ; +}); + +const RightSideModal: React.FC = ({ + isOpen, + onClose, + title, + children, +}) => { + return ( + + + + {title} + + + + + + + + {children} + + + ); +}; + +export default RightSideModal; \ No newline at end of file diff --git a/src/components/RoleProtectedRoute.tsx b/src/components/RoleProtectedRoute.tsx new file mode 100644 index 000000000..42041a35e --- /dev/null +++ b/src/components/RoleProtectedRoute.tsx @@ -0,0 +1,34 @@ +import { Navigate } from "react-router-dom"; +import { useAuth } from "../context/AuthContext"; +import { ReactElement } from "react"; + +type RoleProtectedRouteProps = { + children: ReactElement; + allowedRoles: string[]; +}; + +export const RoleProtectedRoute = ({ + children, + allowedRoles +}: RoleProtectedRouteProps) => { + const user = JSON.parse(localStorage.getItem("userData") || "{}"); + const token = localStorage.getItem("accessToken"); + + // First check if user is authenticated + if (!token) { + return ; + } + + // Then check if user has the required role (case-insensitive comparison) + const userRole = (user as any)?.role?.toLowerCase(); + const normalizedAllowedRoles = allowedRoles.map(role => role.toLowerCase()); + + if (!userRole || !normalizedAllowedRoles.includes(userRole)) { + // Redirect to a default route or show unauthorized + // You can customize this based on your needs + return ; + } + + return children; +}; + diff --git a/src/components/SideNav.tsx b/src/components/SideNav.tsx new file mode 100644 index 000000000..04ec705a8 --- /dev/null +++ b/src/components/SideNav.tsx @@ -0,0 +1,202 @@ +import { useState, useEffect, useRef } from "react"; +import { Box, useBreakpointValue } from "@chakra-ui/react"; +import MainMenu from "./MainMenu"; +import Chatbot from "./Chatbot"; +import MiniSidebar from "./MiniSidebar"; + +type ParentProps = { + isOpen: boolean; + setIsOpen: React.Dispatch>; + onMobileClose?: () => void; +}; + +const SideNav: React.FC = ({ isOpen, setIsOpen, onMobileClose }) => { + // Initialize with menu open if sidebar is open (for initial login) + const [activePanel, setActivePanel] = useState<"menu" | "chat" | null>(isOpen ? "menu" : null); + const isMobile = useBreakpointValue({ base: true, lg: false }); + const prevIsOpenRef = useRef(undefined); + + // Sync activePanel with isOpen prop changes + useEffect(() => { + const prevIsOpen = prevIsOpenRef.current; + + if (!isOpen) { + // When sidebar is closed externally, clear active panel + setActivePanel(null); + } else if (isOpen && (prevIsOpen === undefined || !prevIsOpen)) { + // When sidebar is opened (changed from false to true, or initial mount with isOpen=true) + // Open menu by default - this is especially important on mobile when the toggle button in Layout is clicked + setActivePanel("menu"); + } + + // Update ref after handling the state change + prevIsOpenRef.current = isOpen; + }, [isOpen, isMobile]); + + // Handle panel toggle + const handlePanelToggle = (panel: "menu" | "chat") => { + if (isMobile) { + // On mobile, opening a panel should also open the drawer + setIsOpen(true); + setActivePanel(activePanel === panel ? null : panel); + } else { + // On desktop, toggle the panel and sidebar state + if (activePanel === panel) { + // If clicking the same panel, minimize + setActivePanel(null); + setIsOpen(false); + } else { + // Switch to the clicked panel + setActivePanel(panel); + setIsOpen(true); + } + } + }; + + const handleChatbotClose = () => { + setActivePanel(null); + setIsOpen(false); + }; + + // Mobile View - Mini sidebar always visible, panels overlay + if (isMobile) { + return ( + <> + {/* Mini Sidebar - Always visible on mobile */} + handlePanelToggle("menu")} + onChatClick={() => handlePanelToggle("chat")} + zIndex={2} + /> + + {/* Menu Panel - Overlay on mobile */} + {activePanel === "menu" && isOpen && ( + <> + + + + {/* Overlay backdrop */} + { + setIsOpen(false); + if (onMobileClose) onMobileClose(); + }} + /> + + )} + + {/* Chatbot Panel - Overlay on mobile */} + {activePanel === "chat" && isOpen && ( + <> + + + + {/* Overlay backdrop */} + { + setIsOpen(false); + if (onMobileClose) onMobileClose(); + }} + /> + + )} + + ); + } + + // Desktop View - Panels push content to the right + return ( + <> + {/* ---------- LEFT MINI SIDEBAR ---------- */} + handlePanelToggle("menu")} + onChatClick={() => handlePanelToggle("chat")} + /> + + {/* ---------- RIGHT MAIN PANEL ---------- */} + {activePanel === "menu" && isOpen && ( + + + + )} + + {/* ---------- CHATBOT PANEL ---------- */} + {activePanel === "chat" && isOpen && ( + + + + )} + + ); +}; + +export default SideNav; diff --git a/src/components/aiChatbox.tsx b/src/components/aiChatbox.tsx new file mode 100644 index 000000000..bb2257389 --- /dev/null +++ b/src/components/aiChatbox.tsx @@ -0,0 +1,159 @@ +import { useState } from "react"; +import { + Box, + VStack, + HStack, + Text, + IconButton, + Input, + InputGroup, + InputRightElement, +} from "@chakra-ui/react"; +import { CloseIcon } from "@chakra-ui/icons"; + +const AIChatBox = ({ onClose }: { onClose: () => void }) => { + const [messages, setMessages] = useState<{ sender: "user" | "ai"; text: string }[]>([]); + const [input, setInput] = useState(""); + + const handleSend = () => { + if (!input.trim()) return; + setMessages([...messages, { sender: "user", text: input }]); + + setTimeout(() => { + setMessages((prev) => [ + ...prev, + { sender: "ai", text: "This is an AI response to: " + input }, + ]); + }, 600); + + setInput(""); + }; + + return ( + + {/* Header */} + + + Copilot + + } + aria-label="close" + variant="ghost" + color="white" + _hover={{ bg: "whiteAlpha.300" }} + onClick={onClose} + /> + + + {/* Messages */} + + {messages.length === 0 ? ( + + Start chatting... + + ) : ( + messages.map((msg, idx) => ( + + {/* Icon before AI message */} + {msg.sender === "ai" && ( + + smart_toy + + )} + + {/* Message Bubble */} + + {msg.text} + + + {/* Icon before User message (placed after bubble but aligned right) */} + {msg.sender === "user" && ( + + person + + )} + + )) + )} + + + {/* Input */} + + + setInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSend()} + /> + + + send + + } + onClick={handleSend} + /> + + + + + ); +}; + +export default AIChatBox; diff --git a/src/components/api.tsx b/src/components/api.tsx new file mode 100644 index 000000000..d9e9f442e --- /dev/null +++ b/src/components/api.tsx @@ -0,0 +1,39 @@ +import axios from "axios"; + +// ----------------- /account instance ----------------- +const api = axios.create({ + baseURL: "http://api-dev2.criticalasset.com/", +}); + +// ----------------- /bff instance ----------------- +const bffApi = axios.create({ + baseURL: "/", +}); + +// Common request interceptor +const requestInterceptor = (config: any) => { + const token = localStorage.getItem("accessToken"); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}; + +// Common response interceptor +const responseInterceptor = (response: any) => response; +const responseErrorInterceptor = async (error: any) => { + if (error.response?.status === 401) { + localStorage.removeItem("accessToken"); + localStorage.removeItem("userData"); + window.location.href = "/login"; + } + return Promise.reject(error); +}; + +// Apply interceptors to both instances +[api, bffApi].forEach((instance) => { + instance.interceptors.request.use(requestInterceptor, (error) => Promise.reject(error)); + instance.interceptors.response.use(responseInterceptor, responseErrorInterceptor); +}); + +export { api, bffApi }; diff --git a/src/components/assetIcons/AssetIcon.tsx b/src/components/assetIcons/AssetIcon.tsx new file mode 100644 index 000000000..0c237e6b7 --- /dev/null +++ b/src/components/assetIcons/AssetIcon.tsx @@ -0,0 +1,104 @@ +import React from "react"; +import Box from "@mui/material/Box"; +import tinycolor2 from "tinycolor2"; +import AssetIcons from "../icons/assets/index"; + +export type IconType = "Square" | "Circle"; + +interface AssetIconProps { + iconName: string; + iconSize?: "xs" | "sm" | "md" | "lg"; + iconColor?: string; + iconType: IconType; + sx?: any; +} + +const getIconComponent = (icon: string): React.FC | undefined => { + return (AssetIcons as unknown as { [key: string]: React.FC })[icon]; +}; + +const AssetIcon: React.FC = ({ + iconName, + iconSize = "md", + iconColor = "#4A5568", + iconType, + sx, +}) => { + const Asset = React.useMemo(() => getIconComponent(iconName), [iconName]); + const iconTextColor = React.useMemo( + () => (tinycolor2(iconColor).isDark() ? "white" : "black"), + [iconColor] + ); + + const boxSize = + iconSize === "md" + ? "4rem" + : iconSize === "sm" + ? "3rem" + : iconSize === "xs" + ? "2rem" + : "6rem"; + const iconBoxSize = + iconSize === "md" + ? "3.2rem" + : iconSize === "sm" + ? "2.3rem" + : iconSize === "xs" + ? "1.5rem" + : "5rem"; + let iconTextSize = + iconSize === "md" + ? 1.2 + : iconSize === "sm" + ? 0.9 + : iconSize === "xs" + ? 0.65 + : 1.8; + if (iconName.length > 3) { + iconTextSize -= 0.2; + } + + return ( + + {Asset ? ( + + ) : ( + + {iconName} + + )} + + ); +}; + +export { getIconComponent }; +export default AssetIcon; + diff --git a/src/components/assetIcons/AssetIconField.tsx b/src/components/assetIcons/AssetIconField.tsx new file mode 100644 index 000000000..ff9418cc0 --- /dev/null +++ b/src/components/assetIcons/AssetIconField.tsx @@ -0,0 +1,127 @@ +import React, { useState } from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import FormHelperText from "@mui/material/FormHelperText"; +import AssetIcon, { IconType, getIconComponent } from "./AssetIcon"; +import ColorPicker from "./ColorPicker"; +import IconPicker from "./IconPicker"; +import TypePicker from "./TypePicker"; + +interface AssetIconFieldProps { + iconName: string; + iconColor: string; + iconType: IconType; + onIconNameChange: (value: string) => void; + onIconColorChange: (value: string) => void; + onIconTypeChange: (value: IconType) => void; + parentColor?: string; + error?: string; + touched?: boolean; +} + +const AssetIconField: React.FC = ({ + iconName, + iconColor, + iconType, + onIconNameChange, + onIconColorChange, + onIconTypeChange, + parentColor, + error, + touched, +}) => { + const [isIconPickerOpen, setIsIconPickerOpen] = useState(false); + const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); + + const finalColorValue = iconColor || parentColor || "#4A5568"; + + return ( + + + + + + {iconName ? ( + + ) : ( + + )} + + + + + + + + {error && touched && ( + + {error} + + )} + + setIsColorPickerOpen(false)} + value={iconColor || finalColorValue} + onChange={onIconColorChange} + /> + + setIsIconPickerOpen(false)} + onSelect={onIconNameChange} + color={finalColorValue} + iconType={iconType} + /> + + + ); +}; + +export default AssetIconField; + diff --git a/src/components/assetIcons/ColorPicker.tsx b/src/components/assetIcons/ColorPicker.tsx new file mode 100644 index 000000000..62cd9e540 --- /dev/null +++ b/src/components/assetIcons/ColorPicker.tsx @@ -0,0 +1,120 @@ +import React from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Stack from "@mui/material/Stack"; +import { GithubPicker, MaterialPicker, SliderPicker } from "react-color"; + +export const defaultAssetCategoryColors = [ + "#C0C0C0", + "#808080", + "#2F4F4F", + "#708090", + "#000000", + "#FF0000", + "#8B0000", + "#FFA500", + "#FFD700", + "#F0E68C", + "#FFFF00", + "#4169E1", + "#1E90FF", + "#6495ED", + "#808000", + "#00FF00", + "#98FB98", + "#008000", + "#00FFFF", + "#008080", + "#0000FF", + "#000080", + "#FF00FF", + "#800080", +]; + +interface ColorPickerProps { + parentColor?: string; + isColorPickerOpen: boolean; + onColorPickerClose: () => void; + value: string; + onChange: (color: string) => void; + showAdvanced?: boolean; +} + +const ColorPicker: React.FC = ({ + parentColor, + isColorPickerOpen, + onColorPickerClose, + value, + onChange, + showAdvanced = false, +}) => { + if (!isColorPickerOpen) return null; + + return ( + + span": { + margin: "4px 6px", + }, + }, + }} + > + onChange(color.hex)} + /> + {showAdvanced && ( + <> + + onChange(color.hex)} + /> + + onChange(color.hex)} + /> + + )} + + + + {!!parentColor && !!value && value !== parentColor && ( + + )} + + + ); +}; + +export default ColorPicker; + diff --git a/src/components/assetIcons/IconPicker.tsx b/src/components/assetIcons/IconPicker.tsx new file mode 100644 index 000000000..646786e01 --- /dev/null +++ b/src/components/assetIcons/IconPicker.tsx @@ -0,0 +1,170 @@ +import React, { useState, useEffect } from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Input from "@mui/material/Input"; +import Modal from "@mui/material/Modal"; +import Stack from "@mui/material/Stack"; +import Tabs from "@mui/material/Tabs"; +import Tab from "@mui/material/Tab"; +import Typography from "@mui/material/Typography"; +import AssetIcons from "../icons/assets/index"; +import AssetIcon, { IconType } from "./AssetIcon"; + +interface IconPickerProps { + defaultCustomIconText: string; + value: string; + isIconPickerOpen: boolean; + onIconPickerClose: () => void; + onSelect: (icon: string) => void; + color: string; + iconType: IconType; +} + +const IconPicker: React.FC = ({ + defaultCustomIconText, + value, + isIconPickerOpen, + onIconPickerClose, + onSelect, + color, + iconType, +}) => { + const [customIconText, setCustomIconText] = useState(defaultCustomIconText); + const [tabValue, setTabValue] = useState(0); + + const setIconValue = React.useCallback( + (icon: string) => { + onSelect(icon); + setCustomIconText(""); + onIconPickerClose(); + }, + [onSelect, onIconPickerClose] + ); + + useEffect(() => { + setCustomIconText(defaultCustomIconText); + setTabValue(defaultCustomIconText ? 1 : 0); + }, [defaultCustomIconText]); + + const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => { + setTabValue(newValue); + }; + + return ( + + + + + + + + + + {tabValue === 0 && ( + + {Object.keys(AssetIcons || {}).length === 0 && ( + + No icons available + + )} + {Object.keys(AssetIcons || {}).map((key) => ( + setIconValue(key)} + sx={{ + cursor: "pointer", + padding: "4px", + borderRadius: 1, + border: value === key ? "3px solid" : "3px solid transparent", + borderColor: value === key ? "secondary.main" : "transparent", + "&:hover": { + borderColor: "secondary.main", + backgroundColor: "action.hover", + }, + display: "flex", + alignItems: "center", + justifyContent: "center", + }} + > + + + ))} + + )} + + {tabValue === 1 && ( + + + + + + + Enter three letters for your icon + + + setCustomIconText(e.target.value)} + sx={{ textAlign: "center" }} + /> + + + + + )} + + + ); +}; + +export default IconPicker; + diff --git a/src/components/assetIcons/TypePicker.tsx b/src/components/assetIcons/TypePicker.tsx new file mode 100644 index 000000000..6387de15f --- /dev/null +++ b/src/components/assetIcons/TypePicker.tsx @@ -0,0 +1,75 @@ +import React from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { IconType } from "./AssetIcon"; + +interface TypePickerProps { + color: string; + value: IconType; + onChange: (type: IconType) => void; +} + +const TypePicker: React.FC = ({ color, value, onChange }) => { + return ( + + Icon Style + + + + ); +}; + +export default TypePicker; + diff --git a/src/components/assetIcons/index.ts b/src/components/assetIcons/index.ts new file mode 100644 index 000000000..ac86a3855 --- /dev/null +++ b/src/components/assetIcons/index.ts @@ -0,0 +1,6 @@ +export { default as AssetIcon, getIconComponent, type IconType } from "./AssetIcon"; +export { default as AssetIconField } from "./AssetIconField"; +export { default as ColorPicker, defaultAssetCategoryColors } from "./ColorPicker"; +export { default as IconPicker } from "./IconPicker"; +export { default as TypePicker } from "./TypePicker"; + diff --git a/src/components/icons/add.tsx b/src/components/icons/add.tsx new file mode 100644 index 000000000..f2c1fd27e --- /dev/null +++ b/src/components/icons/add.tsx @@ -0,0 +1,13 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const Add = (props: IconProps & any) => ( + + + +); + +export default Add; diff --git a/src/components/icons/addCircle.tsx b/src/components/icons/addCircle.tsx new file mode 100644 index 000000000..6b851282e --- /dev/null +++ b/src/components/icons/addCircle.tsx @@ -0,0 +1,11 @@ +import { Icon, IconProps } from "@chakra-ui/react"; + +const AddCircle = (props: IconProps & any) => ( + + + + + +); + +export default AddCircle; diff --git a/src/components/icons/addMultiple.tsx b/src/components/icons/addMultiple.tsx new file mode 100644 index 000000000..f0256c4d0 --- /dev/null +++ b/src/components/icons/addMultiple.tsx @@ -0,0 +1,10 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const AddMultiple = (props: IconProps & any) => ( + + + +); + +export default AddMultiple; diff --git a/src/components/icons/address.tsx b/src/components/icons/address.tsx new file mode 100644 index 000000000..9b5802312 --- /dev/null +++ b/src/components/icons/address.tsx @@ -0,0 +1,25 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const address = (props: IconProps & any) => ( + + + + + +); + +export default address; diff --git a/src/components/icons/admin.tsx b/src/components/icons/admin.tsx new file mode 100644 index 000000000..aec0c765a --- /dev/null +++ b/src/components/icons/admin.tsx @@ -0,0 +1,16 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const Admin = (props: IconProps & any) => ( + + + +); + +export default Admin; diff --git a/src/components/icons/ai.tsx b/src/components/icons/ai.tsx new file mode 100644 index 000000000..406e94963 --- /dev/null +++ b/src/components/icons/ai.tsx @@ -0,0 +1,13 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const AI = (props: IconProps & any) => ( + + + +); + +export default AI; diff --git a/src/components/icons/americanExpressCard.tsx b/src/components/icons/americanExpressCard.tsx new file mode 100644 index 000000000..a1cca1eb5 --- /dev/null +++ b/src/components/icons/americanExpressCard.tsx @@ -0,0 +1,28 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const AmericanExpress = (props: IconProps & any) => ( + + + + + + +); + +export default AmericanExpress; diff --git a/src/components/icons/asset.tsx b/src/components/icons/asset.tsx new file mode 100644 index 000000000..331194851 --- /dev/null +++ b/src/components/icons/asset.tsx @@ -0,0 +1,11 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const Asset = (props: IconProps & any) => ( + + + + +); + +export default Asset; diff --git a/src/components/icons/assetCategories.tsx b/src/components/icons/assetCategories.tsx new file mode 100644 index 000000000..a55d1a5db --- /dev/null +++ b/src/components/icons/assetCategories.tsx @@ -0,0 +1,15 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const AssetCategories = (props: IconProps & any) => ( + + + + + + + + +); + +export default AssetCategories; diff --git a/src/components/icons/assets.tsx b/src/components/icons/assets.tsx new file mode 100644 index 000000000..8ec712779 --- /dev/null +++ b/src/components/icons/assets.tsx @@ -0,0 +1,10 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const Asset = (props: IconProps & any) => ( + + + +); + +export default Asset; diff --git a/src/components/icons/assets/ahu1.tsx b/src/components/icons/assets/ahu1.tsx new file mode 100644 index 000000000..fe79a2dd0 --- /dev/null +++ b/src/components/icons/assets/ahu1.tsx @@ -0,0 +1,24 @@ +import React from "react"; + +interface Ahu1Props extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Ahu1: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + +); + +export default Ahu1; diff --git a/src/components/icons/assets/ahu2.tsx b/src/components/icons/assets/ahu2.tsx new file mode 100644 index 000000000..f0989ece2 --- /dev/null +++ b/src/components/icons/assets/ahu2.tsx @@ -0,0 +1,28 @@ +import React from "react"; + +interface Ahu2Props extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Ahu2: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default Ahu2; diff --git a/src/components/icons/assets/airAdmittanceValve.tsx b/src/components/icons/assets/airAdmittanceValve.tsx new file mode 100644 index 000000000..bcd778868 --- /dev/null +++ b/src/components/icons/assets/airAdmittanceValve.tsx @@ -0,0 +1,42 @@ +import React from "react"; + +interface AirAdmittanceValveProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const AirAdmittanceValve: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + + + +); + +export default AirAdmittanceValve; diff --git a/src/components/icons/assets/airCompressor.tsx b/src/components/icons/assets/airCompressor.tsx new file mode 100644 index 000000000..f15e4dd5e --- /dev/null +++ b/src/components/icons/assets/airCompressor.tsx @@ -0,0 +1,26 @@ +import React from "react"; + +interface AirCompressorProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const AirCompressor: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default AirCompressor; diff --git a/src/components/icons/assets/backflowPreventer.tsx b/src/components/icons/assets/backflowPreventer.tsx new file mode 100644 index 000000000..e661c33a7 --- /dev/null +++ b/src/components/icons/assets/backflowPreventer.tsx @@ -0,0 +1,26 @@ +import React from "react"; + +interface BackflowPreventerProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const BackflowPreventer: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default BackflowPreventer; diff --git a/src/components/icons/assets/battery.tsx b/src/components/icons/assets/battery.tsx new file mode 100644 index 000000000..fea3aa0a3 --- /dev/null +++ b/src/components/icons/assets/battery.tsx @@ -0,0 +1,30 @@ +import React from "react"; + +interface BatteryProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Battery: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + +); + +export default Battery; diff --git a/src/components/icons/assets/batteryInverter.tsx b/src/components/icons/assets/batteryInverter.tsx new file mode 100644 index 000000000..2f2f97e83 --- /dev/null +++ b/src/components/icons/assets/batteryInverter.tsx @@ -0,0 +1,36 @@ +import React from "react"; + +interface BatteryInverterProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const BatteryInverter: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + +); + +export default BatteryInverter; diff --git a/src/components/icons/assets/cabinet.tsx b/src/components/icons/assets/cabinet.tsx new file mode 100644 index 000000000..78232b0f3 --- /dev/null +++ b/src/components/icons/assets/cabinet.tsx @@ -0,0 +1,30 @@ +import React from "react"; + +interface CabinetProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Cabinet: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + +); + +export default Cabinet; diff --git a/src/components/icons/assets/cableTray.tsx b/src/components/icons/assets/cableTray.tsx new file mode 100644 index 000000000..ca4cb938b --- /dev/null +++ b/src/components/icons/assets/cableTray.tsx @@ -0,0 +1,32 @@ +import React from "react"; + +interface CableTrayProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const CableTray: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + +); + +export default CableTray; diff --git a/src/components/icons/assets/carbonDioxideSensor.tsx b/src/components/icons/assets/carbonDioxideSensor.tsx new file mode 100644 index 000000000..e32c1c7ca --- /dev/null +++ b/src/components/icons/assets/carbonDioxideSensor.tsx @@ -0,0 +1,38 @@ +import React from "react"; + +interface CarbonDioxideSensorProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const CarbonDioxideSensor: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + +); + +export default CarbonDioxideSensor; diff --git a/src/components/icons/assets/carbonMonoxideDetector.tsx b/src/components/icons/assets/carbonMonoxideDetector.tsx new file mode 100644 index 000000000..79bd1eec3 --- /dev/null +++ b/src/components/icons/assets/carbonMonoxideDetector.tsx @@ -0,0 +1,28 @@ +import React from "react"; + +interface CarbonMonoxideDetectorProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const CarbonMonoxideDetector: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default CarbonMonoxideDetector; diff --git a/src/components/icons/assets/cardReader.tsx b/src/components/icons/assets/cardReader.tsx new file mode 100644 index 000000000..a8f24af03 --- /dev/null +++ b/src/components/icons/assets/cardReader.tsx @@ -0,0 +1,30 @@ +import React from "react"; + +interface CardReaderProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const CardReader: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + +); + +export default CardReader; diff --git a/src/components/icons/assets/ceilingTiles.tsx b/src/components/icons/assets/ceilingTiles.tsx new file mode 100644 index 000000000..610c95eab --- /dev/null +++ b/src/components/icons/assets/ceilingTiles.tsx @@ -0,0 +1,28 @@ +import React from "react"; + +interface CeilingTilesProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const CeilingTiles: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default CeilingTiles; diff --git a/src/components/icons/assets/chillerAirCooled.tsx b/src/components/icons/assets/chillerAirCooled.tsx new file mode 100644 index 000000000..35658daec --- /dev/null +++ b/src/components/icons/assets/chillerAirCooled.tsx @@ -0,0 +1,34 @@ +import React from "react"; + +interface ChillerAirCooledProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const ChillerAirCooled: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + +); + +export default ChillerAirCooled; diff --git a/src/components/icons/assets/chillerWaterCooled.tsx b/src/components/icons/assets/chillerWaterCooled.tsx new file mode 100644 index 000000000..cd6c59186 --- /dev/null +++ b/src/components/icons/assets/chillerWaterCooled.tsx @@ -0,0 +1,34 @@ +import React from "react"; + +interface ChillerWaterCooledProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const ChillerWaterCooled: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + +); + +export default ChillerWaterCooled; diff --git a/src/components/icons/assets/clock.tsx b/src/components/icons/assets/clock.tsx new file mode 100644 index 000000000..e8a191ef1 --- /dev/null +++ b/src/components/icons/assets/clock.tsx @@ -0,0 +1,36 @@ +import React from "react"; + +interface ClockProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Clock: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + +); + +export default Clock; diff --git a/src/components/icons/assets/computer.tsx b/src/components/icons/assets/computer.tsx new file mode 100644 index 000000000..55d9d6f05 --- /dev/null +++ b/src/components/icons/assets/computer.tsx @@ -0,0 +1,38 @@ +import React from "react"; + +interface ComputerProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Computer: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + + +); + +export default Computer; diff --git a/src/components/icons/assets/controlDamper.tsx b/src/components/icons/assets/controlDamper.tsx new file mode 100644 index 000000000..c2a7d0d13 --- /dev/null +++ b/src/components/icons/assets/controlDamper.tsx @@ -0,0 +1,52 @@ +import React from "react"; + +interface ControlDamperProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const ControlDamper: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + + +); + +export default ControlDamper; diff --git a/src/components/icons/assets/coolingTower.tsx b/src/components/icons/assets/coolingTower.tsx new file mode 100644 index 000000000..c398f7e49 --- /dev/null +++ b/src/components/icons/assets/coolingTower.tsx @@ -0,0 +1,38 @@ +import React from "react"; + +interface CoolingTowerProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const CoolingTower: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + + +); + +export default CoolingTower; diff --git a/src/components/icons/assets/dataReceptacle.tsx b/src/components/icons/assets/dataReceptacle.tsx new file mode 100644 index 000000000..7064581bd --- /dev/null +++ b/src/components/icons/assets/dataReceptacle.tsx @@ -0,0 +1,32 @@ +import React from "react"; + +interface DataReceptacleProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const DataReceptacle: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + +); + +export default DataReceptacle; diff --git a/src/components/icons/assets/defibrillator.tsx b/src/components/icons/assets/defibrillator.tsx new file mode 100644 index 000000000..d068b9092 --- /dev/null +++ b/src/components/icons/assets/defibrillator.tsx @@ -0,0 +1,30 @@ +import React from "react"; + +interface DefibrillatorProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Defibrillator: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + +); + +export default Defibrillator; diff --git a/src/components/icons/assets/defibrillator2.tsx b/src/components/icons/assets/defibrillator2.tsx new file mode 100644 index 000000000..fc2368055 --- /dev/null +++ b/src/components/icons/assets/defibrillator2.tsx @@ -0,0 +1,30 @@ +import React from "react"; + +interface Defibrillator2Props extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Defibrillator2: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + +); + +export default Defibrillator2; diff --git a/src/components/icons/assets/dehumidifier.tsx b/src/components/icons/assets/dehumidifier.tsx new file mode 100644 index 000000000..d2fd2f0f4 --- /dev/null +++ b/src/components/icons/assets/dehumidifier.tsx @@ -0,0 +1,42 @@ +import React from "react"; + +interface DehumidifierProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Dehumidifier: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + +); + +export default Dehumidifier; diff --git a/src/components/icons/assets/desktopComputer.tsx b/src/components/icons/assets/desktopComputer.tsx new file mode 100644 index 000000000..c14ee8b39 --- /dev/null +++ b/src/components/icons/assets/desktopComputer.tsx @@ -0,0 +1,40 @@ +import React from "react"; + +interface DesktopComputerProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const DesktopComputer: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + +); + +export default DesktopComputer; diff --git a/src/components/icons/assets/dimmingSwitch.tsx b/src/components/icons/assets/dimmingSwitch.tsx new file mode 100644 index 000000000..2c16958ab --- /dev/null +++ b/src/components/icons/assets/dimmingSwitch.tsx @@ -0,0 +1,43 @@ +import React from "react"; + +interface DimmingSwitchProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const DimmingSwitch: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + +); + +export default DimmingSwitch; diff --git a/src/components/icons/assets/disconnectSwitch.tsx b/src/components/icons/assets/disconnectSwitch.tsx new file mode 100644 index 000000000..d09741375 --- /dev/null +++ b/src/components/icons/assets/disconnectSwitch.tsx @@ -0,0 +1,34 @@ +import React from "react"; + +interface DisconnectSwitchProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const DisconnectSwitch: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + +); + +export default DisconnectSwitch; diff --git a/src/components/icons/assets/disconnectSwitch2.tsx b/src/components/icons/assets/disconnectSwitch2.tsx new file mode 100644 index 000000000..5fc0c277c --- /dev/null +++ b/src/components/icons/assets/disconnectSwitch2.tsx @@ -0,0 +1,28 @@ +import React from "react"; + +interface DisconnectSwitch2Props extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const DisconnectSwitch2: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default DisconnectSwitch2; diff --git a/src/components/icons/assets/disposal.tsx b/src/components/icons/assets/disposal.tsx new file mode 100644 index 000000000..37078d556 --- /dev/null +++ b/src/components/icons/assets/disposal.tsx @@ -0,0 +1,61 @@ +import React from "react"; + +interface DisposalProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Disposal: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + + + + +); + +export default Disposal; diff --git a/src/components/icons/assets/door.tsx b/src/components/icons/assets/door.tsx new file mode 100644 index 000000000..e91033fae --- /dev/null +++ b/src/components/icons/assets/door.tsx @@ -0,0 +1,32 @@ +import React from "react"; + +interface DoorProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Door: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + +); + +export default Door; diff --git a/src/components/icons/assets/doorContact.tsx b/src/components/icons/assets/doorContact.tsx new file mode 100644 index 000000000..24245a694 --- /dev/null +++ b/src/components/icons/assets/doorContact.tsx @@ -0,0 +1,45 @@ +import React from "react"; + +interface DoorContactProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const DoorContact: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + + +); + +export default DoorContact; diff --git a/src/components/icons/assets/drinkingFountain.tsx b/src/components/icons/assets/drinkingFountain.tsx new file mode 100644 index 000000000..8ab7eb05e --- /dev/null +++ b/src/components/icons/assets/drinkingFountain.tsx @@ -0,0 +1,26 @@ +import React from "react"; + +interface DrinkingFountainProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const DrinkingFountain: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default DrinkingFountain; diff --git a/src/components/icons/assets/duckworkSheetmetal.tsx b/src/components/icons/assets/duckworkSheetmetal.tsx new file mode 100644 index 000000000..4cd72a8cd --- /dev/null +++ b/src/components/icons/assets/duckworkSheetmetal.tsx @@ -0,0 +1,26 @@ +import React from "react"; + +interface DuckworkSheetmetalProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const DuckworkSheetmetal: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default DuckworkSheetmetal; diff --git a/src/components/icons/assets/ductworkInsulation.tsx b/src/components/icons/assets/ductworkInsulation.tsx new file mode 100644 index 000000000..517501749 --- /dev/null +++ b/src/components/icons/assets/ductworkInsulation.tsx @@ -0,0 +1,30 @@ +import React from "react"; + +interface DuctworkInsulationProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const DuctworkInsulation: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + +); + +export default DuctworkInsulation; diff --git a/src/components/icons/assets/electrical.tsx b/src/components/icons/assets/electrical.tsx new file mode 100644 index 000000000..9266012c4 --- /dev/null +++ b/src/components/icons/assets/electrical.tsx @@ -0,0 +1,26 @@ +import React from "react"; + +interface ElectricalProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Electrical: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default Electrical; diff --git a/src/components/icons/assets/electricalPanel.tsx b/src/components/icons/assets/electricalPanel.tsx new file mode 100644 index 000000000..23e9e2a21 --- /dev/null +++ b/src/components/icons/assets/electricalPanel.tsx @@ -0,0 +1,34 @@ +import React from "react"; + +interface ElectricalPanelProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const ElectricalPanel: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + +); + +export default ElectricalPanel; diff --git a/src/components/icons/assets/elevator1.tsx b/src/components/icons/assets/elevator1.tsx new file mode 100644 index 000000000..e8eaa58a0 --- /dev/null +++ b/src/components/icons/assets/elevator1.tsx @@ -0,0 +1,30 @@ +import React from "react"; + +interface Elevator1Props extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Elevator1: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + +); + +export default Elevator1; diff --git a/src/components/icons/assets/elevator2.tsx b/src/components/icons/assets/elevator2.tsx new file mode 100644 index 000000000..92f9ea714 --- /dev/null +++ b/src/components/icons/assets/elevator2.tsx @@ -0,0 +1,30 @@ +import React from "react"; + +interface Elevator2Props extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Elevator2: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + +); + +export default Elevator2; diff --git a/src/components/icons/assets/eolResistor.tsx b/src/components/icons/assets/eolResistor.tsx new file mode 100644 index 000000000..9bb05e256 --- /dev/null +++ b/src/components/icons/assets/eolResistor.tsx @@ -0,0 +1,26 @@ +import React from "react"; + +interface EolResistorProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const EolResistor: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default EolResistor; diff --git a/src/components/icons/assets/eolResistor2.tsx b/src/components/icons/assets/eolResistor2.tsx new file mode 100644 index 000000000..418664ca4 --- /dev/null +++ b/src/components/icons/assets/eolResistor2.tsx @@ -0,0 +1,26 @@ +import React from "react"; + +interface EolResistor2Props extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const EolResistor2: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default EolResistor2; diff --git a/src/components/icons/assets/escalator.tsx b/src/components/icons/assets/escalator.tsx new file mode 100644 index 000000000..cac73d710 --- /dev/null +++ b/src/components/icons/assets/escalator.tsx @@ -0,0 +1,38 @@ +import React from "react"; + +interface EscalatorProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Escalator: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + + +); + +export default Escalator; diff --git a/src/components/icons/assets/exhaustFan.tsx b/src/components/icons/assets/exhaustFan.tsx new file mode 100644 index 000000000..a4db14f01 --- /dev/null +++ b/src/components/icons/assets/exhaustFan.tsx @@ -0,0 +1,30 @@ +import React from "react"; + +interface ExhaustFanProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const ExhaustFan: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + +); + +export default ExhaustFan; diff --git a/src/components/icons/assets/expansionTank.tsx b/src/components/icons/assets/expansionTank.tsx new file mode 100644 index 000000000..3707a6b2c --- /dev/null +++ b/src/components/icons/assets/expansionTank.tsx @@ -0,0 +1,29 @@ +import React from "react"; + +interface ExpansionTankProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const ExpansionTank: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default ExpansionTank; diff --git a/src/components/icons/assets/eyeWash.tsx b/src/components/icons/assets/eyeWash.tsx new file mode 100644 index 000000000..b477394aa --- /dev/null +++ b/src/components/icons/assets/eyeWash.tsx @@ -0,0 +1,30 @@ +import React from "react"; + +interface EyeWashProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const EyeWash: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + +); + +export default EyeWash; diff --git a/src/components/icons/assets/fanCoilUnit.tsx b/src/components/icons/assets/fanCoilUnit.tsx new file mode 100644 index 000000000..f3abc3a57 --- /dev/null +++ b/src/components/icons/assets/fanCoilUnit.tsx @@ -0,0 +1,38 @@ +import React from "react"; + +interface FanCoilUnitProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const FanCoilUnit: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + + +); + +export default FanCoilUnit; diff --git a/src/components/icons/assets/faucet.tsx b/src/components/icons/assets/faucet.tsx new file mode 100644 index 000000000..fad61c4a3 --- /dev/null +++ b/src/components/icons/assets/faucet.tsx @@ -0,0 +1,30 @@ +import React from "react"; + +interface FaucetProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Faucet: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + +); + +export default Faucet; diff --git a/src/components/icons/assets/fence.tsx b/src/components/icons/assets/fence.tsx new file mode 100644 index 000000000..94ce30f95 --- /dev/null +++ b/src/components/icons/assets/fence.tsx @@ -0,0 +1,26 @@ +import React from "react"; + +interface FenceProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Fence: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default Fence; diff --git a/src/components/icons/assets/fireAlarm.tsx b/src/components/icons/assets/fireAlarm.tsx new file mode 100644 index 000000000..4da0f517a --- /dev/null +++ b/src/components/icons/assets/fireAlarm.tsx @@ -0,0 +1,39 @@ +import React from "react"; + +interface FireAlarmProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const FireAlarm: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + +); + +export default FireAlarm; diff --git a/src/components/icons/assets/fireAlarmCommunicator.tsx b/src/components/icons/assets/fireAlarmCommunicator.tsx new file mode 100644 index 000000000..0ebd0d00c --- /dev/null +++ b/src/components/icons/assets/fireAlarmCommunicator.tsx @@ -0,0 +1,46 @@ +import React from "react"; + +interface FireAlarmCommunicatorProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const FireAlarmCommunicator: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + + +); + +export default FireAlarmCommunicator; diff --git a/src/components/icons/assets/fireExtinguisher1.tsx b/src/components/icons/assets/fireExtinguisher1.tsx new file mode 100644 index 000000000..1b49583f5 --- /dev/null +++ b/src/components/icons/assets/fireExtinguisher1.tsx @@ -0,0 +1,26 @@ +import React from "react"; + +interface FireExtinguisher1Props extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const FireExtinguisher1: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default FireExtinguisher1; diff --git a/src/components/icons/assets/fireExtinguisher2.tsx b/src/components/icons/assets/fireExtinguisher2.tsx new file mode 100644 index 000000000..47911ca5f --- /dev/null +++ b/src/components/icons/assets/fireExtinguisher2.tsx @@ -0,0 +1,26 @@ +import React from "react"; + +interface FireExtinguisher2Props extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const FireExtinguisher2: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default FireExtinguisher2; diff --git a/src/components/icons/assets/fireJBox.tsx b/src/components/icons/assets/fireJBox.tsx new file mode 100644 index 000000000..544b76be0 --- /dev/null +++ b/src/components/icons/assets/fireJBox.tsx @@ -0,0 +1,42 @@ +import React from "react"; + +interface FireJBoxProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const FireJBox: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + + +); + +export default FireJBox; diff --git a/src/components/icons/assets/fireSafety.tsx b/src/components/icons/assets/fireSafety.tsx new file mode 100644 index 000000000..30d3d1fea --- /dev/null +++ b/src/components/icons/assets/fireSafety.tsx @@ -0,0 +1,27 @@ +import React from "react"; + +interface FireSafetyProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const FireSafety: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default FireSafety; diff --git a/src/components/icons/assets/floorDrain.tsx b/src/components/icons/assets/floorDrain.tsx new file mode 100644 index 000000000..716f1aabf --- /dev/null +++ b/src/components/icons/assets/floorDrain.tsx @@ -0,0 +1,40 @@ +import React from "react"; + +interface FloorDrainProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const FloorDrain: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + + +); + +export default FloorDrain; diff --git a/src/components/icons/assets/floorSink.tsx b/src/components/icons/assets/floorSink.tsx new file mode 100644 index 000000000..ec19aaa60 --- /dev/null +++ b/src/components/icons/assets/floorSink.tsx @@ -0,0 +1,26 @@ +import React from "react"; + +interface FloorSinkProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const FloorSink: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default FloorSink; diff --git a/src/components/icons/assets/fuse.tsx b/src/components/icons/assets/fuse.tsx new file mode 100644 index 000000000..909383b80 --- /dev/null +++ b/src/components/icons/assets/fuse.tsx @@ -0,0 +1,26 @@ +import React from "react"; + +interface FuseProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Fuse: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default Fuse; diff --git a/src/components/icons/assets/gasMeter.tsx b/src/components/icons/assets/gasMeter.tsx new file mode 100644 index 000000000..f91fbce05 --- /dev/null +++ b/src/components/icons/assets/gasMeter.tsx @@ -0,0 +1,34 @@ +import React from "react"; + +interface GasMeterProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const GasMeter: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + +); + +export default GasMeter; diff --git a/src/components/icons/assets/generator.tsx b/src/components/icons/assets/generator.tsx new file mode 100644 index 000000000..3a6ede3c0 --- /dev/null +++ b/src/components/icons/assets/generator.tsx @@ -0,0 +1,30 @@ +import React from "react"; + +interface GeneratorProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Generator: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + +); + +export default Generator; diff --git a/src/components/icons/assets/greaseTrap.tsx b/src/components/icons/assets/greaseTrap.tsx new file mode 100644 index 000000000..049d20ccb --- /dev/null +++ b/src/components/icons/assets/greaseTrap.tsx @@ -0,0 +1,38 @@ +import React from "react"; + +interface GreaseTrapProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const GreaseTrap: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + + +); + +export default GreaseTrap; diff --git a/src/components/icons/assets/groundingCabinet.tsx b/src/components/icons/assets/groundingCabinet.tsx new file mode 100644 index 000000000..e0d448aec --- /dev/null +++ b/src/components/icons/assets/groundingCabinet.tsx @@ -0,0 +1,30 @@ +import React from "react"; + +interface GroundingCabinetProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const GroundingCabinet: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + +); + +export default GroundingCabinet; diff --git a/src/components/icons/assets/gutter.tsx b/src/components/icons/assets/gutter.tsx new file mode 100644 index 000000000..62efae659 --- /dev/null +++ b/src/components/icons/assets/gutter.tsx @@ -0,0 +1,29 @@ +import React from "react"; + +interface GutterProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Gutter: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default Gutter; diff --git a/src/components/icons/assets/heaterBaseboard.tsx b/src/components/icons/assets/heaterBaseboard.tsx new file mode 100644 index 000000000..64c6cf48d --- /dev/null +++ b/src/components/icons/assets/heaterBaseboard.tsx @@ -0,0 +1,26 @@ +import React from "react"; + +interface HeaterBaseboardProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const HeaterBaseboard: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default HeaterBaseboard; diff --git a/src/components/icons/assets/heaterElectric.tsx b/src/components/icons/assets/heaterElectric.tsx new file mode 100644 index 000000000..645e2207a --- /dev/null +++ b/src/components/icons/assets/heaterElectric.tsx @@ -0,0 +1,26 @@ +import React from "react"; + +interface HeaterElectricProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const HeaterElectric: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default HeaterElectric; diff --git a/src/components/icons/assets/heaterInfrared.tsx b/src/components/icons/assets/heaterInfrared.tsx new file mode 100644 index 000000000..170cb4d6d --- /dev/null +++ b/src/components/icons/assets/heaterInfrared.tsx @@ -0,0 +1,30 @@ +import React from "react"; + +interface HeaterInfraredProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const HeaterInfrared: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + +); + +export default HeaterInfrared; diff --git a/src/components/icons/assets/hoseBibb.tsx b/src/components/icons/assets/hoseBibb.tsx new file mode 100644 index 000000000..0d0c2b7e9 --- /dev/null +++ b/src/components/icons/assets/hoseBibb.tsx @@ -0,0 +1,26 @@ +import React from "react"; + +interface HoseBibbProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const HoseBibb: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default HoseBibb; diff --git a/src/components/icons/assets/hubDrain.tsx b/src/components/icons/assets/hubDrain.tsx new file mode 100644 index 000000000..4020348d3 --- /dev/null +++ b/src/components/icons/assets/hubDrain.tsx @@ -0,0 +1,26 @@ +import React from "react"; + +interface HubDrainProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const HubDrain: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default HubDrain; diff --git a/src/components/icons/assets/humidifier.tsx b/src/components/icons/assets/humidifier.tsx new file mode 100644 index 000000000..d54e5b7b9 --- /dev/null +++ b/src/components/icons/assets/humidifier.tsx @@ -0,0 +1,30 @@ +import React from "react"; + +interface HumidifierProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Humidifier: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + +); + +export default Humidifier; diff --git a/src/components/icons/assets/humiditySensor.tsx b/src/components/icons/assets/humiditySensor.tsx new file mode 100644 index 000000000..e66c0a0d6 --- /dev/null +++ b/src/components/icons/assets/humiditySensor.tsx @@ -0,0 +1,42 @@ +import React from "react"; + +interface HumiditySensorProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const HumiditySensor: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + + +); + +export default HumiditySensor; diff --git a/src/components/icons/assets/hvac.tsx b/src/components/icons/assets/hvac.tsx new file mode 100644 index 000000000..a206a325f --- /dev/null +++ b/src/components/icons/assets/hvac.tsx @@ -0,0 +1,34 @@ +import React from "react"; + +interface HvacProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Hvac: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + +); + +export default Hvac; diff --git a/src/components/icons/assets/index.ts b/src/components/icons/assets/index.ts new file mode 100644 index 000000000..6c07087c5 --- /dev/null +++ b/src/components/icons/assets/index.ts @@ -0,0 +1,317 @@ +import React from "react"; +import AHU1 from "./ahu1"; +import AHU2 from "./ahu2"; +import AirAdmittanceValve from "./airAdmittanceValve"; +import AirCompressor from "./airCompressor"; +import BackflowPreventer from "./backflowPreventer"; +import Battery from "./battery"; +import BatteryInverter from "./batteryInverter"; +import Cabinet from "./cabinet"; +import CableTray from "./cableTray"; +import CarbonDioxideSensor from "./carbonDioxideSensor"; +import CarbonMonoxideDetector from "./carbonMonoxideDetector"; +import CardReader from "./cardReader"; +import CeilingTiles from "./ceilingTiles"; +import ChillerAirCooled from "./chillerAirCooled"; +import ChillerWaterCooled from "./chillerWaterCooled"; +import Clock from "./clock"; +import Computer from "./computer"; +import ControlDamper from "./controlDamper"; +import CoolingTower from "./coolingTower"; +import DataReceptacle from "./dataReceptacle"; +import Defibrillator from "./defibrillator"; +import Defibrillator2 from "./defibrillator2"; +import Dehumidifier from "./dehumidifier"; +import DesktopComputer from "./desktopComputer"; +import DimmingSwitch from "./dimmingSwitch"; +import DisconnectSwitch from "./disconnectSwitch"; +import DisconnectSwitch2 from "./disconnectSwitch2"; +import Disposal from "./disposal"; +import Door from "./door"; +import DoorContact from "./doorContact"; +import DrinkingFountain from "./drinkingFountain"; +import DuctworkSheetmetal from "./duckworkSheetmetal"; +import DuctworkInsulation from "./ductworkInsulation"; +import Electrical from "./electrical"; +import ElectricalPanel from "./electricalPanel"; +import Elevator1 from "./elevator1"; +import Elevator2 from "./elevator2"; +import EOLResistor from "./eolResistor"; +import EolResistor2 from "./eolResistor2"; +import Escalator from "./escalator"; +import ExhaustFan from "./exhaustFan"; +import ExpansionTank from "./expansionTank"; +import EyeWash from "./eyeWash"; +import FanCoilUnit from "./fanCoilUnit"; +import Faucet from "./faucet"; +import Fence from "./fence"; +import FireAlarm from "./fireAlarm"; +import FireAlarmCommunicator from "./fireAlarmCommunicator"; +import FireExtinguisher1 from "./fireExtinguisher1"; +import FireExtinguisher2 from "./fireExtinguisher2"; +import FireJBox from "./fireJBox"; +import FireSafety from "./fireSafety"; +import FloorDrain from "./floorDrain"; +import FloorSink from "./floorSink"; +import Fuse from "./fuse"; +import GasMeter from "./gasMeter"; +import Generator from "./generator"; +import GreaseTrap from "./greaseTrap"; +import GroundingCabinet from "./groundingCabinet"; +import Gutter from "./gutter"; +import HeaderBaseboard from "./heaterBaseboard"; +import HeaterElectric from "./heaterElectric"; +import HeaterInfrared from "./heaterInfrared"; +import HoseBibb from "./hoseBibb"; +import HubDrain from "./hubDrain"; +import Humidifier from "./humidifier"; +import HumiditySensor from "./humiditySensor"; +import HVAC from "./hvac"; +import InitiatingDevice1 from "./initiatingDevice1"; +import InitiatingDevice2 from "./initiatingDevice2"; +import IrrigationSystem from "./irrigationSystem"; +import IrrigationZoneValves from "./irrigationZoneValves"; +import JBox from "./jBox"; +import JBox2 from "./jBox2"; +import KitchenHood from "./kitchenHood"; +import Landscape from "./landscape"; +import Laptop from "./laptop"; +import LCDAnnunciator from "./lcdAnnunciator"; +import LightEmergency1 from "./lightEmergency1"; +import LightEmergency2 from "./lightEmergency2"; +import LightExit from "./lightExit"; +import LightFixture from "./lightFixture"; +import LightFluorescent from "./lightFluorescent"; +import LightLed from "./lightLed"; +import LightSwitch from "./lightSwitch"; +import MainDistributionPanel from "./mainDistributionPanel"; +import Mechanical from "./mechanical"; +import Meter from "./meter"; +import MopSink from "./mopSink"; +import MotionDetector from "./motionDetector"; +import Motor from "./motor"; +import MowerLawn from "./mowerLawn"; +import MowerRiding from "./mowerRiding"; +import Mri1 from "./mri1"; +import NotificationAppliance1 from "./notificationAppliance1"; +import NotificationAppliance2 from "./notificationAppliance2"; +import OccupancySensor from "./occupancySensor"; +import Other1 from "./other1"; +import Other2 from "./other2"; +import ParkingLight from "./parkingLight"; +import PhotoSensor from "./photoSensor"; +import PipeHanger from "./pipeHanger"; +import Plug from "./plug"; +import Plumbing from "./plumbing"; +import PortableGenerator from "./portableGenerator"; +import Printer from "./printer"; +import Projector from "./projector"; +import Pumps from "./pumps"; +import RefrigerantCopperPiping from "./refrigerantCopperPiping"; +import Register from "./register"; +import RooftopUnit from "./rooftopUnit"; +import Room from "./room"; +import Security from "./security"; +import SecurityCamera from "./securityCamera"; +import SecurityControlPanel from "./securityControlPanel"; +import ServerRack from "./serverRack"; +import ShowerTub from "./showerTub"; +import Sink from "./sink"; +import SmartPhone from "./smartPhone"; +import SolarPanel from "./solarPanel"; +import SolarPanelBackupBaattery from "./solarPanelBackupBaattery"; +import SolarPanelInverter from "./solarPanelInverter"; +import SplitDxUnit from "./splitDxUnit"; +import Sprinkler from "./sprinkler"; +import SubMeter from "./subMeter"; +import Tablet from "./tablet"; +import TemperatureSensor from "./temperatureSensor"; +import Thermostat from "./thermostat"; +import ThermostaticMixingValve from "./thermostaticMixingValve"; +import Toilet from "./toilet"; +import TransferSwitch from "./transferSwitch"; +import Transformer1 from "./transformer1"; +import Transformer2 from "./transformer2"; +import Transformer3 from "./transformer3"; +import TransmissionTower from "./transmissionTower"; +import Transponder from "./transponder"; +import Urinal from "./urinal"; +import Valve from "./valve"; +import VAVBox from "./vavBox"; +import VrfIndoorUnit from "./vrfIndoorUnit"; +import VrfOutdoorHeatPump from "./vrfOutdoorHeatPump"; +import WallHydrant from "./wallHydrant"; +import WaterBoiler from "./waterBoiler"; +import WaterFlowStation from "./waterFlowStation"; +import WaterHeater from "./waterHeater"; +import WaterMeter from "./waterMeter"; +import WaterSourceHeatPump from "./waterSourceHeatPump"; +import Window from "./window"; +import WirelessRouter from "./wirelessRouter"; +import WiringElectrical from "./wiringElectrical"; +import WiringLowVoltage from "./wiringLowVoltage"; +import Xray1 from "./xray1"; +import Xray2 from "./xray2"; + +interface AssetIconProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const AssetIcons: { [key: string]: React.FC } = { + AHU1, + AHU2, + AirAdmittanceValve, + AirCompressor, + BackflowPreventer, + Battery, + BatteryInverter, + Cabinet, + CableTray, + CarbonDioxideSensor, + CarbonMonoxideDetector, + CardReader, + CeilingTiles, + ChillerAirCooled, + ChillerWaterCooled, + Clock, + Computer, + ControlDamper, + CoolingTower, + DataReceptacle, + Defibrillator, + Defibrillator2, + Dehumidifier, + DesktopComputer, + DimmingSwitch, + DisconnectSwitch, + DisconnectSwitch2, + Disposal, + Door, + DoorContact, + DrinkingFountain, + DuctworkInsulation, + DuctworkSheetmetal, + EOLResistor, + EolResistor2, + Electrical, + ElectricalPanel, + Elevator1, + Elevator2, + Escalator, + ExhaustFan, + ExpansionTank, + EyeWash, + FanCoilUnit, + Faucet, + Fence, + FireAlarm, + FireAlarmCommunicator, + FireExtinguisher1, + FireExtinguisher2, + FireJBox, + FireSafety, + FloorDrain, + FloorSink, + Fuse, + GasMeter, + Generator, + GreaseTrap, + GroundingCabinet, + Gutter, + HVAC, + HeaderBaseboard, + HeaterElectric, + HeaterInfrared, + HoseBibb, + HubDrain, + Humidifier, + HumiditySensor, + InitiatingDevice1, + InitiatingDevice2, + IrrigationSystem, + IrrigationZoneValves, + JBox, + JBox2, + KitchenHood, + LCDAnnunciator, + Landscape, + Laptop, + LightEmergency1, + LightEmergency2, + LightExit, + LightFixture, + LightFluorescent, + LightLed, + LightSwitch, + MainDistributionPanel, + Mechanical, + Meter, + MopSink, + MotionDetector, + Motor, + MowerLawn, + MowerRiding, + Mri1, + NotificationAppliance1, + NotificationAppliance2, + OccupancySensor, + Other1, + Other2, + ParkingLight, + PhotoSensor, + PipeHanger, + Plug, + Plumbing, + PortableGenerator, + Printer, + Projector, + Pumps, + RefrigerantCopperPiping, + Register, + RooftopUnit, + Room, + Security, + SecurityCamera, + SecurityControlPanel, + ServerRack, + ShowerTub, + Sink, + SmartPhone, + SolarPanel, + SolarPanelBackupBaattery, + SolarPanelInverter, + SplitDxUnit, + Sprinkler, + SubMeter, + Tablet, + TemperatureSensor, + Thermostat, + ThermostaticMixingValve, + Toilet, + TransferSwitch, + Transformer1, + Transformer2, + Transformer3, + TransmissionTower, + Transponder, + Urinal, + VAVBox, + Valve, + VrfIndoorUnit, + VrfOutdoorHeatPump, + WallHydrant, + WaterBoiler, + WaterFlowStation, + WaterHeater, + WaterMeter, + WaterSourceHeatPump, + Window, + WirelessRouter, + WiringElectrical, + WiringLowVoltage, + Xray1, + Xray2, +}; + +export default AssetIcons; diff --git a/src/components/icons/assets/initiatingDevice1.tsx b/src/components/icons/assets/initiatingDevice1.tsx new file mode 100644 index 000000000..b35bf7e48 --- /dev/null +++ b/src/components/icons/assets/initiatingDevice1.tsx @@ -0,0 +1,62 @@ +import React from "react"; + +interface InitiatingDevice1Props extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const InitiatingDevice1: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + + + + + +); + +export default InitiatingDevice1; diff --git a/src/components/icons/assets/initiatingDevice2.tsx b/src/components/icons/assets/initiatingDevice2.tsx new file mode 100644 index 000000000..58a2c954d --- /dev/null +++ b/src/components/icons/assets/initiatingDevice2.tsx @@ -0,0 +1,61 @@ +import React from "react"; + +interface InitiatingDevice2Props extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const InitiatingDevice2: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + + + + + +); + +export default InitiatingDevice2; diff --git a/src/components/icons/assets/irrigationSystem.tsx b/src/components/icons/assets/irrigationSystem.tsx new file mode 100644 index 000000000..aca612fa2 --- /dev/null +++ b/src/components/icons/assets/irrigationSystem.tsx @@ -0,0 +1,26 @@ +import React from "react"; + +interface IrrigationSystemProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const IrrigationSystem: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default IrrigationSystem; diff --git a/src/components/icons/assets/irrigationZoneValves.tsx b/src/components/icons/assets/irrigationZoneValves.tsx new file mode 100644 index 000000000..de776da0d --- /dev/null +++ b/src/components/icons/assets/irrigationZoneValves.tsx @@ -0,0 +1,26 @@ +import React from "react"; + +interface IrrigationZoneValvesProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const IrrigationZoneValves: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default IrrigationZoneValves; diff --git a/src/components/icons/assets/jBox.tsx b/src/components/icons/assets/jBox.tsx new file mode 100644 index 000000000..8c11919b7 --- /dev/null +++ b/src/components/icons/assets/jBox.tsx @@ -0,0 +1,29 @@ +import React from "react"; + +interface JBoxProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const JBox: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default JBox; diff --git a/src/components/icons/assets/jBox2.tsx b/src/components/icons/assets/jBox2.tsx new file mode 100644 index 000000000..42545a2f4 --- /dev/null +++ b/src/components/icons/assets/jBox2.tsx @@ -0,0 +1,34 @@ +import React from "react"; + +interface JBox2Props extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const JBox2: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + +); + +export default JBox2; diff --git a/src/components/icons/assets/kitchenHood.tsx b/src/components/icons/assets/kitchenHood.tsx new file mode 100644 index 000000000..2019edb01 --- /dev/null +++ b/src/components/icons/assets/kitchenHood.tsx @@ -0,0 +1,26 @@ +import React from "react"; + +interface KitchenHoodProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const KitchenHood: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default KitchenHood; diff --git a/src/components/icons/assets/landscape.tsx b/src/components/icons/assets/landscape.tsx new file mode 100644 index 000000000..fed0371aa --- /dev/null +++ b/src/components/icons/assets/landscape.tsx @@ -0,0 +1,28 @@ +import React from "react"; + +interface LandscapeProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Landscape: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default Landscape; diff --git a/src/components/icons/assets/laptop.tsx b/src/components/icons/assets/laptop.tsx new file mode 100644 index 000000000..bd7314699 --- /dev/null +++ b/src/components/icons/assets/laptop.tsx @@ -0,0 +1,26 @@ +import React from "react"; + +interface LaptopProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Laptop: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default Laptop; diff --git a/src/components/icons/assets/lcdAnnunciator.tsx b/src/components/icons/assets/lcdAnnunciator.tsx new file mode 100644 index 000000000..83a126179 --- /dev/null +++ b/src/components/icons/assets/lcdAnnunciator.tsx @@ -0,0 +1,62 @@ +import React from "react"; + +interface LcdAnnunciatorProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const LcdAnnunciator: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + + + + +); + +export default LcdAnnunciator; diff --git a/src/components/icons/assets/lightEmergency1.tsx b/src/components/icons/assets/lightEmergency1.tsx new file mode 100644 index 000000000..f63108f67 --- /dev/null +++ b/src/components/icons/assets/lightEmergency1.tsx @@ -0,0 +1,26 @@ +import React from "react"; + +interface LightEmergency1Props extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const LightEmergency1: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default LightEmergency1; diff --git a/src/components/icons/assets/lightEmergency2.tsx b/src/components/icons/assets/lightEmergency2.tsx new file mode 100644 index 000000000..286d1932c --- /dev/null +++ b/src/components/icons/assets/lightEmergency2.tsx @@ -0,0 +1,34 @@ +import React from "react"; + +interface LightEmergency2Props extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const LightEmergency2: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + +); + +export default LightEmergency2; diff --git a/src/components/icons/assets/lightExit.tsx b/src/components/icons/assets/lightExit.tsx new file mode 100644 index 000000000..ab0136b4a --- /dev/null +++ b/src/components/icons/assets/lightExit.tsx @@ -0,0 +1,32 @@ +import React from "react"; + +interface LightExitProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const LightExit: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + +); + +export default LightExit; diff --git a/src/components/icons/assets/lightFixture.tsx b/src/components/icons/assets/lightFixture.tsx new file mode 100644 index 000000000..12f6497e2 --- /dev/null +++ b/src/components/icons/assets/lightFixture.tsx @@ -0,0 +1,26 @@ +import React from "react"; + +interface LightFixtureProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const LightFixture: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default LightFixture; diff --git a/src/components/icons/assets/lightFluorescent.tsx b/src/components/icons/assets/lightFluorescent.tsx new file mode 100644 index 000000000..f5eb8bbb9 --- /dev/null +++ b/src/components/icons/assets/lightFluorescent.tsx @@ -0,0 +1,27 @@ +import React from "react"; + +interface LightFluorescentProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const LightFluorescent: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default LightFluorescent; diff --git a/src/components/icons/assets/lightLed.tsx b/src/components/icons/assets/lightLed.tsx new file mode 100644 index 000000000..e14fadff2 --- /dev/null +++ b/src/components/icons/assets/lightLed.tsx @@ -0,0 +1,30 @@ +import React from "react"; + +interface LightLedProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const LightLed: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + +); + +export default LightLed; diff --git a/src/components/icons/assets/lightSwitch.tsx b/src/components/icons/assets/lightSwitch.tsx new file mode 100644 index 000000000..f5ca5d485 --- /dev/null +++ b/src/components/icons/assets/lightSwitch.tsx @@ -0,0 +1,34 @@ +import React from "react"; + +interface LightSwitchProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const LightSwitch: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + +); + +export default LightSwitch; diff --git a/src/components/icons/assets/mainDistributionPanel.tsx b/src/components/icons/assets/mainDistributionPanel.tsx new file mode 100644 index 000000000..335904f1c --- /dev/null +++ b/src/components/icons/assets/mainDistributionPanel.tsx @@ -0,0 +1,94 @@ +import React from "react"; + +interface MainDistributionPanelProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const MainDistributionPanel: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + + + + + + + + +); + +export default MainDistributionPanel; diff --git a/src/components/icons/assets/mechanical.tsx b/src/components/icons/assets/mechanical.tsx new file mode 100644 index 000000000..8a9f89e57 --- /dev/null +++ b/src/components/icons/assets/mechanical.tsx @@ -0,0 +1,37 @@ +import React from "react"; + +interface MechanicalProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Mechanical: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + +); + +export default Mechanical; diff --git a/src/components/icons/assets/meter.tsx b/src/components/icons/assets/meter.tsx new file mode 100644 index 000000000..c77babb7b --- /dev/null +++ b/src/components/icons/assets/meter.tsx @@ -0,0 +1,28 @@ +import React from "react"; + +interface MeterProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Meter: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default Meter; diff --git a/src/components/icons/assets/mopSink.tsx b/src/components/icons/assets/mopSink.tsx new file mode 100644 index 000000000..db7f7a37f --- /dev/null +++ b/src/components/icons/assets/mopSink.tsx @@ -0,0 +1,26 @@ +import React from "react"; + +interface MopSinkProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const MopSink: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default MopSink; diff --git a/src/components/icons/assets/motionDetector.tsx b/src/components/icons/assets/motionDetector.tsx new file mode 100644 index 000000000..bd9d5926b --- /dev/null +++ b/src/components/icons/assets/motionDetector.tsx @@ -0,0 +1,34 @@ +import React from "react"; + +interface MotionDetectorProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const MotionDetector: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + +); + +export default MotionDetector; diff --git a/src/components/icons/assets/motor.tsx b/src/components/icons/assets/motor.tsx new file mode 100644 index 000000000..31fd46be7 --- /dev/null +++ b/src/components/icons/assets/motor.tsx @@ -0,0 +1,48 @@ +import React from "react"; + +interface MotorProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Motor: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + + +); + +export default Motor; diff --git a/src/components/icons/assets/mowerLawn.tsx b/src/components/icons/assets/mowerLawn.tsx new file mode 100644 index 000000000..169346813 --- /dev/null +++ b/src/components/icons/assets/mowerLawn.tsx @@ -0,0 +1,30 @@ +import React from "react"; + +interface MowerLawnProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const MowerLawn: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + +); + +export default MowerLawn; diff --git a/src/components/icons/assets/mowerRiding.tsx b/src/components/icons/assets/mowerRiding.tsx new file mode 100644 index 000000000..d801fbfb6 --- /dev/null +++ b/src/components/icons/assets/mowerRiding.tsx @@ -0,0 +1,30 @@ +import React from "react"; + +interface MowerRidingProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const MowerRiding: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + +); + +export default MowerRiding; diff --git a/src/components/icons/assets/mri1.tsx b/src/components/icons/assets/mri1.tsx new file mode 100644 index 000000000..a99ba22b4 --- /dev/null +++ b/src/components/icons/assets/mri1.tsx @@ -0,0 +1,30 @@ +import React from "react"; + +interface Mri1Props extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Mri1: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + +); + +export default Mri1; diff --git a/src/components/icons/assets/notificationAppliance1.tsx b/src/components/icons/assets/notificationAppliance1.tsx new file mode 100644 index 000000000..cf7b5feb4 --- /dev/null +++ b/src/components/icons/assets/notificationAppliance1.tsx @@ -0,0 +1,56 @@ +import React from "react"; + +interface NotificationAppliance1Props extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const NotificationAppliance1: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + + +); + +export default NotificationAppliance1; diff --git a/src/components/icons/assets/notificationAppliance2.tsx b/src/components/icons/assets/notificationAppliance2.tsx new file mode 100644 index 000000000..020fa0732 --- /dev/null +++ b/src/components/icons/assets/notificationAppliance2.tsx @@ -0,0 +1,41 @@ +import React from "react"; + +interface NotificationAppliance2Props extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const NotificationAppliance2: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + +); + +export default NotificationAppliance2; diff --git a/src/components/icons/assets/occupancySensor.tsx b/src/components/icons/assets/occupancySensor.tsx new file mode 100644 index 000000000..73726b598 --- /dev/null +++ b/src/components/icons/assets/occupancySensor.tsx @@ -0,0 +1,50 @@ +import React from "react"; + +interface OccupancySensorProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const OccupancySensor: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + + + +); + +export default OccupancySensor; diff --git a/src/components/icons/assets/other1.tsx b/src/components/icons/assets/other1.tsx new file mode 100644 index 000000000..6aab60395 --- /dev/null +++ b/src/components/icons/assets/other1.tsx @@ -0,0 +1,30 @@ +import React from "react"; + +interface Other1Props extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Other1: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + +); + +export default Other1; diff --git a/src/components/icons/assets/other2.tsx b/src/components/icons/assets/other2.tsx new file mode 100644 index 000000000..57171bb4d --- /dev/null +++ b/src/components/icons/assets/other2.tsx @@ -0,0 +1,50 @@ +import React from "react"; + +interface Other2Props extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Other2: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + + +); + +export default Other2; diff --git a/src/components/icons/assets/parkingLight.tsx b/src/components/icons/assets/parkingLight.tsx new file mode 100644 index 000000000..c59743336 --- /dev/null +++ b/src/components/icons/assets/parkingLight.tsx @@ -0,0 +1,28 @@ +import React from "react"; + +interface ParkingLightProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const ParkingLight: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default ParkingLight; diff --git a/src/components/icons/assets/photoSensor.tsx b/src/components/icons/assets/photoSensor.tsx new file mode 100644 index 000000000..bec18edef --- /dev/null +++ b/src/components/icons/assets/photoSensor.tsx @@ -0,0 +1,35 @@ +import React from "react"; + +interface PhotoSensorProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const PhotoSensor: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + + +); + +export default PhotoSensor; diff --git a/src/components/icons/assets/pipeHanger.tsx b/src/components/icons/assets/pipeHanger.tsx new file mode 100644 index 000000000..6836702c9 --- /dev/null +++ b/src/components/icons/assets/pipeHanger.tsx @@ -0,0 +1,56 @@ +import React from "react"; + +interface PipeHangerProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const PipeHanger: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + + + +); + +export default PipeHanger; diff --git a/src/components/icons/assets/plug.tsx b/src/components/icons/assets/plug.tsx new file mode 100644 index 000000000..eb45e1452 --- /dev/null +++ b/src/components/icons/assets/plug.tsx @@ -0,0 +1,34 @@ +import React from "react"; + +interface PlugProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Plug: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + +); + +export default Plug; diff --git a/src/components/icons/assets/plumbing.tsx b/src/components/icons/assets/plumbing.tsx new file mode 100644 index 000000000..6df3ed8cd --- /dev/null +++ b/src/components/icons/assets/plumbing.tsx @@ -0,0 +1,34 @@ +import React from "react"; + +interface PlumbingProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Plumbing: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + +); + +export default Plumbing; diff --git a/src/components/icons/assets/portableGenerator.tsx b/src/components/icons/assets/portableGenerator.tsx new file mode 100644 index 000000000..de9eff7e8 --- /dev/null +++ b/src/components/icons/assets/portableGenerator.tsx @@ -0,0 +1,39 @@ +import React from "react"; + +interface PortableGeneratorProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const PortableGenerator: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + +); + +export default PortableGenerator; diff --git a/src/components/icons/assets/printer.tsx b/src/components/icons/assets/printer.tsx new file mode 100644 index 000000000..21048e591 --- /dev/null +++ b/src/components/icons/assets/printer.tsx @@ -0,0 +1,34 @@ +import React from "react"; + +interface PrinterProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Printer: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + +); + +export default Printer; diff --git a/src/components/icons/assets/projector.tsx b/src/components/icons/assets/projector.tsx new file mode 100644 index 000000000..470d81d51 --- /dev/null +++ b/src/components/icons/assets/projector.tsx @@ -0,0 +1,30 @@ +import React from "react"; + +interface ProjectorProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Projector: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + +); + +export default Projector; diff --git a/src/components/icons/assets/pumps.tsx b/src/components/icons/assets/pumps.tsx new file mode 100644 index 000000000..b0d43e79d --- /dev/null +++ b/src/components/icons/assets/pumps.tsx @@ -0,0 +1,27 @@ +import React from "react"; + +interface PumpsProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Pumps: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + +); + +export default Pumps; diff --git a/src/components/icons/assets/refrigerantCopperPiping.tsx b/src/components/icons/assets/refrigerantCopperPiping.tsx new file mode 100644 index 000000000..68295bc9b --- /dev/null +++ b/src/components/icons/assets/refrigerantCopperPiping.tsx @@ -0,0 +1,28 @@ +import React from "react"; + +interface RefrigerantCopperPipingProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const RefrigerantCopperPiping: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default RefrigerantCopperPiping; diff --git a/src/components/icons/assets/register.tsx b/src/components/icons/assets/register.tsx new file mode 100644 index 000000000..f0629891e --- /dev/null +++ b/src/components/icons/assets/register.tsx @@ -0,0 +1,68 @@ +import React from "react"; + +interface RegisterProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Register: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + + + + +); + +export default Register; diff --git a/src/components/icons/assets/rooftopUnit.tsx b/src/components/icons/assets/rooftopUnit.tsx new file mode 100644 index 000000000..14dbda268 --- /dev/null +++ b/src/components/icons/assets/rooftopUnit.tsx @@ -0,0 +1,36 @@ +import React from "react"; + +interface RooftopUnitProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const RooftopUnit: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + +); + +export default RooftopUnit; diff --git a/src/components/icons/assets/room.tsx b/src/components/icons/assets/room.tsx new file mode 100644 index 000000000..0cf3b9fd7 --- /dev/null +++ b/src/components/icons/assets/room.tsx @@ -0,0 +1,34 @@ +import React from "react"; + +interface RoomProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Room: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + +); + +export default Room; diff --git a/src/components/icons/assets/security.tsx b/src/components/icons/assets/security.tsx new file mode 100644 index 000000000..04885baf2 --- /dev/null +++ b/src/components/icons/assets/security.tsx @@ -0,0 +1,30 @@ +import React from "react"; + +interface SecurityProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Security: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + +); + +export default Security; diff --git a/src/components/icons/assets/securityCamera.tsx b/src/components/icons/assets/securityCamera.tsx new file mode 100644 index 000000000..33b03444a --- /dev/null +++ b/src/components/icons/assets/securityCamera.tsx @@ -0,0 +1,26 @@ +import React from "react"; + +interface SecurityCameraProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const SecurityCamera: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default SecurityCamera; diff --git a/src/components/icons/assets/securityControlPanel.tsx b/src/components/icons/assets/securityControlPanel.tsx new file mode 100644 index 000000000..d9ee7284a --- /dev/null +++ b/src/components/icons/assets/securityControlPanel.tsx @@ -0,0 +1,30 @@ +import React from "react"; + +interface SecurityControlPanelProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const SecurityControlPanel: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + +); + +export default SecurityControlPanel; diff --git a/src/components/icons/assets/serverRack.tsx b/src/components/icons/assets/serverRack.tsx new file mode 100644 index 000000000..c8ceb66ac --- /dev/null +++ b/src/components/icons/assets/serverRack.tsx @@ -0,0 +1,30 @@ +import React from "react"; + +interface ServerRackProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const ServerRack: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + +); + +export default ServerRack; diff --git a/src/components/icons/assets/showerTub.tsx b/src/components/icons/assets/showerTub.tsx new file mode 100644 index 000000000..cd239724e --- /dev/null +++ b/src/components/icons/assets/showerTub.tsx @@ -0,0 +1,28 @@ +import React from "react"; + +interface ShowerTubProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const ShowerTub: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default ShowerTub; diff --git a/src/components/icons/assets/sink.tsx b/src/components/icons/assets/sink.tsx new file mode 100644 index 000000000..60b0053cc --- /dev/null +++ b/src/components/icons/assets/sink.tsx @@ -0,0 +1,26 @@ +import React from "react"; + +interface SinkProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Sink: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default Sink; diff --git a/src/components/icons/assets/smartPhone.tsx b/src/components/icons/assets/smartPhone.tsx new file mode 100644 index 000000000..7956ab0e8 --- /dev/null +++ b/src/components/icons/assets/smartPhone.tsx @@ -0,0 +1,34 @@ +import React from "react"; + +interface SmartPhoneProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const SmartPhone: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + +); + +export default SmartPhone; diff --git a/src/components/icons/assets/solarPanel.tsx b/src/components/icons/assets/solarPanel.tsx new file mode 100644 index 000000000..79d9d9bd9 --- /dev/null +++ b/src/components/icons/assets/solarPanel.tsx @@ -0,0 +1,32 @@ +import React from "react"; + +interface SolarPanelProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const SolarPanel: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + +); + +export default SolarPanel; diff --git a/src/components/icons/assets/solarPanelBackupBaattery.tsx b/src/components/icons/assets/solarPanelBackupBaattery.tsx new file mode 100644 index 000000000..f9d0a0401 --- /dev/null +++ b/src/components/icons/assets/solarPanelBackupBaattery.tsx @@ -0,0 +1,28 @@ +import React from "react"; + +interface SolarPanelBackupBaatteryProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const SolarPanelBackupBaattery: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default SolarPanelBackupBaattery; diff --git a/src/components/icons/assets/solarPanelInverter.tsx b/src/components/icons/assets/solarPanelInverter.tsx new file mode 100644 index 000000000..3a30c770b --- /dev/null +++ b/src/components/icons/assets/solarPanelInverter.tsx @@ -0,0 +1,28 @@ +import React from "react"; + +interface SolarPanelInverterProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const SolarPanelInverter: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default SolarPanelInverter; diff --git a/src/components/icons/assets/splitDxUnit.tsx b/src/components/icons/assets/splitDxUnit.tsx new file mode 100644 index 000000000..ef714f28e --- /dev/null +++ b/src/components/icons/assets/splitDxUnit.tsx @@ -0,0 +1,30 @@ +import React from "react"; + +interface SplitDxUnitProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const SplitDxUnit: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + +); + +export default SplitDxUnit; diff --git a/src/components/icons/assets/sprinkler.tsx b/src/components/icons/assets/sprinkler.tsx new file mode 100644 index 000000000..c328143d9 --- /dev/null +++ b/src/components/icons/assets/sprinkler.tsx @@ -0,0 +1,26 @@ +import React from "react"; + +interface SprinklerProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Sprinkler: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default Sprinkler; diff --git a/src/components/icons/assets/subMeter.tsx b/src/components/icons/assets/subMeter.tsx new file mode 100644 index 000000000..c9afcd374 --- /dev/null +++ b/src/components/icons/assets/subMeter.tsx @@ -0,0 +1,61 @@ +import React from "react"; + +interface SubMeterProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const SubMeter: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + + + + + + + + + +); + +export default SubMeter; diff --git a/src/components/icons/assets/tablet.tsx b/src/components/icons/assets/tablet.tsx new file mode 100644 index 000000000..c1b232c5b --- /dev/null +++ b/src/components/icons/assets/tablet.tsx @@ -0,0 +1,30 @@ +import React from "react"; + +interface TabletProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Tablet: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + +); + +export default Tablet; diff --git a/src/components/icons/assets/temperatureSensor.tsx b/src/components/icons/assets/temperatureSensor.tsx new file mode 100644 index 000000000..e3a6ae19f --- /dev/null +++ b/src/components/icons/assets/temperatureSensor.tsx @@ -0,0 +1,34 @@ +import React from "react"; + +interface TemperatureSensorProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const TemperatureSensor: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + +); + +export default TemperatureSensor; diff --git a/src/components/icons/assets/thermostat.tsx b/src/components/icons/assets/thermostat.tsx new file mode 100644 index 000000000..dc322d50e --- /dev/null +++ b/src/components/icons/assets/thermostat.tsx @@ -0,0 +1,144 @@ +import React from "react"; + +interface ThermostatProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Thermostat: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + + + + + + + + + + + +); + +export default Thermostat; diff --git a/src/components/icons/assets/thermostaticMixingValve.tsx b/src/components/icons/assets/thermostaticMixingValve.tsx new file mode 100644 index 000000000..a973d83bb --- /dev/null +++ b/src/components/icons/assets/thermostaticMixingValve.tsx @@ -0,0 +1,26 @@ +import React from "react"; + +interface ThermostaticMixingValveProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const ThermostaticMixingValve: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default ThermostaticMixingValve; diff --git a/src/components/icons/assets/toilet.tsx b/src/components/icons/assets/toilet.tsx new file mode 100644 index 000000000..dac96652f --- /dev/null +++ b/src/components/icons/assets/toilet.tsx @@ -0,0 +1,42 @@ +import React from "react"; + +interface ToiletProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Toilet: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + + +); + +export default Toilet; diff --git a/src/components/icons/assets/transferSwitch.tsx b/src/components/icons/assets/transferSwitch.tsx new file mode 100644 index 000000000..0ee4e3918 --- /dev/null +++ b/src/components/icons/assets/transferSwitch.tsx @@ -0,0 +1,26 @@ +import React from "react"; + +interface TransferSwitchProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const TransferSwitch: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default TransferSwitch; diff --git a/src/components/icons/assets/transformer1.tsx b/src/components/icons/assets/transformer1.tsx new file mode 100644 index 000000000..c84b5974e --- /dev/null +++ b/src/components/icons/assets/transformer1.tsx @@ -0,0 +1,36 @@ +import React from "react"; + +interface Transformer1Props extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Transformer1: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + +); + +export default Transformer1; diff --git a/src/components/icons/assets/transformer2.tsx b/src/components/icons/assets/transformer2.tsx new file mode 100644 index 000000000..401656eaf --- /dev/null +++ b/src/components/icons/assets/transformer2.tsx @@ -0,0 +1,39 @@ +import React from "react"; + +interface Transformer2Props extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Transformer2: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + +); + +export default Transformer2; diff --git a/src/components/icons/assets/transformer3.tsx b/src/components/icons/assets/transformer3.tsx new file mode 100644 index 000000000..2248dedf5 --- /dev/null +++ b/src/components/icons/assets/transformer3.tsx @@ -0,0 +1,48 @@ +import React from "react"; + +interface Transformer3Props extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Transformer3: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + + + + +); + +export default Transformer3; diff --git a/src/components/icons/assets/transmissionTower.tsx b/src/components/icons/assets/transmissionTower.tsx new file mode 100644 index 000000000..71bb26a85 --- /dev/null +++ b/src/components/icons/assets/transmissionTower.tsx @@ -0,0 +1,28 @@ +import React from "react"; + +interface TransmissionTowerProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const TransmissionTower: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default TransmissionTower; diff --git a/src/components/icons/assets/transponder.tsx b/src/components/icons/assets/transponder.tsx new file mode 100644 index 000000000..ba6831ecf --- /dev/null +++ b/src/components/icons/assets/transponder.tsx @@ -0,0 +1,42 @@ +import React from "react"; + +interface TransponderProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Transponder: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + + +); + +export default Transponder; diff --git a/src/components/icons/assets/urinal.tsx b/src/components/icons/assets/urinal.tsx new file mode 100644 index 000000000..3a726c7d8 --- /dev/null +++ b/src/components/icons/assets/urinal.tsx @@ -0,0 +1,34 @@ +import React from "react"; + +interface UrinalProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Urinal: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + +); + +export default Urinal; diff --git a/src/components/icons/assets/valve.tsx b/src/components/icons/assets/valve.tsx new file mode 100644 index 000000000..b12380239 --- /dev/null +++ b/src/components/icons/assets/valve.tsx @@ -0,0 +1,32 @@ +import React from "react"; + +interface ValveProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Valve: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + +); + +export default Valve; diff --git a/src/components/icons/assets/vavBox.tsx b/src/components/icons/assets/vavBox.tsx new file mode 100644 index 000000000..caa493f40 --- /dev/null +++ b/src/components/icons/assets/vavBox.tsx @@ -0,0 +1,42 @@ +import React from "react"; + +interface VavBoxProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const VavBox: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + + +); + +export default VavBox; diff --git a/src/components/icons/assets/vrfIndoorUnit.tsx b/src/components/icons/assets/vrfIndoorUnit.tsx new file mode 100644 index 000000000..9a282b985 --- /dev/null +++ b/src/components/icons/assets/vrfIndoorUnit.tsx @@ -0,0 +1,34 @@ +import React from "react"; + +interface VrfIndoorUnitProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const VrfIndoorUnit: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + +); + +export default VrfIndoorUnit; diff --git a/src/components/icons/assets/vrfOutdoorHeatPump.tsx b/src/components/icons/assets/vrfOutdoorHeatPump.tsx new file mode 100644 index 000000000..e010cf848 --- /dev/null +++ b/src/components/icons/assets/vrfOutdoorHeatPump.tsx @@ -0,0 +1,26 @@ +import React from "react"; + +interface VrfOutdoorHeatPumpProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const VrfOutdoorHeatPump: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default VrfOutdoorHeatPump; diff --git a/src/components/icons/assets/wallHydrant.tsx b/src/components/icons/assets/wallHydrant.tsx new file mode 100644 index 000000000..350c2afb3 --- /dev/null +++ b/src/components/icons/assets/wallHydrant.tsx @@ -0,0 +1,51 @@ +import React from "react"; + +interface WallHydrantProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const WallHydrant: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + + + + +); + +export default WallHydrant; diff --git a/src/components/icons/assets/waterBoiler.tsx b/src/components/icons/assets/waterBoiler.tsx new file mode 100644 index 000000000..e433f42fe --- /dev/null +++ b/src/components/icons/assets/waterBoiler.tsx @@ -0,0 +1,29 @@ +import React from "react"; + +interface WaterBoilerProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const WaterBoiler: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default WaterBoiler; diff --git a/src/components/icons/assets/waterFlowStation.tsx b/src/components/icons/assets/waterFlowStation.tsx new file mode 100644 index 000000000..cec9ed3ba --- /dev/null +++ b/src/components/icons/assets/waterFlowStation.tsx @@ -0,0 +1,26 @@ +import React from "react"; + +interface WaterFlowStationProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const WaterFlowStation: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default WaterFlowStation; diff --git a/src/components/icons/assets/waterHeater.tsx b/src/components/icons/assets/waterHeater.tsx new file mode 100644 index 000000000..71b9ab947 --- /dev/null +++ b/src/components/icons/assets/waterHeater.tsx @@ -0,0 +1,34 @@ +import React from "react"; + +interface WaterHeaterProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const WaterHeater: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + +); + +export default WaterHeater; diff --git a/src/components/icons/assets/waterMeter.tsx b/src/components/icons/assets/waterMeter.tsx new file mode 100644 index 000000000..56cc16423 --- /dev/null +++ b/src/components/icons/assets/waterMeter.tsx @@ -0,0 +1,28 @@ +import React from "react"; + +interface WaterMeterProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const WaterMeter: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + + + + +); + +export default WaterMeter; diff --git a/src/components/icons/assets/waterSourceHeatPump.tsx b/src/components/icons/assets/waterSourceHeatPump.tsx new file mode 100644 index 000000000..4e312130c --- /dev/null +++ b/src/components/icons/assets/waterSourceHeatPump.tsx @@ -0,0 +1,31 @@ +import React from "react"; + +interface WaterSourceHeatPumpProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const WaterSourceHeatPump: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + +); + +export default WaterSourceHeatPump; diff --git a/src/components/icons/assets/window.tsx b/src/components/icons/assets/window.tsx new file mode 100644 index 000000000..77c679fe0 --- /dev/null +++ b/src/components/icons/assets/window.tsx @@ -0,0 +1,24 @@ +import React from "react"; + +interface WindowProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Window: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + +); + +export default Window; diff --git a/src/components/icons/assets/wirelessRouter.tsx b/src/components/icons/assets/wirelessRouter.tsx new file mode 100644 index 000000000..32036d9c1 --- /dev/null +++ b/src/components/icons/assets/wirelessRouter.tsx @@ -0,0 +1,38 @@ +import React from "react"; + +interface WirelessRouterProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const WirelessRouter: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + + + +); + +export default WirelessRouter; diff --git a/src/components/icons/assets/wiringElectrical.tsx b/src/components/icons/assets/wiringElectrical.tsx new file mode 100644 index 000000000..2d3d8fb07 --- /dev/null +++ b/src/components/icons/assets/wiringElectrical.tsx @@ -0,0 +1,26 @@ +import React from "react"; + +interface WiringElectricalProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const WiringElectrical: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + +); + +export default WiringElectrical; diff --git a/src/components/icons/assets/wiringLowVoltage.tsx b/src/components/icons/assets/wiringLowVoltage.tsx new file mode 100644 index 000000000..0f81fcf3f --- /dev/null +++ b/src/components/icons/assets/wiringLowVoltage.tsx @@ -0,0 +1,30 @@ +import React from "react"; + +interface WiringLowVoltageProps extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const WiringLowVoltage: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + +); + +export default WiringLowVoltage; diff --git a/src/components/icons/assets/xray1.tsx b/src/components/icons/assets/xray1.tsx new file mode 100644 index 000000000..4130f6cb6 --- /dev/null +++ b/src/components/icons/assets/xray1.tsx @@ -0,0 +1,44 @@ +import React from "react"; + +interface Xray1Props extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Xray1: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + + + + +); + +export default Xray1; diff --git a/src/components/icons/assets/xray2.tsx b/src/components/icons/assets/xray2.tsx new file mode 100644 index 000000000..9b439ba20 --- /dev/null +++ b/src/components/icons/assets/xray2.tsx @@ -0,0 +1,24 @@ +import React from "react"; + +interface Xray2Props extends React.SVGProps { + color?: string; + fontSize?: string | number; +} + +const Xray2: React.FC = ({ color = "currentColor", fontSize = "32px", ...props }) => ( + + + +); + +export default Xray2; diff --git a/src/components/icons/authentication.tsx b/src/components/icons/authentication.tsx new file mode 100644 index 000000000..2523fb355 --- /dev/null +++ b/src/components/icons/authentication.tsx @@ -0,0 +1,15 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const Authentication = (props: IconProps & any) => ( + + + ; + +); + +export default Authentication; diff --git a/src/components/icons/bankAccount.tsx b/src/components/icons/bankAccount.tsx new file mode 100644 index 000000000..859be28ec --- /dev/null +++ b/src/components/icons/bankAccount.tsx @@ -0,0 +1,16 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const BankAccount = (props: IconProps & any) => ( + + + +); + +export default BankAccount; diff --git a/src/components/icons/bell.tsx b/src/components/icons/bell.tsx new file mode 100644 index 000000000..e3f507ba1 --- /dev/null +++ b/src/components/icons/bell.tsx @@ -0,0 +1,14 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const Bell = (props: IconProps & any) => ( + + + +); + +export default Bell; diff --git a/src/components/icons/billing.tsx b/src/components/icons/billing.tsx new file mode 100644 index 000000000..79b87b2da --- /dev/null +++ b/src/components/icons/billing.tsx @@ -0,0 +1,14 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const Billing = (props: IconProps & any) => ( + + + +); + +export default Billing; diff --git a/src/components/icons/briefcase.tsx b/src/components/icons/briefcase.tsx new file mode 100644 index 000000000..22c90147c --- /dev/null +++ b/src/components/icons/briefcase.tsx @@ -0,0 +1,17 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const Briefcase = (props: IconProps & any) => ( + + + + +); + +export default Briefcase; diff --git a/src/components/icons/call.tsx b/src/components/icons/call.tsx new file mode 100644 index 000000000..9d4adfbf2 --- /dev/null +++ b/src/components/icons/call.tsx @@ -0,0 +1,32 @@ +import { Icon, IconProps } from "@chakra-ui/react"; + +const Call = (props: IconProps & any) => ( + + + + + + + + +); + +export default Call; diff --git a/src/components/icons/callHover.tsx b/src/components/icons/callHover.tsx new file mode 100644 index 000000000..adbf4dd94 --- /dev/null +++ b/src/components/icons/callHover.tsx @@ -0,0 +1,32 @@ +import { Icon, IconProps } from "@chakra-ui/react"; + +const CallHover = (props: IconProps & any) => ( + + + + + + + + +); + +export default CallHover; diff --git a/src/components/icons/close.tsx b/src/components/icons/close.tsx new file mode 100644 index 000000000..f946896f9 --- /dev/null +++ b/src/components/icons/close.tsx @@ -0,0 +1,31 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const Close = (props: IconProps & any) => ( + + + + +); + +export default Close; diff --git a/src/components/icons/commonCreditCard.tsx b/src/components/icons/commonCreditCard.tsx new file mode 100644 index 000000000..108688989 --- /dev/null +++ b/src/components/icons/commonCreditCard.tsx @@ -0,0 +1,20 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const CommonCreditCard = (props: IconProps & any) => ( + + + +); + +export default CommonCreditCard; diff --git a/src/components/icons/crown.tsx b/src/components/icons/crown.tsx new file mode 100644 index 000000000..9c71ca43b --- /dev/null +++ b/src/components/icons/crown.tsx @@ -0,0 +1,20 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const Crown = (props: IconProps & any) => ( + + + +); + +export default Crown; diff --git a/src/components/icons/cup.tsx b/src/components/icons/cup.tsx new file mode 100644 index 000000000..d44af7a4d --- /dev/null +++ b/src/components/icons/cup.tsx @@ -0,0 +1,27 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import { useState } from "react"; + +const Cup = (props: IconProps & any) => { + const [id] = useState(`cup_clip${Math.random()}`); + return ( + + + + + + + + + + + + ); +}; + +export default Cup; diff --git a/src/components/icons/dashboard.tsx b/src/components/icons/dashboard.tsx new file mode 100644 index 000000000..c0951fa36 --- /dev/null +++ b/src/components/icons/dashboard.tsx @@ -0,0 +1,21 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const Dashboard = (props: IconProps & any) => ( + + + + + + +); + +export default Dashboard; diff --git a/src/components/icons/download.tsx b/src/components/icons/download.tsx new file mode 100644 index 000000000..d469a6454 --- /dev/null +++ b/src/components/icons/download.tsx @@ -0,0 +1,14 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const Download = (props: IconProps & any) => ( + + + +); + +export default Download; diff --git a/src/components/icons/edit.tsx b/src/components/icons/edit.tsx new file mode 100644 index 000000000..00817e4a3 --- /dev/null +++ b/src/components/icons/edit.tsx @@ -0,0 +1,14 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const EditIcon = (props: IconProps & any) => ( + + + +); + +export default EditIcon; diff --git a/src/components/icons/exclamation.tsx b/src/components/icons/exclamation.tsx new file mode 100644 index 000000000..f89276fa1 --- /dev/null +++ b/src/components/icons/exclamation.tsx @@ -0,0 +1,20 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const Exclamation = (props: IconProps & any) => ( + + + +); + +export default Exclamation; diff --git a/src/components/icons/feedback.tsx b/src/components/icons/feedback.tsx new file mode 100644 index 000000000..7031d5628 --- /dev/null +++ b/src/components/icons/feedback.tsx @@ -0,0 +1,30 @@ +import { Icon, IconProps } from "@chakra-ui/react"; + +const Feedback = (props: IconProps & any) => ( + + + + + + + + + +); + +export default Feedback; diff --git a/src/components/icons/feedbackHover.tsx b/src/components/icons/feedbackHover.tsx new file mode 100644 index 000000000..071c9fd08 --- /dev/null +++ b/src/components/icons/feedbackHover.tsx @@ -0,0 +1,29 @@ +import { Icon, IconProps } from "@chakra-ui/react"; + +const FeedbackHover = (props: IconProps & any) => ( + + + + + + + + +); + +export default FeedbackHover; diff --git a/src/components/icons/files.tsx b/src/components/icons/files.tsx new file mode 100644 index 000000000..8564c44c4 --- /dev/null +++ b/src/components/icons/files.tsx @@ -0,0 +1,15 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const Files = (props: IconProps & any) => ( + + + + + +); + +export default Files; diff --git a/src/components/icons/flag.tsx b/src/components/icons/flag.tsx new file mode 100644 index 000000000..d3e44f211 --- /dev/null +++ b/src/components/icons/flag.tsx @@ -0,0 +1,16 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const FlagIcon = (props: IconProps & any) => ( + + + +); + +export default FlagIcon; diff --git a/src/components/icons/floor.tsx b/src/components/icons/floor.tsx new file mode 100644 index 000000000..76234e685 --- /dev/null +++ b/src/components/icons/floor.tsx @@ -0,0 +1,10 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const Floor = (props: IconProps & any) => ( + + + +); + +export default Floor; diff --git a/src/components/icons/folder.tsx b/src/components/icons/folder.tsx new file mode 100644 index 000000000..8a37d6fd8 --- /dev/null +++ b/src/components/icons/folder.tsx @@ -0,0 +1,49 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import { useState } from "react"; + +const Folder = (props: IconProps & any) => { + const [id1] = useState(`folder_a${Math.random()}`); + + return ( + + + + + + + + + + + + ); +}; + +export default Folder; diff --git a/src/components/icons/help.tsx b/src/components/icons/help.tsx new file mode 100644 index 000000000..2eda9a36b --- /dev/null +++ b/src/components/icons/help.tsx @@ -0,0 +1,14 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const Help = (props: IconProps & any) => ( + + + +); + +export default Help; diff --git a/src/components/icons/invite.tsx b/src/components/icons/invite.tsx new file mode 100644 index 000000000..fb06d07db --- /dev/null +++ b/src/components/icons/invite.tsx @@ -0,0 +1,14 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const InviteIcon = (props: IconProps & any) => ( + + + +); + +export default InviteIcon; diff --git a/src/components/icons/listView.tsx b/src/components/icons/listView.tsx new file mode 100644 index 000000000..c286d3095 --- /dev/null +++ b/src/components/icons/listView.tsx @@ -0,0 +1,9 @@ +import { Icon, IconProps } from "@chakra-ui/react"; + +const ListView = (props: IconProps & any) => ( + + + +); + +export default ListView; diff --git a/src/components/icons/location.tsx b/src/components/icons/location.tsx new file mode 100644 index 000000000..1e86acd33 --- /dev/null +++ b/src/components/icons/location.tsx @@ -0,0 +1,10 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const Location = (props: IconProps & any) => ( + + + +); + +export default Location; diff --git a/src/components/icons/locations.tsx b/src/components/icons/locations.tsx new file mode 100644 index 000000000..4440e745c --- /dev/null +++ b/src/components/icons/locations.tsx @@ -0,0 +1,10 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const Locations = (props: IconProps & any) => ( + + + +); + +export default Locations; diff --git a/src/components/icons/lock.tsx b/src/components/icons/lock.tsx new file mode 100644 index 000000000..e41622b2f --- /dev/null +++ b/src/components/icons/lock.tsx @@ -0,0 +1,14 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const LockIcon = (props: IconProps & any) => ( + + + +); + +export default LockIcon; diff --git a/src/components/icons/logo.tsx b/src/components/icons/logo.tsx new file mode 100644 index 000000000..0f13a9793 --- /dev/null +++ b/src/components/icons/logo.tsx @@ -0,0 +1,83 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import { useState } from "react"; + +const Logo = (props: IconProps & any) => { + const [id] = useState(`clip0_logo${Math.random()}`); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default Logo; diff --git a/src/components/icons/logoDarkBg.tsx b/src/components/icons/logoDarkBg.tsx new file mode 100644 index 000000000..b891a4c92 --- /dev/null +++ b/src/components/icons/logoDarkBg.tsx @@ -0,0 +1,74 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const LogoDarkBg = (props: IconProps & any) => ( + + + + + + + + + + + + + + + + +); + +export default LogoDarkBg; diff --git a/src/components/icons/logoIcon.tsx b/src/components/icons/logoIcon.tsx new file mode 100644 index 000000000..83ab0a599 --- /dev/null +++ b/src/components/icons/logoIcon.tsx @@ -0,0 +1,19 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const LogoIcon = (props: IconProps & any) => ( + + + +); + +export default LogoIcon; diff --git a/src/components/icons/logoIconDarkBg.tsx b/src/components/icons/logoIconDarkBg.tsx new file mode 100644 index 000000000..88a22256a --- /dev/null +++ b/src/components/icons/logoIconDarkBg.tsx @@ -0,0 +1,19 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const LogoIconDarkBg = (props: IconProps & any) => ( + + + +); + +export default LogoIconDarkBg; diff --git a/src/components/icons/logoSquare.tsx b/src/components/icons/logoSquare.tsx new file mode 100644 index 000000000..6c6a19106 --- /dev/null +++ b/src/components/icons/logoSquare.tsx @@ -0,0 +1,33 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const LogoSquare = (props: IconProps & any) => ( + + + + + + + + +); + +export default LogoSquare; diff --git a/src/components/icons/logoSquareDarkBg.tsx b/src/components/icons/logoSquareDarkBg.tsx new file mode 100644 index 000000000..aa8627f73 --- /dev/null +++ b/src/components/icons/logoSquareDarkBg.tsx @@ -0,0 +1,23 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const LogoSquareDarkBg = (props: IconProps & any) => ( + + + + +); + +export default LogoSquareDarkBg; diff --git a/src/components/icons/manager.tsx b/src/components/icons/manager.tsx new file mode 100644 index 000000000..b4f384558 --- /dev/null +++ b/src/components/icons/manager.tsx @@ -0,0 +1,16 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const Manager = (props: IconProps & any) => ( + + + +); + +export default Manager; diff --git a/src/components/icons/masterCard.tsx b/src/components/icons/masterCard.tsx new file mode 100644 index 000000000..4029524a7 --- /dev/null +++ b/src/components/icons/masterCard.tsx @@ -0,0 +1,32 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const MasterCard = (props: IconProps & any) => ( + + + + + + + +); + +export default MasterCard; diff --git a/src/components/icons/menu.tsx b/src/components/icons/menu.tsx new file mode 100644 index 000000000..1aa0d16fa --- /dev/null +++ b/src/components/icons/menu.tsx @@ -0,0 +1,19 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const Menu = (props: IconProps & any) => ( + + + + + +); + +export default Menu; diff --git a/src/components/icons/moreV.tsx b/src/components/icons/moreV.tsx new file mode 100644 index 000000000..83c355ff2 --- /dev/null +++ b/src/components/icons/moreV.tsx @@ -0,0 +1,14 @@ +import { Icon, IconProps } from "@chakra-ui/react"; + +const MoreVIcon = (props: IconProps & any) => { + return ( + + + + ); +}; + +export default MoreVIcon; diff --git a/src/components/icons/mySettings.tsx b/src/components/icons/mySettings.tsx new file mode 100644 index 000000000..c76084e5b --- /dev/null +++ b/src/components/icons/mySettings.tsx @@ -0,0 +1,14 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const MySettings = (props: IconProps & any) => ( + + + +); + +export default MySettings; diff --git a/src/components/icons/newFolder.tsx b/src/components/icons/newFolder.tsx new file mode 100644 index 000000000..6e2154b2d --- /dev/null +++ b/src/components/icons/newFolder.tsx @@ -0,0 +1,77 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import { useState } from "react"; + +const NewFolder = (props: IconProps & any) => { + const [id1] = useState(`mask0_new_folder${Math.random()}`); + const [id2] = useState(`paint0_linear_new_folder${Math.random()}`); + const [id3] = useState(`paint1_linear_new_folder${Math.random()}`); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default NewFolder; diff --git a/src/components/icons/noCircle.tsx b/src/components/icons/noCircle.tsx new file mode 100644 index 000000000..8a451732d --- /dev/null +++ b/src/components/icons/noCircle.tsx @@ -0,0 +1,20 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const NoCircle = (props: IconProps & any) => ( + + + + +); + +export default NoCircle; diff --git a/src/components/icons/noResource.tsx b/src/components/icons/noResource.tsx new file mode 100644 index 000000000..221dd69c9 --- /dev/null +++ b/src/components/icons/noResource.tsx @@ -0,0 +1,49 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import { useState } from "react"; + +const NoResource = (props: IconProps) => { + const [id] = useState(`filter0_dashboard-${Math.random()}`); + return ( + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default NoResource; diff --git a/src/components/icons/offlineIcon.tsx b/src/components/icons/offlineIcon.tsx new file mode 100644 index 000000000..5816e5c75 --- /dev/null +++ b/src/components/icons/offlineIcon.tsx @@ -0,0 +1,9 @@ +import { Icon, IconProps } from "@chakra-ui/react"; + +const OfflineIcon = (props: IconProps & any) => ( + + + +); + +export default OfflineIcon; diff --git a/src/components/icons/onBoard.tsx b/src/components/icons/onBoard.tsx new file mode 100644 index 000000000..bfae4ecd7 --- /dev/null +++ b/src/components/icons/onBoard.tsx @@ -0,0 +1,13 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const OnBoard = (props: IconProps & any) => ( + + + +); + +export default OnBoard; diff --git a/src/components/icons/pageNotFound.tsx b/src/components/icons/pageNotFound.tsx new file mode 100644 index 000000000..63f340502 --- /dev/null +++ b/src/components/icons/pageNotFound.tsx @@ -0,0 +1,98 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import { useState } from "react"; + +const PageNotFound = (props: IconProps & any) => { + const [id] = useState(`pageNotFound_clip0${Math.random()}`); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default PageNotFound; diff --git a/src/components/icons/phone.tsx b/src/components/icons/phone.tsx new file mode 100644 index 000000000..ecd0856da --- /dev/null +++ b/src/components/icons/phone.tsx @@ -0,0 +1,14 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const PhoneIcon = (props: IconProps & any) => ( + + + +); + +export default PhoneIcon; diff --git a/src/components/icons/planIcon.tsx b/src/components/icons/planIcon.tsx new file mode 100644 index 000000000..1db2f81b4 --- /dev/null +++ b/src/components/icons/planIcon.tsx @@ -0,0 +1,31 @@ +import { IconProps, Text } from "@chakra-ui/react"; +import React from "react"; + +import Briefcase from "./briefcase"; +import Crown from "./crown"; +import StarShip from "./starShip"; +import Unlimited from "./unlimited"; + +interface PlanIconProps extends IconProps { + planName: string; +} + +const PlanIcon: React.FC = ({ planName, ...props }) => { + let icon = ; + + switch (planName) { + case "Premium": + icon = ; + break; + case "Ultimate": + icon = ; + break; + case "Professional": + icon = ; + break; + } + + return {icon}; +}; + +export default PlanIcon; diff --git a/src/components/icons/resend.tsx b/src/components/icons/resend.tsx new file mode 100644 index 000000000..4f23bd44a --- /dev/null +++ b/src/components/icons/resend.tsx @@ -0,0 +1,17 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const Resend = (props: IconProps & any) => ( + + + +); + +export default Resend; diff --git a/src/components/icons/rocket.tsx b/src/components/icons/rocket.tsx new file mode 100644 index 000000000..fb5a13b3a --- /dev/null +++ b/src/components/icons/rocket.tsx @@ -0,0 +1,14 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const Rocket = (props: IconProps & any) => ( + + + +); + +export default Rocket; diff --git a/src/components/icons/rooms.tsx b/src/components/icons/rooms.tsx new file mode 100644 index 000000000..076ae826f --- /dev/null +++ b/src/components/icons/rooms.tsx @@ -0,0 +1,10 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const Room = (props: IconProps & any) => ( + + + +); + +export default Room; diff --git a/src/components/icons/search.tsx b/src/components/icons/search.tsx new file mode 100644 index 000000000..a555e1c01 --- /dev/null +++ b/src/components/icons/search.tsx @@ -0,0 +1,16 @@ +import { Icon, IconProps } from "@chakra-ui/react"; + +const Search = (props: IconProps & any) => ( + + + + + {" "} + +); + +export default Search; diff --git a/src/components/icons/searchArticles.tsx b/src/components/icons/searchArticles.tsx new file mode 100644 index 000000000..98c523919 --- /dev/null +++ b/src/components/icons/searchArticles.tsx @@ -0,0 +1,42 @@ +import { Icon, IconProps } from "@chakra-ui/react"; + +const SearchArticles = (props: IconProps & any) => ( + + + + + + + + + + + + + + + + +); + +export default SearchArticles; diff --git a/src/components/icons/searchArticlesHover.tsx b/src/components/icons/searchArticlesHover.tsx new file mode 100644 index 000000000..4403b4124 --- /dev/null +++ b/src/components/icons/searchArticlesHover.tsx @@ -0,0 +1,42 @@ +import { Icon, IconProps } from "@chakra-ui/react"; + +const SearchArticlesHover = (props: IconProps & any) => ( + + + + + + + + + + + + + + + + +); + +export default SearchArticlesHover; diff --git a/src/components/icons/settings.tsx b/src/components/icons/settings.tsx new file mode 100644 index 000000000..3212e74b9 --- /dev/null +++ b/src/components/icons/settings.tsx @@ -0,0 +1,10 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const Settings = (props: IconProps & any) => ( + + + +); + +export default Settings; diff --git a/src/components/icons/signOut.tsx b/src/components/icons/signOut.tsx new file mode 100644 index 000000000..5fb98b43f --- /dev/null +++ b/src/components/icons/signOut.tsx @@ -0,0 +1,14 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const SignOut = (props: IconProps & any) => ( + + + +); + +export default SignOut; diff --git a/src/components/icons/starShip.tsx b/src/components/icons/starShip.tsx new file mode 100644 index 000000000..580004522 --- /dev/null +++ b/src/components/icons/starShip.tsx @@ -0,0 +1,16 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const StarShip = (props: IconProps & any) => ( + + + +); + +export default StarShip; diff --git a/src/components/icons/submitTicket.tsx b/src/components/icons/submitTicket.tsx new file mode 100644 index 000000000..466e78b03 --- /dev/null +++ b/src/components/icons/submitTicket.tsx @@ -0,0 +1,43 @@ +import { Icon, IconProps } from "@chakra-ui/react"; + +const SubmitTicket = (props: IconProps & any) => ( + + + + + + + + + + + + + + + + +); + +export default SubmitTicket; diff --git a/src/components/icons/submitTicketHover.tsx b/src/components/icons/submitTicketHover.tsx new file mode 100644 index 000000000..3ea63f3d6 --- /dev/null +++ b/src/components/icons/submitTicketHover.tsx @@ -0,0 +1,43 @@ +import { Icon, IconProps } from "@chakra-ui/react"; + +const SubmitTicketHover = (props: IconProps & any) => ( + + + + + + + + + + + + + + + + +); + +export default SubmitTicketHover; diff --git a/src/components/icons/supportAgent.tsx b/src/components/icons/supportAgent.tsx new file mode 100644 index 000000000..fcfb4ca0e --- /dev/null +++ b/src/components/icons/supportAgent.tsx @@ -0,0 +1,36 @@ +import { Icon, IconProps } from "@chakra-ui/react"; + +const SupportAgent = (props: IconProps & any) => ( + + + + + + + + + +); + +export default SupportAgent; diff --git a/src/components/icons/supportAgentHover.tsx b/src/components/icons/supportAgentHover.tsx new file mode 100644 index 000000000..96ef37a0f --- /dev/null +++ b/src/components/icons/supportAgentHover.tsx @@ -0,0 +1,32 @@ +import { Icon, IconProps } from "@chakra-ui/react"; + +const SupportAgentHover = (props: IconProps & any) => ( + + + + + + + + +); + +export default SupportAgentHover; diff --git a/src/components/icons/team.tsx b/src/components/icons/team.tsx new file mode 100644 index 000000000..5e0841bdd --- /dev/null +++ b/src/components/icons/team.tsx @@ -0,0 +1,10 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const Team = (props: IconProps & any) => ( + + + +); + +export default Team; diff --git a/src/components/icons/tickets.tsx b/src/components/icons/tickets.tsx new file mode 100644 index 000000000..0dac88058 --- /dev/null +++ b/src/components/icons/tickets.tsx @@ -0,0 +1,36 @@ +import { Icon, IconProps } from "@chakra-ui/react"; + +const Tickets = (props: IconProps & any) => ( + + + + + + + + + +); + +export default Tickets; diff --git a/src/components/icons/ticketsHover.tsx b/src/components/icons/ticketsHover.tsx new file mode 100644 index 000000000..f1503e05c --- /dev/null +++ b/src/components/icons/ticketsHover.tsx @@ -0,0 +1,36 @@ +import { Icon, IconProps } from "@chakra-ui/react"; + +const TicketsHover = (props: IconProps & any) => ( + + + + + + + + + +); + +export default TicketsHover; diff --git a/src/components/icons/tilesView.tsx b/src/components/icons/tilesView.tsx new file mode 100644 index 000000000..64856e4b0 --- /dev/null +++ b/src/components/icons/tilesView.tsx @@ -0,0 +1,13 @@ +import { Icon, IconProps } from "@chakra-ui/react"; + +const TilesView = (props: IconProps & any) => ( + + + +); + +export default TilesView; diff --git a/src/components/icons/toggleNav.tsx b/src/components/icons/toggleNav.tsx new file mode 100644 index 000000000..52cce6368 --- /dev/null +++ b/src/components/icons/toggleNav.tsx @@ -0,0 +1,47 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import { useState } from "react"; + +const ToggleNav = (props: IconProps & any) => { + const [id] = useState(`filter0_toggle_nav${Math.random()}`); + return ( + + + + + + + + + + + + + + + + + + ); +}; + +export default ToggleNav; diff --git a/src/components/icons/unlimited.tsx b/src/components/icons/unlimited.tsx new file mode 100644 index 000000000..f0e44e293 --- /dev/null +++ b/src/components/icons/unlimited.tsx @@ -0,0 +1,20 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const Unlimited = (props: IconProps & any) => ( + + + + +); + +export default Unlimited; diff --git a/src/components/icons/user.tsx b/src/components/icons/user.tsx new file mode 100644 index 000000000..33fc2720e --- /dev/null +++ b/src/components/icons/user.tsx @@ -0,0 +1,20 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const UserIcon = (props: IconProps & any) => ( + + + +); + +export default UserIcon; diff --git a/src/components/icons/visa.tsx b/src/components/icons/visa.tsx new file mode 100644 index 000000000..122fb2add --- /dev/null +++ b/src/components/icons/visa.tsx @@ -0,0 +1,45 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import { useState } from "react"; + +const Visa = (props: IconProps & any) => { + const [id] = useState(`clip0_visa${Math.random()}`); + return ( + + + + + + + + + + + + + + + + ); +}; + +export default Visa; diff --git a/src/components/icons/yesCircle.tsx b/src/components/icons/yesCircle.tsx new file mode 100644 index 000000000..172c1f02e --- /dev/null +++ b/src/components/icons/yesCircle.tsx @@ -0,0 +1,22 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const YesCircle = (props: IconProps & any) => ( + + + + +); + +export default YesCircle; diff --git a/src/components/icons/zone.tsx b/src/components/icons/zone.tsx new file mode 100644 index 000000000..2763fedc8 --- /dev/null +++ b/src/components/icons/zone.tsx @@ -0,0 +1,10 @@ +import { Icon, IconProps } from "@chakra-ui/react"; +import React from "react"; + +const Zone = (props: IconProps & any) => ( + + + +); + +export default Zone; diff --git a/src/components/toastService.tsx b/src/components/toastService.tsx new file mode 100644 index 000000000..c44cf7b8d --- /dev/null +++ b/src/components/toastService.tsx @@ -0,0 +1,29 @@ +import { createStandaloneToast } from "@chakra-ui/react"; + +const { toast } = createStandaloneToast(); + +interface ToastOptions { + status: "success" | "error" | "warning" | "info"; + title: string; + description?: string; + duration?: number; + position?: "top" | "bottom" | "top-right" | "top-left" | "bottom-right" | "bottom-left"; +} + +export const showToast = ({ + status, + title, + description, + duration = 3000, + position = "top", +}: ToastOptions) => { + toast({ + title, + description, + status, + duration, + isClosable: true, + variant: "left-accent", + position, + }); +}; diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx new file mode 100644 index 000000000..fda05bd50 --- /dev/null +++ b/src/context/AuthContext.tsx @@ -0,0 +1,68 @@ +import { createContext, useContext, useState, ReactNode, useEffect, useCallback } from "react"; +import { useNavigate } from "react-router-dom"; + +export type Tenant = { + id: string; + displayname: string; + slug: string; + // Optional fields that are present on tenant objects returned from the API + name?: string; + email?: string; + phone?: string; +}; + +type AuthContextType = { + user: any | null; + tenantList: Tenant[]; + setTenantList: (tenants: Tenant[]) => void; + login: (userDetails: any) => void; + logout: () => void; +}; +const AuthContext = createContext(undefined); + +export const AuthProvider = ({ children }: { children: ReactNode }) => { + const [user, setUser] = useState(null); + const [tenantList, setTenantList] = useState([]); + const navigate = useNavigate(); + + useEffect(() => { + const storedUser = localStorage.getItem("userData"); + const storedTenants = localStorage.getItem("tenantList"); + if (storedUser) { + setUser(JSON.parse(storedUser)); + } + if (storedTenants) { + setTenantList(JSON.parse(storedTenants)); + } + }, []); + + const login = (userDetails: any) => { + setUser(userDetails); + localStorage.setItem("userData", JSON.stringify(userDetails)); + }; + + const handleSetTenantList = useCallback((tenants: Tenant[]) => { + setTenantList(tenants); + // localStorage.setItem("tenantList", JSON.stringify(tenants)); + }, []); + + const logout = () => { + setUser(null); + setTenantList([]); + localStorage.clear(); + sessionStorage.clear(); + navigate("/login"); + }; + + return ( + + {children} + + ); +}; + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) throw new Error("useAuth must be used within AuthProvider"); + return context; +}; diff --git a/src/context/LoaderContext.tsx b/src/context/LoaderContext.tsx new file mode 100644 index 000000000..ad125365d --- /dev/null +++ b/src/context/LoaderContext.tsx @@ -0,0 +1,35 @@ +// LoaderContext.tsx +import { Flex, Spinner } from "@chakra-ui/react"; +import React, { createContext, useContext, useState, ReactNode } from "react"; + +interface LoaderContextType { + showLoader: () => void; + hideLoader: () => void; +} + +const LoaderContext = createContext(undefined); + +export const LoaderProvider = ({ children }: { children: ReactNode }) => { + const [loading, setLoading] = useState(false); + + const showLoader = () => setLoading(true); + const hideLoader = () => setLoading(false); + + return ( + + {children} + {loading && ( +
+ {/* */} +
+
+ )} +
+ ); +}; + +export const useLoader = () => { + const context = useContext(LoaderContext); + if (!context) throw new Error("useLoader must be used within LoaderProvider"); + return context; +}; diff --git a/src/global.d.ts b/src/global.d.ts new file mode 100644 index 000000000..87dc1be5a --- /dev/null +++ b/src/global.d.ts @@ -0,0 +1,7 @@ +// declare module "recharts" { +// export const Pie: any; +// export const PieChart: any; +// export const Cell: any; +// export const Tooltip: any; +// export const ResponsiveContainer: any; +// } \ No newline at end of file diff --git a/src/graphql/apolloClient.ts b/src/graphql/apolloClient.ts new file mode 100644 index 000000000..0f0047288 --- /dev/null +++ b/src/graphql/apolloClient.ts @@ -0,0 +1,96 @@ +import { ApolloClient, InMemoryCache, createHttpLink, from, ApolloLink, Observable } from "@apollo/client"; +import { setContext } from "@apollo/client/link/context"; + +// HTTP Link - GraphQL endpoint +const httpLink = createHttpLink({ + // uri: "http://localhost:3100/graphql", + uri: "http://admin.criticalasset.com/api", + // uri: "http://localhost:4000/graphql", +}); + +// Auth Link - Add token to requests +const authLink = setContext((_, { headers }) => { + const token = localStorage.getItem("accessToken"); + return { + headers: { + ...headers, + authorization: token ? `Bearer ${token}` : "", + }, + }; +}); + +// Helper function to handle unauthorized errors +const handleUnauthorized = () => { + // Clear all localStorage items + localStorage.clear(); + // Redirect to login page + window.location.href = "/login"; +}; + +// Error Link - Handle errors globally +const errorLink = new ApolloLink((operation, forward) => { + const observable = forward(operation); + + return new Observable((observer) => { + const subscription = observable.subscribe({ + next: (result: any) => { + // Handle GraphQL errors + if (result.errors) { + result.errors.forEach((error: any) => { + console.error(`GraphQL error: Message: ${error.message}, Path: ${error.path}`); + + // Check for unauthorized errors in GraphQL response + const errorCode = error.extensions?.code || error.extensions?.exception?.statusCode; + if (errorCode === 401 || errorCode === 403 || + error.message?.toLowerCase().includes('unauthorized') || + error.message?.toLowerCase().includes('forbidden')) { + handleUnauthorized(); + return; + } + }); + } + observer.next(result); + }, + error: (error: any) => { + console.error(`Network error: ${error}`); + + // Handle 401/403 unauthorized errors from network + const statusCode = error.statusCode || + (error as any).status || + (error as any).networkError?.statusCode || + error.networkError?.statusCode; + + if (statusCode === 401 || statusCode === 403) { + handleUnauthorized(); + return; + } + + observer.error(error); + }, + complete: () => { + observer.complete(); + }, + }); + + return () => { + subscription.unsubscribe(); + }; + }); +}); + +// Create Apollo Client +const apolloClient = new ApolloClient({ + link: from([errorLink, authLink, httpLink]), + cache: new InMemoryCache(), + defaultOptions: { + watchQuery: { + errorPolicy: "all", + }, + query: { + errorPolicy: "all", + }, + }, +}); + +export default apolloClient; + diff --git a/src/graphql/mutations.ts b/src/graphql/mutations.ts new file mode 100644 index 000000000..51e982e2f --- /dev/null +++ b/src/graphql/mutations.ts @@ -0,0 +1,1107 @@ +import { gql } from "@apollo/client"; + +// Login Mutation +export const LOGIN_MUTATION = gql`mutation Login($input: LoginInput!) { + login(input: $input) { + accessToken + refreshToken + user { + id + email + firstName + lastName + role + schema + } + } +}`; + +// Forgot Password Mutation (Request Password Reset) +export const FORGOT_PASSWORD_MUTATION = gql` + mutation RequestPasswordReset($input: ResetPasswordRequestInput!) { + requestPasswordReset(input: $input) + } +`; + +// Reset Password Mutation +export const RESET_PASSWORD_MUTATION = gql` + mutation ResetPassword($input: ResetPasswordInput!) { + resetPassword(input: $input) + } +`; + +// Company Signup Mutation (creates tenant company with schema) +export const SIGNUP_MUTATION = gql` + mutation Signup($input: SignupInput!) { + signup(input: $input) { + accessToken + refreshToken + user { + id + email + firstName + lastName + role + companyId + schema + } + } + } +`; + +export const CREATE_COMPANY_MUTATION = gql` + mutation CompanySignup($input: SignupInput!) { + signup(input: $input) { + accessToken + } + } +` +export const CREATE_PARTNER_MUTATION = gql` + mutation PartnerSignup($input: PartnerSignupInput!) { + partnerSignup(input: $input) { + accessToken + } + } +`; + +export const UPDATE_COMPANY_MUTATION = gql` + mutation UpdateCompany($input: UpdateCompanyInput!) { + updateCompany(input: $input) { + id + name + email + address + city + state + country + zip + phoneNumber + } + } +`; + +export const DELETE_COMPANY_MUTATION = gql` + mutation DeleteCompany($id: ID!) { + deleteCompany(id: $id) + } +`; + +export const UPDATE_COMPANY_USER_MUTATION = gql` +mutation UpdateCompanyUser($input: UpdateCompanyUserInput!) { +updateCompanyUser(input: $input) { + id + email + firstName + role + lastName +} +} +`; + +export const DELETE_COMPANY_USER_MUTATION = gql` + mutation DeleteCompanyUser($userId: ID!) { + deleteCompanyUser(userId: $userId) + } +`; + +// export const CREATE_USER_MUTATION = gql` +// mutation CreateUser($input: CreateUserInput!) { +// createUser(input: $input) { +// belongsToCompanyId +// createdAt +// deletedAt +// displayName +// email +// emailConfirmed +// firstName +// id +// lastName +// phone +// phoneConfirmed +// role +// twoFactorEnabled +// updatedAt +// } +// } +// `; + +export const UPDATE_USER_MUTATION = gql` + mutation UpdateUser($input: UpdateUserInput!) { + updateUser(input: $input) { + belongsToCompanyId + createdAt + deletedAt + displayName + email + emailConfirmed + firstName + id + lastName + phone + phoneConfirmed + role + twoFactorEnabled + updatedAt + } + } +`; + +export const REMOVE_USER_MUTATION = gql` + mutation RemoveUser($id: ID!) { + removeUser(id: $id) + } +`; + +export const UPDATE_TICKET_MUTATION = gql` + mutation UpdateTicket($input: UpdateTicketInput!) { + updateTicket(input: $input) { + category + description + id + priority + status + title + } + } +`; + +export const CREATE_TICKET_MUTATION = gql` + mutation CreateTicket($input: CreateTicketInput!) { + createTicket(input: $input) { + category + description + id + priority + status + title + } + } +`; + +export const ACCEPT_INVITATION_MUTATION = gql` +mutation AcceptInvitation($input: AcceptInvitationInput!) { + acceptInvitation(input: $input) { + accessToken + refreshToken + __typename + } +} +`; + + +// Send Invitation Mutation (using backend API) +export const SEND_INVITATION_MUTATION = gql` + mutation SendInvitation($input: SendInvitationInput!) { + sendInvitation(input: $input) { + id + email + role + } + } +`; + +// Resend Invitation Mutation +export const RESEND_INVITATION_MUTATION = gql` + mutation ResendInvitation($input: ResendInvitationInput!) { + resendInvitation(input: $input){ + id + email + role + status + expiresAt + } + } +`; + +export const CREATE_AI_ADDON_MUTATION = gql` + mutation CreateAIAddon($input: CreateAIAddonInput!) { + createAIAddon(input: $input) { + id + name + description + pricingType + amount + currency + interval + intervalCount + creditPoolSize + stripePriceId + stripeProductId + eligiblePlanIds + active + type + credits + createdAt + updatedAt + deletedAt + } + } +`; + +export const UPDATE_AI_ADDON_MUTATION = gql` + mutation UpdateAIAddon($input: UpdateAIAddonInput!) { + updateAIAddon(input: $input) { + id + name + description + pricingType + amount + currency + interval + intervalCount + creditPoolSize + credits + stripeProductId + stripePriceId + eligiblePlanIds + active + type + createdAt + updatedAt + deletedAt + } + } +`; + +export const DELETE_AI_ADDON_MUTATION = gql` + mutation DeleteAIAddon($input: DeleteAIAddonInput!) { + deleteAIAddon(input: $input) { + success + message + } + } +`; + +// ==================== Master Data Mutations ==================== + +// Master Asset Categories +export const CREATE_MASTER_ASSET_CATEGORY_MUTATION = gql` + mutation CreateMasterAssetCategory($input: CreateMasterAssetCategoryInput!) { + createMasterAssetCategory(input: $input) { + id + name + description + icon_name + icon_color + icon_type + is_default + created_at + updated_at + } + } +`; + +export const UPDATE_MASTER_ASSET_CATEGORY_MUTATION = gql` + mutation UpdateMasterAssetCategory($input: UpdateMasterAssetCategoryInput!) { + updateMasterAssetCategory(input: $input) { + id + name + description + icon_name + icon_color + icon_type + is_default + created_at + updated_at + } + } +`; + +export const DELETE_MASTER_ASSET_CATEGORY_MUTATION = gql` + mutation DeleteMasterAssetCategory($input: DeleteMasterAssetCategoryInput!) { + deleteMasterAssetCategory(input: $input) { + success + message + } + } +`; + + + +// Master Asset Parts +export const CREATE_MASTER_ASSET_PART_MUTATION = gql` + mutation CreateMasterAssetPart($input: CreateMasterAssetPartInput!) { + createMasterAssetPart(input: $input) { + id + name + description + asset_type_id + created_at + updated_at + } + } +`; + +export const UPDATE_MASTER_ASSET_PART_MUTATION = gql` + mutation UpdateMasterAssetPart($input: UpdateMasterAssetPartInput!) { + updateMasterAssetPart(input: $input) { + id + name + description + asset_type_id + created_at + updated_at + } + } +`; + +export const DELETE_MASTER_ASSET_PART_MUTATION = gql` + mutation DeleteMasterAssetPart($input: DeleteMasterAssetPartInput!) { + deleteMasterAssetPart(input: $input) { + success + message + } + } +`; + +// Master Asset Service Types +export const CREATE_MASTER_ASSET_SERVICE_TYPE_MUTATION = gql` + mutation CreateMasterAssetServiceType($input: CreateMasterAssetServiceTypeInput!) { + createMasterAssetServiceType(input: $input) { + id + name + description + asset_category_ids + created_at + updated_at + } + } +`; + +export const UPDATE_MASTER_ASSET_SERVICE_TYPE_MUTATION = gql` + mutation UpdateMasterAssetServiceType($input: UpdateMasterAssetServiceTypeInput!) { + updateMasterAssetServiceType(input: $input) { + id + name + description + asset_category_ids + created_at + updated_at + } + } +`; + +export const DELETE_MASTER_ASSET_SERVICE_TYPE_MUTATION = gql` + mutation DeleteMasterAssetServiceType($input: DeleteMasterAssetServiceTypeInput!) { + deleteMasterAssetServiceType(input: $input) { + success + message + } + } +`; + + + +// Master Asset Types +export const CREATE_MASTER_ASSET_TYPE_MUTATION = gql` + mutation CreateMasterAssetType($input: CreateMasterAssetTypeInput!) { + createMasterAssetType(input: $input) { + id + name + description + asset_category_id + is_default + icon_name + icon_color + icon_type + created_at + updated_at + } + } +`; + +export const UPDATE_MASTER_ASSET_TYPE_MUTATION = gql` + mutation UpdateMasterAssetType($input: UpdateMasterAssetTypeInput!) { + updateMasterAssetType(input: $input) { + id + name + description + asset_category_id + is_default + icon_name + icon_color + icon_type + created_at + updated_at + } + } +`; + +export const DELETE_MASTER_ASSET_TYPE_MUTATION = gql` + mutation DeleteMasterAssetType($input: DeleteMasterAssetTypeInput!) { + deleteMasterAssetType(input: $input) { + success + message + } + } +`; + +// Master Manufacturers +export const CREATE_MASTER_MANUFACTURER_MUTATION = gql` + mutation CreateMasterManufacturer($input: CreateMasterManufacturerInput!) { + createMasterManufacturer(input: $input) { + id + name + description + company_name + phone_number + country_code + contact_person + created_at + updated_at + } + } +`; + +export const UPDATE_MASTER_MANUFACTURER_MUTATION = gql` + mutation UpdateMasterManufacturer($input: UpdateMasterManufacturerInput!) { + updateMasterManufacturer(input: $input) { + id + name + description + company_name + phone_number + country_code + contact_person + created_at + updated_at + } + } +`; + +export const DELETE_MASTER_MANUFACTURER_MUTATION = gql` + mutation DeleteMasterManufacturer($input: DeleteMasterManufacturerInput!) { + deleteMasterManufacturer(input: $input) { + success + message + } + } +`; + +// Master Vendors +export const CREATE_MASTER_VENDOR_MUTATION = gql` + mutation CreateMasterVendor($input: CreateMasterVendorInput!) { + createMasterVendor(input: $input) { + id + name + description + company_id + company_name + phone_number + country_code + vendor_type + can_login + invited_by_user + created_at + updated_at + } + } +`; + +export const UPDATE_MASTER_VENDOR_MUTATION = gql` + mutation UpdateMasterVendor($input: UpdateMasterVendorInput!) { + updateMasterVendor(input: $input) { + id + name + description + company_id + company_name + phone_number + country_code + vendor_type + can_login + invited_by_user + created_at + updated_at + } + } +`; + +export const DELETE_MASTER_VENDOR_MUTATION = gql` + mutation DeleteMasterVendor($input: DeleteMasterVendorInput!) { + deleteMasterVendor(input: $input) { + success + message + } + } +`; + + +// Register Mutation (for Partner Admin signup) + + + + + + +// ==================== ==================== Master Data Mutations ==================== ==================== + +// Create Manufacturer Mutation +export const CREATE_MANUFACTURER_MUTATION = gql` + mutation CreateMasterManufacturer($input: CreateMasterManufacturerInput!) { + createMasterManufacturer(input: $input) { + id + name + email + company_name + website + address + phone_number + country_code + contact_person + created_at + updated_at + } + } +`; + +// Update Manufacturer Mutation +export const UPDATE_MANUFACTURER_MUTATION = gql` + mutation UpdateMasterManufacturer($input: UpdateMasterManufacturerInput!) { + updateMasterManufacturer(input: $input) { + id + name + email + company_name + website + address + phone_number + country_code + contact_person + created_at + updated_at + } + } +`; + +// Delete Manufacturer Mutation +export const DELETE_MANUFACTURER_MUTATION = gql` + mutation DeleteMasterManufacturer($id: ID!) { + deleteMasterManufacturer(input: { id: $id }) { + success + message + } + } +`; + + + +// Create Vendor Mutation +export const CREATE_VENDOR_MUTATION = gql` + mutation CreateMasterVendor($input: CreateMasterVendorInput!) { + createMasterVendor(input: $input) { + id + company_name + website + email + name + phone_number + country_code + vendor_type + can_login + invited_by_user + created_at + updated_at + } + } +`; + +// Update Vendor Mutation +export const UPDATE_VENDOR_MUTATION = gql` + mutation UpdateMasterVendor($input: UpdateMasterVendorInput!) { + updateMasterVendor(input: $input) { + id + company_name + website + email + name + phone_number + country_code + vendor_type + can_login + invited_by_user + created_at + updated_at + } + } +`; + +// Delete Vendor Mutation +export const DELETE_VENDOR_MUTATION = gql` + mutation DeleteMasterVendor($id: ID!) { + deleteMasterVendor(input: { id: $id }) { + success + message + } + } +`; + +// Create Asset Category Mutation +export const CREATE_ASSET_CATEGORY_MUTATION = gql` + mutation CreateMasterAssetCategory($input: CreateMasterAssetCategoryInput!) { + createMasterAssetCategory(input: $input) { + id + name + description + icon_name + icon_color + icon_type + is_default + created_at + updated_at + } + } +`; + +// Update Asset Category Mutation +export const UPDATE_ASSET_CATEGORY_MUTATION = gql` + mutation UpdateMasterAssetCategory($input: UpdateMasterAssetCategoryInput!) { + updateMasterAssetCategory(input: $input) { + id + name + description + icon_name + icon_color + icon_type + is_default + created_at + updated_at + } + } +`; + +// Delete Asset Category Mutation +export const DELETE_ASSET_CATEGORY_MUTATION = gql` + mutation DeleteMasterAssetCategory($id: ID!) { + deleteMasterAssetCategory(input: { id: $id }) { + success + message + } + } +`; + + + +// Create Work Order Stage Mutation +export const CREATE_WORK_ORDER_STAGE_MUTATION = gql` + mutation CreateWorkOrderStage($input: CreateWorkOrderStageInput!) { + createWorkOrderStage(input: $input) { + color + createdAt + deletedAt + id + isDefault + name + updatedAt + } + } +`; + +// Update Work Order Stage Mutation +export const UPDATE_WORK_ORDER_STAGE_MUTATION = gql` + mutation UpdateWorkOrderStage($input: UpdateWorkOrderStageInput!) { + updateWorkOrderStage(input: $input) { + color + createdAt + deletedAt + id + isDefault + name + updatedAt + } + } +`; + +// Delete Work Order Stage Mutation +export const DELETE_WORK_ORDER_STAGE_MUTATION = gql` + mutation RemoveWorkOrderStage($id: ID!) { + removeWorkOrderStage(id: $id) + } +`; + +// Create Service Type Mutation +export const CREATE_SERVICE_TYPE_MUTATION = gql` + mutation CreateMasterAssetServiceType($input: CreateMasterAssetServiceTypeInput!) { + createMasterAssetServiceType(input: $input) { + id + name + asset_category_ids + description + created_at + updated_at + } + } +`; + +// Update Service Type Mutation +export const UPDATE_SERVICE_TYPE_MUTATION = gql` + mutation UpdateMasterAssetServiceType($input: UpdateMasterAssetServiceTypeInput!) { + updateMasterAssetServiceType(input: $input) { + id + name + asset_category_ids + description + created_at + updated_at + } + } +`; + +// Delete Service Type Mutation +export const DELETE_SERVICE_TYPE_MUTATION = gql` + mutation DeleteMasterAssetServiceType($id: ID!) { + deleteMasterAssetServiceType(input: { id: $id }) { + success + message + } + } +`; + +// Create Work Order Type Mutation +export const CREATE_WORK_ORDER_TYPE_MUTATION = gql` + mutation CreateMasterWorkOrderType($input: CreateMasterWorkOrderTypeInput!) { + createMasterWorkOrderType(input: $input) { + id + company_id + name + description + created_at + updated_at + createdAt + updatedAt + deletedAt + } + } +`; + +// Update Work Order Type Mutation +export const UPDATE_WORK_ORDER_TYPE_MUTATION = gql` + mutation UpdateMasterWorkOrderType($input: UpdateMasterWorkOrderTypeInput!) { + updateMasterWorkOrderType(input: $input) { + id + company_id + name + created_at + updated_at + description + createdAt + updatedAt + deletedAt + } + } +`; + +// Delete Work Order Type Mutation +export const DELETE_WORK_ORDER_TYPE_MUTATION = gql` + mutation DeleteMasterWorkOrderType($input: DeleteMasterWorkOrderTypeInput!, $id: ID) { + deleteMasterWorkOrderType(input: $input, id: $id) { + success + message + } + } +`; + +// Create Service Category Mutation +export const CREATE_SERVICE_CATEGORY_MUTATION = gql` + mutation CreateWorkOrderServiceCategory($input: CreateWorkOrderServiceCategoryInput!) { + createWorkOrderServiceCategory(input: $input) { + createdAt + deletedAt + id + name + updatedAt + } + } +`; + +// Update Service Category Mutation +export const UPDATE_SERVICE_CATEGORY_MUTATION = gql` + mutation UpdateWorkOrderServiceCategory($input: UpdateWorkOrderServiceCategoryInput!) { + updateWorkOrderServiceCategory(input: $input) { + createdAt + deletedAt + id + name + updatedAt + } + } +`; + +// Delete Service Category Mutation +export const DELETE_SERVICE_CATEGORY_MUTATION = gql` + mutation RemoveWorkOrderServiceCategory($id: ID!) { + removeWorkOrderServiceCategory(id: $id) + } +`; + +// Create Assignment Mutation +export const CREATE_ASSIGNMENT_MUTATION = gql` + mutation CreateWorkOrderAssignment($input: CreateWorkOrderAssignmentInput!) { + createWorkOrderAssignment(input: $input) { + assignmentType + createdAt + deletedAt + id + updatedAt + userId + workOrderId + } + } +`; + +// Update Assignment Mutation +export const UPDATE_ASSIGNMENT_MUTATION = gql` + mutation updateWorkOrderAssignment($input: UpdateWorkOrderAssignmentInput!) { + updateWorkOrderAssignment(input: $input) { + assignmentType + createdAt + deletedAt + id + updatedAt + userId + workOrderId + } + } +`; + +// Delete Assignment Mutation +export const DELETE_ASSIGNMENT_MUTATION = gql` + mutation RemoveWorkOrderAssignment($id: ID!) { + removeWorkOrderAssignment(id: $id) + } +`; + +// Create Asset Type Mutation +export const CREATE_ASSET_TYPE_MUTATION = gql` + mutation CreateMasterAssetType($input: CreateMasterAssetTypeInput!) { + createMasterAssetType(input: $input) { + id + asset_category_id + name + description + icon_name + icon_color + icon_type + is_default + created_at + updated_at + } + } +`; + +// Update Asset Type Mutation +export const UPDATE_ASSET_TYPE_MUTATION = gql` + mutation UpdateMasterAssetType($input: UpdateMasterAssetTypeInput!) { + updateMasterAssetType(input: $input) { + id + asset_category_id + name + description + icon_name + icon_color + icon_type + is_default + created_at + updated_at + } + } +`; + +// Delete Asset Type Mutation +export const DELETE_ASSET_TYPE_MUTATION = gql` + mutation DeleteMasterAssetType($id: ID!) { + deleteMasterAssetType(input: { id: $id }) { + success + message + } + } +`; + +// Create Asset Part Mutation +export const CREATE_ASSET_PART_MUTATION = gql` + mutation CreateMasterAssetPart($input: CreateMasterAssetPartInput!) { + createMasterAssetPart(input: $input) { + id + name + description + asset_type_id + created_at + updated_at + } + } +`; + +// Update Asset Part Mutation +export const UPDATE_ASSET_PART_MUTATION = gql` + mutation UpdateMasterAssetPart($input: UpdateMasterAssetPartInput!) { + updateMasterAssetPart(input: $input) { + id + name + description + asset_type_id + created_at + updated_at + } + } +`; + +// Remove Asset Part Mutation +export const REMOVE_ASSET_PART_MUTATION = gql` + mutation DeleteMasterAssetPart($id: ID!) { + deleteMasterAssetPart(input: { id: $id }) { + success + message + } + } +`; +// Master Asset Part Fields +export const CREATE_ASSET_PART_FIELD_MUTATION = gql` + mutation CreateMasterAssetPartField($input: CreateMasterAssetPartFieldInput!) { + createMasterAssetPartField(input: $input) { + id + parent_id + asset_part_id + field_name + description + field_type + allowed_values + unit + is_required + display_order + show_in_panel + created_at + updated_at + } + } +`; + +export const UPDATE_ASSET_PART_FIELD_MUTATION = gql` + mutation UpdateMasterAssetPartField($input: UpdateMasterAssetPartFieldInput!) { + updateMasterAssetPartField(input: $input) { + id + parent_id + asset_part_id + field_name + description + field_type + allowed_values + unit + is_required + display_order + show_in_panel + created_at + updated_at + } + } +`; + +export const DELETE_ASSET_PART_FIELD_MUTATION = gql` + mutation DeleteMasterAssetPartField($id: ID!) { + deleteMasterAssetPartField(input: { id: $id }) { + success + message + } + } +`; +// Master Asset Type Fields +export const CREATE_ASSET_TYPE_FIELD_MUTATION = gql` + mutation CreateMasterAssetTypeField($input: CreateMasterAssetTypeFieldInput!) { + createMasterAssetTypeField(input: $input) { + id + asset_type_id + field_type + field_name + allowed_values + unit + is_required + display_order + parent_field_id + show_in_panel + } + } +`; + +export const UPDATE_ASSET_TYPE_FIELD_MUTATION = gql` + mutation UpdateMasterAssetTypeField($input: UpdateMasterAssetTypeFieldInput!) { + updateMasterAssetTypeField(input: $input) { + id + asset_type_id + field_type + field_name + allowed_values + unit + is_required + display_order + parent_field_id + show_in_panel + created_at + updated_at + } + } +`; + +export const DELETE_ASSET_TYPE_FIELD_MUTATION = gql` + mutation DeleteMasterAssetTypeField($id: ID!) { + deleteMasterAssetTypeField(input: { id: $id }) { + success + message + } + } +`; + +// ==================== Plan Mutations ==================== + +export const CREATE_PLAN_MUTATION = gql` + mutation CreatePlan($input: CreatePlanInput!) { + createPlan(input: $input) { + id + name + description + amount + currency + interval + interval_count + features + limits + is_default + prorata_amount + active + stripe_product_id + stripe_price_id + created_at + updated_at + } + } +`; + +export const UPDATE_PLAN_MUTATION = gql` + mutation UpdatePlan($input: UpdatePlanInput!) { + updatePlan(input: $input) { + id + name + description + amount + currency + interval + interval_count + features + limits + active + is_default + prorata_amount + stripe_product_id + stripe_price_id + updated_at + } + } +`; + +export const DELETE_PLAN_MUTATION = gql` + mutation DeletePlan($input: DeletePlanInput!) { + deletePlan(input: $input) { + success + message + } + } +`; + diff --git a/src/graphql/queries.ts b/src/graphql/queries.ts new file mode 100644 index 000000000..deacf18d4 --- /dev/null +++ b/src/graphql/queries.ts @@ -0,0 +1,886 @@ +import { gql } from "@apollo/client"; + +// User Query +export const USER_QUERY = gql` + query Me { + me { + id + email + firstName + lastName + role + companyId + schema + twoFactorEnabled + } +} +`; + +// Companies Query +export const COMPANIES_QUERY = gql` + query Companies { + companies { + id + name + email + subdomain + role + parentCompanyId + schemaName + schemaStatus + address + city + state + country + zip + phoneNumber + countryCode + createdAt + updatedAt + website + businessType + industry + } + } +`; + +// Company Users Query +export const COMPANY_USERS_QUERY = gql` + query CompanyUsers { + companyUsers { + id + email + firstName + lastName + role + companyId + schema + twoFactorEnabled + invitationStatus + invitedBy + invitedOn + } +}`; + +// Users Query (deprecated - use COMPANY_USERS_QUERY instead) +export const USERS_QUERY = gql` + query Users($roles: [SuperAdminRole!]) { + users(filter: { roles: $roles }) { + belongsToCompanyId + createdAt + deletedAt + displayName + email + emailConfirmed + firstName + id + lastName + phone + phoneConfirmed + role + twoFactorEnabled + updatedAt + belongsToCompany { + active + address1 + address2 + assetCount + businessType + canAddLocation + city + config + coordinates + countryAlpha2 + createdAt + createdById + deletedAt + deletedById + email + fileSizeTotal + id + industry + isMainCompany + name + parentId + phone + planId + planStripeCancelAt + planStripeCurrentPeriodEnd + planStripeEndedAt + planStripeId + planStripeStatus + planStripeTrialEnd + requestIP + schemaName + state + stripeId + updatedAt + updatedById + website + zip + } + } + } +`; + +// Partners Query +export const PARTNERS_QUERY = gql` + query Users { + users(filter: { roles: [PARTNER_ADMIN] }) { + belongsToCompanyId + createdAt + deletedAt + displayName + email + emailConfirmed + firstName + id + lastName + phone + phoneConfirmed + role + twoFactorEnabled + updatedAt + } + } +`; + +export const GET_TICKETING_QUERY = gql` + query Tickets { + tickets(status: null, category: null) { + id + short_code + parent_ticket + company_id + created_by + title + description + priority + category + sub_category + status + estimated_time + start_date + end_date + assigned_to + assigned_by + assigned_at + resolved_at + closed_at + created_at + updated_at + deleted_at + } +} +`; + +export const AI_ADDONS_QUERY = gql` + query GetAIAddons { + aiAddons { + id + name + description + pricingType + amount + currency + interval + intervalCount + creditPoolSize + stripeProductId + stripePriceId + eligiblePlanIds + active + type + credits + createdAt + updatedAt + deletedAt + } +} +`; + +// ==================== Master Data Queries ==================== + +export const MASTER_ASSET_CATEGORIES_QUERY = gql` + query MasterAssetCategories { + masterAssetCategories { + id + name + description + icon_name + icon_color + icon_type + is_default + created_at + updated_at + } + } +`; + +export const MASTER_ASSET_PART_FIELDS_QUERY = gql` + query MasterAssetPartFields($asset_part_id: ID) { + masterAssetPartFields(asset_part_id: $asset_part_id) { + id + asset_part_id + parent_id + field_name + field_type + allowed_values + is_required + display_order + show_in_panel + created_at + updated_at + } + } +`; + +export const MASTER_ASSET_PARTS_QUERY = gql` + query MasterAssetParts($asset_type_id: ID) { + masterAssetParts(asset_type_id: $asset_type_id) { + id + name + description + asset_type_id + created_at + updated_at + } + } +`; + +export const MASTER_ASSET_SERVICE_TYPES_QUERY = gql` + query MasterAssetServiceTypes { + masterAssetServiceTypes { + id + name + description + asset_category_ids + created_at + updated_at + } + } +`; + +export const MASTER_ASSET_TYPE_FIELDS_QUERY = gql` + query MasterAssetTypeFields($asset_type_id: ID) { + masterAssetTypeFields(asset_type_id: $asset_type_id) { + id + asset_type_id + parent_field_id + field_name + field_type + allowed_values + is_required + display_order + show_in_panel + created_at + updated_at + } + } +`; + +export const MASTER_ASSET_TYPES_QUERY = gql` + query MasterAssetTypes($asset_category_id: ID) { + masterAssetTypes(asset_category_id: $asset_category_id) { + id + name + description + asset_category_id + is_default + icon_name + icon_color + icon_type + created_at + updated_at + } + } +`; + +export const MASTER_MANUFACTURERS_QUERY = gql` + query MasterManufacturers { + masterManufacturers { + id + name + description + company_name + phone_number + country_code + contact_person + created_at + updated_at + } + } +`; + +export const MASTER_VENDORS_QUERY = gql` + query MasterVendors { + masterVendors { + id + name + description + company_id + company_name + phone_number + country_code + vendor_type + can_login + invited_by_user + created_at + updated_at + } + } +`; + + + + + + + +// Assets Query +export const ASSETS_QUERY = gql` + query Assets { + assets { + assetTypeId + createdAt + createdById + deletedAt + deletedById + description + fileIds + id + locationId + maintenanceIds + manufacturerId + name + position + status + updatedAt + updatedById + userIds + vendorId + assetSops { + approvedAt + approvedById + assetId + createdAt + createdById + deletedAt + deletedById + description + documentPath + documentType + effectiveDate + expiryDate + fileSize + id + name + status + updatedAt + updatedById + version + } + assetFieldValues { + assetFieldId + assetId + createdAt + deletedAt + id + updatedAt + value + assetField { + assetTypeId + createdAt + deletedAt + description + id + label + order + required + selectOptions + type + unit + updatedAt + } + } + assetMaintenanceSchedules { + assetId + createdAt + deletedAt + description + endTime + id + intervalType + intervalValue + name + remindBeforeType + remindBeforeValue + startTime + timezone + updatedAt + } + assetUsers { + assetId + createdAt + deletedAt + id + updatedAt + userId + } + location { + address1 + address2 + city + coordinates + countryAlpha2 + createdAt + deletedAt + description + fileId + id + name + parentId + state + sublocation + type + updatedAt + zip + } + } + } +`; + +// Work Orders Query +export const WORK_ORDERS_QUERY = gql`query Workorders { + workorders { + createdAt + createdById + deletedAt + deletedById + description + endTime + id + key + locationId + parentId + pocUserIds + serviceCategoryId + severity + startTime + timezone + title + updatedAt + updatedById + workOrderStageId + workOrderTypeId + assetWorkOrders { + assetId + comment + createdAt + deletedAt + id + serviceTypeId + subLocationId + updatedAt + workOrderId + assetPlans { + assetWorkOrderId + comment + createdAt + deletedAt + description + id + itemId + name + planId + sublocation + type + updatedAt + } + } + workOrderStage { + color + createdAt + deletedAt + id + isDefault + name + updatedAt + } + location { + address1 + address2 + city + coordinates + countryAlpha2 + createdAt + deletedAt + description + fileId + id + name + parentId + state + sublocation + type + updatedAt + zip + assets { + assetTypeId + config + createdAt + createdById + deletedAt + deletedById + description + fileIds + id + locationId + maintenanceIds + manufacturerId + name + position + status + updatedAt + updatedById + userIds + vendorId + assetFieldValues { + assetFieldId + assetId + createdAt + deletedAt + id + updatedAt + value + assetField { + assetTypeId + createdAt + deletedAt + description + id + label + order + required + selectOptions + type + unit + updatedAt + } + } + assetMaintenanceSchedules { + assetId + createdAt + deletedAt + description + endTime + id + intervalType + intervalValue + name + remindBeforeType + remindBeforeValue + startTime + timezone + updatedAt + } + assetSops { + approvedAt + approvedById + assetId + createdAt + createdById + deletedAt + deletedById + description + documentPath + documentType + effectiveDate + expiryDate + fileSize + id + name + status + updatedAt + updatedById + version + } + assetUsers { + assetId + createdAt + deletedAt + id + updatedAt + userId + } + location { + address1 + address2 + city + coordinates + countryAlpha2 + createdAt + deletedAt + description + fileId + id + name + parentId + state + sublocation + type + updatedAt + zip + file { + belongsToId + belongsToType + createdAt + deletedAt + description + fileSize + folderId + id + name + path + type + updatedAt + } + } + } + file { + belongsToId + belongsToType + createdAt + deletedAt + description + fileSize + folderId + id + name + path + type + updatedAt + } + } + } +} + +`; + + + +// ==================== ==================== Master Data Mutations ==================== ==================== +// Manufacturers Query +export const MANUFACTURERS_QUERY = gql` + query MasterManufacturers { + masterManufacturers { + id + name + email + company_name + website + address + phone_number + country_code + contact_person + } +} +`; + +// Vendors Query +export const VENDORS_QUERY = gql` + query MasterVendors { + masterVendors { + id + company_name + website + email + name + phone_number + country_code + vendor_type + can_login + invited_by_user + created_at + updated_at + } +} +`; + + +// Asset Categories Query +export const ASSET_CATEGORIES_QUERY = gql` + query MasterAssetCategories { + masterAssetCategories { + id + name + description + icon_name + icon_color + icon_type + is_default + created_at + updated_at + } +} +`; + + +export const WORKORDER_STAGES_QUERY = gql`query WorkOrderStages { + workOrderStages { + id + company_id + name + color_code + is_default + display_order + created_at + updated_at + colorCode + isDefault + displayOrder + createdAt + updatedAt + deletedAt + } +}`; + +export const WORKORDER_TYPES_QUERY = gql`query WorkOrderTypes { + workOrderTypes { + id + company_id + name + created_at + updated_at + description + createdAt + updatedAt + deletedAt + } +}`; + +// Work Order Types Query +export const SERVICE_TYPE_QUERY = gql`query MasterAssetServiceTypes { + masterAssetServiceTypes { + id + name + asset_category_ids + description + } +} +`; +// Work Order Service Categories Query +export const SERVICE_CATEGORIES_QUERY = gql`query Workorderservicecategories { + workorderservicecategories { + createdAt + deletedAt + id + name + updatedAt + } +}`; + +// Service Categories Query +export const ASSIGNMENTS_TYPES_QUERY = gql`query Workorderassignments { + workorderassignments { + assignmentType + createdAt + deletedAt + id + updatedAt + userId + workOrderId + } +}`; + +// Asset Types Query +export const ASSET_TYPES_QUERY = gql` + query MasterAssetTypes($asset_category_id: ID) { + masterAssetTypes(asset_category_id: $asset_category_id) { + id + asset_category_id + name + description + icon_name + icon_color + icon_type + is_default + } + } +`; +// Asset Parts Query +export const ASSET_PARTS_QUERY = gql` + query MasterAssetParts { + masterAssetParts { + id + name + description + asset_type_id + created_at + updated_at + } +} +`; + +// Asset Part Fields Query +export const ASSET_PART_FIELDS_QUERY = gql` + query MasterAssetPartFields($asset_part_id: ID) { + masterAssetPartFields(asset_part_id: $asset_part_id) { + id + parent_id + asset_part_id + field_name + description + field_type + allowed_values + unit + is_required + display_order + show_in_panel + } + } +`; + +// Asset Fields Query +export const ASSET_FIELDS_QUERY = gql` + query MasterAssetTypeFields($asset_type_id: ID) { + masterAssetTypeFields(asset_type_id: $asset_type_id) { + id + asset_type_id + field_type + field_name + allowed_values + unit + is_required + display_order + parent_field_id + show_in_panel + created_at + updated_at + } + } +`; + +export const PLANS_QUERY = gql` + query Plans { + plans { + id + name + description + amount + currency + interval + interval_count + features + limits + active + is_default + prorata_amount + stripe_product_id + stripe_price_id + created_at + updated_at + deleted_at + } + } +`; \ No newline at end of file diff --git a/src/index.css b/src/index.css new file mode 100644 index 000000000..dd63855bf --- /dev/null +++ b/src/index.css @@ -0,0 +1,498 @@ +:root { + --tab-bg-active: #ffffff; + /* --tab-text-active: #3b82f6; */ + --tab-text-active: #1e3a8a; + /* --tab-bg-inactive: #3b82f6; */ + --tab-bg-inactive: #1e3a8a; + --tab-text-inactive: #ffffff; + --text-color: #6b7280; + --text-size: 14px; + --text-weight: 400; + --white-color: #ffffff; + --navy-deep: #1e3a8a; + --tech-blue: #3b82f6; + --bright-indigo: #743ee4; + --green-deep: #10b981; + --orange-deep: #f59e0b; +} + +body { + margin: 0; + font-family: 'Inter', sans-serif; + color: var(--text-color); + font-size: var(--text-size); + font-weight: var(--text-weight); +} +p{margin: 0;} +h1{margin: 0;} +h2{margin: 0;} +h3{margin: 0;} +h4{margin: 0;} +h5{margin: 0;} +h6{margin: 0;} + +.alert-tabs .badge, .alerts-menu .badge { + background: var(--tab-bg-inactive); + color: var(--tab-text-inactive); + border-radius: 50%; + font-size: 10px; + font-weight: bold; + display: flex; + margin-left: 8px; + width: 20px; + height: 20px; + align-items: center; + justify-content: center; + padding: 4px; +} +.emergency-alert .badge { + background: var(--chakra-colors-red-400);color: var(--white-color); +} +.critical-alert .badge { + background: 'var(--chakra-colors-blue-400)';color: var(--white-color); +} +.security-alert .badge { + background: var(--chakra-colors-orange-400);color: var(--white-color); +} +.system-alert .badge { + background: var(--chakra-colors-yellow-500);color: var(--white-color); +} + +.alert-tabs .chakra-tabs__tab[aria-selected="true"] .badge { + background: var(--tab-bg-active); + color: var(--tab-text-active); +} + +.custom-card{ + border-radius: 16px ; + box-shadow: 0 0 6px #7faee052; + background-color: var(--white-color); + border:none; +} +.custom-card .card-header{ + border-radius: 16px 16px 0 0; + border-bottom: 1px solid #EFEFEF; + padding: 16px; + background-color: var(--white-color); +} +.card-padding{ + padding: 16px; +} +.card-padding .align-items-center{ + padding-bottom: 8px; +} + +.MuiDataGrid-columnHeader:focus, .MuiDataGrid-columnHeaderTitle:focus,.MuiDataGrid-cell:focus, .MuiDataGrid-columnHeader:focus-within { + outline: none !important; +} +/* Table CCS */ +.master-data-table{ + border-top: none; +} + +/* .table-card{ + background-color: var(--white-color); + box-shadow: none; + border: 1px solid #E2E8F0; + overflow: hidden; + border-radius: 8px; +} */ + +/* .table-card-header{ + align-items: center; + justify-content: space-between; + padding: 8px 16px; +} */ + +/* .table-card-title{ + font-size: 20px; + font-weight: 700; + color: var(--navy-deep); +} + +.table-card table tr th{ + font-size: 14px !important; + color: var(--navy-deep) !important; + cursor: pointer; + padding: 16px !important; + background-color: #e2ebf7; +} */ +.search-field .MuiInputBase-input{ + /* border: 1px solid #d0d0d2 !important; */ + /* border-radius: 4px !important; */ + font-size: 12px !important; +} + +.add-btn{ + font-size: 14px !important; + /* font-weight: 600 !important; */ + /* padding: 8px 16px !important; */ + background-color: var(--tech-blue) !important; + /* border-radius: 4px !important; */ + text-transform: capitalize !important; +} + +/* .table-card tr td{ + font-size: 13px !important; + font-weight: 400 !important; + color: #6b7280; + padding: 8px 16px !important; +} */ + +/* .edit-btn, .delete-btn{ + border-radius: 4px !important; +} */ +.table-card table tr th:last-child,.table-card table tr td:last-child{text-align: center;} + +/* Modal css */ +.form-modal{ + height: calc(100vh - 126px); + overflow-y: auto; + padding: 16px; +} + +.validation-form label{ + font-size: 14px !important; + font-weight: 600 !important; + color: #6b7280 !important; + margin-bottom: 4px !important; +} + +.validation-form input{ + padding: 8px 12px; + border-radius: 4px; +} + +.validation-form input::placeholder, .validation-form select{ + font-size: 14px; + font-weight: 400; + color: #bcbfc4; +} + +.validation-form select{ + color: #6b7280; +} + +.view-data{ + margin-bottom: 12px; +} +.fs-14{ + font-size: 14px; +} +.view-data h6{ + font-size: 14px; + font-weight: 600; + color: var(--text-color); + margin-bottom: 2px; +} +.view-data p{ + font-size: 14px; + font-weight: 400; + color: var(--text-color); + margin-bottom: 0; +} +.view-data .MuiChip-label{ + font-size: 12px !important; +} + +.admin-menu-button h2,.admin-menu-button h6{ + overflow: hidden; + text-overflow: ellipsis; + width: 150px; + white-space: nowrap; +} +/* .ticket-card-container .MuiBox-root, .ticket-card-container .MuiStack-root{padding: 12px !important;} */ +.ticket-card { + background-color: #ffffff; + border: none !important; + border-radius: 8px !important; + box-shadow: 0 0 6px #7faee052 !important; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.ticket-card:hover { + transform: translateY(-4px); + box-shadow: 0 16px 40px rgba(15, 23, 42, 0.12); +} + +.ticket-card--dragging { + box-shadow: 0 20px 48px rgba(59, 130, 246, 0.35); + transform: translateY(-2px); +} + +.ticket-card__content { + padding: 12px; + display: flex; + flex-direction: column; + /* gap: 16px; */ +} + +.ticket-card__header { + gap: 12px; +} + +.ticket-card__id { + font-size: 11px !important; + font-weight: 600 !important; + color: #738096 !important; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.ticket-card__title { + font-size: 14px !important; + color: var(--text-color) !important; + font-weight: 700 !important; + margin-top: 4px !important; + /* line-height: 1.6 !important; */ +} + +.ticket-card__description { + font-size: 12px !important; + color: var(--text-color) !important; + margin-top: 4px !important; + /* line-height: 1.6 !important; */ +} + +.ticket-card__details { + flex-direction: row !important; +} + +.ticket-card__assignee { + /* min-height: 36px; */ +} + +.ticket-card__avatar { + width: 24px; + height: 24px; + border-radius: 12px; + /* background: linear-gradient(135deg, #743ee4 0%, #3b82f6 100%); */ + background: var(--tech-blue); + color: #ffffff; + font-weight: 700; + font-size: 12px; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 12px 32px rgba(116, 62, 228, 0.25); +} + +.ticket-card__avatar--unassigned { + background: var(--tech-blue); + color: #ffffff; + box-shadow: none; +} + +.ticket-card__stat { + font-size: 12px; + color: #6b7280; +} + +.ticket-card__stat .MuiTypography-root { + font-size: 12px !important; + color: #6b7280 !important; + font-weight: 500; +} + +.ticket-card__stat svg { + color: #94a3b8; +} + +.ticket-priority, .green-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 4px 12px; + line-height: normal; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: capitalize; + background-color: #e5e7eb; + color: #374151; +} + +.ticket-priority--low,.green-badge { + background-color: #d3ffec; + color: var(--green-deep); +} + +.ticket-priority--medium, .orange-badge { + background-color: #fff3e0; + color: var(--orange-deep); +} + +.ticket-priority--high, .red-badge { + background-color: #fee2e2; + color: #b91c1c; +} + +.ticket-priority--critical { + background-color: #fef2f2; + color: #7f1d1d; +} + +.blue-badge { + background-color: #dbeafe; + color: #1e40af; +} + +.purple-badge { + background-color: #f3e8ff; + color: #7c3aed; +} +.form-error { + color: #dc2626 !important; + font-size: 12px !important; + margin-top: 4px !important; +} + +.tag-autocomplete .MuiChip-root { + height: 22px; + font-size: 12px; + padding: 0 6px; +} + +.tag-autocomplete .MuiChip-label { + padding: 0 4px; + margin-right: 3px; + color: var(--text-color); +} +.subscription-plan-badge{ + position: absolute; + top: -10px; + left: 0; + right: 0; + text-align: center; +} +.subscription-plan-badge p{ + background-color: #3b82f6; + color: #fff; + text-align: center; + border-radius: 26px; + font-size: 11px; + padding: 2px 16px; + display: inline-block; +} +.subscription-feature-comparison-table tbody tr td:first-child, .subscription-feature-comparison-table thead tr th:first-child { + text-align: left; + width: 260px; + min-width: 260px; +} +.subscription-feature-comparison-table tbody tr td:first-child{ +background-color:transparent; +} +.subscription-feature-comparison-table tbody tr:nth-child(odd) { + background-color: transparent ; +} + +/* Custom Scrollbar Styles */ +::-webkit-scrollbar { + width: 4px; + height: 4px; +} + +::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 10px; +} + +::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 10px; +} + +::-webkit-scrollbar-thumb:hover { + background: #94a3b8; +} + +/* Firefox scrollbar */ +* { + scrollbar-width: thin; + scrollbar-color: #cbd5e1 #f1f1f1; +} + +.subscription-plan-card-header{ + /* background: linear-gradient(135deg, #155DFC 0%, #432DD7 100%); */ + background: linear-gradient(135deg, var(--navy-deep) 0%, var(--tech-blue) 100%); + border-radius: 8px; + padding: 24px; +} +.subscription-plan-card-feature{ + border: 1px solid #dbe9ff; + background-color: #eff5ff; + border-radius: 8px; + padding: 8px; + height: 100%; +} +.subscription-plan-card-feature h5{ + font-size: 10px; + font-weight: 400; + color: var(--text-color); +} +.subscription-plan-card-feature p{ + font-size: 12px; + font-weight: 700; + color: var(--text-color); +} +.modal-form-sec{ + display: flex ; + flex-direction: column; + height: 100%; +} +.modal-form{ + flex: 1; + padding: 24px 24px 16px; + overflow-y: auto; +} +.modal-form-buttons{ + display: flex; + flex-direction: row; + padding: 0 24px 24px; +} +.gap{ + gap: 16px; +} +.gap{ + gap: 16px; +} +.form-modal{ + height: calc(100vh - 126px); + overflow-y: auto; + padding: 16px; + display: flex ; + flex-direction: column; + gap: 16px; +} +.form-modal-buttons{ + padding: 16px; +} +.asset-icon-container .MuiBox-root{ + width: 32px !important ; + height: 32px !important; + font-size: 10px !important; +} +.asset-icon-container svg{ + width: 20px !important; + height: 20px !important; +} +.loader { + border: 8px solid #f3f3f3; + border-radius: 50%; + border-top: 8px solid #3498db; + width: 70px; + height: 70px; + animation: spin 1s linear infinite; +} +/* Safari */ +@-webkit-keyframes spin { + 0% { -webkit-transform: rotate(0deg); } + 100% { -webkit-transform: rotate(360deg); } +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} \ No newline at end of file diff --git a/src/index.tsx b/src/index.tsx new file mode 100644 index 000000000..e821adbb4 --- /dev/null +++ b/src/index.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import 'bootstrap/dist/css/bootstrap.min.css'; +import './index.css'; +import App from './App'; +import reportWebVitals from './reportWebVitals'; + +const root = ReactDOM.createRoot( + document.getElementById('root') as HTMLElement +); +root.render( + + + +); + +// If you want to start measuring performance in your app, pass a function +// to log results (for example: reportWebVitals(console.log)) +// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals +reportWebVitals(); diff --git a/src/logo.svg b/src/logo.svg new file mode 100644 index 000000000..9dfc1c058 --- /dev/null +++ b/src/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/pages/Forget-Password.tsx b/src/pages/Forget-Password.tsx new file mode 100644 index 000000000..ed142172d --- /dev/null +++ b/src/pages/Forget-Password.tsx @@ -0,0 +1,256 @@ +import { useState } from "react"; +import { + Box, + Button, + Grid, + Link, + Stack, + TextField, + Typography, +} from "@mui/material"; +import { ThemeProvider } from "@mui/material/styles"; +import CssBaseline from "@mui/material/CssBaseline"; +import { useMutation } from "@apollo/client/react"; +import { useNavigate } from "react-router-dom"; +import { showToast } from "../components/toastService"; +import { useLoader } from "../context/LoaderContext"; +import { useForm } from "react-hook-form"; +import * as yup from "yup"; +import { yupResolver } from "@hookform/resolvers/yup"; +import muiTheme from "../themes/muiTheme"; +import { FORGOT_PASSWORD_MUTATION } from "../graphql/mutations"; + +type ForgotPasswordFormInputs = { + email: string; +}; + +const schema = yup.object({ + email: yup + .string() + .email("Invalid email format") + .required("Email is required"), +}); + +const ForgetPassword = () => { + const navigate = useNavigate(); + const { showLoader, hideLoader } = useLoader(); + + const [forgotPasswordMutation, { loading: forgotPasswordLoading }] = + useMutation(FORGOT_PASSWORD_MUTATION); + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: yupResolver(schema), + mode: "all", + defaultValues: { + email: "", + }, + }); + + const handleForgotPassword = async (data: ForgotPasswordFormInputs) => { + try { + showLoader(); + + await forgotPasswordMutation({ + variables: { + input: { + email: data.email.trim(), + }, + }, + }); + + showToast({ + status: "success", + title: "Password reset email sent! Please check your inbox.", + }); + navigate("/login"); + } catch (error: any) { + const errorMessage = + error?.graphQLErrors?.[0]?.message || + error?.message || + "Failed to send reset email. Please try again."; + + // Check for user not found error + if (errorMessage.toLowerCase().includes("not found") || + errorMessage.toLowerCase().includes("not exist") || + errorMessage.toLowerCase().includes("no user")) { + showToast({ status: "error", title: "User not found. Please check your email address." }); + } else { + showToast({ status: "error", title: errorMessage }); + } + } finally { + hideLoader(); + } + }; + + const handleCancel = () => { + navigate("/login"); + }; + + return ( + + + + + + + +
+ +
+ + The world's smartest buildings are powered by CriticalAsset + + + CriticalAsset is trusted by the world's best companies, cities, + schools, healthcare facilities, contractors and property + managers. + +
+ +
+ +
+ + Forgot Password + + + Enter your email address and we'll send you a link to reset your + password. + + + + + + + + + + {/* + + Remember your password?{" "} + + Sign in + + + */} + +
+
+
+
+
+ ); +}; + +export default ForgetPassword; + diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx new file mode 100644 index 000000000..d435d10a5 --- /dev/null +++ b/src/pages/Login.tsx @@ -0,0 +1,306 @@ +import { useState } from "react"; +import { + Box, + Button, + FormControl, + FormHelperText, + Grid, + IconButton, + InputAdornment, + InputLabel, + Link, + OutlinedInput, + TextField, + Typography, +} from "@mui/material"; +import { Visibility, VisibilityOff } from "@mui/icons-material"; +import { ThemeProvider } from "@mui/material/styles"; +import CssBaseline from "@mui/material/CssBaseline"; +import { useMutation, useLazyQuery } from "@apollo/client/react"; +import { useAuth } from "../context/AuthContext"; +import { useNavigate } from "react-router-dom"; +import { showToast } from "../components/toastService"; +import { useLoader } from "../context/LoaderContext"; +import {useForm} from "react-hook-form"; +import * as yup from "yup"; +import {yupResolver} from "@hookform/resolvers/yup"; +import muiTheme from "../themes/muiTheme"; +import { LOGIN_MUTATION } from "../graphql/mutations"; +import { USER_QUERY } from "../graphql/queries"; + +type loginFormInputs = { + email: string; + password: string; +}; + +// Helper function to decode JWT token +const decodeJWT = (token: string): any => { + try { + const base64Url = token.split('.')[1]; + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); + const jsonPayload = decodeURIComponent( + atob(base64) + .split('') + .map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)) + .join('') + ); + return JSON.parse(jsonPayload); + } catch { + return null; + } +}; + +const schema = yup.object({ + email: yup.string().email("Invalid email format").required("Email is required"), + password: yup.string().required("Password is required"), + // password: yup.string().required("Password is required").min(6, "Password must be at least 6 characters").matches(/[a-zA-Z]/, "Password must contain at least one letter").matches(/[0-9]/, "Password must contain at least one number"), +}); + +const Login = () => { + const { login } = useAuth(); + const navigate = useNavigate(); + const { showLoader, hideLoader } = useLoader(); + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: yupResolver(schema), + mode: "all", + defaultValues: { + email: "", + password: "", + }, + }); + + const [showPassword, setShowPassword] = useState(false); + const [loginMutation, { loading: loginLoading }] = useMutation(LOGIN_MUTATION); + const [fetchUser] = useLazyQuery(USER_QUERY, { fetchPolicy: 'network-only' }); + + const togglePasswordVisibility = () => { + setShowPassword((prev) => !prev); + }; + + const handleLogin = async (data: loginFormInputs) => { + try { + showLoader(); + + const result = await loginMutation({ + variables: { + input: { + email: data.email, + password: data.password, + }, + }, + }); + + if (result.data && typeof result.data === 'object' && 'login' in result.data) { + const accessToken = (result.data as { login: { accessToken: string } }).login.accessToken; + localStorage.setItem("accessToken", accessToken); + const refreshToken = (result.data as { login: { refreshToken: string } }).login.refreshToken; + localStorage.setItem("refreshToken", refreshToken); + + // Fetch user data using GraphQL query + try { + const userResult = await fetchUser(); + + if (userResult.data && typeof userResult.data === 'object' && 'me' in userResult.data && userResult.data.me) { + const user = (userResult.data as { me: any }).me; + login(user); + navigate("/dashboard"); + } else { + // User query returned but no user data - decode token as fallback + console.warn("User query returned no user data, using token data"); + const tokenData = decodeJWT(accessToken); + if (tokenData) { + login({ id: tokenData.sub, email: tokenData.email, role: tokenData.role }); + } + navigate("/dashboard"); + } + } catch (userError: any) { + // If user query fails, decode token and still navigate + console.error("Error fetching user:", userError); + const tokenData = decodeJWT(accessToken); + if (tokenData) { + login({ id: tokenData.sub, email: tokenData.email, role: tokenData.role }); + } + navigate("/dashboard"); + } + } + } catch (error: any) { + const errorMessage = error?.graphQLErrors?.[0]?.message || error?.message || 'Login failed. Please check your details and try again.'; + showToast({ status: "error", title: errorMessage }); + } finally { + hideLoader(); + } + }; + + return ( + + + + + + + {/*
*/} + + +
+ +
+ + The world's smartest buildings are powered by CriticalAsset + + + CriticalAsset is trusted by the world's best companies, cities, schools, healthcare facilities, contractors and property managers. + +
+ {/*
*/} + +
+ +
+ + Welcome back! + + + + + Password + + event.preventDefault()} + edge="end" + aria-label={showPassword ? "Hide password" : "Show password"} + > + {showPassword ? : } + + + } + label="Password" + /> + {errors.password?.message} + + + + + + Forgot Password + + + + Don't have an account?{" "} + + Sign up + + + + +
+
+
+
+
+ ); +}; + +export default Login; diff --git a/src/pages/Reset-password.tsx b/src/pages/Reset-password.tsx new file mode 100644 index 000000000..c80a4b3f3 --- /dev/null +++ b/src/pages/Reset-password.tsx @@ -0,0 +1,346 @@ +import { useState } from "react"; +import { + Box, + Button, + FormControl, + FormHelperText, + Grid, + IconButton, + InputAdornment, + InputLabel, + Link, + OutlinedInput, + Stack, + Typography, +} from "@mui/material"; +import { Visibility, VisibilityOff } from "@mui/icons-material"; +import { ThemeProvider } from "@mui/material/styles"; +import CssBaseline from "@mui/material/CssBaseline"; +import { useMutation } from "@apollo/client/react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { showToast } from "../components/toastService"; +import { useLoader } from "../context/LoaderContext"; +import { useForm } from "react-hook-form"; +import * as yup from "yup"; +import { yupResolver } from "@hookform/resolvers/yup"; +import muiTheme from "../themes/muiTheme"; +import { RESET_PASSWORD_MUTATION } from "../graphql/mutations"; + +type ResetPasswordFormInputs = { + newPassword: string; + confirmPassword: string; +}; + +const schema = yup.object({ + newPassword: yup + .string() + .required("New password is required") + .min(8, "Password must be at least 8 characters") + .matches(/[a-zA-Z]/, "Password must contain at least one letter") + .matches(/[0-9]/, "Password must contain at least one number"), + confirmPassword: yup + .string() + .required("Confirm password is required") + .oneOf([yup.ref("newPassword")], "Passwords must match"), +}); + +const ResetPassword = () => { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const token = searchParams.get("token"); + const { showLoader, hideLoader } = useLoader(); + + const [showNewPassword, setShowNewPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + + const [resetPasswordMutation, { loading: resetPasswordLoading }] = + useMutation(RESET_PASSWORD_MUTATION); + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: yupResolver(schema), + mode: "all", + defaultValues: { + newPassword: "", + confirmPassword: "", + }, + }); + + const toggleNewPasswordVisibility = () => { + setShowNewPassword((prev) => !prev); + }; + + const toggleConfirmPasswordVisibility = () => { + setShowConfirmPassword((prev) => !prev); + }; + + const handleResetPassword = async (data: ResetPasswordFormInputs) => { + if (!token) { + showToast({ + status: "error", + title: "Invalid reset link. Please request a new password reset.", + }); + return; + } + + try { + showLoader(); + + await resetPasswordMutation({ + variables: { + input: { + token: token, + newPassword: data.newPassword, + }, + }, + }); + + showToast({ + status: "success", + title: "Password reset successfully! Please login with your new password.", + }); + navigate("/login"); + } catch (error: any) { + const errorMessage = + error?.graphQLErrors?.[0]?.message || + error?.message || + "Failed to reset password. Please try again."; + showToast({ status: "error", title: errorMessage }); + } finally { + hideLoader(); + } + }; + + const handleCancel = () => { + navigate("/login"); + }; + + return ( + + + + + + + +
+ +
+ + The world's smartest buildings are powered by CriticalAsset + + + CriticalAsset is trusted by the world's best companies, cities, + schools, healthcare facilities, contractors and property + managers. + +
+ +
+ +
+ + Reset Password + + + Enter your new password below. + + + {/* New Password */} + + New Password + + event.preventDefault()} + edge="end" + aria-label={ + showNewPassword ? "Hide password" : "Show password" + } + > + {showNewPassword ? ( + + ) : ( + + )} + + + } + label="New Password" + placeholder="Enter new password" + /> + {errors.newPassword?.message} + + + {/* Confirm Password */} + + + Confirm Password + + + event.preventDefault()} + edge="end" + aria-label={ + showConfirmPassword ? "Hide password" : "Show password" + } + > + {showConfirmPassword ? ( + + ) : ( + + )} + + + } + label="Confirm Password" + placeholder="Confirm new password" + /> + {errors.confirmPassword?.message} + + + + + + + + + + Remember your password?{" "} + + Sign in + + + + +
+
+
+
+
+ ); +}; + +export default ResetPassword; + diff --git a/src/pages/Signup.tsx b/src/pages/Signup.tsx new file mode 100644 index 000000000..4b0237700 --- /dev/null +++ b/src/pages/Signup.tsx @@ -0,0 +1,500 @@ +import { useState } from "react"; +import { + Box, + Button, + FormControl, + FormHelperText, + Grid, + IconButton, + InputAdornment, + InputLabel, + Link, + MenuItem, + OutlinedInput, + TextField, + Typography, +} from "@mui/material"; +import { Visibility, VisibilityOff } from "@mui/icons-material"; +import { ThemeProvider } from "@mui/material/styles"; +import CssBaseline from "@mui/material/CssBaseline"; +import { useMutation } from "@apollo/client/react"; +import { useNavigate } from "react-router-dom"; +import { showToast } from "../components/toastService"; +import { useLoader } from "../context/LoaderContext"; +import { useForm } from "react-hook-form"; +import * as yup from "yup"; +import { yupResolver } from "@hookform/resolvers/yup"; +import muiTheme from "../themes/muiTheme"; +import { SIGNUP_MUTATION } from "../graphql/mutations"; + +type SignupFormInputs = { + companyName: string; + subdomain: string; + firstName: string; + lastName: string; + phoneNumber: string; + countryCode: string; + email: string; + password: string; + jobTitle: string; + industry: string; +}; + +// Type for signup mutation response +type SignupUser = { + id: string; + email: string; + firstName?: string; + lastName?: string; + role: string; + companyId?: string; + schema?: string; +}; + +type SignupResponse = { + signup: { + accessToken: string; + refreshToken: string; + user: SignupUser; + }; +}; + +const schema = yup.object({ + companyName: yup.string().required("Company Name is required"), + // subdomain: yup + // .string() + // .required("Subdomain is required") + // .matches(/^[a-z0-9-]+$/, "Subdomain can only contain lowercase letters, numbers, and hyphens") + // .min(3, "Subdomain must be at least 3 characters"), + firstName: yup.string().required("First Name is required"), + lastName: yup.string().required("Last Name is required"), + phoneNumber: yup.string().optional(), + countryCode: yup.string().optional(), + email: yup + .string() + .email("Invalid email format") + .required("Email is required"), + password: yup + .string() + .required("Password is required") + .min(8, "Password must be at least 8 characters") + .matches(/[a-zA-Z]/, "Password must contain at least one letter") + .matches(/[0-9]/, "Password must contain at least one number"), + jobTitle: yup.string().required("Job Title is required"), + industry: yup.string().required("Industry is required"), +}) as yup.ObjectSchema; + +const jobTitleOptions = [ + "CEO/President/Owner", + "CFO", + "CIO", + "COO", + "CTO", + "Building Owner", + "Facility Manager", + "Property Manager", + "Operations Manager", + "Others", +]; + +const industryOptions = [ + "Commercial Real Estate", + "Healthcare", + "Education", + "Government", + "Manufacturing", + "Retail", + "Hospitality", + "Technology", + "Finance", + "Others", +]; + +const Signup = () => { + const navigate = useNavigate(); + const { showLoader, hideLoader } = useLoader(); + const [showPassword, setShowPassword] = useState(false); + + const [signupMutation, { loading: signupLoading }] = useMutation(SIGNUP_MUTATION); + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: yupResolver(schema), + mode: "all", + defaultValues: { + companyName: "", + subdomain: "", + firstName: "", + lastName: "", + phoneNumber: "", + countryCode: "+1", + email: "", + password: "", + jobTitle: "", + industry: "", + }, + }); + + const togglePasswordVisibility = () => { + setShowPassword((prev) => !prev); + }; + + const handleSignup = async (data: SignupFormInputs) => { + try { + showLoader(); + + const result = await signupMutation({ + variables: { + input: { + companyName: data.companyName.trim(), + subdomain: data.subdomain.toLowerCase().trim(), + firstName: data.firstName.trim(), + lastName: data.lastName.trim(), + email: data.email.trim(), + password: data.password, + phoneNumber: data.phoneNumber?.trim() || undefined, + countryCode: data.countryCode?.trim() || undefined, + jobTitle: data.jobTitle, + industry: data.industry, + }, + }, + }); + + if (result.data?.signup) { + showToast({ status: "success", title: "Account created successfully! Please login." }); + navigate("/login"); + } + } catch (error: any) { + const errorMessage = + error?.graphQLErrors?.[0]?.message || + error?.message || + "Signup failed. Please try again."; + + // Check for subdomain already taken error + if (errorMessage.toLowerCase().includes("subdomain") && + (errorMessage.toLowerCase().includes("taken") || + errorMessage.toLowerCase().includes("already"))) { + showToast({ status: "error", title: "Subdomain already taken. Please choose a different one." }); + } else { + showToast({ status: "error", title: errorMessage }); + } + } finally { + hideLoader(); + } + }; + + return ( + + + + + + + +
+ +
+ + The world's smartest buildings are powered by CriticalAsset + + + CriticalAsset is trusted by the world's best companies, cities, + schools, healthcare facilities, contractors and property + managers. + +
+ +
+ +
+ + Create an Account + + + {/* Company Name */} + + + {/* Subdomain */} + + + {/* First Name & Last Name */} + + + + + + + + + + {/* Email */} + + + {/* Phone with Country Code (Optional) */} + + + + + + + + + + {/* Job Title & Industry */} + + + + Select Job Title + {jobTitleOptions.map((option) => ( + + {option} + + ))} + + + + + Select Industry + {industryOptions.map((option) => ( + + {option} + + ))} + + + + + {/* Password */} + + Password + + event.preventDefault()} + edge="end" + aria-label={ + showPassword ? "Hide password" : "Show password" + } + > + {showPassword ? ( + + ) : ( + + )} + + + } + label="Password" + /> + {errors.password?.message} + + + + + + Already have an account?{" "} + + Sign in + + + + +
+
+
+
+
+ ); +}; + +export default Signup; diff --git a/src/pages/accept-invitation/AcceptInvitation.tsx b/src/pages/accept-invitation/AcceptInvitation.tsx new file mode 100644 index 000000000..1bcd21b9d --- /dev/null +++ b/src/pages/accept-invitation/AcceptInvitation.tsx @@ -0,0 +1,357 @@ +import { useState, useEffect } from "react"; +import { + Box, + Button, + FormControl, + FormHelperText, + Grid, + IconButton, + InputAdornment, + InputLabel, + Link, + OutlinedInput, + TextField, + Typography, +} from "@mui/material"; +import { Visibility, VisibilityOff } from "@mui/icons-material"; +import { ThemeProvider } from "@mui/material/styles"; +import CssBaseline from "@mui/material/CssBaseline"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { useMutation } from "@apollo/client/react"; +import { showToast } from "../../components/toastService"; +import { useLoader } from "../../context/LoaderContext"; +import { useForm } from "react-hook-form"; +import * as yup from "yup"; +import { yupResolver } from "@hookform/resolvers/yup"; +import muiTheme from "../../themes/muiTheme"; +import { ACCEPT_INVITATION_MUTATION } from "../../graphql/mutations"; + +type AcceptInvitationFormInputs = { + firstName: string; + lastName: string; + // phone: string; // Commented out + password: string; +}; + +type AcceptInvitationResponse = { + acceptInvitation: boolean | object; +}; + +const schema = yup.object({ + firstName: yup.string().required("First name is required"), + lastName: yup.string().required("Last name is required"), + // phone: yup + // .string() + // .required("Phone is required") + // .test("phone-format", "Invalid phone number format", (value) => { + // if (!value || value.trim() === "") return false; + // // Accept formats like: +1234567890, (123) 456-7890, 123-456-7890, 1234567890 + // const phoneRegex = /^[\+]?[(]?[0-9]{1,4}[)]?[-\s\.]?[(]?[0-9]{1,4}[)]?[-\s\.]?[0-9]{1,9}$/; + // return phoneRegex.test(value); + // }), // Commented out + password: yup + .string() + .required("Password is required") + .min(8, "Password must be at least 8 characters") + .matches(/[a-zA-Z]/, "Password must contain at least one letter") + .matches(/[0-9]/, "Password must contain at least one number"), +}); + +const AcceptInvitation = () => { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const { showLoader, hideLoader } = useLoader(); + const [acceptInvitationMutation] = useMutation(ACCEPT_INVITATION_MUTATION); + + // Get token from URL parameters + const token = searchParams.get("token"); + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: yupResolver(schema), + mode: "all", + defaultValues: { + firstName: "", + lastName: "", + // phone: "", // Commented out + password: "", + }, + }); + + const [showPassword, setShowPassword] = useState(false); + + useEffect(() => { + // Check if required parameter is present + if (!token) { + showToast({ + status: "error", + title: "Invalid invitation link. Missing token parameter.", + }); + // Optionally redirect to login after a delay + setTimeout(() => { + navigate("/login"); + }, 2000); + } + }, [token, navigate]); + + const togglePasswordVisibility = () => { + setShowPassword((prev) => !prev); + }; + + const handleAcceptInvitation = async (data: AcceptInvitationFormInputs) => { + if (!token) { + showToast({ + status: "error", + title: "Invalid invitation link. Missing token parameter.", + }); + return; + } + + try { + showLoader(); + + const result = await acceptInvitationMutation({ + variables: { + input: { + token: token, + firstName: data.firstName.trim(), + lastName: data.lastName.trim(), + // phone: data.phone.trim(), // Commented out + password: data.password, + }, + }, + }); + + if (result.data && typeof result.data === 'object' && 'acceptInvitation' in result.data) { + const response = result.data as AcceptInvitationResponse; + if (response.acceptInvitation) { + showToast({ + status: "success", + title: "Invitation accepted successfully. You can now login.", + }); + // Redirect to login page after successful acceptance + setTimeout(() => { + navigate("/login"); + }, 2000); + } + } + } catch (error: any) { + console.error("Failed to accept invitation", error); + const errorMessage = error?.graphQLErrors?.[0]?.message || + error?.message || + "Failed to accept invitation. Please try again."; + showToast({ + status: "error", + title: errorMessage, + }); + } finally { + hideLoader(); + } + }; + + return ( + + + + + + + +
+ +
+ + The world's smartest buildings are powered by CriticalAsset + + + CriticalAsset is trusted by the world's best companies, cities, + schools, healthcare facilities, contractors and property + managers. + +
+ +
+ +
+ + Accept Invitation + + + + + {/* Phone field commented out */} + {/* */} + + Password + + event.preventDefault()} + edge="end" + aria-label={ + showPassword ? "Hide password" : "Show password" + } + > + {showPassword ? ( + + ) : ( + + )} + + + } + label="Password" + /> + {errors.password?.message} + + + + + Already have an account?{" "} + + Sign in + + + + +
+
+
+
+
+ ); +}; + +export default AcceptInvitation; + diff --git a/src/pages/ai-addons/AiAddonModal.tsx b/src/pages/ai-addons/AiAddonModal.tsx new file mode 100644 index 000000000..702adfada --- /dev/null +++ b/src/pages/ai-addons/AiAddonModal.tsx @@ -0,0 +1,314 @@ +import React, { useState, useEffect } from "react"; +import { + Box, + Button, + TextField, + MenuItem, + FormControlLabel, + Switch, + Typography, + Grid, + Radio, + RadioGroup, + FormControl, + FormLabel, + Select, + InputLabel, + OutlinedInput, + Chip, + SelectChangeEvent, + Stack, + Autocomplete, +} from "@mui/material"; +import { useMutation, useQuery } from "@apollo/client/react"; +import { + CREATE_AI_ADDON_MUTATION, + UPDATE_AI_ADDON_MUTATION, +} from "../../graphql/mutations"; +import { PLANS_QUERY } from "../../graphql/queries"; +import { showToast } from "../../components/toastService"; +import { useLoader } from "../../context/LoaderContext"; + +type AiAddonModalProps = { + data?: any; + onSave: (data: any) => void; + onClose: () => void; + open?: boolean; +}; + +const defaultFormData = { + name: "", + description: "", + pricingType: "subscription", + amount: 0, + currency: "usd", + interval: "month", + intervalCount: 1, + creditPoolSize: 0, + eligiblePlanIds: [] as string[], + credits: 0, +}; + +const AiAddonModal: React.FC = ({ + data, + onSave, + onClose, + open = false, +}) => { + const [formData, setFormData] = useState(defaultFormData); + + const { showLoader, hideLoader } = useLoader(); + + const [createAiAddon] = useMutation(CREATE_AI_ADDON_MUTATION); + const [updateAiAddon] = useMutation(UPDATE_AI_ADDON_MUTATION); + const { data: plansData } = useQuery(PLANS_QUERY); + + useEffect(() => { + if (data) { + setFormData({ + name: data.name || "", + description: data.description || "", + pricingType: data.pricingType || "subscription", + amount: data.amount || 0, + currency: data.currency || "usd", + interval: data.interval || "month", + intervalCount: data.intervalCount || 1, + creditPoolSize: data.creditPoolSize || 0, + eligiblePlanIds: data.eligiblePlanIds || [], + credits: data.credits || 0, + }); + } else { + // Reset to default when data is null/undefined (create mode) + setFormData(defaultFormData); + } + }, [data]); + + // Reset form when modal opens in create mode + useEffect(() => { + if (open && !data) { + setFormData(defaultFormData); + } + }, [open, data]); + + const handleClose = () => { + setFormData(defaultFormData); + onClose(); + }; + + const handleChange = (e: React.ChangeEvent) => { + const { name, value, type, checked } = e.target; + setFormData((prev) => ({ + ...prev, + [name]: type === "checkbox" ? checked : value, + })); + }; + + const handlePlanChange = (_event: any, newValue: any[]) => { + setFormData((prev) => ({ + ...prev, + eligiblePlanIds: newValue.map((plan) => plan.id), + })); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + showLoader(); + + try { + const input = { + ...formData, + amount: Number(formData.amount), + intervalCount: Number(formData.intervalCount), + creditPoolSize: Number(formData.creditPoolSize), + credits: Number(formData.credits), + }; + + if (data?.id) { + await updateAiAddon({ + variables: { + input: { + id: data.id, + ...input, + }, + }, + }); + showToast({ status: "success", title: "AI Addon updated successfully" }); + } else { + await createAiAddon({ + variables: { + input, + }, + }); + showToast({ status: "success", title: "AI Addon created successfully" }); + } + setFormData(defaultFormData); + onSave(formData); + } catch (error: any) { + console.error("Error saving AI Addon:", error); + const errorMessage = + error?.graphQLErrors?.[0]?.message || + error?.message || + "Failed to save AI Addon"; + showToast({ status: "error", title: errorMessage }); + } finally { + hideLoader(); + } + }; + + return ( + + + + + + Subscription + Pay As You Go + + {/* + + Credits + Feature + + */} + + + + {formData.pricingType === "subscription" && ( + <> + + Day + Week + Month + Year + + + + + )} + + {formData.pricingType === "pay_as_you_go" && ( + + )} + + option.name || ""} + isOptionEqualToValue={(option, value) => option.id === value.id} + value={ + plansData?.plans?.filter((plan: any) => + formData.eligiblePlanIds.includes(plan.id) + ) || [] + } + onChange={handlePlanChange} + size="small" + renderInput={(params) => ( + + )} + renderTags={(value, getTagProps) => + value.map((option: any, index: number) => ( + + )) + } + /> + + + + + + ); +}; + +export default AiAddonModal; diff --git a/src/pages/ai-addons/AiAddons.tsx b/src/pages/ai-addons/AiAddons.tsx new file mode 100644 index 000000000..8ebef5845 --- /dev/null +++ b/src/pages/ai-addons/AiAddons.tsx @@ -0,0 +1,257 @@ +import React, { useState, useEffect, useMemo } from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import IconButton from "@mui/material/IconButton"; +import InputAdornment from "@mui/material/InputAdornment"; +import TextField from "@mui/material/TextField"; +import { ThemeProvider } from "@mui/material/styles"; +import { + DataGrid, + GridColDef, + GridPaginationModel, + GridRenderCellParams, +} from "@mui/x-data-grid"; +import { useMutation, useQuery } from "@apollo/client/react"; +import { AI_ADDONS_QUERY } from "../../graphql/queries"; +import { DELETE_AI_ADDON_MUTATION } from "../../graphql/mutations"; +import RightSideModal from "../../components/RightSideModal"; +import AiAddonModal from "./AiAddonModal"; +import { showToast } from "../../components/toastService"; +import { useLoader } from "../../context/LoaderContext"; +import muiTheme from "../../themes/muiTheme"; +import { Search, X, Pencil, Trash2, Plus } from "lucide-react"; + +export interface AiAddon { + id: string; + name: string; + description?: string; + pricingType?: string; + amount: number; + currency: string; + interval: string; + intervalCount?: number; + creditPoolSize?: number; + stripePriceId?: string; + stripeProductId?: string; + eligiblePlanIds?: string[]; + active: boolean; + type: string; + credits: number; + createdAt?: string; + updatedAt?: string; + deletedAt?: string; +} + +interface AiAddonsData { + aiAddons: AiAddon[]; +} + +const AiAddons = () => { + const [searchTerm, setSearchTerm] = useState(""); + const [modalOpen, setModalOpen] = useState(false); + const [selectedAddon, setSelectedAddon] = useState(null); + const [paginationModel, setPaginationModel] = useState({ + page: 0, + pageSize: 10, + }); + const { showLoader, hideLoader } = useLoader(); + + const { data, loading, error, refetch } = useQuery(AI_ADDONS_QUERY, { + fetchPolicy: "network-only", + }); + + const [deleteAiAddon] = useMutation(DELETE_AI_ADDON_MUTATION); + + useEffect(() => { + if (loading) showLoader(); + else hideLoader(); + }, [loading, showLoader, hideLoader]); + + useEffect(() => { + if (error) { + showToast({ status: "error", title: "Failed to fetch AI Addons" }); + console.error(error); + } + }, [error]); + + const filteredData = useMemo(() => { + if (!data?.aiAddons) return []; + return data.aiAddons.filter((item) => { + const term = searchTerm.toLowerCase(); + const name = item.name?.toLowerCase() || ""; + const description = item.description?.toLowerCase() || ""; + return name.includes(term) || description.includes(term); + }); + }, [data, searchTerm]); + + const openAddModal = () => { + setSelectedAddon(null); + setModalOpen(true); + }; + + const openEditModal = (addon: any) => { + setSelectedAddon(addon); + setModalOpen(true); + }; + + const handleSave = async () => { + await refetch(); + setModalOpen(false); + setSelectedAddon(null); + }; + + const handleDelete = async (id: string) => { + if (!window.confirm("Are you sure you want to delete this addon?")) return; + + showLoader(); + try { + const result = await deleteAiAddon({ variables: { input: { id } } }); + const response = (result.data as any)?.deleteAIAddon; + if (response?.success) { + showToast({ status: "success", title: response.message || "AI Addon deleted successfully" }); + await refetch(); + } else { + showToast({ status: "error", title: response?.message || "Failed to delete AI Addon" }); + } + } catch (error: any) { + console.error("Delete failed:", error); + const errorMessage = + error?.graphQLErrors?.[0]?.message || + error?.message || + "Failed to delete AI Addon"; + showToast({ status: "error", title: errorMessage }); + } finally { + hideLoader(); + } + }; + + const columns: GridColDef[] = [ + { field: "name", headerName: "Name", flex: 1, minWidth: 150 }, + { field: "description", headerName: "Description", flex: 2, minWidth: 200 }, + { field: "pricingType", headerName: "Pricing Type", width: 130 }, + { field: "amount", headerName: "Amount", width: 100 }, + { field: "currency", headerName: "Currency", width: 100 }, + { field: "interval", headerName: "Interval", width: 100 }, + { field: "type", headerName: "Type", width: 100 }, + { field: "credits", headerName: "Credits", width: 100 }, + { + field: "active", + headerName: "Active", + width: 100, + renderCell: (params) => ( + {params.value ? "Yes" : "No"} + ), + }, + { + field: "actions", + headerName: "Actions", + width: 120, + sortable: false, + filterable: false, + renderCell: (params: GridRenderCellParams) => ( + + openEditModal(params.row)} + size="small" + > + + + handleDelete(params.row.id)} + size="small" + > + + + + ), + }, + ]; + + return ( + + +
+
+ setSearchTerm(e.target.value)} + size="small" + fullWidth + sx={{ maxWidth: { xs: "100%", sm: 240, md: 300 } }} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: searchTerm ? ( + + setSearchTerm("")} + edge="end" + > + + + + ) : undefined, + }} + /> + +
+
+ +
+
+ + { + setModalOpen(false); + setSelectedAddon(null); + }} + title={selectedAddon ? "Edit AI Addon" : "Create AI Addon"} + > + { + setModalOpen(false); + setSelectedAddon(null); + }} + open={modalOpen} + /> + +
+
+ ); +}; + +export default AiAddons; diff --git a/src/pages/company/Company.tsx b/src/pages/company/Company.tsx new file mode 100644 index 000000000..76abf9fbf --- /dev/null +++ b/src/pages/company/Company.tsx @@ -0,0 +1,449 @@ +import React, { useState, useEffect, useMemo } from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import IconButton from "@mui/material/IconButton"; +import InputAdornment from "@mui/material/InputAdornment"; +import TextField from "@mui/material/TextField"; +import { ThemeProvider } from "@mui/material/styles"; +import { + DataGrid, + GridColDef, + GridPaginationModel, + GridRenderCellParams, +} from "@mui/x-data-grid"; +import { useMutation, useQuery } from "@apollo/client/react"; +import { DELETE_COMPANY_MUTATION } from "../../graphql/mutations"; +import { COMPANIES_QUERY } from "../../graphql/queries"; +import RightSideModal from "../../components/RightSideModal"; +import CompanyModal from "./CompanyModal"; +import EditCompanyModal from "./EditCompanyModal"; +import { showToast } from "../../components/toastService"; +import { useLoader } from "../../context/LoaderContext"; +import muiTheme from "../../themes/muiTheme"; +import { Search, X, Pencil, Trash2, Plus } from "lucide-react"; + +const Company = () => { + const [searchTerm, setSearchTerm] = useState(""); + const [companyModalOpen, setCompanyModalOpen] = useState(false); + const [editCompanyModalOpen, setEditCompanyModalOpen] = useState(false); + const [selectedCompany, setSelectedCompany] = useState(null); + const [companyData, setCompanyData] = useState([]); + const [paginationModel, setPaginationModel] = useState({ + page: 0, + pageSize: 10, + }); + const { showLoader, hideLoader } = useLoader(); + const [deleteCompanyMutation] = useMutation(DELETE_COMPANY_MUTATION, { + refetchQueries: [{ query: COMPANIES_QUERY }], + awaitRefetchQueries: true, + }); + + // Fetch companies when component mounts and filter by role "company" + const { data: companiesData, refetch: refetchCompanies } = useQuery(COMPANIES_QUERY, { + fetchPolicy: 'cache-and-network', + }); + + // Filter companies by role "company" + const companiesWithRoleCompany = useMemo(() => { + if (!companiesData || !(companiesData as any)?.companies) return []; + return (companiesData as any).companies.filter((company: any) => { + return company.role?.toLowerCase() === 'company'; + }); + }, [companiesData]); + + // Update companyData when filtered companies change + useEffect(() => { + if (companiesWithRoleCompany.length > 0 || (companiesData && companiesWithRoleCompany.length === 0)) { + // Transform companies to match expected format + const transformedCompanies = companiesWithRoleCompany.map((company: any) => ({ + id: company.id, + displayname: company.name || company.displayName || '', + slug: company.id, + ...company, + })); + + // Sort by createdAt descending (newest first) + const sortedCompanies = [...transformedCompanies].sort((a: any, b: any) => { + const dateA = new Date(a.createdAt || 0).getTime(); + const dateB = new Date(b.createdAt || 0).getTime(); + return dateB - dateA; + }); + + setCompanyData(sortedCompanies); + } else if (!companiesData) { + setCompanyData([]); + } + }, [companiesWithRoleCompany, companiesData]); + + const filteredData = useMemo( + () => { + // Filter based on search term (company name only) + return companyData.filter((item: any) => { + if (!searchTerm) return true; // Show all if no search term + const term = searchTerm.toLowerCase(); + const name = (item.name || item.displayname || "").toLowerCase(); + return name.includes(term); + }); + }, + [companyData, searchTerm] + ); + + const handleNameClick = (company: any) => { + const authToken = localStorage.getItem("accessToken"); + const id = company.id || ""; + const companyName = company.name || company.displayname || ""; + + if (authToken) { + // Navigate to external application with authToken and all company details as query parameters + const url = new URL("http://localhost:3005/login"); + // const url = new URL("http://company.criticalasset.com/login"); + url.searchParams.set("id", id); + url.searchParams.set("authToken", authToken); + if (companyName) url.searchParams.set("companyName", companyName); + if (company.email) url.searchParams.set("email", company.email); + if (company.phoneNumber) url.searchParams.set("phone", company.phoneNumber); + if (company.address) url.searchParams.set("address", company.address); + if (company.city) url.searchParams.set("city", company.city); + if (company.state) url.searchParams.set("state", company.state); + if (company.country) url.searchParams.set("country", company.country); + if (company.zip) url.searchParams.set("zip", company.zip); + if (company.schemaName) url.searchParams.set("schemaName", company.schemaName); + + const finalUrl = url.toString(); + console.log("Opening company URL:", finalUrl); + + // Use a link element to open the URL (avoids popup blocker issues) + const link = document.createElement('a'); + link.href = finalUrl; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } else { + console.log("No authToken found in localStorage"); + } + }; + + const openAddModal = () => { + setSelectedCompany(null); + setCompanyModalOpen(true); + }; + + const openEditModal = (company: any) => { + setSelectedCompany(company); + setEditCompanyModalOpen(true); + }; + + const handleSaveCompany = (companyData: any) => { + // Company data is managed via companyData state + // The CompanyModal will trigger refetch through onCompanyCreated callback + }; + + const handleUpdateCompany = async (companyData: any) => { + // Refetch companies to refresh the table + try { + await refetchCompanies(); + } catch (error) { + console.error("Failed to refetch companies:", error); + } + }; + + const deleteCompany = async (companyId: any) => { + try { + showLoader(); + + // Delete company using GraphQL mutation + const result = await deleteCompanyMutation({ + variables: { + id: companyId.id, + }, + }); + + if (result.data && typeof result.data === 'object' && 'removeCompany' in result.data) { + const companyName = companyId.name || companyId.displayname || "Company"; + + // Refetch companies to get updated list (refetchQueries should handle this, but we'll also do it manually) + try { + await refetchCompanies(); + } catch (getError) { + console.error("Failed to fetch updated company list:", getError); + // Fallback: remove from companyData manually + setCompanyData((prev) => prev.filter((company) => company.id !== companyId.id)); + } + + showToast({ + status: "success", + title: `Successfully deleted company ${companyName}` + }); + } + } catch (error: any) { + console.error("Delete failed:", error); + const errorMessage = error?.graphQLErrors?.[0]?.message || error?.message || "Failed to delete company"; + showToast({ status: "error", title: errorMessage }); + } finally { + hideLoader(); + } + }; + + const columns: GridColDef[] = [ + { + field: "name", + headerName: "Company Name", + flex: 1, + minWidth: 180, + sortable: true, + valueGetter: (params) => { + return params.row.name || params.row.displayname || ""; + }, + renderCell: (params: GridRenderCellParams) => { + const companyName = params.row.name || params.row.displayname || ""; + return ( + handleNameClick(params.row)} + sx={{ + color: "#3b82f6", + cursor: "pointer", + textDecoration: "underline", + "&:hover": { + color: "#2563eb", + }, + }} + > + {companyName || "N/A"} + + ); + }, + }, + { + field: "subdomain", + headerName: "Subdomain", + flex: 1, + minWidth: 180, + sortable: true, + renderCell: (params: GridRenderCellParams) => ( + {params.value || "--"} + ), + }, + { + field: "email", + headerName: "Email", + flex: 1, + minWidth: 180, + sortable: true, + renderCell: (params: GridRenderCellParams) => ( + {params.value || "--"} + ), + }, + { + field: "phoneNumber", + headerName: "Phone", + flex: 1, + minWidth: 140, + sortable: true, + renderCell: (params: GridRenderCellParams) => ( + {params.value || "--"} + ), + }, + { + field: "address", + headerName: "Address", + flex: 1, + minWidth: 180, + sortable: true, + renderCell: (params: GridRenderCellParams) => ( + {params.value || "--"} + ), + }, + { + field: "city", + headerName: "City", + flex: 1, + minWidth: 120, + sortable: true, + renderCell: (params: GridRenderCellParams) => ( + {params.value || "--"} + ), + }, + { + field: "state", + headerName: "State", + flex: 1, + minWidth: 100, + sortable: true, + renderCell: (params: GridRenderCellParams) => ( + {params.value || "--"} + ), + }, + { + field: "country", + headerName: "Country", + flex: 1, + minWidth: 100, + sortable: true, + renderCell: (params: GridRenderCellParams) => ( + {params.value || "--"} + ), + }, + { + field: "zip", + headerName: "Zip", + flex: 1, + minWidth: 100, + sortable: true, + renderCell: (params: GridRenderCellParams) => ( + {params.value || "--"} + ), + }, + { + field: "actions", + headerName: "Actions", + width: 160, + sortable: false, + filterable: false, + renderCell: (params: GridRenderCellParams) => ( + + openEditModal(params.row)} + size="small" + > + + + deleteCompany(params.row)} + size="small" + > + + + + ), + }, + ]; + + return ( + + +
+
+ setSearchTerm(e.target.value)} + size="small" + fullWidth + sx={{ maxWidth: { xs: "100%", sm: 240, md: 300 } }} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: searchTerm ? ( + + setSearchTerm("")} + edge="end" + > + + + + ) : undefined, + }} + /> + +
+
+ ({ + ...row, + id: row.id || row.ID || `company_${Math.random()}`, + }))} + columns={columns} + disableRowSelectionOnClick + sortingOrder={["asc", "desc"]} + paginationModel={paginationModel} + pageSizeOptions={[5, 10, 25, 50]} + onPaginationModelChange={setPaginationModel} + disableColumnMenu + disableColumnFilter + localeText={{ + noRowsLabel: "No Data Found", + }} + /> +
+
+ + { + setCompanyModalOpen(false); + }} + title="Create Company" + > + {companyModalOpen && ( + { + setCompanyModalOpen(false); + }} + onCompanyCreated={async () => { + // Refetch companies to refresh the table + try { + await refetchCompanies(); + } catch (error) { + console.error("Failed to refetch companies:", error); + } + }} + /> + )} + + + { + setEditCompanyModalOpen(false); + setSelectedCompany(null); + }} + title="Edit Company" + > + { + setEditCompanyModalOpen(false); + setSelectedCompany(null); + }} + onCompanyUpdated={async () => { + // Refetch companies to refresh the table + try { + await refetchCompanies(); + } catch (error) { + console.error("Failed to refetch companies:", error); + } + }} + /> + +
+
+ ); +}; + +export default Company; + diff --git a/src/pages/company/CompanyModal.tsx b/src/pages/company/CompanyModal.tsx new file mode 100644 index 000000000..2bf0ab4b6 --- /dev/null +++ b/src/pages/company/CompanyModal.tsx @@ -0,0 +1,461 @@ +import React, { useEffect, useState } from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import TextField from "@mui/material/TextField"; +import MenuItem from "@mui/material/MenuItem"; +import Stack from "@mui/material/Stack"; +import IconButton from "@mui/material/IconButton"; +import InputAdornment from "@mui/material/InputAdornment"; +import { Visibility, VisibilityOff } from "@mui/icons-material"; +import { useForm, Controller } from "react-hook-form"; +import * as yup from "yup"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { useMutation, useLazyQuery } from "@apollo/client/react"; +import { CREATE_COMPANY_MUTATION } from "../../graphql/mutations"; +import { COMPANIES_QUERY } from "../../graphql/queries"; +import { showToast } from "../../components/toastService"; +import { useLoader } from "../../context/LoaderContext"; +import { useAuth, type Tenant } from "../../context/AuthContext"; + +type CompanyModalProps = { + onClose: () => void; + onSave: (companyData: any) => void; + onCompanyCreated?: () => void | Promise; +}; + +type CompanyFormInputs = { + firstName: string; + lastName: string; + phoneNumber: string; + name: string; // Company Name + subdomain: string; + jobTitle: string; + industry: string; + email: string; + password: string; +}; + +const schema = yup.object({ + firstName: yup.string().required("First Name is required"), + lastName: yup.string().required("Last Name is required"), + phoneNumber: yup + .string() + .required("Phone Number is required") + .matches(/^[0-9]{10}$/, "Phone number must be exactly 10 digits and contain only numbers"), + name: yup.string().required("Company Name is required"), + subdomain: yup + .string() + .required("Subdomain is required") + .matches(/^[a-z0-9-]+$/, "Subdomain can only contain lowercase letters, numbers, and hyphens") + .min(3, "Subdomain must be at least 3 characters"), + jobTitle: yup.string().required("Job Title is required"), + industry: yup.string().required("Industry is required"), + email: yup + .string() + .email("Invalid email format") + .required("Email is required"), + password: yup + .string() + .required("Password is required") + .min(8, "Password must be at least 8 characters") + .matches(/[a-zA-Z]/, "Password must contain at least one letter") + .matches(/[0-9]/, "Password must contain at least one number"), +}) as yup.ObjectSchema; + +const jobTitleOptions = [ + "CEO/President/Owner", + "CFO", + "CIO", + "COO", + "CTO", + "Building Owner", + "Others", +]; + +const industryOptions = [ + "Agriculture & Farming", + "Automotive & Transportation", + "Aviation & Aerospace", + "Commercial Real Estate & Property Management", + "Construction & Engineering", + "Energy", + "Others", +]; + +const CompanyModal: React.FC = ({ + onClose, + onSave, + onCompanyCreated, +}) => { + const { user, tenantList, setTenantList } = useAuth(); + const { showLoader, hideLoader } = useLoader(); + const [showPassword, setShowPassword] = useState(false); + const [createCompanyMutation, { loading: createCompanyLoading }] = useMutation(CREATE_COMPANY_MUTATION, { + refetchQueries: [{ query: COMPANIES_QUERY }], + awaitRefetchQueries: true, + }); + const [fetchCompanies] = useLazyQuery(COMPANIES_QUERY); + + const { + register, + handleSubmit, + control, + formState: { errors, isSubmitting }, + reset, + } = useForm({ + resolver: yupResolver(schema), + mode: "all", + defaultValues: { + firstName: "", + lastName: "", + phoneNumber: "", + name: "", + subdomain: "", + jobTitle: "", + industry: "", + email: "", + password: "", + }, + }); + + const togglePasswordVisibility = () => { + setShowPassword((prev) => !prev); + }; + + // Reset form when modal opens + useEffect(() => { + reset({ + firstName: "", + lastName: "", + phoneNumber: "", + name: "", + subdomain: "", + jobTitle: "", + industry: "", + email: "", + password: "", + }); + }, [reset]); + + // Reset form when component unmounts (modal closes) + useEffect(() => { + return () => { + // Cleanup: reset form when component unmounts + reset({ + firstName: "", + lastName: "", + phoneNumber: "", + name: "", + subdomain: "", + jobTitle: "", + industry: "", + email: "", + password: "", + }); + }; + }, [reset]); + + const onSubmit = async (formData: CompanyFormInputs) => { + try { + showLoader(); + + // Prepare GraphQL mutation input + // SignupInput requires: companyName, email, password, firstName, lastName, subdomain + const input = { + companyName: formData.name.trim(), // Company Name + email: formData.email.trim(), + password: formData.password, + firstName: formData.firstName.trim(), + lastName: formData.lastName.trim(), + subdomain: formData.subdomain.trim().toLowerCase(), + phoneNumber: formData.phoneNumber.trim(), + jobTitle: formData.jobTitle, + industry: formData.industry, + // Note: jobTitle and industry are not supported by SignupInput + }; + + // Create company using GraphQL mutation + const result = await createCompanyMutation({ + variables: { + input: input, + }, + }); + + if (result.data && typeof result.data === 'object' && 'signup' in result.data) { + // signup returns AuthResponse with accessToken + // Fetch updated company list after successful creation + try { + const companiesResult = await fetchCompanies(); + if (companiesResult.data && typeof companiesResult.data === 'object' && 'companies' in companiesResult.data) { + const companies = (companiesResult.data as { companies: any[] }).companies; + // Find the newly created company by email or name + const newCompany = companies.find( + (company: any) => company.email === formData.email || company.name === formData.name + ); + + if (newCompany) { + // Map companies to Tenant format + const tenants = companies.map((company: any) => ({ + id: company.id, + displayname: company.name || company.displayName || '', + slug: company.id, + ...company, + })); + setTenantList(tenants); + onSave(newCompany); + + // Call parent's refresh callback if provided + if (onCompanyCreated) { + await onCompanyCreated(); + } + } else { + // Fallback: use form data + onSave({ + name: formData.name, + email: formData.email, + }); + + // Call parent's refresh callback if provided + if (onCompanyCreated) { + await onCompanyCreated(); + } + } + } + } catch (getError) { + console.error("Failed to fetch updated company list:", getError); + // Fallback: use form data + onSave({ + name: formData.name, + email: formData.email, + }); + + // Call parent's refresh callback if provided + if (onCompanyCreated) { + await onCompanyCreated(); + } + } + + showToast({ + status: "success", + title: "Company has been created successfully.", + }); + + // Reset form and close + handleClose(); + } + } catch (error: any) { + console.error("Failed to save company", error); + const errorMessage = error?.graphQLErrors?.[0]?.message || error?.message || "Something went wrong while saving the data."; + showToast({ + status: "error", + title: errorMessage, + }); + } finally { + hideLoader(); + } + }; + + const handleClose = () => { + // Reset form when closing modal + reset({ + firstName: "", + lastName: "", + phoneNumber: "", + name: "", + subdomain: "", + jobTitle: "", + industry: "", + email: "", + password: "", + }); + onClose(); + }; + + return ( + + + {/* First Name - Required */} + + + {/* Last Name - Required */} + + + {/* Phone Number - Required */} + { + e.target.value = e.target.value.replace(/[^0-9]/g, ''); + }} + /> + + {/* Company Name - Required */} + + + {/* Subdomain - Required */} + { + e.target.value = e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''); + }} + /> + + {/* Job Title - Required */} + ( + + + Select Job Title + + {jobTitleOptions.map((option) => ( + + {option} + + ))} + + )} + /> + + {/* Industry - Required */} + ( + + + Select Industry + + {industryOptions.map((option) => ( + + {option} + + ))} + + )} + /> + + {/* Email - Required */} + + + {/* Password - Required */} + + + {showPassword ? : } + + + ), + }} + /> + + + + + + ); +}; + +export default CompanyModal; + diff --git a/src/pages/company/EditCompanyModal.tsx b/src/pages/company/EditCompanyModal.tsx new file mode 100644 index 000000000..3c9786d36 --- /dev/null +++ b/src/pages/company/EditCompanyModal.tsx @@ -0,0 +1,455 @@ +import React, { useEffect } from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import TextField from "@mui/material/TextField"; +import MenuItem from "@mui/material/MenuItem"; +import Stack from "@mui/material/Stack"; +import { useForm, Controller } from "react-hook-form"; +import * as yup from "yup"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { useMutation, useLazyQuery } from "@apollo/client/react"; +import { UPDATE_COMPANY_MUTATION } from "../../graphql/mutations"; +import { COMPANIES_QUERY } from "../../graphql/queries"; +import { showToast } from "../../components/toastService"; +import { useLoader } from "../../context/LoaderContext"; +import { useAuth } from "../../context/AuthContext"; + +type EditCompanyModalProps = { + data: any; + onClose: () => void; + onSave: (companyData: any) => void; + onCompanyUpdated?: () => void | Promise; +}; + +type EditCompanyFormInputs = { + name: string; + email?: string; + address?: string; + city?: string; + state?: string; + zip?: string; + website?: string; + industry?: string; + phone: string; + businessType?: string; + countryAlpha2: string; +}; + +const schema = yup.object({ + name: yup.string().required("Name is required"), + email: yup + .string() + .optional() + .test("email-format", "Invalid email format", (value) => { + if (!value || value.trim() === "") return true; // Optional field + return yup.string().email().isValidSync(value); + }), + phone: yup + .string() + .required("Phone Number is required") + .matches(/^[0-9]{10}$/, "Phone number must be exactly 10 digits and contain only numbers"), + businessType: yup.string().optional(), + countryAlpha2: yup.string().required("Country is required"), + website: yup + .string() + .optional() + .test("website-format", "Invalid website URL format", (value) => { + if (!value || value.trim() === "") return true; // Optional field + try { + const url = new URL(value.startsWith("http") ? value : `https://${value}`); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } + }), + industry: yup.string().optional(), + address: yup.string().optional(), + city: yup.string().optional(), + state: yup.string().optional(), + zip: yup.string().optional(), +}) as yup.ObjectSchema; + +const industryOptions = [ + "Agriculture & Farming", + "Automotive & Transportation", + "Aviation & Aerospace", + "Commercial Real Estate & Property Management", + "Construction & Engineering", + "Energy", + "Other", +]; +const US_STATES = [ + "AL", "AK", "AZ", "AR", "CA", "CO", "CT", "DE", "FL", "GA", + "HI", "ID", "IL", "IN", "IA", "KS", "KY", "LA", "ME", "MD", + "MA", "MI", "MN", "MS", "MO", "MT", "NE", "NV", "NH", "NJ", + "NM", "NY", "NC", "ND", "OH", "OK", "OR", "PA", "RI", "SC", + "SD", "TN", "TX", "UT", "VT", "VA", "WA", "WV", "WI", "WY" +]; +const BUSINESS_TYPES = [ + "Corporation", + "LLC", + "Partnership", + "Sole Proprietorship", + "Non-Profit", + "Other", +]; + +const EditCompanyModal: React.FC = ({ + data, + onClose, + onSave, + onCompanyUpdated, +}) => { + const { tenantList, setTenantList } = useAuth(); + const { showLoader, hideLoader } = useLoader(); + const [updateCompanyMutation, { loading: updateCompanyLoading }] = useMutation(UPDATE_COMPANY_MUTATION, { + refetchQueries: [{ query: COMPANIES_QUERY }], + awaitRefetchQueries: true, + }); + const [fetchCompanies] = useLazyQuery(COMPANIES_QUERY); + + const { + register, + handleSubmit, + control, + formState: { errors, isSubmitting }, + reset, + } = useForm({ + resolver: yupResolver(schema), + mode: "all", + defaultValues: { + name: "", + email: "", + address: "", + city: "", + state: "", + zip: "", + website: "", + industry: "", + phone: "", + businessType: "", + countryAlpha2: "US", + }, + }); + + useEffect(() => { + if (data) { + // Map query fields to form fields + // Query returns: phoneNumber, address (single), countryCode + // Form expects: phone, address, countryAlpha2 + reset({ + name: data.name || "", + email: data.email || "", + address: data.address || "", + city: data.city || "", + state: data.state || "", + zip: data.zip || "", + website: data.website || "", + industry: data.industry || "", + phone: data.phoneNumber || "", + businessType: data.businessType || "", + countryAlpha2: data.countryCode || "US", + }); + } + }, [data, reset]); + + const onSubmit = async (formData: EditCompanyFormInputs) => { + try { + showLoader(); + + // Prepare GraphQL mutation input - all fields included, null for empty values + // Map single address field to address1, set address2 to null + const addressValue = formData.address?.trim() || null; + const input = { + id: data.id || null, + name: formData.name.trim() || null, + email: formData.email?.trim() || null, + phoneNumber: formData.phone.trim() || null, // Backend expects 'phone', not 'phoneNumber' + address: addressValue, // Backend expects 'address1', not 'address' + city: formData.city?.trim() || null, + state: formData.state?.trim() || null, + zip: formData.zip?.trim() || null, + website: formData.website?.trim() || null, + industry: formData.industry?.trim() || null, + businessType: formData.businessType?.trim() || null, + countryCode: formData.countryAlpha2?.trim() || null, // Backend expects 'countryCode' + }; + + // Update company using GraphQL mutation + const result = await updateCompanyMutation({ + variables: { + input: input, + }, + }); + + if (result.data && typeof result.data === 'object' && 'updateCompany' in result.data) { + const companyData = (result.data as { updateCompany: any }).updateCompany; + + // Fetch updated company list after successful update + try { + const companiesResult = await fetchCompanies(); + if (companiesResult.data && typeof companiesResult.data === 'object' && 'companies' in companiesResult.data) { + const companies = (companiesResult.data as { companies: any[] }).companies; + // Map companies to Tenant format + const tenants = companies.map((company: any) => ({ + id: company.id, + displayname: company.name || company.displayName || '', + slug: company.id, + ...company, + })); + setTenantList(tenants); + } + } catch (getError) { + console.error("Failed to fetch updated company list:", getError); + } + + onSave(companyData); + + // Call parent's refresh callback if provided + if (onCompanyUpdated) { + await onCompanyUpdated(); + } + + showToast({ + status: "success", + title: "Company information has been updated.", + }); + + reset({ + name: "", + email: "", + address: "", + city: "", + state: "", + zip: "", + website: "", + industry: "", + phone: "", + businessType: "", + countryAlpha2: "US", + }); + + onClose(); + } + } catch (error: any) { + console.error("Failed to update company", error); + const errorMessage = error?.graphQLErrors?.[0]?.message || error?.message || "Something went wrong while updating the data."; + showToast({ + status: "error", + title: errorMessage, + }); + } finally { + hideLoader(); + } + }; + + return ( + + + {/* Name - Required */} + + + {/* Email - Optional */} + + + {/* Address - Optional */} + + + {/* City - Optional */} + + + {/* State - Optional */} + + + {/* Zip - Optional */} + + + {/* Website URL - Optional */} + + + {/* Industry - Optional */} + ( + + + None + + {industryOptions.map((option) => ( + + {option} + + ))} + + )} + /> + + {/* Phone Number - Required */} + { + e.target.value = e.target.value.replace(/[^0-9]/g, ''); + }} + InputLabelProps={{ shrink: true }} + /> + + {/* Business Type - Optional */} + ( + + + None + + {BUSINESS_TYPES.map((option) => ( + + {option} + + ))} + + )} + /> + + {/* Country Alpha 2 - Required, Disabled, US only */} + ( + + US + + )} + /> + + + + + + ); +}; + +export default EditCompanyModal; + diff --git a/src/pages/dashboard/Dashboard.tsx b/src/pages/dashboard/Dashboard.tsx new file mode 100644 index 000000000..d72e4c232 --- /dev/null +++ b/src/pages/dashboard/Dashboard.tsx @@ -0,0 +1,157 @@ +import React, { useEffect, useState } from "react"; +import { + DragDropContext, + Droppable, + Draggable, + DropResult, +} from "@hello-pangea/dnd"; +import { Box, SimpleGrid } from "@chakra-ui/react"; +import GreetingBar from "../../components/GreetingBar"; +import CrisisCheck from "../../components/CrisisCheck"; +import WorkOrders from "./WorkOrders"; +import AssetPerformance from "../../components/AssetPerformance"; +import { bffApi } from "../../components/api"; +import { useAuth } from "../../context/AuthContext"; +import ExecutiveDashboard from "./ExecutiveDashboard"; + +const initialRows = [ + [ + { id: "critical_alerts", type: "critical_alerts" }, + { id: "work_orders", type: "work_orders" }, + ], + [{ id: "asset_performance", type: "asset_performance" }], +]; + +const Dashboard = () => { + const [rows, setRows] = useState(initialRows); + const [dashboardData, setDashboardData] = useState(); +const { user } = useAuth(); + // 🔹 Drag handler + const onDragEnd = (result: DropResult) => { + const { source, destination } = result; + if (!destination) return; + + const newRows = [...rows]; + + if (source.droppableId === destination.droppableId) { + // same row reorder + const rowIndex = parseInt(source.droppableId); + const row = Array.from(newRows[rowIndex]); + const [moved] = row.splice(source.index, 1); + row.splice(destination.index, 0, moved); + newRows[rowIndex] = row; + } else { + // move between rows + const sourceRowIndex = parseInt(source.droppableId); + const destRowIndex = parseInt(destination.droppableId); + const sourceRow = Array.from(newRows[sourceRowIndex]); + const destRow = Array.from(newRows[destRowIndex]); + const [moved] = sourceRow.splice(source.index, 1); + destRow.splice(destination.index, 0, moved); + newRows[sourceRowIndex] = sourceRow; + newRows[destRowIndex] = destRow; + } + setRows(newRows); + }; + + // 🔹 Card rendering + const getCardComponent = (type: string) => { + switch (type) { + case "critical_alerts": + return dashboardData?.panel_1_critical_alerts ? ( + + ) : null; + case "work_orders": + return dashboardData?.panel_2_work_orders ? ( + + ) : null; + case "asset_performance": + return dashboardData?.panel_3_asset_performance ? ( + + ) : null; + default: + return null; + } + }; + + const getItemStyle = (isDragging: boolean, draggableStyle: any) => ({ + userSelect: "none", + ...draggableStyle, + }); + + // 🔹 Load API data + useEffect(() => { + (async () => { + try { + const res = await bffApi.get("property_manager_json_payloads.json"); + setDashboardData(res?.data?.property_manager_dashboard); + } catch (error: any) { + console.log(error.message); + } + })(); + }, []); + + return ( + + + + + + {user?.properties?.roles[0]?.displayname == 'propertymanager' ? + <> + + {rows.map((rowItems, rowIndex) => ( + + {(provided: any) => ( + + {rowItems.map((item, index) => ( + + {(provided: any, snapshot: any) => ( + + {getCardComponent(item.type)} + + )} + + ))} + {provided.placeholder} + + )} + + ))} + + :( + <> + + + )} + + + + ); +}; + +export default Dashboard; diff --git a/src/pages/dashboard/ExecutiveDashboard.tsx b/src/pages/dashboard/ExecutiveDashboard.tsx new file mode 100644 index 000000000..158b4fc22 --- /dev/null +++ b/src/pages/dashboard/ExecutiveDashboard.tsx @@ -0,0 +1,66 @@ +import { useEffect, useState } from "react"; +import { + Box, + Spinner, + Text, + VStack, + Heading, + Card, + CardHeader, + CardBody, + SimpleGrid, +} from "@chakra-ui/react"; +import { bffApi } from "../../components/api"; +import SustainabilityESGAccordion from "./excutive-dashboard-components/SustainabilityESGAccordion"; +import OperationalEfficiencyPanel from "./excutive-dashboard-components/OperationalEfficiencyPanel"; +import AIDrivenStrategicInsightsPanel from "./excutive-dashboard-components/AIDrivenStrategicInsightsPanel"; +import StrategicKPIPerformancePanel from "./excutive-dashboard-components/StrategicKPIPerformancePanel"; +import FinancialHealth from "./excutive-dashboard-components/FinancialHealth"; + +type Executive = { + id: number; + name: string; + role: string; + department: string; + email: string; + phone: string; + [key: string]: any; // if there are extra fields +}; + +const ExecutiveDashboard = () => { + const [dashboardData, setDashboardData] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + (async () => { + try { + const res = await bffApi.get("executive_team_json_payloads.json"); + setDashboardData(res?.data?.executive_dashboard); + } catch (error: any) { + console.log(error.message); + } + })(); + }, []); + + + return ( + + {dashboardData.length !== 0 && ( + <> + + + + + + + + + )} + + + + ); +}; + +export default ExecutiveDashboard; diff --git a/src/pages/dashboard/MainDashboard.tsx b/src/pages/dashboard/MainDashboard.tsx new file mode 100644 index 000000000..91c41bf11 --- /dev/null +++ b/src/pages/dashboard/MainDashboard.tsx @@ -0,0 +1,216 @@ +import React from "react"; +import { useQuery } from "@apollo/client/react"; +import { useNavigate } from "react-router-dom"; +import { + Box, + Card, + CardContent, + Typography, + Grid, + CircularProgress, +} from "@mui/material"; +import { + Groups as TeamIcon, + Business as PartnersIcon, + BusinessCenter as CompanyIcon, + SupportAgent as TicketingIcon, +} from "@mui/icons-material"; +import { + COMPANY_USERS_QUERY, + COMPANIES_QUERY, + GET_TICKETING_QUERY, +} from "../../graphql/queries"; +import GreetingBar from "../../components/GreetingBar"; +import { useAuth } from "../../context/AuthContext"; + +interface CompanyUser { + id: string; + email: string; + firstName: string; + lastName: string; + role: string; +} + +interface Company { + id: string; + name: string; + role?: string; +} + +interface Ticket { + id: string; +} + +interface CompanyUsersResponse { + companyUsers: CompanyUser[]; +} + +interface CompaniesResponse { + companies: Company[]; +} + +interface TicketsResponse { + tickets: Ticket[]; +} + +interface DashboardCardProps { + title: string; + count: number; + icon: React.ReactNode; + onClick: () => void; + loading?: boolean; +} + +const DashboardCard: React.FC = ({ + title, + count, + icon, + onClick, + loading = false, +}) => { + return ( + + + + + + {title} + + {loading ? ( + + ) : ( + + {count} + + )} + + + {icon} + + + + + ); +}; + +const MainDashboard = () => { + const navigate = useNavigate(); + const { user } = useAuth(); + + // Fetch team members count + const { data: teamData, loading: teamLoading } = useQuery(COMPANY_USERS_QUERY); + const teamCount = teamData?.companyUsers?.length || 0; + + // Fetch companies count (includes partners) + const { data: companiesData, loading: companiesLoading } = useQuery(COMPANIES_QUERY); + const allCompanies = companiesData?.companies || []; + + // Filter partners from companies (companies with role 'partner') + const partnersCount = allCompanies.filter( + (company: Company) => company.role?.toLowerCase() === 'partner' + ).length; + + // Count all companies + const companiesCount = allCompanies.length; + + // Fetch tickets count + const { data: ticketsData, loading: ticketsLoading } = useQuery(GET_TICKETING_QUERY); + const ticketsCount = ticketsData?.tickets?.length || 0; + + // Get user's name for greeting + const userName = user?.displayname || user?.firstName || user?.email || "User"; + + // Get company info for buildingInfo + const userCompany = allCompanies.find((c: Company) => c.id === user?.companyId); + const buildingInfo = userCompany?.name || "Admin Dashboard"; + + const cards = [ + { + title: "Team", + count: teamCount, + icon: , + path: "/team", + loading: teamLoading, + }, + { + title: "Partners", + count: partnersCount, + icon: , + path: "/partners", + loading: companiesLoading, + }, + { + title: "Company", + count: companiesCount, + icon: , + path: "/company", + loading: companiesLoading, + }, + { + title: "Ticketing", + count: ticketsCount, + icon: , + path: "/ticketing", + loading: ticketsLoading, + }, + ]; + + return ( + + {/* Greeting Bar */} + + + + + {/* 4 Cards Section */} + + {cards.map((card) => ( + + navigate(card.path)} + loading={card.loading} + /> + + ))} + + + ); +}; + +export default MainDashboard; + diff --git a/src/pages/dashboard/Property_Manager.json b/src/pages/dashboard/Property_Manager.json new file mode 100644 index 000000000..51ef29701 --- /dev/null +++ b/src/pages/dashboard/Property_Manager.json @@ -0,0 +1,394 @@ +{ + "property_manager_dashboard": { + "user_context": { + "user_id": "USR-pm-001", + "tenant_id": "tenant_001", + "property_ids": ["PROP-DOWNTOWN-01", "PROP-RIVERSIDE-02"], + "timestamp": "2025-08-11T07:00:00Z", + "timezone": "America/New_York" + }, + + "panel_1_critical_alerts": { + "panel_id": "critical_alerts_emergency_status", + "title": "Critical Alerts & Emergency Status", + "priority": 1, + "refresh_interval_seconds": 30, + "data": { + "summary": { + "total_active_alerts": 3, + "emergency_count": 1, + "critical_count": 1, + "security_count": 1, + "system_failure_count": 0 + }, + "alerts": [ + { + "alert_id": "ALT-2025-08-11-001", + "property_id": "PROP-DOWNTOWN-01", + "property_name": "Downtown Office Complex", + "timestamp": "2025-08-11T06:45:23Z", + "alert_type": "EMERGENCY", + "severity_level": 1, + "title": "HVAC System Complete Failure - Building A", + "description": "Main HVAC unit offline, temperature rising rapidly, 200+ tenants affected", + "affected_areas": ["Floor 1-5", "Conference Rooms", "Main Lobby"], + "estimated_impact": "High - 200 tenants affected, potential revenue loss $15K/day", + "status": "IN_PROGRESS", + "assigned_to": "John Smith", + "assigned_to_phone": "+1-555-1001", + "eta_resolution": "2025-08-11T10:00:00Z", + "escalation_level": 2, + "action_required": "Emergency HVAC vendor contacted, backup systems activated, tenant notifications sent", + "data_source": "IoT_SENSORS", + "contact_info": { + "vendor": "HVAC Solutions Inc", + "phone": "+1-555-0123", + "technician": "Bob Wilson", + "eta": "09:30" + }, + "time_since_alert": "75 minutes", + "urgency_indicator": "IMMEDIATE" + }, + { + "alert_id": "ALT-2025-08-11-002", + "property_id": "PROP-DOWNTOWN-01", + "timestamp": "2025-08-11T07:15:45Z", + "alert_type": "SECURITY", + "severity_level": 3, + "title": "Security Camera Offline - Floor 3", + "description": "Main security camera in lobby area offline for 45 minutes", + "affected_areas": ["Floor 3 Main Lobby"], + "estimated_impact": "Medium - Security monitoring gap, no immediate tenant impact", + "status": "ACKNOWLEDGED", + "assigned_to": "Security Team", + "eta_resolution": "2025-08-11T14:00:00Z", + "escalation_level": 1, + "action_required": "Security vendor notified, temporary coverage arranged", + "data_source": "SECURITY_SYSTEM", + "contact_info": { + "vendor": "SecureWatch Pro", + "phone": "+1-555-0789" + }, + "time_since_alert": "45 minutes", + "urgency_indicator": "MONITOR" + }, + { + "alert_id": "ALT-2025-08-11-001", + "property_id": "PROP-DOWNTOWN-01", + "property_name": "Downtown Office Complex", + "timestamp": "2025-08-11T06:45:23Z", + "alert_type": "CRITICAL", + "severity_level": 1, + "title": "HVAC System Complete Failure - Building A", + "description": "Main HVAC unit offline, temperature rising rapidly, 200+ tenants affected", + "affected_areas": ["Floor 1-5", "Conference Rooms", "Main Lobby"], + "estimated_impact": "High - 200 tenants affected, potential revenue loss $15K/day", + "status": "IN_PROGRESS", + "assigned_to": "John Smith", + "assigned_to_phone": "+1-555-1001", + "eta_resolution": "2025-08-11T10:00:00Z", + "escalation_level": 2, + "action_required": "Emergency HVAC vendor contacted, backup systems activated, tenant notifications sent", + "data_source": "IoT_SENSORS", + "contact_info": { + "vendor": "HVAC Solutions Inc", + "phone": "+1-555-0123", + "technician": "Bob Wilson", + "eta": "09:30" + }, + "time_since_alert": "75 minutes", + "urgency_indicator": "IMMEDIATE" + } + ], + "emergency_readiness": { + "backup_systems_status": "ONLINE", + "emergency_contacts_available": true, + "vendor_response_confirmed": "2_OF_3_CONFIRMED", + "evacuation_procedures_ready": true + } + } + }, + + "panel_2_work_orders": { + "panel_id": "work_order_pipeline_resources", + "title": "Today's Work Order Pipeline & Resource Allocation", + "priority": 2, + "refresh_interval_seconds": 300, + "data": { + "summary": { + "total_work_orders_today": 8, + "overdue_count": 1, + "in_progress_count": 3, + "scheduled_count": 4, + "technician_utilization_pct": 75, + "budget_utilized_today": 3200.00, + "budget_planned_today": 3850.00 + }, + "work_orders": [ + { + "work_order_id": "WO-2025-08-11-0001", + "property_id": "PROP-DOWNTOWN-01", + "title": "Emergency Elevator Repair - Building A Main", + "priority": "EMERGENCY", + "category": "EMERGENCY", + "status": "OVERDUE", + "scheduled_date": "2025-08-10", + "scheduled_time": "14:00:00", + "estimated_duration": 240, + "assigned_technician": "Mike Chen (TECH-001)", + "technician_skills": ["ELEVATORS", "Mechanical", "Safety_Certified"], + "technician_status": "AVAILABLE", + "team_size": 2, + "location_detail": "Building A, Main Elevator Bank, Unit 1", + "parts_required": [ + {"part": "Elevator Motor Controller", "qty": 1, "status": "IN_STOCK"}, + {"part": "Safety Cable", "qty": 2, "status": "ORDERED"} + ], + "tools_required": ["Specialized Elevator Tools", "Safety Harness", "Multimeter"], + "vendor_involved": "Elevator Services Pro", + "vendor_eta": "2025-08-11T09:30:00Z", + "tenant_impact": "HIGH", + "access_requirements": "Building manager escort required, tenant notifications sent", + "completion_percentage": 0, + "notes": "Waiting for specialized parts delivery, vendor confirmed 9:30 AM arrival", + "due_date": "2025-08-10T18:00:00Z", + "cost_estimate": 1200.00, + "actual_cost": 0.00, + "sla_status": "BREACHED", + "overdue_hours": 15 + }, + { + "work_order_id": "WO-2025-08-11-0002", + "property_id": "PROP-DOWNTOWN-01", + "title": "HVAC Filter Replacement - Floor 3", + "priority": "MEDIUM", + "category": "PREVENTIVE", + "status": "IN_PROGRESS", + "scheduled_date": "2025-08-11", + "scheduled_time": "09:00:00", + "estimated_duration": 120, + "assigned_technician": "John Smith (TECH-002), Sarah Lee (TECH-003)", + "technician_skills": ["HVAC", "Preventive_Maintenance"], + "team_size": 2, + "location_detail": "Floor 3, Room 315, Main HVAC Unit", + "parts_required": [ + {"part": "HVAC Filter - High Efficiency", "qty": 4, "status": "IN_STOCK"} + ], + "tools_required": ["Screwdriver Set", "Cleaning Supplies"], + "vendor_involved": null, + "tenant_impact": "MINIMAL", + "access_requirements": "Standard access, brief noise during installation", + "completion_percentage": 75, + "notes": "75% complete, final filter installation in progress", + "due_date": "2025-08-11T11:00:00Z", + "cost_estimate": 450.00, + "actual_cost": 425.00, + "eta_completion": "2025-08-11T10:45:00Z" + }, + { + "work_order_id": "WO-2025-08-11-0003", + "property_id": "PROP-DOWNTOWN-01", + "title": "Plumbing Inspection - Floors 1-2", + "priority": "LOW", + "category": "INSPECTION", + "status": "PENDING_PARTS", + "scheduled_date": "2025-08-11", + "scheduled_time": "14:00:00", + "estimated_duration": 180, + "assigned_technician": null, + "technician_skills": ["Plumbing", "Inspection_Certified"], + "team_size": 1, + "location_detail": "Floors 1-2, All Restroom Facilities", + "parts_required": [], + "tools_required": ["Inspection Camera", "Pressure Gauge"], + "vendor_involved": null, + "tenant_impact": "NONE", + "access_requirements": "Coordinate with tenants for restroom access", + "completion_percentage": 0, + "notes": "CONFLICT: All qualified technicians assigned to higher priority work", + "due_date": "2025-08-11T17:00:00Z", + "cost_estimate": 200.00, + "actual_cost": 0.00, + "conflict_reason": "NO_TECHNICIAN_AVAILABLE", + "suggested_reschedule": "2025-08-12T09:00:00Z" + } + ], + "resource_utilization": { + "technicians": { + "total_available": 8, + "currently_assigned": 6, + "utilization_percentage": 75, + "on_break": 1, + "off_duty": 1 + }, + "parts_inventory": { + "total_items_tracked": 150, + "items_in_stock": 142, + "critical_low_items": 2, + "items_on_order": 6 + }, + "vendor_coordination": { + "appointments_scheduled": 3, + "vendors_confirmed": 2, + "vendors_delayed": 1, + "emergency_vendors_on_call": 5 + }, + "budget_tracking": { + "daily_budget_planned": 3850.00, + "actual_spent": 2425.00, + "remaining_budget": 1425.00, + "utilization_percentage": 62.98 + } + } + } + }, + + "panel_3_asset_performance": { + "panel_id": "asset_performance_predictive_maintenance", + "title": "Asset Performance & Predictive Maintenance Intelligence", + "priority": 3, + "refresh_interval_seconds": 3600, + "data": { + "summary": { + "total_assets_monitored": 47, + "healthy_assets": 38, + "warning_assets": 6, + "critical_assets": 3, + "avg_portfolio_health": 84.2, + "ai_predictions_generated": 12, + "potential_cost_savings": 23400.00, + "energy_efficiency_avg": 87.5 + }, + "assets": [ + { + "asset_id": "AST-HVAC-B1-001", + "asset_name": "Main HVAC Unit - Building A", + "property_id": "PROP-DOWNTOWN-01", + "asset_type": "HVAC", + "location_detail": "Building A, Roof Level, Unit 1", + "current_status": "CRITICAL", + "health_score": 45, + "performance_trend": "DECLINING", + "efficiency_rating": 67.5, + "baseline_efficiency": 92.0, + "efficiency_variance": -24.5, + "energy_consumption_current": 285.7, + "energy_consumption_baseline": 220.3, + "energy_variance_pct": 29.7, + "temperature_reading": 78.5, + "pressure_reading": 12.8, + "vibration_level": 3.2, + "runtime_hours_today": 20.5, + "last_maintenance_date": "2025-07-15", + "next_scheduled_maintenance": "2025-09-15", + "predicted_failure_date": "2025-08-25", + "failure_probability": 78.5, + "predicted_failure_type": "Motor Bearing Wear", + "maintenance_priority": "IMMEDIATE", + "cost_impact_prediction": 8500.00, + "preventive_cost_estimate": 1200.00, + "roi_preventive_action": 7.08, + "anomaly_detected": true, + "anomaly_description": "Temperature 15°F above normal, vibration levels elevated for 3 consecutive days", + "anomaly_severity": "CRITICAL", + "recommended_action": "Schedule emergency motor bearing replacement within 48 hours", + "action_urgency": "IMMEDIATE", + "vendor_recommendation": "HVAC Solutions Inc", + "parts_required_prediction": [ + {"part": "Motor Bearing Assembly", "confidence": 90}, + {"part": "Cooling Fan", "confidence": 65} + ], + "ai_confidence_score": 92, + "warranty_status": "ACTIVE", + "compliance_status": "COMPLIANT", + "days_until_failure": 14 + }, + { + "asset_id": "AST-ELEV-001", + "asset_name": "Main Elevator - Building A", + "property_id": "PROP-DOWNTOWN-01", + "asset_type": "ELEVATORS", + "location_detail": "Building A, Main Elevator Bank", + "current_status": "WARNING", + "health_score": 72, + "performance_trend": "DECLINING", + "efficiency_rating": 89.0, + "baseline_efficiency": 95.0, + "energy_consumption_current": 145.2, + "energy_consumption_baseline": 130.0, + "energy_variance_pct": 11.7, + "runtime_hours_today": 16.0, + "last_maintenance_date": "2025-06-01", + "next_scheduled_maintenance": "2025-09-01", + "predicted_failure_date": "2025-11-15", + "failure_probability": 35.2, + "predicted_failure_type": "Cable Tension System", + "maintenance_priority": "MEDIUM", + "cost_impact_prediction": 12000.00, + "preventive_cost_estimate": 800.00, + "roi_preventive_action": 15.0, + "anomaly_detected": true, + "anomaly_description": "Energy consumption 12% above baseline, slight increase in travel time", + "anomaly_severity": "MODERATE", + "recommended_action": "Schedule comprehensive inspection within 4 weeks", + "action_urgency": "THIS_MONTH", + "vendor_recommendation": "Elevator Services Pro", + "ai_confidence_score": 78, + "warranty_status": "ACTIVE", + "compliance_status": "DUE_INSPECTION", + "days_until_failure": 96 + }, + { + "asset_id": "AST-HVAC-B2-002", + "asset_name": "Secondary HVAC - Building A", + "property_id": "PROP-DOWNTOWN-01", + "asset_type": "HVAC", + "location_detail": "Building A, Floor 10, Unit 2", + "current_status": "HEALTHY", + "health_score": 94, + "performance_trend": "IMPROVING", + "efficiency_rating": 96.0, + "baseline_efficiency": 92.0, + "energy_consumption_current": 195.8, + "energy_consumption_baseline": 210.0, + "energy_variance_pct": -6.8, + "temperature_reading": 68.2, + "runtime_hours_today": 18.0, + "last_maintenance_date": "2025-07-01", + "next_scheduled_maintenance": "2025-12-01", + "predicted_failure_date": "2026-06-15", + "failure_probability": 8.5, + "maintenance_priority": "LOW", + "cost_impact_prediction": 4500.00, + "preventive_cost_estimate": 400.00, + "roi_preventive_action": 11.25, + "anomaly_detected": false, + "recommended_action": "Continue standard monitoring, no intervention needed", + "action_urgency": "NEXT_QUARTER", + "ai_confidence_score": 95, + "warranty_status": "ACTIVE", + "compliance_status": "COMPLIANT", + "days_until_failure": 308 + } + ], + "portfolio_intelligence": { + "total_assets": 47, + "ai_predictions": 12, + "cost_savings_potential": 23400.00, + "energy_efficiency_trend": "IMPROVING", + "compliance_percentage": 94.0, + "inspections_due_30_days": 3, + "predictive_maintenance_opportunities": 8, + "emergency_repairs_prevented": 2 + }, + "ai_insights": { + "top_recommendation": "Prioritize HVAC-B1-001 motor bearing replacement to prevent $8,500 emergency repair", + "cost_optimization": "Implementing all AI recommendations could save $23,400 in emergency repairs", + "efficiency_trend": "Portfolio energy efficiency improved 2.3% vs last month", + "risk_assessment": "3 assets require immediate attention, 6 assets trending toward issues" + } + } + } + } + } + \ No newline at end of file diff --git a/src/pages/dashboard/WorkOrders.tsx b/src/pages/dashboard/WorkOrders.tsx new file mode 100644 index 000000000..f8f47afc2 --- /dev/null +++ b/src/pages/dashboard/WorkOrders.tsx @@ -0,0 +1,458 @@ + +import React, { useState } from "react"; +import { + Tabs, + TabList, + Tab, + TabPanels, + TabPanel, + Accordion, + AccordionItem, + AccordionButton, + AccordionPanel, + AccordionIcon, + Box, + Text, + Flex, + SimpleGrid, + Divider, + Button, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + CardBody, + Card, + CardHeader, + Heading, + IconButton, + Badge, + Image, + Menu, + MenuButton, + MenuItem, + MenuList, + filter +} from "@chakra-ui/react"; +import { InfoOutlineIcon } from "@chakra-ui/icons"; + +type Summary = { + total_work_orders_today: number; + overdue_count: number; + in_progress_count: number; + scheduled_count: number; + technician_utilization_pct: number; + budget_utilized_today: number; + budget_planned_today: number; +}; + +type Part = { + part: string; + qty: number; + status: string; +}; + +type Order = { + current_status: "CRITICAL" | "WARNING" | "HEALTHY" | string; + work_order_id: string; + property_id: string; + title: string; + priority: string; + category: string; + status: string; + scheduled_date: string; + scheduled_time: string; + estimated_duration: number; + assigned_technician: string | null; + technician_skills: string[]; + technician_status?: string; + team_size: number; + location_detail: string; + parts_required: Part[]; + tools_required: string[]; + vendor_involved: string | null; + vendor_eta?: string; + tenant_impact: string; + access_requirements: string; + completion_percentage: number; + notes: string; + due_date: string; + cost_estimate: number; + actual_cost: number; + sla_status?: string; + overdue_hours?: number; + eta_completion?: string; + conflict_reason?: string; + suggested_reschedule?: string; +}; + +type WorkOrdersPanel = { + panel_id: string; + title: string; + priority: number; + refresh_interval_seconds: number; + data: { + summary: Summary; + work_orders: Order[]; + }; +}; + +type Props = { + panel: WorkOrdersPanel; +}; + +const WorkOrders = ({ panel }: Props) => { + const { summary, work_orders } = panel.data; + const [filterWorkOrdersData, setFilterWorkOrdersData] = useState(work_orders || []); + const [workSummarymodal, setWorkSummaryModal] = useState(false); + const handleWorkSummaryModalClose = () => setWorkSummaryModal(false); + const filterWorkOrders =(status:string)=>{ + if(status == 'ALL'){ + setFilterWorkOrdersData(work_orders) + }else{ + const filters = work_orders?.filter((data:any) => data.status == status) + setFilterWorkOrdersData(filters) + } + } + return ( + <> + + + + + + assignment + + + {panel.title} + + + + filter_list + } + variant='outline' + /> + + filterWorkOrders('ALL')}> + + Total Work Orders Today + {summary?.total_work_orders_today} + + + filterWorkOrders('OVERDUE')}> + Overdue{summary.overdue_count} + filterWorkOrders('IN_PROGRESS')}> + In Progress {summary.in_progress_count} + + filterWorkOrders('SCHEDULED')}> + Scheduled {summary.scheduled_count} + + + + + + + + Technician Utilization + 75% + + + Budget Utilized Today + $3,200 + + + Budget Planned Today + $3,850 + + + + + + {filterWorkOrdersData?.map((order:any) => ( + + + + + + {order.title} + {/* + {order.status} • Priority: {order.priority} + */} + {order?.status} + + + + + + + + {/* Responsive Grid like Summary */} + + + + Work Order ID + + {order.work_order_id} + + + + + Property ID + + {order.property_id} + + + + + Category + + {order.category} + + + + + Priority + + {order.priority} + + + + + Status + + {order.status} + + + + + Location + + {order.location_detail} + + + + + Scheduled + + + {order.scheduled_date} {order.scheduled_time} + + + + + + Technician + + + {order.assigned_technician ?? "Not Assigned"} + + + + + + Estimated Duration + + {order.estimated_duration} mins + + + + + Completion + + {order.completion_percentage}% + + + + + Cost Estimate + + ${order.cost_estimate} + + + + {/* Parts Required */} + {order.parts_required.length > 0 && ( + + + Parts Required + + {order.parts_required.map((p:any, i:any) => ( + + - {p.part} (x{p.qty}) [{p.status}] + + ))} + + )} + + {/* Notes */} + {order.notes && ( + + Notes: {order.notes} + + )} + + + ))} + {filterWorkOrdersData?.length == 0 && ( + No alerts found. + )} + + + + + + + + + + + Summary + + + + + + Total Work Orders Today + + {summary.total_work_orders_today} + + + + + Overdue + + + {summary.overdue_count} + + + + + + In Progress + + + {summary.in_progress_count} + + + + + + Scheduled + + + {summary.scheduled_count} + + + + + + Technician Utilization + + + {summary.technician_utilization_pct}% + + + + + + Budget Utilized Today + + + ${summary.budget_utilized_today.toLocaleString()} + + + + + + Budget Planned Today + + + ${summary.budget_planned_today.toLocaleString()} + + + + + + + + + ); +}; + +export default WorkOrders; + + \ No newline at end of file diff --git a/src/pages/dashboard/excutive-dashboard-components/AIDrivenStrategicInsightsPanel.tsx b/src/pages/dashboard/excutive-dashboard-components/AIDrivenStrategicInsightsPanel.tsx new file mode 100644 index 000000000..2670870ce --- /dev/null +++ b/src/pages/dashboard/excutive-dashboard-components/AIDrivenStrategicInsightsPanel.tsx @@ -0,0 +1,576 @@ +import React from "react"; +import { + Accordion, + AccordionItem, + AccordionButton, + AccordionPanel, + AccordionIcon, + Box, + Text, + Stack, + Badge, + Divider, + Heading, + SimpleGrid, + CardBody, + Card, + CardHeader, + Flex, +} from "@chakra-ui/react"; +import { CheckIcon } from "@chakra-ui/icons"; + +interface AIDrivenStrategicInsightsPanelProps { + data: any; +} +const StatBlock = ({ label, value }: { label: string; value: string | number;}) => ( + + + {label} + {value} + +); + +const AIDrivenStrategicInsightsPanel: React.FC = ({ data }) => { + if (!data) return null; + + const { + investment_optimization, + market_intelligence, + risk_analytics, + ai_executive_summary, + } = data.data; + + return ( + <> + + + + + + smart_toy + + + AI-Driven Strategic Insights & Predictive Analytics + + + + + + + {/* Investment Optimization */} + + + + + + + Investment Optimization + + {/* + { + investment_optimization?.portfolio_optimization_score + } + */} + + + + readiness_score + + {investment_optimization?.portfolio_optimization_score} + + + + + + + + {/* + Portfolio Optimization Score:{" "} + {investment_optimization?.portfolio_optimization_score} + */} + + + + AI Recommendations + + + + + {investment_optimization?.ai_recommendations.map( + (rec: any) => ( + + {rec.title} + + + + + {Object.entries(rec.financial_impact).map( + ([k, v]: any) => ( + char.toUpperCase())} value={v} /> + ) + )} + + + + Urgency + + {rec.urgency} + + + + Confidence + {rec.implementation_timeline} + + + + + + + ) + )} + + Predictive Maintenance ROI + + + + + + + + + + + + + + + {/* Market Intelligence */} + + + + + + + Market Intelligence + + {/* + { + investment_optimization?.portfolio_optimization_score + } + */} + + + + + + + + Market Trend Analysis + + + + {Object.entries( + market_intelligence?.market_trend_analysis ?? {} + ).map(([k, v]: any) => ( + + // + // {k.replace(/_/g, " ")}:{" "} + // {Array.isArray(v) ? v.join(", ") : v?.toString()} + // + ))} + + + + Competitive Intelligence + + + + {/* */} + + Advantages + + {market_intelligence?.competitive_intelligence.competitive_advantages.map((data: any, idx: number) => ( + + {data} + + ))} + + Threats + {market_intelligence?.competitive_intelligence.threats.map((data: any, idx: number) => ( + + {data} + + ))} + + + + + Expansion Opportunities + + {market_intelligence?.expansion_opportunities.map( + (op: any) => ( + + {op.market} + + + + + + Risk Level + + {op.risk_level} + + + + + ) + )} + + + + + + {/* Risk Analytics */} + + + + + + + Risk Analytics + + + + + + + + + + + + Portfolio Risk Modeling + + + + {risk_analytics?.portfolio_risk_modeling.stress_test_scenarios.map( + (s: any, i: number) => ( + + {s.scenario} + + + + + + {s.occupancy_impact_pct && ( + + )} + {s.revenue_at_risk && ( + + )} + + + Key Highlights + + {s.mitigation_strategies.map((data: any, idx: number) => ( + + {data} + + ))} + + + + + ) + )} + + + + + + Early Warning Indicators + + + + {risk_analytics?.early_warning_indicators.map( + (ind: any, i: number) => ( + + + + + + status + {ind.status } + + {/* */} + + + {/* */} + + status + {ind.status } + + + + + ) + )} + + + + + + + + {/* AI Executive Summary */} + + + + + + + AI Executive Summary + + + + + + + + {/* + Key Satisfaction Drivers + + {tenant_satisfaction?.key_satisfaction_drivers.map((data: any, idx: number) => ( + // {sk}: {sv} + + ))} + + */} + + Daily Executive Brief + {ai_executive_summary?.daily_executive_brief} + + + Strategic Priorities + + {ai_executive_summary?.strategic_priorities.map( + (p: any, i: number) => ( + + {p.priority} + + + + + + + ) + )} + + Board Talking Points + + + + {ai_executive_summary?.board_talking_points.map( + (data: string, i: number) => ( + + {data} + + ) + )} + + + + + + + + + + + ); +}; + +export default AIDrivenStrategicInsightsPanel; diff --git a/src/pages/dashboard/excutive-dashboard-components/FinancialHealth.tsx b/src/pages/dashboard/excutive-dashboard-components/FinancialHealth.tsx new file mode 100644 index 000000000..b8363b149 --- /dev/null +++ b/src/pages/dashboard/excutive-dashboard-components/FinancialHealth.tsx @@ -0,0 +1,1079 @@ +import React from 'react'; +import { + Box, + Card, + CardHeader, + CardBody, + Heading, + Accordion, + AccordionItem, + AccordionButton, + AccordionPanel, + AccordionIcon, + Badge, + SimpleGrid, + Stat, + StatLabel, + StatNumber, + StatHelpText, + StatArrow, + VStack, + HStack, + Text, + Progress, + Divider, + Alert, + AlertIcon, + AlertTitle, + AlertDescription, + Flex, + Icon, +} from '@chakra-ui/react'; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + PieChart, + Pie, + Cell, + ResponsiveContainer, + LineChart, + Line +} from 'recharts'; +import { TooltipProps } from 'recharts'; +import { WarningIcon } from '@chakra-ui/icons'; +interface FinancialHealthProps { + data: { + title: string; + data: { + real_time_performance: any; + budget_variance_analysis: any; + risk_assessment: any; + investment_performance: any; + ai_financial_insights: any; + }; + }; +} +const StatBlock = ({ label, value, help }: { label: string; value: string | number; help?: string }) => ( + + {label} + {value} + +); + +const CustomTooltip = ({ active, payload, label }: TooltipProps) => { + if (active && payload && payload.length) { + return ( +
+

{label}

+ {payload.map((entry, index) => ( +

+ {entry.name}: ${entry.value}M +

+ ))} +
+ ); + } + return null; +}; +const formatCompactCurrency = (amount: number) => { + if (amount >= 1000000) { + return `$${(amount / 1000000).toFixed(1)}M`; + } else if (amount >= 1000) { + return `$${(amount / 1000).toFixed(0)}K`; + } + return formatCurrency(amount); +}; +const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(amount); +}; +const FinancialHealth: React.FC = ({ data }) => { + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(amount); + }; + + const getStatusColor = (status: string) => { + switch (status) { + case 'OVER_BUDGET': return 'red'; + case 'UNDER_UTILIZED': return 'orange'; + case 'SLIGHTLY_OVER': return 'yellow'; + case 'FAVORABLE': return 'green'; + default: return 'gray'; + } + }; + + const getTrendColor = (trend: string) => { + switch (trend) { + case 'POSITIVE': return 'green'; + case 'FAVORABLE': return 'green'; + case 'NEGATIVE': return 'red'; + default: return 'gray'; + } + }; + + const getRiskColor = (category: string) => { + switch (category) { + case 'LOW': return 'green'; + case 'MEDIUM': return 'yellow'; + case 'HIGH': return 'red'; + default: return 'gray'; + } + }; + + // Chart data preparations + const performanceData = [ + { + name: 'Portfolio NOI', + current: data.data.real_time_performance.portfolio_noi.current / 1000000, + budget: data.data.real_time_performance.portfolio_noi.budget / 1000000, + }, + { + name: 'Total Revenue', + current: data.data.real_time_performance.total_revenue.current / 1000000, + budget: data.data.real_time_performance.total_revenue.budget / 1000000, + }, + { + name: 'Operating Expenses', + current: data.data.real_time_performance.operating_expenses.current / 1000000, + budget: data.data.real_time_performance.operating_expenses.budget / 1000000, + }, + ]; + + const riskData = [ + { name: 'Low Risk', value: 75, color: '#48BB78' }, + { name: 'Medium Risk', value: 20, color: '#ED8936' }, + { name: 'High Risk', value: 5, color: '#F56565' }, + ]; + + const budgetVarianceData = Object.entries(data.data.budget_variance_analysis).map(([key, value]: [string, any]) => ({ + name: key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()), + variance: value.variance_pct || 0, + status: value.status, + })); + + return ( + + + + + + planner_review + + + {data?.title} + + + + + + + + {/* Portfolio Value Card */} + + + + {/* */} + + finance_mode + + + + Portfolio Value + + + {/* */} + + {formatCompactCurrency(data.data.investment_performance.portfolio_value)} + + + +{data.data.investment_performance.value_appreciation_ytd_pct}% YTD + + + + {/* */} + + + + + {/* Occupancy Rate Card */} + + + + {/* */} + + work + + + + Occupancy Rate + + + {/* */} + + {data.data.real_time_performance.occupancy_rate_pct}% + + + {data.data.real_time_performance.occupancy_change_pct}% vs Q3 + + + + {/* */} + + + + + {/* Monthly Maintenance Cost Card */} + + + + {/* */} + + attach_money + + + + Monthly Maintenance Cost + + + {/* */} + + {formatCompactCurrency(data.data.budget_variance_analysis.maintenance_costs.actual)} + + + {data.data.budget_variance_analysis.maintenance_costs.variance_pct}% vs Budget + + + + {/* */} + + + + + {/* Risk Score Card */} + + + + {/* */} + + readiness_score + + + + Risk Score + + + {/* */} + + {data.data.risk_assessment.portfolio_risk_score} + + + Risk: {data.data.risk_assessment.risk_category} + + + + {/* */} + + + + + + + {/* Real-Time Performance */} + + + + + + + Real-Time Performance + + + + + + + + + + Portfolio NOI + + {formatCurrency(data.data.real_time_performance.portfolio_noi.current)} + + + + {data.data.real_time_performance.portfolio_noi.variance_pct}% vs budget + + + + + Total Revenue + + {formatCurrency(data.data.real_time_performance.total_revenue.current)} + + + + {data.data.real_time_performance.total_revenue.variance_pct}% vs budget + + + + + ROI Percentage + + {data.data.real_time_performance.roi_percentage}% + + Excellent + + + + Occupancy Rate + + {data.data.real_time_performance.occupancy_rate_pct}% + + + + {data.data.real_time_performance.occupancy_change_pct}% change + + + + + + + Performance vs Budget (in Millions) + + + + + + + {/* [`$${value}M`, '']} /> */} + } /> + + + + + + + + + {/* Budget Variance Analysis */} + + + + Budget Variance Analysis + + + + + + {Object.entries(data.data.budget_variance_analysis).map(([key, value]: [string, any]) => ( + + + + + + {key.replace(/_/g, ' ')} + + + {value.status?.replace(/_/g, ' ')} + + + + + + + {/* */} + + + + Variance + 0 ? 'red.500' : 'green.500'} + > + {value.variance_pct > 0 ? '+' : ''}{value.variance_pct}% + + + + + {/* + Actual: + {formatCurrency(value.actual || value.utilized || 0)} + + + Budget: + {formatCurrency(value.budget || value.allocated || 0)} + + + Variance: + 0 ? 'red.500' : 'green.500'} + > + {value.variance_pct > 0 ? '+' : ''}{value.variance_pct}% + + */} + + {value.explanation && ( + + + + <> + + info + + + {value.explanation} + + + + + )} + + {/* */} + + + + ))} + + + + + Budget Variance by Category + + + + + + + [`${value}%`, 'Variance']} /> + {/* entry.variance > 0 ? '#F56565' : '#48BB78'} /> */} + + {budgetVarianceData.map((entry, index) => ( + 0 ? '#1e3a8a' : '#48BB78'} + /> + ))} + + + + + + + + + {/* Risk Assessment */} + + + + Risk Assessment + + + + + + + Portfolio Risk Score + + + {data.data.risk_assessment.portfolio_risk_score} + + + + {data.data.risk_assessment.risk_category} RISK + + + + + Compliance Status + + + {data.data.risk_assessment.compliance_percentage}% + + + + {data.data.risk_assessment.active_violations} active violations + + {/* */} + {/* + {data.data.risk_assessment.active_violations} active violations + */} + + + + Debt Coverage Ratio + {/* + {data.data.risk_assessment.debt_service_coverage_ratio}x + */} + + {data.data.risk_assessment.debt_service_coverage_ratio}x + + + + + Loan To Value Ratio + {/* + {data.data.risk_assessment.debt_service_coverage_ratio}x + */} + + {data.data.risk_assessment.loan_to_value_ratio}x + + + + + Audit Findings + {/* + {data.data.risk_assessment.debt_service_coverage_ratio}x + */} + + {data.data.risk_assessment.audit_findings} + + + + + Lease Expiry Risk + {/* + {data.data.risk_assessment.debt_service_coverage_ratio}x + */} + + {data.data.risk_assessment.lease_expiry_risk_12m_pct} + + + + + insurance claims Ytd + {/* + {data.data.risk_assessment.debt_service_coverage_ratio}x + */} + + {data.data.risk_assessment.insurance_claims_ytd} + + + + + LTV Ratio + {/* + {data.data.risk_assessment.loan_to_value_ratio}% + */} + + {data.data.risk_assessment.loan_to_value_ratio}% + + + + + Tenant Risk Score + {/* + {data.data.risk_assessment.tenant_payment_risk_score} + */} + + {data.data.risk_assessment.tenant_payment_risk_score} + + + + + Market Risk Score + {/* + {data.data.risk_assessment.tenant_payment_risk_score} + */} + + {data.data.risk_assessment.market_risk_score} + + + + + {/* + + Risk Distribution + + + + `${name} ${(percent * 100).toFixed(0)}%`} + outerRadius={80} + fill="#8884d8" + dataKey="value" + > + {riskData.map((entry, index) => ( + + ))} + + + + + */} + + + + {/* Investment Performance */} + + + + Investment Performance + + + + + + + Portfolio Value + + {formatCurrency(data.data.investment_performance.portfolio_value)} + + + + {data.data.investment_performance.value_appreciation_ytd_pct}% YTD + + + + + Monthly Cash Flow + + {formatCurrency(data.data.investment_performance.cash_flow_monthly)} + + Stable + + + + Cash Reserves + + {formatCurrency(data.data.investment_performance.cash_reserves)} + + + {data.data.investment_performance.days_cash_on_hand} days on hand + + + + + Market Position + + {data.data.investment_performance.market_position.replace(/_/g, ' ')} + + ESG Score: {data.data.investment_performance.esg_score} + + + + + + {/* AI Financial Insights */} + + + + AI Financial Insights + + + + + + {/* Cost Optimization Opportunities */} + + Cost Optimization Opportunities + + {data.data.ai_financial_insights.cost_optimization_opportunities.map((opp: any, index: number) => ( + + {opp.opportunity} + + + + + ROI Timeline + {opp.roi_years} years + + + Confidence + {/* {opp.confidence}% */} + {opp.confidence}% + + + + + {/* + Annual Savings: + {formatCurrency(opp.estimated_savings_annual)} + + + Implementation Cost: + {formatCurrency(opp.implementation_cost)} + */} + {/* + ROI Timeline: + {opp.roi_years} years + + + Confidence: + {opp.confidence}% + */} + + + ))} + + + + + + {/* Risk Predictions */} + + Risk Predictions + + {data.data.ai_financial_insights.risk_predictions.map((risk: any, index: number) => ( + + + + + {risk.risk_type} + + + + {risk.probability_pct}% probability + + + {formatCurrency(risk.impact_estimate)} impact + + + {risk.mitigation} + + {/* {alert.time} */} + + ))} + + {/* + {data.data.ai_financial_insights.risk_predictions.map((risk: any, index: number) => ( + + + + {risk.risk_type} + + + + {risk.probability_pct}% probability + + + {formatCurrency(risk.impact_estimate)} impact + + + {risk.mitigation} + + + + ))} + */} + + + + + {/* Market Opportunities */} + + Market Opportunities + {data.data.ai_financial_insights.market_opportunities.map((opp: any, index: number) => ( + + {opp.opportunity} + + + Revenue Potential + {formatCurrency(opp.revenue_potential_annual)} + annually + + + Market Gap + {opp.market_rent_gap_pct}% + + + Timeline + {opp.implementation_timeline} + + + + ))} + + + + + + + + + ); +}; + +export default FinancialHealth; \ No newline at end of file diff --git a/src/pages/dashboard/excutive-dashboard-components/OperationalEfficiencyPanel.tsx b/src/pages/dashboard/excutive-dashboard-components/OperationalEfficiencyPanel.tsx new file mode 100644 index 000000000..d9e58378b --- /dev/null +++ b/src/pages/dashboard/excutive-dashboard-components/OperationalEfficiencyPanel.tsx @@ -0,0 +1,351 @@ +// OperationalEfficiencyPanel.tsx +import React from "react"; +import { + Accordion, + AccordionItem, + AccordionButton, + AccordionPanel, + AccordionIcon, + Box, + Stat, + StatLabel, + StatNumber, + SimpleGrid, + Text, + VStack, + Divider, + Card, + CardBody, + CardHeader, + Flex, + Heading, + position, +} from "@chakra-ui/react"; +import { CheckIcon } from "@chakra-ui/icons"; + +const formatLabel = (label: string) => label.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); + +const StatCard = ({ label, value }: { label: string; value: any }) => ( +// +// +// {label} +// {value} +// +// + + + {formatLabel(label)} + + {value} + +); + +const OperationalEfficiencyPanel = ({ data }: any) => { + const panelData = data.data; + + return ( + <> + + + + + + manufacturing + + + {data?.title} + + {/* setWorkSummaryModal(true)}> */} + + + + + + + + + + + Portfolio Performance + + + + + + + + + + + {Object.entries(panelData.portfolio_performance).map(([k, v]) => ( + + ))} + + + + + {/* Resource Utilization */} + + + + + + Resource Utilization + + + + + + + {Object.entries(panelData.resource_utilization).map(([section, obj]) => ( + + + + + + {section.replace(/_/g, " ")} + + + + {Object.entries(obj as Record).map(([k, v]) => ( + + ))} + + + {/* {section.replace(/_/g, " ")} + + {Object.entries(obj as Record).map(([k, v]) => ( + + ))} + */} + + ))} + + + + {/* Cost Optimization */} + + + + + + Cost Optimization + + + + + + + {Object.entries(panelData.cost_optimization).map(([section, obj]) => ( + + + + {section.replace(/_/g, " ")} + + + + {Object.entries(obj as Record).map(([k, v]) => ( + + ))} + + + ))} + + + + {/* Performance Benchmarking */} + + + + + + + Performance Benchmarking + + + + + + + Industry Comparisons + + {Object.entries(panelData.performance_benchmarking.industry_comparisons).map(([k, obj]) => ( + + {k.replace(/_/g, " ")} + + {Object.entries(obj as Record).map(([sk, sv]) => ( + // {sk}: {sv} + + ))} + + + ))} + + Improvement Opportunities + {panelData.performance_benchmarking.improvement_opportunities.map((op:any, i:any) => ( + + {op.area} + + + + + + + + ))} + + + + {/* Operational Insights */} + + + + + + Operational Insights + + + + + + + + Efficiency Trends + + {panelData.operational_insights.efficiency_trends.map((t:any, i:any) => ( + + {t} + + ))} + + + + + Optimization Recommendations + {panelData.operational_insights.optimization_recommendations.map((r:any, i:any) => ( + + {r.recommendation} + + + + + + + + ))} + + + + + + + + + ); +} +export default OperationalEfficiencyPanel; \ No newline at end of file diff --git a/src/pages/dashboard/excutive-dashboard-components/StrategicKPIPerformancePanel.tsx b/src/pages/dashboard/excutive-dashboard-components/StrategicKPIPerformancePanel.tsx new file mode 100644 index 000000000..b8fa772f6 --- /dev/null +++ b/src/pages/dashboard/excutive-dashboard-components/StrategicKPIPerformancePanel.tsx @@ -0,0 +1,459 @@ +import { CalendarIcon, CheckIcon } from "@chakra-ui/icons"; +import { + Accordion, + AccordionItem, + AccordionButton, + AccordionPanel, + AccordionIcon, + Box, + Text, + VStack, + HStack, + Badge, + List, + ListItem, + Divider, + Stat, + StatLabel, + StatNumber, + StatHelpText, + Card, + CardBody, + CardHeader, + Heading, + Flex, + SimpleGrid, + Image + } from "@chakra-ui/react"; + + type Panel2Props = { + data: any; + }; + + const StatBlock = ({ label, value, help }: { label: string; value: string | number; help?: string }) => ( + // + // {label} + // {value} + // {help && {help}} + // + + {label} + {value} + {/* {help && {help}} */} + + ); + + export default function StrategicKPIPerformancePanel({ data }: Panel2Props) { + if (!data) return null; + + const { + operational_efficiency, + tenant_satisfaction, + market_positioning, + growth_metrics, + board_presentation_metrics, + } = data.data; + + return ( + + + + + + analytics + + + Strategic KPI Performance & Market Position + + + + + + + {/* Operational Efficiency */} + + + + + + + + Operational Efficiency + + + + + + + + + + + + Portfolio Wide Metrics + + + + + + + + + Rank VS Peers + {operational_efficiency?.portfolio_wide_metrics.rank_vs_peers } + + + + + + + + + + + Maintenance Efficiency + + + + + + + + + + + + + + + + + + Energy Performance + + + + + + + + + + + + + + + + + + {/* Tenant Satisfaction */} + + + + + + Tenant Satisfaction + + + + + + + + + + + + + Satisfaction Trend + + {tenant_satisfaction?.satisfaction_trend} + + + + + + Key Satisfaction Drivers + + {tenant_satisfaction?.key_satisfaction_drivers.map((data: any, idx: number) => ( + // {sk}: {sv} + + ))} + + + + + + {/* Market Positioning */} + + + + + + Market Positioning + + + + + + + + + + + + + + + + + + + + {/* Growth Metrics */} + + + + + + Growth Metrics + + + + + + + + + + + + + + + + + + {/* Board Presentation */} + + + + + + Board Presentation + + + + + + + + Key Highlights + + {board_presentation_metrics?.key_highlights.map((data: any, idx: number) => ( + + {data} + + ))} + + + + Areas of Focus + + {board_presentation_metrics?.areas_of_focus.map((data: any, idx: number) => ( + + {data} + + ))} + + + + Upcoming Milestones + + {board_presentation_metrics?.upcoming_milestones.map((data: any, idx: number) => ( + + {/* */} + + calendar_month + + {data.milestone} + {data.date} + + ))} + + + + + + + + + + ); + } + \ No newline at end of file diff --git a/src/pages/dashboard/excutive-dashboard-components/SustainabilityESGAccordion.tsx b/src/pages/dashboard/excutive-dashboard-components/SustainabilityESGAccordion.tsx new file mode 100644 index 000000000..2012979af --- /dev/null +++ b/src/pages/dashboard/excutive-dashboard-components/SustainabilityESGAccordion.tsx @@ -0,0 +1,190 @@ +import { CheckIcon, CloseIcon, InfoOutlineIcon } from "@chakra-ui/icons"; +import { + Accordion, + AccordionItem, + AccordionButton, + AccordionPanel, + AccordionIcon, + Box, + SimpleGrid, + Text, + VStack, + Badge, + Card, + CardBody, + CardHeader, + Flex, + Heading, +} from "@chakra-ui/react"; + +type SustainabilityESGData = { + environmental_performance: Record; + social_responsibility: Record; + governance_compliance: Record; + esg_ratings_benchmarks: Record; +}; + +const SustainabilityESGAccordion = ({ data }: any) => { + + const sections = [ + { key: "environmental_performance", title: "Environmental Performance" }, + { key: "social_responsibility", title: "Social Responsibility" }, + { key: "governance_compliance", title: "Governance & Compliance" }, + { key: "esg_ratings_benchmarks", title: "ESG Ratings & Benchmarks" }, + ]; + + // format metric labels + const formatLabel = (label: string) => label.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); + + // format metric values + const formatValue = (value: any) => { + if (typeof value === "boolean") { + return value ? ( + Yes + ) : ( + No + ); + } + if (typeof value === "number") { + return new Intl.NumberFormat("en-US").format(value); + } + if (Array.isArray(value)) { + return ( + + {value.map((v, i) => ( + + • {JSON.stringify(v)} + + ))} + + ); + } + if (typeof value === "object" && value !== null) { + return ( + + {Object.entries(value).map(([k, v]) => ( + // + // + // + // {formatLabel(k)} + // + // + // {formatValue(v)} + // + // + // + + + {formatLabel(k)} + + {formatValue(v)} + + ))} + + ); + } + return value.toString(); + }; + + // render section recursively + const renderSection = (obj: Record) => ( + + {Object.entries(obj).map(([key, value]) => ( + + + + {formatLabel(key)} + + + {formatValue(value)} + + ))} + + ); + + return ( + + + + + + nest_eco_leaf + + + {data.title} + + {/* setWorkSummaryModal(true)}> */} + + + + + + {sections.map((section) => ( + + + + + + {section?.title} + + + + + + + + {renderSection( + data.data[section.key as keyof SustainabilityESGData] + )} + + + ))} + + + + + ); +}; + +export default SustainabilityESGAccordion; diff --git a/src/pages/gallery/Gallery.tsx b/src/pages/gallery/Gallery.tsx new file mode 100644 index 000000000..64ab4dd98 --- /dev/null +++ b/src/pages/gallery/Gallery.tsx @@ -0,0 +1,631 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Box, + Button, + Checkbox, + Chip, + Dialog, + DialogContent, + DialogTitle, + Divider, + IconButton, + InputAdornment, + Paper, + Stack, + TextField, + ToggleButton, + ToggleButtonGroup, + Tooltip, + Typography, +} from "@mui/material"; +import { + Close as CloseIcon, + Delete as DeleteIcon, + DownloadOutlined as DownloadIcon, + Search as SearchIcon, + ZoomOutMap as ZoomOutMapIcon, + AssignmentTurnedIn as AssignIcon, + AssignmentReturn as UnassignIcon, + Upload as UploadIcon, +} from "@mui/icons-material"; + +type GalleryImage = { + id: string; + name: string; + src: string; + isObjectUrl?: boolean; + status: "assigned" | "unassigned"; +}; + +const seedImages: GalleryImage[] = [ + { + id: "seed-alert", + name: "alert.svg", + src: "/images/alert.svg", + status: "assigned", + }, + +]; + +const Gallery: React.FC = () => { + const [images, setImages] = useState(seedImages); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [searchTerm, setSearchTerm] = useState(""); + const [activeFilter, setActiveFilter] = + useState<"assigned" | "unassigned" | "all">("assigned"); + const [previewImage, setPreviewImage] = useState(null); + const objectUrlsRef = useRef>(new Set()); + + useEffect( + () => () => { + objectUrlsRef.current.forEach((url) => URL.revokeObjectURL(url)); + objectUrlsRef.current.clear(); + }, + [] + ); + + useEffect(() => { + setSelectedIds((prev) => { + const next = new Set(); + images.forEach((image) => { + if (prev.has(image.id)) { + next.add(image.id); + } + }); + return next.size === prev.size ? prev : next; + }); + }, [images]); + + const handleUpload: React.ChangeEventHandler = (event) => { + const files = event.target.files; + if (!files || files.length === 0) return; + + const nextImages: GalleryImage[] = []; + Array.from(files).forEach((file) => { + const objectUrl = URL.createObjectURL(file); + objectUrlsRef.current.add(objectUrl); + nextImages.push({ + id: `upload-${crypto.randomUUID()}`, + name: file.name, + src: objectUrl, + isObjectUrl: true, + status: "unassigned", + }); + }); + + setImages((prev) => [...prev, ...nextImages]); + event.target.value = ""; + }; + + const toggleSelection = useCallback((imageId: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(imageId)) { + next.delete(imageId); + } else { + next.add(imageId); + } + return next; + }); + }, []); + + const handleImageClick = (image: GalleryImage) => { + setPreviewImage(image); + }; + + const handleDeleteSelected = () => { + if (selectedIds.size === 0) return; + + setImages((prev) => { + const remaining = prev.filter((image) => { + if (!selectedIds.has(image.id)) { + return true; + } + if (image.isObjectUrl) { + URL.revokeObjectURL(image.src); + objectUrlsRef.current.delete(image.src); + } + return false; + }); + return remaining; + }); + setSelectedIds(new Set()); + }; + + const handlePreviewClose = () => { + setPreviewImage(null); + }; + + const filteredImages = useMemo(() => { + const term = searchTerm.trim().toLowerCase(); + return images.filter((image) => { + const matchesTerm = term ? image.name.toLowerCase().includes(term) : true; + const matchesFilter = + activeFilter === "all" ? true : image.status === activeFilter; + return matchesTerm && matchesFilter; + }); + }, [images, searchTerm, activeFilter]); + + const selectedImages = useMemo( + () => images.filter((image) => selectedIds.has(image.id)), + [images, selectedIds] + ); + + const clearSelection = () => setSelectedIds(new Set()); + + const handleFilterChange = useCallback( + ( + _event: React.MouseEvent, + nextValue: "assigned" | "unassigned" | "all" | null + ) => { + if (!nextValue) return; + setActiveFilter(nextValue); + }, + [] + ); + + const handleDownloadSelected = () => { + selectedImages.forEach((image) => { + const link = document.createElement("a"); + link.href = image.src; + link.download = image.name; + link.rel = "noopener"; + link.target = "_blank"; + link.click(); + }); + }; + + const updateStatusForSelected = (status: GalleryImage["status"]) => { + if (selectedIds.size === 0) return; + setImages((prev) => + prev.map((image) => + selectedIds.has(image.id) ? { ...image, status } : image + ) + ); + clearSelection(); + }; + + const assignedCount = useMemo( + () => images.filter((image) => image.status === "assigned").length, + [images] + ); + + const unassignedCount = useMemo( + () => images.filter((image) => image.status === "unassigned").length, + [images] + ); + + return ( + + + theme.palette.mode === "light" + ? "rgba(255,255,255,0.9)" + : "rgba(18,18,18,0.9)", + backdropFilter: "blur(14px)", + }} + > + + + setSearchTerm(event.target.value)} + placeholder="Search by name" + size="small" + InputProps={{ + startAdornment: ( + + + + ), + }} + sx={{ + width: { xs: "100%", sm: 220, md: 240 }, + }} + /> + + + + + + Assigned + + + + + + + + Unassigned + + + + + + + + All + + + + + + + + + + + + + + + {selectedIds.size > 0 && ( + + + {selectedIds.size} {selectedIds.size === 1 ? "item" : "items"} selected + + + + + {(activeFilter === "unassigned" || activeFilter === "all") && ( + + )} + {(activeFilter === "assigned" || activeFilter === "all") && ( + + )} + + + + )} + + + {filteredImages.map((image) => { + const isSelected = selectedIds.has(image.id); + return ( + + isSelected + ? `2px solid ${theme.palette.primary.main}` + : `1px solid ${theme.palette.divider}`, + boxShadow: isSelected ? 6 : 1, + transition: "box-shadow 160ms ease, transform 160ms ease", + cursor: "pointer", + "&:hover": { + transform: "scale(1.01)", + boxShadow: 6, + }, + "&:hover .gallery-overlay": { + opacity: 1, + pointerEvents: "auto", + }, + }} + onClick={() => handleImageClick(image)} + > + *": { + pointerEvents: "auto", + }, + }} + > + event.stopPropagation()} + onChange={() => toggleSelection(image.id)} + sx={{ + backgroundColor: "rgba(255,255,255,0.75)", + borderRadius: "50%", + }} + /> + { + event.stopPropagation(); + handleImageClick(image); + }} + sx={{ + backgroundColor: "rgba(255,255,255,0.85)", + }} + > + + + + + + + {/* {image.name} */} + + + + + ); + })} + {filteredImages.length === 0 && ( + + + No images found + + + Try adjusting the filters or uploading new images. + + + )} + + + + {previewImage && ( + <> + + + {previewImage.name} + + + + + + + + + + )} + + + ); +}; + +export default Gallery; diff --git a/src/pages/gallery/index.ts b/src/pages/gallery/index.ts new file mode 100644 index 000000000..694336a3d --- /dev/null +++ b/src/pages/gallery/index.ts @@ -0,0 +1,2 @@ +export { default } from "./Gallery"; + diff --git a/src/pages/master-data/MasterData.tsx b/src/pages/master-data/MasterData.tsx new file mode 100644 index 000000000..3e5052e17 --- /dev/null +++ b/src/pages/master-data/MasterData.tsx @@ -0,0 +1,223 @@ +import React, { useState } from "react"; +import { + Box, + Button, + Card, + Typography, + IconButton, + Tooltip, + Tabs, + Tab, +} from "@mui/material"; +import { DataGrid, GridColDef } from "@mui/x-data-grid"; +import { useQuery, useMutation } from "@apollo/client/react"; +import { Plus, Edit, Trash2 } from "lucide-react"; +import { showToast } from "../../components/toastService"; +import { useLoader } from "../../context/LoaderContext"; +import MasterDataModal from "./MasterDataModal"; +import * as Queries from "../../graphql/queries"; +import * as Mutations from "../../graphql/mutations"; + +// Type definition for query data +interface MasterDataQueryResult { + [key: string]: any[]; +} + +const ENTITY_TYPES = [ + { value: "masterAssetCategories", label: "Asset Categories" }, + { value: "masterAssetPartFields", label: "Asset Part Fields" }, + { value: "masterAssetParts", label: "Asset Parts" }, + { value: "masterAssetServiceTypes", label: "Asset Service Types" }, + { value: "masterAssetTypeFields", label: "Asset Type Fields" }, + { value: "masterAssetTypes", label: "Asset Types" }, + { value: "masterManufacturers", label: "Manufacturers" }, + { value: "masterVendors", label: "Vendors" }, +]; + +const QUERY_MAP: any = { + masterAssetCategories: Queries.MASTER_ASSET_CATEGORIES_QUERY, + masterAssetPartFields: Queries.MASTER_ASSET_PART_FIELDS_QUERY, + masterAssetParts: Queries.MASTER_ASSET_PARTS_QUERY, + masterAssetServiceTypes: Queries.MASTER_ASSET_SERVICE_TYPES_QUERY, + masterAssetTypeFields: Queries.MASTER_ASSET_TYPE_FIELDS_QUERY, + masterAssetTypes: Queries.MASTER_ASSET_TYPES_QUERY, + masterManufacturers: Queries.MASTER_MANUFACTURERS_QUERY, + masterVendors: Queries.MASTER_VENDORS_QUERY, +}; + +const DELETE_MUTATION_MAP: any = { + masterAssetCategories: Mutations.DELETE_MASTER_ASSET_CATEGORY_MUTATION, + // masterAssetPartFields: Mutations.DELETE_MASTER_ASSET_PART_FIELD_MUTATION, + masterAssetParts: Mutations.DELETE_MASTER_ASSET_PART_MUTATION, + masterAssetServiceTypes: Mutations.DELETE_MASTER_ASSET_SERVICE_TYPE_MUTATION, + // masterAssetTypeFields: Mutations.DELETE_MASTER_ASSET_TYPE_FIELD_MUTATION, + masterAssetTypes: Mutations.DELETE_MASTER_ASSET_TYPE_MUTATION, + masterManufacturers: Mutations.DELETE_MASTER_MANUFACTURER_MUTATION, + masterVendors: Mutations.DELETE_MASTER_VENDOR_MUTATION, +}; + +const MasterData = () => { + const [selectedEntity, setSelectedEntity] = useState(ENTITY_TYPES[0].value); + const [isModalOpen, setIsModalOpen] = useState(false); + const [selectedItem, setSelectedItem] = useState(null); + + const { showLoader, hideLoader } = useLoader(); + + const { data, loading, refetch } = useQuery(QUERY_MAP[selectedEntity], { + fetchPolicy: "network-only", + }); + + const [deleteMutation] = useMutation(DELETE_MUTATION_MAP[selectedEntity]); + + const handleDelete = async (id: string) => { + if (window.confirm("Are you sure you want to delete this item?")) { + showLoader(); + try { + await deleteMutation({ + variables: { input: { id } }, + }); + showToast({ status: "success", title: "Deleted successfully" }); + refetch(); + } catch (error: any) { + console.error("Error deleting:", error); + showToast({ status: "error", title: "Failed to delete" }); + } finally { + hideLoader(); + } + } + }; + + const handleEdit = (item: any) => { + setSelectedItem(item); + setIsModalOpen(true); + }; + + const handleCreate = () => { + setSelectedItem(null); + setIsModalOpen(true); + }; + + const getColumns = (): GridColDef[] => { + const baseColumns: GridColDef[] = [ + { field: "id", headerName: "ID", width: 90 }, + { field: "name", headerName: "Name", flex: 1 }, + { field: "description", headerName: "Description", flex: 1 }, + ]; + + // Add specific columns based on entity type if needed + if (selectedEntity === "masterAssetCategories") { + baseColumns.push( + { field: "icon_name", headerName: "Icon", width: 120 }, + { field: "is_default", headerName: "Default", width: 100, type: "boolean" } + ); + } else if (selectedEntity === "masterAssetPartFields" || selectedEntity === "masterAssetTypeFields") { + baseColumns.push( + { field: "field_name", headerName: "Field Name", flex: 1 }, + { field: "field_type", headerName: "Type", width: 120 }, + { field: "is_required", headerName: "Required", width: 100, type: "boolean" } + ); + } else if (selectedEntity === "masterManufacturers") { + baseColumns.push( + { field: "company_name", headerName: "Company", flex: 1 }, + { field: "contact_person", headerName: "Contact", flex: 1 } + ); + } else if (selectedEntity === "masterVendors") { + baseColumns.push( + { field: "company_name", headerName: "Company", flex: 1 }, + { field: "vendor_type", headerName: "Type", width: 150 } + ); + } + + baseColumns.push({ + field: "actions", + headerName: "Actions", + width: 120, + renderCell: (params) => ( + + + handleEdit(params.row)} size="small"> + + + + + handleDelete(params.row.id)} size="small" color="error"> + + + + + ), + }); + + return baseColumns; + }; + + const rows = data ? data[selectedEntity] || [] : []; + + return ( + + + + Master Data Management + + + + + + setSelectedEntity(newValue)} + variant="scrollable" + scrollButtons="auto" + sx={{ + borderBottom: 1, + borderColor: 'divider', + '& .MuiTab-root': { + textTransform: 'none', + minWidth: 120, + fontWeight: 500, + }, + }} + > + {ENTITY_TYPES.map((type) => ( + + ))} + + + + + + + + {isModalOpen && ( + setIsModalOpen(false)} + entityType={selectedEntity} + data={selectedItem} + onSave={refetch} + /> + )} + + ); +}; + +export default MasterData; diff --git a/src/pages/master-data/MasterDataModal.tsx b/src/pages/master-data/MasterDataModal.tsx new file mode 100644 index 000000000..e3fdeb33b --- /dev/null +++ b/src/pages/master-data/MasterDataModal.tsx @@ -0,0 +1,288 @@ +import React, { useEffect, useState } from "react"; +import { + Box, + Button, + TextField, + Grid, + MenuItem, + FormControlLabel, + Checkbox, +} from "@mui/material"; +import { useMutation } from "@apollo/client/react"; +import { useLoader } from "../../context/LoaderContext"; +import { showToast } from "../../components/toastService"; +import RightSideModal from "../../components/RightSideModal"; +import * as Mutations from "../../graphql/mutations"; + +interface MasterDataModalProps { + open: boolean; + onClose: () => void; + entityType: string; + data?: any; + onSave: () => void; +} + +// Helper function to convert camelCase to snake_case +const camelToSnake = (obj: any): any => { + if (obj === null || obj === undefined) return obj; + if (typeof obj !== 'object') return obj; + if (Array.isArray(obj)) return obj.map(camelToSnake); + + const snakeObj: any = {}; + Object.keys(obj).forEach(key => { + const snakeKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`); + snakeObj[snakeKey] = camelToSnake(obj[key]); + }); + return snakeObj; +}; + +const ENTITY_CONFIG: any = { + masterAssetCategories: { + createMutation: Mutations.CREATE_MASTER_ASSET_CATEGORY_MUTATION, + updateMutation: Mutations.UPDATE_MASTER_ASSET_CATEGORY_MUTATION, + fields: [ + { name: "name", label: "Name", type: "text", required: true }, + { name: "description", label: "Description", type: "text", multiline: true }, + { name: "iconName", label: "Icon Name", type: "text" }, + { name: "iconColor", label: "Icon Color", type: "text" }, + { name: "iconType", label: "Icon Type", type: "text" }, + { name: "isDefault", label: "Is Default", type: "checkbox" }, + ], + }, + masterAssetPartFields: { + // createMutation: Mutations.CREATE_MASTER_ASSET_PART_FIELD_MUTATION, + // updateMutation: Mutations.UPDATE_MASTER_ASSET_PART_FIELD_MUTATION, + fields: [ + { name: "assetPartId", label: "Asset Part ID", type: "text", required: true }, + { name: "parentId", label: "Parent ID", type: "text" }, + { name: "fieldName", label: "Field Name", type: "text", required: true }, + { name: "fieldType", label: "Field Type", type: "select", options: ["text", "number", "date", "boolean", "select"] }, + { name: "allowedValues", label: "Allowed Values (comma separated)", type: "text" }, + { name: "isRequired", label: "Is Required", type: "checkbox" }, + { name: "displayOrder", label: "Display Order", type: "number" }, + { name: "showInPanel", label: "Show In Panel", type: "checkbox" }, + ], + }, + masterAssetParts: { + createMutation: Mutations.CREATE_MASTER_ASSET_PART_MUTATION, + updateMutation: Mutations.UPDATE_MASTER_ASSET_PART_MUTATION, + fields: [ + { name: "name", label: "Name", type: "text", required: true }, + { name: "description", label: "Description", type: "text", multiline: true }, + { name: "assetTypeId", label: "Asset Type ID", type: "text", required: true }, + ], + }, + masterAssetServiceTypes: { + createMutation: Mutations.CREATE_MASTER_ASSET_SERVICE_TYPE_MUTATION, + updateMutation: Mutations.UPDATE_MASTER_ASSET_SERVICE_TYPE_MUTATION, + fields: [ + { name: "name", label: "Name", type: "text", required: true }, + { name: "description", label: "Description", type: "text", multiline: true }, + ], + }, + masterAssetTypeFields: { + // createMutation: Mutations.CREATE_MASTER_ASSET_TYPE_FIELD_MUTATION, + // updateMutation: Mutations.UPDATE_MASTER_ASSET_TYPE_FIELD_MUTATION, + fields: [ + { name: "assetTypeId", label: "Asset Type ID", type: "text", required: true }, + { name: "parentFieldId", label: "Parent Field ID", type: "text" }, + { name: "fieldName", label: "Field Name", type: "text", required: true }, + { name: "fieldType", label: "Field Type", type: "select", options: ["text", "number", "date", "boolean", "select"] }, + { name: "allowedValues", label: "Allowed Values (comma separated)", type: "text" }, + { name: "isRequired", label: "Is Required", type: "checkbox" }, + { name: "displayOrder", label: "Display Order", type: "number" }, + { name: "showInPanel", label: "Show In Panel", type: "checkbox" }, + ], + }, + masterAssetTypes: { + createMutation: Mutations.CREATE_MASTER_ASSET_TYPE_MUTATION, + updateMutation: Mutations.UPDATE_MASTER_ASSET_TYPE_MUTATION, + fields: [ + { name: "name", label: "Name", type: "text", required: true }, + { name: "description", label: "Description", type: "text", multiline: true }, + { name: "assetCategoryId", label: "Asset Category ID", type: "text", required: true }, + { name: "isDefault", label: "Is Default", type: "checkbox" }, + { name: "iconName", label: "Icon Name", type: "text" }, + { name: "iconColor", label: "Icon Color", type: "text" }, + { name: "iconType", label: "Icon Type", type: "text" }, + ], + }, + masterManufacturers: { + createMutation: Mutations.CREATE_MASTER_MANUFACTURER_MUTATION, + updateMutation: Mutations.UPDATE_MASTER_MANUFACTURER_MUTATION, + fields: [ + { name: "name", label: "Name", type: "text", required: true }, + { name: "description", label: "Description", type: "text", multiline: true }, + { name: "companyName", label: "Company Name", type: "text" }, + { name: "phoneNumber", label: "Phone Number", type: "text" }, + { name: "countryCode", label: "Country Code", type: "text" }, + { name: "contactPerson", label: "Contact Person", type: "text" }, + ], + }, + masterVendors: { + createMutation: Mutations.CREATE_MASTER_VENDOR_MUTATION, + updateMutation: Mutations.UPDATE_MASTER_VENDOR_MUTATION, + fields: [ + { name: "name", label: "Name", type: "text", required: true }, + { name: "description", label: "Description", type: "text", multiline: true }, + { name: "companyId", label: "Company ID", type: "text" }, + { name: "companyName", label: "Company Name", type: "text" }, + { name: "phoneNumber", label: "Phone Number", type: "text" }, + { name: "countryCode", label: "Country Code", type: "text" }, + { name: "vendorType", label: "Vendor Type", type: "text" }, + { name: "canLogin", label: "Can Login", type: "checkbox" }, + { name: "invitedByUser", label: "Invited By User", type: "text" }, + ], + }, +}; + +const MasterDataModal: React.FC = ({ + open, + onClose, + entityType, + data, + onSave, +}) => { + const config = ENTITY_CONFIG[entityType]; + const [formData, setFormData] = useState({}); + const { showLoader, hideLoader } = useLoader(); + + const [createMutation] = useMutation(config?.createMutation); + const [updateMutation] = useMutation(config?.updateMutation); + + useEffect(() => { + if (data) { + setFormData(data); + } else { + setFormData({}); + } + }, [data, entityType]); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value, type, checked } = e.target; + setFormData((prev: any) => ({ + ...prev, + [name]: type === "checkbox" ? checked : value, + })); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + showLoader(); + + try { + let input = { ...formData }; + + // Clean up fields based on type + config.fields.forEach((field: any) => { + if (field.type === 'number' && input[field.name]) { + input[field.name] = Number(input[field.name]); + } + }); + + // Remove __typename and other non-input fields + delete input.__typename; + delete input.createdAt; + delete input.updatedAt; + delete input.created_at; + delete input.updated_at; + delete input.deletedAt; + delete input.deleted_at; + + // Convert camelCase to snake_case for backend + input = camelToSnake(input); + + if (data?.id) { + await updateMutation({ + variables: { + input: { + id: data.id, + ...input, + }, + }, + }); + showToast({ status: "success", title: "Updated successfully" }); + } else { + await createMutation({ + variables: { + input, + }, + }); + showToast({ status: "success", title: "Created successfully" }); + } + onSave(); + onClose(); + } catch (error: any) { + console.error("Error saving:", error); + const errorMessage = + error?.graphQLErrors?.[0]?.message || + error?.message || + "Failed to save"; + showToast({ status: "error", title: errorMessage }); + } finally { + hideLoader(); + } + }; + + if (!config) return null; + + return ( + + + + {config.fields.map((field: any) => ( + + {field.type === "checkbox" ? ( + + } + label={field.label} + /> + ) : ( + + {field.type === "select" && + field.options.map((option: string) => ( + + {option} + + ))} + + )} + + ))} + + + + + + + + ); +}; + +export default MasterDataModal; diff --git a/src/pages/masterData/assetCategory/AssetCategory.tsx b/src/pages/masterData/assetCategory/AssetCategory.tsx new file mode 100644 index 000000000..f6009d84d --- /dev/null +++ b/src/pages/masterData/assetCategory/AssetCategory.tsx @@ -0,0 +1,295 @@ +import React, { useState, useEffect, useMemo } from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import IconButton from "@mui/material/IconButton"; +import InputAdornment from "@mui/material/InputAdornment"; +import TextField from "@mui/material/TextField"; +import { ThemeProvider } from "@mui/material/styles"; +import { + DataGrid, + GridColDef, + GridPaginationModel, + GridRenderCellParams, +} from "@mui/x-data-grid"; +import { useLazyQuery, useMutation } from "@apollo/client/react"; +import AssetCategoryModal from "./AssetCategoryModal"; +import RightSideModal from "../../../components/RightSideModal"; +import { showToast } from "../../../components/toastService"; +import { useLoader } from "../../../context/LoaderContext"; +import muiTheme from "../../../themes/muiTheme"; +import { Search, X, Pencil, Trash2, Plus } from "lucide-react"; +import { AssetIcon, IconType } from "../../../components/assetIcons"; +import { ASSET_CATEGORIES_QUERY } from "../../../graphql/queries"; +import { DELETE_ASSET_CATEGORY_MUTATION } from "../../../graphql/mutations"; + +const AssetCategory = () => { + const [filter, setFilter] = useState(""); + const [data, setData] = useState([]); + const [modalOpen, setModalOpen] = useState(false); + const [modalMode, setModalMode] = useState<"add" | "edit">("add"); + const [selectedCategory, setSelectedCategory] = useState(null); + const [paginationModel, setPaginationModel] = useState({ + page: 0, + pageSize: 10, + }); + const { showLoader, hideLoader } = useLoader(); + const [fetchAssetCategories, { loading: queryLoading, refetch }] = useLazyQuery(ASSET_CATEGORIES_QUERY); + const [hasFetched, setHasFetched] = useState(false); + const [deleteAssetCategory] = useMutation(DELETE_ASSET_CATEGORY_MUTATION, { + refetchQueries: [{ query: ASSET_CATEGORIES_QUERY }], + }); + + // Fetch asset categories from GraphQL API + const getAssetCategories = async () => { + try { + showLoader(); + + // Use refetch if query has been executed before, otherwise use fetchAssetCategories + let queryData, error; + if (hasFetched && refetch) { + const result = await refetch(); + queryData = result.data; + error = result.error; + } else { + const result = await fetchAssetCategories(); + queryData = result.data; + error = result.error; + setHasFetched(true); + } + + if (error) { + console.error("GraphQL error:", error); + // showToast({ + // status: "error", + // title: "Failed to fetch asset categories.", + // }); + setData([]); + return; + } + + const resultData = (queryData as any)?.masterAssetCategories; + const normalizedData = Array.isArray(resultData) + ? resultData.map((item: any) => ({ + ...item, + // Map GraphQL field names to component expected names + icon: item.icon_name || item.iconName || item.icon, + iconcolor: item.icon_color || item.iconColor || item.iconcolor, + iconType: item.icon_type || item.iconType || item.icontype, + })) + : resultData + ? [{ + ...resultData, + icon: resultData.icon_name || resultData.iconName || resultData.icon, + iconcolor: resultData.icon_color || resultData.iconColor || resultData.iconcolor, + iconType: resultData.icon_type || resultData.iconType || resultData.icontype, + }] + : []; + setData(normalizedData); + } catch (err) { + console.error("Error fetching asset categories:", err); + showToast({ + status: "error", + title: "Failed to fetch asset categories.", + }); + setData([]); + } finally { + hideLoader(); + } + }; + + const handleDelete = async (assetCategoryId: string) => { + try { + showLoader(); + + const result = await deleteAssetCategory({ + variables: { id: assetCategoryId }, + }); + + const deleteResult = (result.data as any)?.deleteMasterAssetCategory; + if (deleteResult?.success) { + showToast({ + status: "success", + title: deleteResult.message || "Asset category deleted successfully.", + }); + + // Refresh table + getAssetCategories(); + } + } catch (error: any) { + console.error("Error deleting asset category:", error); + showToast({ + status: "error", + title: error?.message || "Failed to delete the asset category.", + }); + } finally { + hideLoader(); + } + }; + + + + // Sorting logic + const filteredData = useMemo( + () => + data.filter((row) => + row.name?.toLowerCase().includes(filter.toLowerCase()) + ), + [data, filter] + ); + + const openAddModal = () => { + setModalMode("add"); + setSelectedCategory(null); + setModalOpen(true); + }; + + const openEditModal = (category: any) => { + setModalMode("edit"); + setSelectedCategory(category); + setModalOpen(true); + }; + + useEffect(() => { + getAssetCategories(); + }, []); + + return ( + + +
+
+ setFilter(e.target.value)} + size="small" + fullWidth + sx={{ maxWidth: { xs: "100%", sm: 240, md: 300 } }} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: filter ? ( + + setFilter("")} + edge="end" + > + + + + ) : undefined, + }} + /> + +
+
+ ({ + ...row, + id: row.id || row.ID || row.name, + }))} + columns={[ + { + field: "icon", + headerName: "Icon", + width: 100, + sortable: false, + filterable: false, + renderCell: (params: GridRenderCellParams) => ( + + + + ), + }, + { + field: "name", + headerName: "Name", + flex: 1, + minWidth: 160, + }, + { + field: "description", + headerName: "Description", + flex: 1, + minWidth: 180, + valueGetter: (params) => params.row.description || "NA", + }, + { + field: "actions", + headerName: "Actions", + width: 180, + sortable: false, + filterable: false, + renderCell: (params: GridRenderCellParams) => ( + + openEditModal(params.row)} + size="small" + > + + + handleDelete(params.row.id)} + size="small" + > + + + + ), + }, + ] as GridColDef[]} + disableRowSelectionOnClick + sortingOrder={["asc", "desc"]} + paginationModel={paginationModel} + pageSizeOptions={[5, 10, 25, 50]} + onPaginationModelChange={setPaginationModel} + disableColumnMenu + disableColumnFilter + localeText={{ + noRowsLabel: "No Data Found", + }} + /> +
+
+ + setModalOpen(false)} + title={modalMode === "add" ? "Add Asset Category" : "Edit Asset Category"} + > + setModalOpen(false)} + /> + +
+
+ ); +}; + +export default AssetCategory; diff --git a/src/pages/masterData/assetCategory/AssetCategoryModal.tsx b/src/pages/masterData/assetCategory/AssetCategoryModal.tsx new file mode 100644 index 000000000..e1d046974 --- /dev/null +++ b/src/pages/masterData/assetCategory/AssetCategoryModal.tsx @@ -0,0 +1,304 @@ +import React, { useState, useEffect } from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Stack from "@mui/material/Stack"; +import TextField from "@mui/material/TextField"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import Switch from "@mui/material/Switch"; +import { useMutation } from "@apollo/client/react"; +import { showToast } from "../../../components/toastService"; +import { useLoader } from "../../../context/LoaderContext"; +import { AssetIconField, IconType } from "../../../components/assetIcons"; +import { defaultAssetCategoryColors } from "../../../components/assetIcons/ColorPicker"; +import { CREATE_ASSET_CATEGORY_MUTATION, UPDATE_ASSET_CATEGORY_MUTATION } from "../../../graphql/mutations"; +import { ASSET_CATEGORIES_QUERY } from "../../../graphql/queries"; + +type AssetCategoryModalProps = { + mode: "add" | "edit"; + data?: any; + onClose: () => void; + getData: () => void; +}; + +const AssetCategoryModal: React.FC = ({ + mode, + data, + onClose, + getData, +}) => { + const [formData, setFormData] = useState({ + name: "", + description: "", + icon: "", + iconcolor: "", + icontype: "Square" as IconType, + is_default: false, + }); + + const [errors, setErrors] = useState({ + name: "", + icon: "", + }); + const [touched, setTouched] = useState({ + icon: false, + }); + + const { showLoader, hideLoader } = useLoader(); + const [createAssetCategory, { loading: createLoading }] = useMutation(CREATE_ASSET_CATEGORY_MUTATION, { + refetchQueries: [{ query: ASSET_CATEGORIES_QUERY }], + }); + const [updateAssetCategory, { loading: updateLoading }] = useMutation(UPDATE_ASSET_CATEGORY_MUTATION, { + refetchQueries: [{ query: ASSET_CATEGORIES_QUERY }], + }); + + useEffect(() => { + if (mode === "edit" && data) { + setFormData({ + name: data.name || "", + description: data.description || "", + icon: data.icon_name || data.icon || data.iconName || "", + iconcolor: data.icon_color || data.iconcolor || data.iconColor || defaultAssetCategoryColors[0], + icontype: (data.icon_type || data.icontype || data.iconType || "Square") as IconType, + is_default: data.is_default || false, + }); + } else { + setFormData({ + name: "", + description: "", + icon: "", + iconcolor: defaultAssetCategoryColors[0], + icontype: "Square" as IconType, + is_default: false, + }); + } + setErrors({ name: "", icon: "" }); + setTouched({ icon: false }); + }, [mode, data]); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + + if (name === "name" && value.trim() !== "") { + setErrors((prev) => ({ ...prev, name: "" })); + } + }; + + const validateForm = () => { + let isValid = true; + let newErrors = { name: "", icon: "" }; + + if (!formData.name.trim()) { + newErrors.name = "Name is required."; + isValid = false; + } + + // Icon validation: iconName is mandatory + const hasIconName = !!formData.icon?.trim(); + const hasIconColor = !!formData.iconcolor?.trim(); + const hasIconType = !!formData.icontype; + + if (!hasIconName) { + newErrors.icon = "Icon is required."; + isValid = false; + } else if (hasIconName && (!hasIconColor || !hasIconType)) { + newErrors.icon = "Icon Color and Icon Type are required when Icon is provided."; + isValid = false; + } + + setErrors(newErrors); + setTouched({ icon: true }); + return isValid; + }; + + // const handleUpdate = async () => { + // if (!validateForm()) return; + + // const payload = { + // operations: [ + // { + // actions: { + // ca_asset_category: mode === "edit" ? "update" : "create", + // }, + // data: { + // ca_asset_category: { + // name: formData.name, + // description: formData.description, + // // icon: formData.icon, + // // iconcolor: formData.iconcolor, + // // ...(mode === "edit" && data?.id ? { id: data.id } : {}), + // }, + // }, + // }, + // ], + // }; + + // try { + // await api({ + // url: "/data/rest", + // method: mode === "edit" ? "put" : "post", + // data: payload, + // }); + + // getData(); + + // toast({ + // title: mode === "edit" ? "Details updated." : "Asset Category added.", + // description: + // mode === "edit" + // ? "Asset category information has been saved." + // : "New asset category has been created.", + // status: "success", + // duration: 3000, + // isClosable: true, + // }); + + // onClose(); + // } catch (error) { + // toast({ + // title: "Error", + // description: "Something went wrong while saving the data.", + // status: "error", + // duration: 3000, + // isClosable: true, + // }); + // } + // }; + + + const handleUpdate = async () => { + if (!validateForm()) return; + + try { + showLoader(); + + // Prepare input data with proper field mapping + const input: any = { + name: formData.name || null, + description: formData.description || null, + icon_name: formData.icon || null, + icon_color: formData.iconcolor || null, + icon_type: formData.icontype || null, + is_default: formData.is_default || false, + }; + + // Add id only in edit mode + if (mode === "edit" && data?.id) { + input.id = data.id; + } + + let result; + if (mode === "edit") { + result = await updateAssetCategory({ + variables: { input }, + }); + } else { + result = await createAssetCategory({ + variables: { input }, + }); + } + + if (result.data) { + getData(); + showToast({ + status: "success", + title: + mode === "edit" + ? "Asset category updated successfully." + : "Asset category created successfully.", + }); + onClose(); + } + } catch (error: any) { + console.error("Error saving asset category:", error); + showToast({ + status: "error", + title: error?.message || "Something went wrong while saving the data.", + }); + } finally { + hideLoader(); + } + }; + + + return ( + + + + + + + { + setFormData((prev) => ({ ...prev, icon: value })); + if (value && formData.iconcolor && formData.icontype) { + setErrors((prev) => ({ ...prev, icon: "" })); + } + setTouched((prev) => ({ ...prev, icon: true })); + }} + onIconColorChange={(value) => { + setFormData((prev) => ({ ...prev, iconcolor: value })); + if (formData.icon && value && formData.icontype) { + setErrors((prev) => ({ ...prev, icon: "" })); + } + setTouched((prev) => ({ ...prev, icon: true })); + }} + onIconTypeChange={(value) => { + setFormData((prev) => ({ ...prev, icontype: value })); + if (formData.icon && formData.iconcolor && value) { + setErrors((prev) => ({ ...prev, icon: "" })); + } + setTouched((prev) => ({ ...prev, icon: true })); + }} + error={errors.icon} + touched={touched.icon} + /> + + setFormData((prev) => ({ ...prev, is_default: e.target.checked }))} + color="primary" + /> + } + label="Is Default" + /> + + + + + + ); +}; + +export default AssetCategoryModal; diff --git a/src/pages/masterData/assetType/AssetType.tsx b/src/pages/masterData/assetType/AssetType.tsx new file mode 100644 index 000000000..db334924a --- /dev/null +++ b/src/pages/masterData/assetType/AssetType.tsx @@ -0,0 +1,343 @@ +import React, { useState, useEffect, useMemo } from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import IconButton from "@mui/material/IconButton"; +import InputAdornment from "@mui/material/InputAdornment"; +import TextField from "@mui/material/TextField"; +import { ThemeProvider } from "@mui/material/styles"; +import { + DataGrid, + GridColDef, + GridPaginationModel, + GridRenderCellParams, +} from "@mui/x-data-grid"; +import { useLazyQuery, useMutation, useQuery } from "@apollo/client/react"; +import { Search, X, Trash2, Pencil, Plus } from "lucide-react"; +import AssetTypeModal from "./AssetTypeModal"; +import RightSideModal from "../../../components/RightSideModal"; +import muiTheme from "../../../themes/muiTheme"; +import { showToast } from "../../../components/toastService"; +import { useLoader } from "../../../context/LoaderContext"; +import { AssetIcon, IconType } from "../../../components/assetIcons"; +import { ASSET_TYPES_QUERY, ASSET_CATEGORIES_QUERY } from "../../../graphql/queries"; +import { DELETE_ASSET_TYPE_MUTATION } from "../../../graphql/mutations"; + +const AssetType = () => { + const [filter, setFilter] = useState(""); + const [data, setData] = useState([]); + const [modalOpen, setModalOpen] = useState(false); + const [modalMode, setModalMode] = useState<"add" | "edit">("add"); + const [selectedType, setSelectedType] = useState(null); + const [paginationModel, setPaginationModel] = useState({ + page: 0, + pageSize: 10, + }); + const { showLoader, hideLoader } = useLoader(); + const [fetchAssetTypes, { loading: queryLoading, refetch }] = useLazyQuery(ASSET_TYPES_QUERY); + const [hasFetched, setHasFetched] = useState(false); + const [deleteAssetType] = useMutation(DELETE_ASSET_TYPE_MUTATION, { + refetchQueries: [{ query: ASSET_TYPES_QUERY, variables: { asset_category_id: null } }], + }); + + // Fetch asset categories to map asset_category_id to name + const { data: assetCategoriesData } = useQuery(ASSET_CATEGORIES_QUERY); + + // Create asset category map + const assetCategoryMap = useMemo(() => { + const assetCategories = (assetCategoriesData as any)?.masterAssetCategories || []; + return new Map(assetCategories.map((ac: any) => [ac.id, ac.name || ""])); + }, [assetCategoriesData]); + + // Fetch asset types from GraphQL API + const getAssetTypes = async () => { + try { + showLoader(); + + // Use refetch if query has been executed before, otherwise use fetchAssetTypes + let queryData, error; + if (hasFetched && refetch) { + const result = await refetch({ asset_category_id: null }); + queryData = result.data; + error = result.error; + } else { + const result = await fetchAssetTypes({ variables: { asset_category_id: null } }); + queryData = result.data; + error = result.error; + setHasFetched(true); + } + + if (error) { + console.error("GraphQL error:", error); + // showToast({ + // status: "error", + // title: "Failed to fetch asset types.", + // }); + setData([]); + return; + } + + const resultData = (queryData as any)?.masterAssetTypes; + const normalizedData = Array.isArray(resultData) + ? resultData.map((item: any) => ({ + ...item, + // Map GraphQL field names to component expected names + icon: item.icon_name || item.iconName || item.icon, + iconcolor: item.icon_color || item.iconColor || item.iconcolor, + iconType: item.icon_type || item.iconType || item.icontype, + assetCategoryName: item.asset_category_id + ? (assetCategoryMap.get(item.asset_category_id) || "N/A") + : "N/A", + })) + : resultData + ? [{ + ...resultData, + icon: resultData.icon_name || resultData.iconName || resultData.icon, + iconcolor: resultData.icon_color || resultData.iconColor || resultData.iconcolor, + iconType: resultData.icon_type || resultData.iconType || resultData.icontype, + assetCategoryName: resultData.asset_category_id + ? (assetCategoryMap.get(resultData.asset_category_id) || "N/A") + : "N/A", + }] + : []; + + setData(normalizedData); + } catch (err) { + console.error("Error fetching asset types:", err); + // showToast({ + // status: "error", + // title: "Failed to fetch asset types.", + // }); + setData([]); + } finally { + hideLoader(); + } + }; + + const handleDelete = async (assetTypeId: string) => { + try { + showLoader(); + + const result = await deleteAssetType({ + variables: { id: assetTypeId }, + }); + + const deleteResult = (result.data as any)?.deleteMasterAssetType; + if (deleteResult?.success) { + showToast({ + status: "success", + title: deleteResult.message || "Asset type deleted successfully.", + }); + + // Refresh table + getAssetTypes(); + } + } catch (error: any) { + console.error("Error deleting asset type:", error); + showToast({ + status: "error", + title: error?.message || "Failed to delete the asset type.", + }); + } finally { + hideLoader(); + } + }; + + // Re-map asset type data when asset categories are loaded + useEffect(() => { + if (assetCategoriesData && data.length > 0) { + const assetCategories = (assetCategoriesData as any)?.masterAssetCategories || []; + const assetCategoryMap = new Map(assetCategories.map((ac: any) => [ac.id, ac.name || ""])); + + const updatedData = data.map((item: any) => ({ + ...item, + assetCategoryName: item.asset_category_id + ? (assetCategoryMap.get(item.asset_category_id) || "N/A") + : "N/A", + })); + + setData(updatedData); + } + }, [assetCategoriesData]); + + useEffect(() => { + getAssetTypes(); + }, []); + + // Sorting logic + const filteredData = useMemo( + () => + data.filter((row) => { + const value = filter.toLowerCase(); + return ( + row.name?.toLowerCase().includes(value) || + row.assetCategoryName?.toLowerCase().includes(value) || + row.description?.toLowerCase().includes(value) + ); + }), + [data, filter] + ); + + const openAddModal = () => { + setModalMode("add"); + setSelectedType(null); + setModalOpen(true); + }; + + const openEditModal = (type: any) => { + setModalMode("edit"); + setSelectedType(type); + setModalOpen(true); + }; + + return ( + + +
+
+ setFilter(e.target.value)} + size="small" + fullWidth + sx={{ maxWidth: { xs: "100%", sm: 240, md: 300 } }} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: filter ? ( + + setFilter("")} + edge="end" + > + + + + ) : undefined, + }} + /> + +
+
+ ({ + ...row, + id: row.id || row.ID || row.name, + }))} + columns={[ + { + field: "icon", + headerName: "Icon", + width: 100, + sortable: false, + filterable: false, + renderCell: (params: GridRenderCellParams) => ( + + + + ), + }, + { + field: "name", + headerName: "Name", + flex: 1, + minWidth: 160, + }, + { + field: "description", + headerName: "Description", + flex: 1, + minWidth: 180, + valueGetter: (params) => params.row.description || "NA", + }, + { + field: "assetCategoryName", + headerName: "Asset Category", + flex: 1, + minWidth: 160, + renderCell: (params: GridRenderCellParams) => ( + + {params.row.assetCategoryName || "N/A"} + + ), + }, + { + field: "actions", + headerName: "Actions", + width: 180, + sortable: false, + filterable: false, + renderCell: (params: GridRenderCellParams) => ( + + openEditModal(params.row)} + size="small" + > + + + handleDelete(params.row.id)} + size="small" + > + + + + ), + }, + ] as GridColDef[]} + disableRowSelectionOnClick + sortingOrder={["asc", "desc"]} + paginationModel={paginationModel} + pageSizeOptions={[5, 10, 25, 50]} + onPaginationModelChange={setPaginationModel} + disableColumnMenu + disableColumnFilter + localeText={{ + noRowsLabel: "No Data Found", + }} + /> +
+
+ + setModalOpen(false)} + title={modalMode === "add" ? "Add Asset Type" : "Edit Asset Type"} + key={modalOpen ? `${modalMode}-${selectedType?.id || 'new'}` : undefined} + > + setModalOpen(false)} + /> + +
+
+ ); +}; + +export default AssetType; diff --git a/src/pages/masterData/assetType/AssetTypeModal.tsx b/src/pages/masterData/assetType/AssetTypeModal.tsx new file mode 100644 index 000000000..75e3c78ca --- /dev/null +++ b/src/pages/masterData/assetType/AssetTypeModal.tsx @@ -0,0 +1,355 @@ +import React, { useState, useEffect, useMemo } from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Autocomplete from "@mui/material/Autocomplete"; +import InputAdornment from "@mui/material/InputAdornment"; +import FormHelperText from "@mui/material/FormHelperText"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import Switch from "@mui/material/Switch"; +import Stack from "@mui/material/Stack"; +import TextField from "@mui/material/TextField"; +import { useMutation, useQuery } from "@apollo/client/react"; +import { Search } from "lucide-react"; +import { showToast } from "../../../components/toastService"; +import { useLoader } from "../../../context/LoaderContext"; +import { AssetIconField, IconType } from "../../../components/assetIcons"; +import { defaultAssetCategoryColors } from "../../../components/assetIcons/ColorPicker"; +import { CREATE_ASSET_TYPE_MUTATION, UPDATE_ASSET_TYPE_MUTATION } from "../../../graphql/mutations"; +import { ASSET_TYPES_QUERY, ASSET_CATEGORIES_QUERY } from "../../../graphql/queries"; + +type AssetTypeModalProps = { + mode: "add" | "edit"; + data?: any; + onClose: () => void; + getData: () => void; +}; + +const AssetTypeModal: React.FC = ({ + mode, + data, + onClose, + getData, +}) => { + const [formData, setFormData] = useState({ + name: "", + description: "", + icon: "", + iconcolor: "", + icontype: "Square" as IconType, + assetCategoryId: "", + is_default: false, + }); + + const [errors, setErrors] = useState({ + name: "", + assetCategoryId: "", + icon: "", + }); + const [touched, setTouched] = useState({ + icon: false, + }); + const { showLoader, hideLoader } = useLoader(); + const [createAssetType, { loading: createLoading }] = useMutation(CREATE_ASSET_TYPE_MUTATION, { + refetchQueries: [{ query: ASSET_TYPES_QUERY, variables: { asset_category_id: null } }], + }); + const [updateAssetType, { loading: updateLoading }] = useMutation(UPDATE_ASSET_TYPE_MUTATION, { + refetchQueries: [{ query: ASSET_TYPES_QUERY, variables: { asset_category_id: null } }], + }); + + // Fetch asset categories for dropdown + const { data: assetCategoriesData, loading: categoriesLoading, error: categoriesError } = useQuery(ASSET_CATEGORIES_QUERY, { + fetchPolicy: 'cache-and-network', + errorPolicy: 'all', + }); + + useEffect(() => { + if (categoriesError) { + console.error("Error fetching asset categories:", categoriesError); + showToast({ + status: "error", + title: "Failed to load asset categories.", + }); + } + if (assetCategoriesData) { + console.log("Asset categories data:", assetCategoriesData); + const categories = (assetCategoriesData as any)?.masterAssetCategories || []; + console.log("Categories array:", categories); + } + }, [assetCategoriesData, categoriesError]); + + useEffect(() => { + if (mode === "edit" && data) { + setFormData({ + name: data.name || "", + description: data.description || "", + icon: data.icon_name || data.icon || data.iconName || "", + iconcolor: data.icon_color || data.iconcolor || data.iconColor || defaultAssetCategoryColors[0], + icontype: (data.icon_type || data.icontype || data.iconType || "Square") as IconType, + assetCategoryId: data.asset_category_id || data.assetCategoryId || "", + is_default: data.is_default || false, + }); + } else { + setFormData({ + name: "", + description: "", + icon: "", + iconcolor: defaultAssetCategoryColors[0], + icontype: "Square" as IconType, + assetCategoryId: "", + is_default: false, + }); + } + setErrors({ name: "", assetCategoryId: "", icon: "" }); + setTouched({ icon: false }); + }, [mode, data]); + + const handleClose = () => { + setFormData({ + name: "", + description: "", + icon: "", + iconcolor: defaultAssetCategoryColors[0], + icontype: "Square" as IconType, + assetCategoryId: "", + is_default: false, + }); + setErrors({ name: "", assetCategoryId: "", icon: "" }); + setTouched({ icon: false }); + onClose(); + }; + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + + if (name === "name" && value.trim() !== "") { + setErrors((prev) => ({ ...prev, name: "" })); + } + }; + + const handleAssetCategoryChange = (_: any, newValue: any) => { + setFormData((prev) => ({ ...prev, assetCategoryId: newValue?.id || "" })); + if (newValue) { + setErrors((prev) => ({ ...prev, assetCategoryId: "" })); + } + }; + + const validateForm = () => { + let isValid = true; + let newErrors = { name: "", assetCategoryId: "", icon: "" }; + + if (!formData.name.trim()) { + newErrors.name = "Name is required."; + isValid = false; + } + + if (!formData.assetCategoryId) { + newErrors.assetCategoryId = "Asset Category is required."; + isValid = false; + } + + // Icon validation: iconName is mandatory + // If any icon field is filled, all must be filled + const hasIconName = !!formData.icon?.trim(); + const hasIconColor = !!formData.iconcolor?.trim(); + const hasIconType = !!formData.icontype; + + if (!hasIconName) { + newErrors.icon = "Icon Name is required."; + isValid = false; + } else if (hasIconName && (!hasIconColor || !hasIconType)) { + newErrors.icon = "Icon Color and Icon Type are required when Icon Name is provided."; + isValid = false; + } else if ((hasIconColor || hasIconType) && !hasIconName) { + newErrors.icon = "Icon Name is required when Icon Color or Icon Type is provided."; + isValid = false; + } + + setErrors(newErrors); + setTouched({ icon: true }); + return isValid; + }; + + const handleSubmit = async () => { + if (!validateForm()) return; + + try { + showLoader(); + + // Prepare input data with proper field mapping + const input: any = { + asset_category_id: formData.assetCategoryId || null, + name: formData.name || null, + description: formData.description || null, + icon_name: formData.icon || null, + icon_color: formData.iconcolor || null, + icon_type: formData.icontype || null, + is_default: formData.is_default || false, + }; + + // Add id only in edit mode + if (mode === "edit" && data?.id) { + input.id = data.id; + } + + let result; + if (mode === "edit") { + result = await updateAssetType({ + variables: { input }, + }); + } else { + result = await createAssetType({ + variables: { input }, + }); + } + + if (result.data) { + getData(); + showToast({ + status: "success", + title: + mode === "edit" + ? "Asset type updated successfully." + : "Asset type created successfully.", + }); + // Reset form and close modal + setFormData({ + name: "", + description: "", + icon: "", + iconcolor: defaultAssetCategoryColors[0], + icontype: "Square" as IconType, + assetCategoryId: "", + is_default: false, + }); + setErrors({ name: "", assetCategoryId: "", icon: "" }); + setTouched({ icon: false }); + onClose(); + } + } catch (error: any) { + console.error("Error saving asset type:", error); + showToast({ + status: "error", + title: error?.message || "Something went wrong while saving the asset type.", + }); + } finally { + hideLoader(); + } + }; + + const assetCategories = useMemo(() => { + const categories = (assetCategoriesData as any)?.masterAssetCategories || []; + console.log("Computed assetCategories:", categories); + return categories; + }, [assetCategoriesData]); + + return ( + + + + + + option.name || ""} + value={assetCategories.find((cat: any) => cat.id === formData.assetCategoryId) || null} + onChange={handleAssetCategoryChange} + renderInput={(params) => ( + + + + + {params.InputProps.startAdornment} + + ), + }} + /> + )} + fullWidth + /> + { + setFormData((prev) => ({ ...prev, icon: value })); + if (value && formData.iconcolor && formData.icontype) { + setErrors((prev) => ({ ...prev, icon: "" })); + } + setTouched((prev) => ({ ...prev, icon: true })); + }} + onIconColorChange={(value) => { + setFormData((prev) => ({ ...prev, iconcolor: value })); + if (formData.icon && value && formData.icontype) { + setErrors((prev) => ({ ...prev, icon: "" })); + } + setTouched((prev) => ({ ...prev, icon: true })); + }} + onIconTypeChange={(value) => { + setFormData((prev) => ({ ...prev, icontype: value })); + if (formData.icon && formData.iconcolor && value) { + setErrors((prev) => ({ ...prev, icon: "" })); + } + setTouched((prev) => ({ ...prev, icon: true })); + }} + error={errors.icon} + touched={touched.icon} + /> + + setFormData((prev) => ({ ...prev, is_default: e.target.checked }))} + color="primary" + /> + } + label="Is Default" + /> + + + + + + ); +}; + +export default AssetTypeModal; diff --git a/src/pages/masterData/assetfields/AssetFields.tsx b/src/pages/masterData/assetfields/AssetFields.tsx new file mode 100644 index 000000000..48d68adff --- /dev/null +++ b/src/pages/masterData/assetfields/AssetFields.tsx @@ -0,0 +1,336 @@ +import React, { useState, useEffect, useMemo } from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import IconButton from "@mui/material/IconButton"; +import InputAdornment from "@mui/material/InputAdornment"; +import TextField from "@mui/material/TextField"; +import { ThemeProvider } from "@mui/material/styles"; +import { + DataGrid, + GridColDef, + GridPaginationModel, + GridRenderCellParams, +} from "@mui/x-data-grid"; +import { Search, X, Trash2, Pencil, Plus } from "lucide-react"; +import { useLazyQuery, useMutation } from "@apollo/client/react"; +import RightSideModal from "../../../components/RightSideModal"; +import AssetFieldsForm from "./AssetFieldsForm"; +import { showToast } from "../../../components/toastService"; +import { useLoader } from "../../../context/LoaderContext"; +import muiTheme from "../../../themes/muiTheme"; +import { ASSET_FIELDS_QUERY } from "../../../graphql/queries"; +import { DELETE_ASSET_TYPE_FIELD_MUTATION } from "../../../graphql/mutations"; + +const AssetFields = () => { + const [searchTerm, setSearchTerm] = useState(""); + const [assetFieldsData, setAssetFieldsData] = useState([]); + const [modalOpen, setModalOpen] = useState(false); + const [modalMode, setModalMode] = useState<"add" | "edit">("add"); + const [selectedField, setSelectedField] = useState(null); + const [paginationModel, setPaginationModel] = useState({ + page: 0, + pageSize: 10, + }); + const { showLoader, hideLoader } = useLoader(); + const [fetchAssetFields, { loading: queryLoading, refetch }] = useLazyQuery(ASSET_FIELDS_QUERY); + const [hasFetched, setHasFetched] = useState(false); + const [deleteAssetField] = useMutation(DELETE_ASSET_TYPE_FIELD_MUTATION, { + refetchQueries: [{ query: ASSET_FIELDS_QUERY, variables: { asset_type_id: null } }], + }); + + const filteredData = useMemo( + () => + assetFieldsData.filter((item) => + item.label?.toLowerCase().includes(searchTerm.toLowerCase()) || + item.description?.toLowerCase().includes(searchTerm.toLowerCase()) || + item.type?.toLowerCase().includes(searchTerm.toLowerCase()) + ), + [assetFieldsData, searchTerm] + ); + + // Fetch asset fields data from GraphQL API + const getAssetFieldsDetails = async () => { + try { + showLoader(); + + // Use refetch if query has been executed before, otherwise use fetchAssetFields + let queryData, error; + if (hasFetched && refetch) { + const result = await refetch({ asset_type_id: null }); + queryData = result.data; + error = result.error; + } else { + const result = await fetchAssetFields({ + variables: { asset_type_id: null }, + }); + queryData = result.data; + error = result.error; + setHasFetched(true); + } + + if (error) { + console.error("GraphQL error:", error); + setAssetFieldsData([]); + return; + } + + const resultData = (queryData as any)?.masterAssetTypeFields; + + // Map asset fields to display format + const normalizedData = Array.isArray(resultData) + ? resultData.map((item: any) => { + return { + id: item.id, + asset_type_id: item.asset_type_id || "", + parent_field_id: item.parent_field_id || null, + label: item.field_name || "", + field_name: item.field_name || "", + description: item.description || "", + type: item.field_type || "", + field_type: item.field_type || "", + unit: item.unit || "", + required: item.is_required ? "Yes" : "No", + is_required: item.is_required || false, + order: item.display_order || 0, + display_order: item.display_order || 0, + selectOptions: item.allowed_values || "", + allowed_values: item.allowed_values || "", + showInPanel: item.show_in_panel ? "Yes" : "No", + show_in_panel: item.show_in_panel || false, + }; + }) + : resultData + ? [{ + id: resultData.id, + asset_type_id: resultData.asset_type_id || "", + parent_field_id: resultData.parent_field_id || null, + label: resultData.field_name || "", + field_name: resultData.field_name || "", + description: resultData.description || "", + type: resultData.field_type || "", + field_type: resultData.field_type || "", + unit: resultData.unit || "", + required: resultData.is_required ? "Yes" : "No", + is_required: resultData.is_required || false, + order: resultData.display_order || 0, + display_order: resultData.display_order || 0, + selectOptions: resultData.allowed_values || "", + allowed_values: resultData.allowed_values || "", + showInPanel: resultData.show_in_panel ? "Yes" : "No", + show_in_panel: resultData.show_in_panel || false, + }] + : []; + + // Sort by display_order, then by id + const sortedData = [...normalizedData].sort((a: any, b: any) => { + if (a.display_order !== b.display_order) { + return (a.display_order || 0) - (b.display_order || 0); + } + return (a.id || "").localeCompare(b.id || ""); + }); + + setAssetFieldsData(sortedData); + } catch (err: any) { + console.error("Error fetching asset fields:", err); + setAssetFieldsData([]); + showToast({ + status: "error", + title: "Failed to load asset fields.", + }); + } finally { + hideLoader(); + } + }; + + const handleDelete = async (fieldId: string) => { + try { + showLoader(); + + const result = await deleteAssetField({ + variables: { id: fieldId }, + }); + + const deleteResult = (result.data as any)?.deleteMasterAssetTypeField; + if (deleteResult?.success) { + showToast({ + status: "success", + title: deleteResult.message || "Asset field deleted successfully.", + }); + + // Refresh table + getAssetFieldsDetails(); + } + } catch (error: any) { + console.error("Error deleting asset field:", error); + showToast({ + status: "error", + title: error?.message || "Failed to delete the asset field.", + }); + } finally { + hideLoader(); + } + }; + + const openAddModal = () => { + setModalMode("add"); + setSelectedField(null); + setModalOpen(true); + }; + + const openEditModal = (field: any) => { + setModalMode("edit"); + setSelectedField(field); + setModalOpen(true); + }; + + useEffect(() => { + getAssetFieldsDetails(); + }, []); + + const columns: GridColDef[] = [ + { + field: "label", + headerName: "Label", + flex: 1, + minWidth: 160, + sortable: true, + }, + { + field: "type", + headerName: "Field Type", + flex: 1, + minWidth: 120, + sortable: true, + }, + { + field: "description", + headerName: "Description", + flex: 1.5, + minWidth: 180, + sortable: true, + renderCell: (params: GridRenderCellParams) => ( + + {params.row.description || "NA"} + + ), + }, + { + field: "actions", + headerName: "Actions", + width: 160, + sortable: false, + filterable: false, + renderCell: (params: GridRenderCellParams) => ( + + openEditModal(params.row)} + size="small" + > + + + handleDelete(params.row.id)} + size="small" + > + + + + ), + }, + ]; + + const rows = useMemo( + () => + filteredData.map((row) => ({ + ...row, + id: row.id || row.label, + })), + [filteredData] + ); + + return ( + + +
+
+ setSearchTerm(e.target.value)} + size="small" + fullWidth + sx={{ maxWidth: { xs: "100%", sm: 200, md: 250 } }} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: searchTerm ? ( + + setSearchTerm("")} + edge="end" + > + + + + ) : undefined, + }} + /> + +
+
+ +
+
+ + setModalOpen(false)} + title={modalMode === "add" ? "Add Asset Field" : "Edit Asset Field"} + key={modalOpen ? `${modalMode}-${selectedField?.id || 'new'}` : undefined} + > + setModalOpen(false)} + /> + +
+
+ ); +}; + +export default AssetFields; + diff --git a/src/pages/masterData/assetfields/AssetFieldsForm.tsx b/src/pages/masterData/assetfields/AssetFieldsForm.tsx new file mode 100644 index 000000000..3650abfd9 --- /dev/null +++ b/src/pages/masterData/assetfields/AssetFieldsForm.tsx @@ -0,0 +1,480 @@ +import React, { useEffect } from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import TextField from "@mui/material/TextField"; +import Stack from "@mui/material/Stack"; +import Autocomplete from "@mui/material/Autocomplete"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import Switch from "@mui/material/Switch"; +import MenuItem from "@mui/material/MenuItem"; +import InputAdornment from "@mui/material/InputAdornment"; +import { useForm, Controller } from "react-hook-form"; +import { yupResolver } from "@hookform/resolvers/yup"; +import * as yup from "yup"; +import { useMutation, useQuery } from "@apollo/client/react"; +import { Search } from "lucide-react"; +import { showToast } from "../../../components/toastService"; +import { useLoader } from "../../../context/LoaderContext"; +import { CREATE_ASSET_TYPE_FIELD_MUTATION, UPDATE_ASSET_TYPE_FIELD_MUTATION } from "../../../graphql/mutations"; +import { ASSET_TYPES_QUERY, ASSET_FIELDS_QUERY } from "../../../graphql/queries"; + +const fieldTypeOptions = [ + { value: "text", label: "Text" }, + { value: "number", label: "Number" }, + { value: "date", label: "Date" }, + { value: "datetime", label: "DateTime" }, + { value: "boolean", label: "Boolean" }, + { value: "select", label: "Select" }, + { value: "multi_select", label: "Multi Select" }, + { value: "file", label: "File" }, + { value: "url", label: "URL" }, + { value: "email", label: "Email" }, + { value: "phone", label: "Phone" }, +]; + +const schema = yup.object({ + asset_type_id: yup.string().required("Asset Type is required"), + field_name: yup.string().required("Name is required").min(1, "Name must be at least 1 character"), + description: yup.string().nullable().default(""), + field_type: yup.string().required("Field Type is required"), + allowed_values: yup.string().when("field_type", { + is: (val: string) => val === "select" || val === "multi_select", + then: (schema) => schema.required("Values are required when Field Type is Select or Multi Select"), + otherwise: (schema) => schema.nullable(), + }), + unit: yup.string().nullable().matches(/^\d*$/, "Unit must contain only numbers").default(""), + display_order: yup.number().nullable().transform((value, originalValue) => { + return originalValue === "" ? null : value; + }), + is_required: yup.boolean().default(false), + show_in_panel: yup.boolean().default(false), +}); + +type AssetFieldFormInputs = yup.InferType; + +type AssetFieldsFormProps = { + mode: "add" | "edit"; + data?: any; + onClose: () => void; + getData: () => void; +}; + +const AssetFieldsForm: React.FC = ({ + mode, + data, + onClose, + getData, +}) => { + const { showLoader, hideLoader } = useLoader(); + const [createAssetField, { loading: createLoading }] = useMutation(CREATE_ASSET_TYPE_FIELD_MUTATION, { + refetchQueries: [{ query: ASSET_FIELDS_QUERY, variables: { asset_type_id: null } }], + }); + const [updateAssetField, { loading: updateLoading }] = useMutation(UPDATE_ASSET_TYPE_FIELD_MUTATION, { + refetchQueries: [{ query: ASSET_FIELDS_QUERY, variables: { asset_type_id: null } }], + }); + + // Fetch asset types for dropdown + const { data: assetTypesData, loading: assetTypesLoading } = useQuery(ASSET_TYPES_QUERY, { + variables: { asset_category_id: null }, + fetchPolicy: 'cache-and-network', + }); + + const { + control, + handleSubmit, + reset, + formState: { errors }, + watch, + clearErrors, + setValue, + } = useForm({ + resolver: yupResolver(schema) as any, + mode: "onSubmit", + reValidateMode: "onChange", + defaultValues: { + asset_type_id: "", + field_name: "", + description: "", + field_type: "text", + allowed_values: "", + unit: "", + display_order: null, + is_required: false, + show_in_panel: false, + }, + }); + + const fieldType = watch("field_type"); + const showAllowedValues = fieldType === "select" || fieldType === "multi_select"; + + // Clear allowed_values when field_type changes and it's not select/multi_select + useEffect(() => { + if (fieldType && fieldType !== "select" && fieldType !== "multi_select") { + setValue("allowed_values", ""); + clearErrors("allowed_values"); + } + }, [fieldType, setValue, clearErrors]); + + useEffect(() => { + clearErrors(); + + if (mode === "edit" && data) { + reset({ + asset_type_id: data.asset_type_id || "", + field_name: data.field_name || "", + description: data.description || "", + field_type: data.field_type || "text", + allowed_values: Array.isArray(data.allowed_values) + ? data.allowed_values.join(', ') + : (data.allowed_values || ""), + unit: data.unit || "", + display_order: data.display_order || null, + is_required: data.is_required || false, + show_in_panel: data.show_in_panel || false, + }); + } else { + reset({ + asset_type_id: "", + field_name: "", + description: "", + field_type: "text", + allowed_values: "", + unit: "", + display_order: null, + is_required: false, + show_in_panel: false, + }); + } + }, [mode, data, reset, clearErrors]); + + const handleClose = () => { + reset({ + asset_type_id: "", + field_name: "", + description: "", + field_type: "text", + allowed_values: "", + unit: "", + display_order: null, + is_required: false, + show_in_panel: false, + }); + clearErrors(); + onClose(); + }; + + const onSubmit = async (formData: AssetFieldFormInputs) => { + try { + showLoader(); + + // Prepare input data (description is not part of the input type) + const input: any = { + asset_type_id: formData.asset_type_id || null, + field_name: formData.field_name || null, + field_type: formData.field_type || null, + allowed_values: formData.allowed_values?.trim() + ? formData.allowed_values.split(',').map((val: string) => val.trim()).filter((val: string) => val.length > 0) + : null, + unit: formData.unit?.trim() || null, + display_order: formData.display_order || null, + is_required: formData.is_required || false, + show_in_panel: formData.show_in_panel || false, + }; + + // Add id only in edit mode + if (mode === "edit" && data?.id) { + input.id = data.id; + } + + let result; + if (mode === "edit") { + result = await updateAssetField({ + variables: { input }, + }); + } else { + result = await createAssetField({ + variables: { input }, + }); + } + + if (result.data) { + getData(); + showToast({ + status: "success", + title: + mode === "edit" + ? "Asset field updated successfully." + : "Asset field created successfully.", + }); + // Reset form and close modal + reset({ + asset_type_id: "", + field_name: "", + description: "", + field_type: "text", + allowed_values: "", + unit: "", + display_order: null, + is_required: false, + show_in_panel: false, + }); + clearErrors(); + onClose(); + } + } catch (error: any) { + console.error("Error saving asset field:", error); + showToast({ + status: "error", + title: error?.message || "Something went wrong while saving the asset field.", + }); + } finally { + hideLoader(); + } + }; + + const assetTypes = (assetTypesData as any)?.masterAssetTypes || []; + + return ( + + + + ( + option.name || `Asset Type ${option.id}` || ""} + isOptionEqualToValue={(option, value) => option.id === value.id} + value={assetTypes.find((type: any) => type.id === field.value) || null} + onChange={(_event, newValue: any | null) => { + field.onChange(newValue?.id || ""); + }} + onBlur={field.onBlur} + disabled={assetTypesLoading} + loading={assetTypesLoading} + size="small" + renderInput={(params) => ( + + + + + {params.InputProps.startAdornment} + + ), + }} + /> + )} + fullWidth + /> + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + { + field.onChange(e); + // Clear allowed_values when changing to non-select/multi_select types + if (e.target.value !== "select" && e.target.value !== "multi_select") { + setValue("allowed_values", ""); + clearErrors("allowed_values"); + } + }} + > + {fieldTypeOptions.map((option) => ( + + {option.label} + + ))} + + )} + /> + + {showAllowedValues && ( + ( + + )} + /> + )} + + ( + { + const value = e.target.value; + // Only allow numbers + if (value === "" || /^\d+$/.test(value)) { + field.onChange(value); + } + }} + /> + )} + /> + + ( + { + const value = e.target.value === "" ? null : Number(e.target.value); + field.onChange(value); + }} + inputProps={{ + min: 0, + }} + /> + )} + /> + + ( + + } + label="Is Required" + /> + )} + /> + + ( + + } + label="Show In Panel" + /> + )} + /> + + + + + + + ); +}; + +export default AssetFieldsForm; + diff --git a/src/pages/masterData/assetpartfields/AssetPartFields.tsx b/src/pages/masterData/assetpartfields/AssetPartFields.tsx new file mode 100644 index 000000000..ea0663013 --- /dev/null +++ b/src/pages/masterData/assetpartfields/AssetPartFields.tsx @@ -0,0 +1,336 @@ +import React, { useState, useEffect, useMemo } from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import IconButton from "@mui/material/IconButton"; +import InputAdornment from "@mui/material/InputAdornment"; +import TextField from "@mui/material/TextField"; +import { ThemeProvider } from "@mui/material/styles"; +import { + DataGrid, + GridColDef, + GridPaginationModel, + GridRenderCellParams, +} from "@mui/x-data-grid"; +import { Search, X, Trash2, Pencil, Plus } from "lucide-react"; +import { useLazyQuery, useMutation } from "@apollo/client/react"; +import RightSideModal from "../../../components/RightSideModal"; +import AssetPartFieldsForm from "./AssetPartFieldsForm"; +import { showToast } from "../../../components/toastService"; +import { useLoader } from "../../../context/LoaderContext"; +import muiTheme from "../../../themes/muiTheme"; +import { ASSET_PART_FIELDS_QUERY } from "../../../graphql/queries"; +import { DELETE_ASSET_PART_FIELD_MUTATION } from "../../../graphql/mutations"; + +const AssetPartFields = () => { + const [searchTerm, setSearchTerm] = useState(""); + const [assetPartFieldsData, setAssetPartFieldsData] = useState([]); + const [modalOpen, setModalOpen] = useState(false); + const [modalMode, setModalMode] = useState<"add" | "edit">("add"); + const [selectedField, setSelectedField] = useState(null); + const [paginationModel, setPaginationModel] = useState({ + page: 0, + pageSize: 10, + }); + const { showLoader, hideLoader } = useLoader(); + const [fetchAssetPartFields, { loading: queryLoading, refetch }] = useLazyQuery(ASSET_PART_FIELDS_QUERY); + const [hasFetched, setHasFetched] = useState(false); + const [deleteAssetPartField] = useMutation(DELETE_ASSET_PART_FIELD_MUTATION, { + refetchQueries: [{ query: ASSET_PART_FIELDS_QUERY, variables: { asset_part_id: null } }], + }); + + const filteredData = useMemo( + () => + assetPartFieldsData.filter((item) => + item.label?.toLowerCase().includes(searchTerm.toLowerCase()) || + item.description?.toLowerCase().includes(searchTerm.toLowerCase()) || + item.type?.toLowerCase().includes(searchTerm.toLowerCase()) + ), + [assetPartFieldsData, searchTerm] + ); + + // Fetch asset part fields data from GraphQL API + const getAssetPartFieldsDetails = async () => { + try { + showLoader(); + + // Use refetch if query has been executed before, otherwise use fetchAssetPartFields + let queryData, error; + if (hasFetched && refetch) { + const result = await refetch({ asset_part_id: null }); + queryData = result.data; + error = result.error; + } else { + const result = await fetchAssetPartFields({ + variables: { asset_part_id: null }, + }); + queryData = result.data; + error = result.error; + setHasFetched(true); + } + + if (error) { + console.error("GraphQL error:", error); + setAssetPartFieldsData([]); + return; + } + + const resultData = (queryData as any)?.masterAssetPartFields; + + // Map asset part fields to display format + const normalizedData = Array.isArray(resultData) + ? resultData.map((item: any) => { + return { + id: item.id, + parent_id: item.parent_id || null, + asset_part_id: item.asset_part_id || "", + label: item.field_name || "", + field_name: item.field_name || "", + description: item.description || "", + type: item.field_type || "", + field_type: item.field_type || "", + unit: item.unit || "", + required: item.is_required ? "Yes" : "No", + is_required: item.is_required || false, + order: item.display_order || 0, + display_order: item.display_order || 0, + selectOptions: item.allowed_values || "", + allowed_values: item.allowed_values || "", + showInPanel: item.show_in_panel ? "Yes" : "No", + show_in_panel: item.show_in_panel || false, + }; + }) + : resultData + ? [{ + id: resultData.id, + parent_id: resultData.parent_id || null, + asset_part_id: resultData.asset_part_id || "", + label: resultData.field_name || "", + field_name: resultData.field_name || "", + description: resultData.description || "", + type: resultData.field_type || "", + field_type: resultData.field_type || "", + unit: resultData.unit || "", + required: resultData.is_required ? "Yes" : "No", + is_required: resultData.is_required || false, + order: resultData.display_order || 0, + display_order: resultData.display_order || 0, + selectOptions: resultData.allowed_values || "", + allowed_values: resultData.allowed_values || "", + showInPanel: resultData.show_in_panel ? "Yes" : "No", + show_in_panel: resultData.show_in_panel || false, + }] + : []; + + // Sort by display_order, then by id + const sortedData = [...normalizedData].sort((a: any, b: any) => { + if (a.display_order !== b.display_order) { + return (a.display_order || 0) - (b.display_order || 0); + } + return (a.id || "").localeCompare(b.id || ""); + }); + + setAssetPartFieldsData(sortedData); + } catch (err: any) { + console.error("Error fetching asset part fields:", err); + setAssetPartFieldsData([]); + showToast({ + status: "error", + title: "Failed to load asset part fields.", + }); + } finally { + hideLoader(); + } + }; + + const handleDelete = async (fieldId: string) => { + try { + showLoader(); + + const result = await deleteAssetPartField({ + variables: { id: fieldId }, + }); + + const deleteResult = (result.data as any)?.deleteMasterAssetPartField; + if (deleteResult?.success) { + showToast({ + status: "success", + title: deleteResult.message || "Asset part field deleted successfully.", + }); + + // Refresh table + getAssetPartFieldsDetails(); + } + } catch (error: any) { + console.error("Error deleting asset part field:", error); + showToast({ + status: "error", + title: error?.message || "Failed to delete the asset part field.", + }); + } finally { + hideLoader(); + } + }; + + const openAddModal = () => { + setModalMode("add"); + setSelectedField(null); + setModalOpen(true); + }; + + const openEditModal = (field: any) => { + setModalMode("edit"); + setSelectedField(field); + setModalOpen(true); + }; + + useEffect(() => { + getAssetPartFieldsDetails(); + }, []); + + const columns: GridColDef[] = [ + { + field: "label", + headerName: "Label", + flex: 1, + minWidth: 160, + sortable: true, + }, + { + field: "type", + headerName: "Field Type", + flex: 1, + minWidth: 120, + sortable: true, + }, + { + field: "description", + headerName: "Description", + flex: 1.5, + minWidth: 180, + sortable: true, + renderCell: (params: GridRenderCellParams) => ( + + {params.row.description || "NA"} + + ), + }, + { + field: "actions", + headerName: "Actions", + width: 160, + sortable: false, + filterable: false, + renderCell: (params: GridRenderCellParams) => ( + + openEditModal(params.row)} + size="small" + > + + + handleDelete(params.row.id)} + size="small" + > + + + + ), + }, + ]; + + const rows = useMemo( + () => + filteredData.map((row) => ({ + ...row, + id: row.id || row.label, + })), + [filteredData] + ); + + return ( + + +
+
+ setSearchTerm(e.target.value)} + size="small" + fullWidth + sx={{ maxWidth: { xs: "100%", sm: 200, md: 250 } }} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: searchTerm ? ( + + setSearchTerm("")} + edge="end" + > + + + + ) : undefined, + }} + /> + +
+
+ +
+
+ + setModalOpen(false)} + title={modalMode === "add" ? "Add Asset Part Field" : "Edit Asset Part Field"} + key={modalOpen ? `${modalMode}-${selectedField?.id || 'new'}` : undefined} + > + setModalOpen(false)} + /> + +
+
+ ); +}; + +export default AssetPartFields; + diff --git a/src/pages/masterData/assetpartfields/AssetPartFieldsForm.tsx b/src/pages/masterData/assetpartfields/AssetPartFieldsForm.tsx new file mode 100644 index 000000000..33dd4410f --- /dev/null +++ b/src/pages/masterData/assetpartfields/AssetPartFieldsForm.tsx @@ -0,0 +1,478 @@ +import React, { useEffect } from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import TextField from "@mui/material/TextField"; +import Stack from "@mui/material/Stack"; +import Autocomplete from "@mui/material/Autocomplete"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import Switch from "@mui/material/Switch"; +import MenuItem from "@mui/material/MenuItem"; +import InputAdornment from "@mui/material/InputAdornment"; +import { useForm, Controller } from "react-hook-form"; +import { yupResolver } from "@hookform/resolvers/yup"; +import * as yup from "yup"; +import { useMutation, useQuery } from "@apollo/client/react"; +import { Search } from "lucide-react"; +import { showToast } from "../../../components/toastService"; +import { useLoader } from "../../../context/LoaderContext"; +import { CREATE_ASSET_PART_FIELD_MUTATION, UPDATE_ASSET_PART_FIELD_MUTATION } from "../../../graphql/mutations"; +import { ASSET_PARTS_QUERY, ASSET_PART_FIELDS_QUERY } from "../../../graphql/queries"; + +const fieldTypeOptions = [ + { value: "text", label: "Text" }, + { value: "number", label: "Number" }, + { value: "date", label: "Date" }, + { value: "datetime", label: "DateTime" }, + { value: "boolean", label: "Boolean" }, + { value: "select", label: "Select" }, + { value: "multi_select", label: "Multi Select" }, + { value: "file", label: "File" }, + { value: "url", label: "URL" }, + { value: "email", label: "Email" }, + { value: "phone", label: "Phone" }, +]; + +const schema = yup.object({ + asset_part_id: yup.string().required("Asset Part is required"), + field_name: yup.string().required("Name is required").min(1, "Name must be at least 1 character"), + description: yup.string().nullable().default(""), + field_type: yup.string().required("Field Type is required"), + allowed_values: yup.string().when("field_type", { + is: (val: string) => val === "select" || val === "multi_select", + then: (schema) => schema.required("Values are required when Field Type is Select or Multi Select"), + otherwise: (schema) => schema.nullable(), + }), + unit: yup.string().nullable().matches(/^\d*$/, "Unit must contain only numbers").default(""), + display_order: yup.number().nullable().transform((value, originalValue) => { + return originalValue === "" ? null : value; + }), + is_required: yup.boolean().default(false), + show_in_panel: yup.boolean().default(false), +}); + +type AssetPartFieldFormInputs = yup.InferType; + +type AssetPartFieldsFormProps = { + mode: "add" | "edit"; + data?: any; + onClose: () => void; + getData: () => void; +}; + +const AssetPartFieldsForm: React.FC = ({ + mode, + data, + onClose, + getData, +}) => { + const { showLoader, hideLoader } = useLoader(); + const [createAssetPartField, { loading: createLoading }] = useMutation(CREATE_ASSET_PART_FIELD_MUTATION, { + refetchQueries: [{ query: ASSET_PART_FIELDS_QUERY, variables: { asset_part_id: null } }], + }); + const [updateAssetPartField, { loading: updateLoading }] = useMutation(UPDATE_ASSET_PART_FIELD_MUTATION, { + refetchQueries: [{ query: ASSET_PART_FIELDS_QUERY, variables: { asset_part_id: null } }], + }); + + // Fetch asset parts for dropdown + const { data: assetPartsData, loading: assetPartsLoading } = useQuery(ASSET_PARTS_QUERY, { + fetchPolicy: 'cache-and-network', + }); + + const { + control, + handleSubmit, + reset, + formState: { errors }, + watch, + clearErrors, + setValue, + } = useForm({ + resolver: yupResolver(schema) as any, + mode: "onSubmit", + reValidateMode: "onChange", + defaultValues: { + asset_part_id: "", + field_name: "", + description: "", + field_type: "text", + allowed_values: "", + unit: "", + display_order: null, + is_required: false, + show_in_panel: false, + }, + }); + + const fieldType = watch("field_type"); + const showAllowedValues = fieldType === "select" || fieldType === "multi_select"; + + // Clear allowed_values when field_type changes and it's not select/multi_select + useEffect(() => { + if (fieldType && fieldType !== "select" && fieldType !== "multi_select") { + setValue("allowed_values", ""); + clearErrors("allowed_values"); + } + }, [fieldType, setValue, clearErrors]); + + useEffect(() => { + clearErrors(); + + if (mode === "edit" && data) { + reset({ + asset_part_id: data.asset_part_id || "", + field_name: data.field_name || "", + description: data.description || "", + field_type: data.field_type || "text", + allowed_values: data.allowed_values || "", + unit: data.unit || "", + display_order: data.display_order || null, + is_required: data.is_required || false, + show_in_panel: data.show_in_panel || false, + }); + } else { + reset({ + asset_part_id: "", + field_name: "", + description: "", + field_type: "text", + allowed_values: "", + unit: "", + display_order: null, + is_required: false, + show_in_panel: false, + }); + } + }, [mode, data, reset, clearErrors]); + + const handleClose = () => { + reset({ + asset_part_id: "", + field_name: "", + description: "", + field_type: "text", + allowed_values: "", + unit: "", + display_order: null, + is_required: false, + show_in_panel: false, + }); + clearErrors(); + onClose(); + }; + + const onSubmit = async (formData: AssetPartFieldFormInputs) => { + try { + showLoader(); + + // Prepare input data + const input: any = { + asset_part_id: formData.asset_part_id || null, + field_name: formData.field_name || null, + description: formData.description?.trim() || null, + field_type: formData.field_type || null, + allowed_values: formData.allowed_values?.trim() + ? formData.allowed_values.split(',').map((val: string) => val.trim()).filter((val: string) => val.length > 0) + : null, + unit: formData.unit?.trim() || null, + display_order: formData.display_order || null, + is_required: formData.is_required || false, + show_in_panel: formData.show_in_panel || false, + }; + + // Add id only in edit mode + if (mode === "edit" && data?.id) { + input.id = data.id; + } + + let result; + if (mode === "edit") { + result = await updateAssetPartField({ + variables: { input }, + }); + } else { + result = await createAssetPartField({ + variables: { input }, + }); + } + + if (result.data) { + getData(); + showToast({ + status: "success", + title: + mode === "edit" + ? "Asset part field updated successfully." + : "Asset part field created successfully.", + }); + // Reset form and close modal + reset({ + asset_part_id: "", + field_name: "", + description: "", + field_type: "text", + allowed_values: "", + unit: "", + display_order: null, + is_required: false, + show_in_panel: false, + }); + clearErrors(); + onClose(); + } + } catch (error: any) { + console.error("Error saving asset part field:", error); + showToast({ + status: "error", + title: error?.message || "Something went wrong while saving the asset part field.", + }); + } finally { + hideLoader(); + } + }; + + const assetParts = (assetPartsData as any)?.masterAssetParts || []; + + return ( + + + + ( + option.name || `Asset Part ${option.id}` || ""} + isOptionEqualToValue={(option, value) => option.id === value.id} + value={assetParts.find((part: any) => part.id === field.value) || null} + onChange={(_event, newValue: any | null) => { + field.onChange(newValue?.id || ""); + }} + onBlur={field.onBlur} + disabled={assetPartsLoading} + loading={assetPartsLoading} + size="small" + renderInput={(params) => ( + + + + + {params.InputProps.startAdornment} + + ), + }} + /> + )} + fullWidth + /> + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + { + field.onChange(e); + // Clear allowed_values when changing to non-select/multi_select types + if (e.target.value !== "select" && e.target.value !== "multi_select") { + setValue("allowed_values", ""); + clearErrors("allowed_values"); + } + }} + > + {fieldTypeOptions.map((option) => ( + + {option.label} + + ))} + + )} + /> + + {showAllowedValues && ( + ( + + )} + /> + )} + + ( + { + const value = e.target.value; + // Only allow numbers + if (value === "" || /^\d+$/.test(value)) { + field.onChange(value); + } + }} + /> + )} + /> + + ( + { + const value = e.target.value === "" ? null : Number(e.target.value); + field.onChange(value); + }} + inputProps={{ + min: 0, + }} + /> + )} + /> + + ( + + } + label="Is Required" + /> + )} + /> + + ( + + } + label="Show In Panel" + /> + )} + /> + + + + + + + ); +}; + +export default AssetPartFieldsForm; + diff --git a/src/pages/masterData/assetparts/AssetPartModal.tsx b/src/pages/masterData/assetparts/AssetPartModal.tsx new file mode 100644 index 000000000..a3669ced5 --- /dev/null +++ b/src/pages/masterData/assetparts/AssetPartModal.tsx @@ -0,0 +1,259 @@ +import React, { useEffect } from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import TextField from "@mui/material/TextField"; +import Stack from "@mui/material/Stack"; +import Autocomplete from "@mui/material/Autocomplete"; +import { useForm, Controller } from "react-hook-form"; +import { yupResolver } from "@hookform/resolvers/yup"; +import * as yup from "yup"; +import { useMutation, useQuery } from "@apollo/client/react"; +import { useLoader } from "../../../context/LoaderContext"; +import { showToast } from "../../../components/toastService"; +import { CREATE_ASSET_PART_MUTATION, UPDATE_ASSET_PART_MUTATION } from "../../../graphql/mutations"; +import { ASSET_TYPES_QUERY, ASSET_PARTS_QUERY } from "../../../graphql/queries"; + +type AssetPartFormInputs = { + name: string; + description: string; + assetTypeId: string; +}; + +const schema = yup.object({ + name: yup.string().required("Name is required").min(1, "Name must be at least 1 character"), + description: yup.string().default(""), + assetTypeId: yup.string().required("Asset Type is required"), +}); + +type AssetPartModalProps = { + mode: "add" | "edit"; + data?: any; + onClose: () => void; + getData: () => void; +}; + +const AssetPartModal: React.FC = ({ + mode, + data, + onClose, + getData +}) => { + const { showLoader, hideLoader } = useLoader(); + const [createAssetPart, { loading: createLoading }] = useMutation(CREATE_ASSET_PART_MUTATION, { + refetchQueries: [{ query: ASSET_PARTS_QUERY }], + }); + const [updateAssetPart, { loading: updateLoading }] = useMutation(UPDATE_ASSET_PART_MUTATION, { + refetchQueries: [{ query: ASSET_PARTS_QUERY }], + }); + + // Fetch asset types for dropdown + const { data: assetTypesData, loading: assetTypesLoading } = useQuery(ASSET_TYPES_QUERY, { + variables: { asset_category_id: null }, + fetchPolicy: 'cache-and-network', + }); + + const { + control, + handleSubmit, + reset, + formState: { errors }, + clearErrors, + trigger, + } = useForm({ + resolver: yupResolver(schema), + mode: "onSubmit", + reValidateMode: "onChange", + defaultValues: { + name: "", + description: "", + assetTypeId: "", + }, + }); + + useEffect(() => { + // Clear all errors and reset form when modal opens + clearErrors(); + + if (mode === "edit" && data) { + reset({ + name: data.name || "", + description: data.description || "", + assetTypeId: data.asset_type_id || data.assetTypeId || "", + }); + } else { + reset({ + name: "", + description: "", + assetTypeId: "", + }); + } + }, [mode, data, reset, clearErrors]); + + // Reset form when modal closes + const handleClose = () => { + reset({ + name: "", + description: "", + assetTypeId: "", + }); + clearErrors(); + onClose(); + }; + + const onSubmit = async (formData: AssetPartFormInputs) => { + // Trigger validation to ensure all fields are validated + const isValid = await trigger(); + if (!isValid) { + return; + } + + try { + showLoader(); + + // Prepare input data + const input: any = { + name: formData.name || null, + description: formData.description?.trim() || null, + asset_type_id: formData.assetTypeId || null, + }; + + // Add id only in edit mode + if (mode === "edit" && data?.id) { + input.id = data.id; + } + + let result; + if (mode === "edit") { + result = await updateAssetPart({ + variables: { input }, + }); + } else { + result = await createAssetPart({ + variables: { input }, + }); + } + + if (result.data) { + // Refetch data to update the grid + getData(); + const text = mode === "edit" + ? "Asset part has been updated successfully." + : "Asset part has been created successfully."; + showToast({ status: "success", title: text }); + // Reset form after successful submission + reset({ + name: "", + description: "", + assetTypeId: "", + }); + clearErrors(); + handleClose(); + } + } catch (error: any) { + console.error("Error saving asset part:", error); + showToast({ + status: "error", + title: error?.message || "Something went wrong while saving the data.", + }); + } finally { + hideLoader(); + } + }; + + // Get asset types for dropdown + const assetTypes = (assetTypesData as any)?.masterAssetTypes || []; + + return ( + + + + ( + option.name || `Asset Type ${option.id}` || ""} + isOptionEqualToValue={(option, value) => option.id === value.id} + value={assetTypes.find((assetType: any) => assetType.id === field.value) || null} + onChange={(_event, newValue: any | null) => { + field.onChange(newValue?.id || ""); + }} + onBlur={field.onBlur} + disabled={assetTypesLoading} + loading={assetTypesLoading} + size="small" + renderInput={(params) => ( + + )} + /> + )} + /> + ( + + )} + /> + + ( + + )} + /> + + + + + + + + + ); +}; + +export default AssetPartModal; + diff --git a/src/pages/masterData/assetparts/AssetParts.tsx b/src/pages/masterData/assetparts/AssetParts.tsx new file mode 100644 index 000000000..28c4b93c0 --- /dev/null +++ b/src/pages/masterData/assetparts/AssetParts.tsx @@ -0,0 +1,357 @@ +import React, { useState, useEffect, useMemo } from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import IconButton from "@mui/material/IconButton"; +import InputAdornment from "@mui/material/InputAdornment"; +import TextField from "@mui/material/TextField"; +import { ThemeProvider } from "@mui/material/styles"; +import { + DataGrid, + GridColDef, + GridPaginationModel, + GridRenderCellParams, +} from "@mui/x-data-grid"; +import { Search, X, Trash2, Pencil, Plus } from "lucide-react"; +import { useLazyQuery, useMutation, useQuery } from "@apollo/client/react"; +import RightSideModal from "../../../components/RightSideModal"; +import AssetPartModal from "./AssetPartModal"; +import { showToast } from "../../../components/toastService"; +import { useLoader } from "../../../context/LoaderContext"; +import muiTheme from "../../../themes/muiTheme"; +import { ASSET_PARTS_QUERY, ASSET_TYPES_QUERY } from "../../../graphql/queries"; +import { REMOVE_ASSET_PART_MUTATION } from "../../../graphql/mutations"; + +const AssetParts = () => { + const [searchTerm, setSearchTerm] = useState(""); + const [assetPartModalOpen, setAssetPartModalOpen] = useState(false); + const [assetPartData, setAssetPartData] = useState([]); + const [modalMode, setModalMode] = useState<"add" | "edit">("add"); + const [selectedAssetPart, setSelectedAssetPart] = useState(null); + const [paginationModel, setPaginationModel] = useState({ + page: 0, + pageSize: 10, + }); + const { showLoader, hideLoader } = useLoader(); + const [fetchAssetParts, { loading: queryLoading, refetch }] = useLazyQuery(ASSET_PARTS_QUERY); + const [hasFetched, setHasFetched] = useState(false); + const [deleteAssetPart] = useMutation(REMOVE_ASSET_PART_MUTATION, { + refetchQueries: [{ query: ASSET_PARTS_QUERY }], + }); + + // Fetch asset types to map asset_type_id to name + const { data: assetTypesData } = useQuery(ASSET_TYPES_QUERY, { + variables: { asset_category_id: null }, + }); + + // Create asset type map + const assetTypeMap = useMemo(() => { + const assetTypes = (assetTypesData as any)?.masterAssetTypes || []; + return new Map(assetTypes.map((at: any) => [at.id, at.name || ""])); + }, [assetTypesData]); + + const filteredData = useMemo( + () => + assetPartData.filter((item) => + item.name?.toLowerCase().includes(searchTerm.toLowerCase()) || + item.description?.toLowerCase().includes(searchTerm.toLowerCase()) + ), + [assetPartData, searchTerm] + ); + + // Fetch asset parts data from GraphQL API + const getAssetPartDetails = async () => { + try { + showLoader(); + + // Use refetch if query has been executed before, otherwise use fetchAssetParts + let queryData, error; + if (hasFetched && refetch) { + const result = await refetch(); + queryData = result.data; + error = result.error; + } else { + const result = await fetchAssetParts(); + queryData = result.data; + error = result.error; + setHasFetched(true); + } + + if (error) { + console.error("GraphQL error:", error); + setAssetPartData([]); + return; + } + + const resultData = (queryData as any)?.masterAssetParts; + + // Map asset parts to display format + const normalizedData = Array.isArray(resultData) + ? resultData.map((item: any) => { + return { + id: item.id, + name: item.name || "", + description: item.description || "", + asset_type_id: item.asset_type_id || "", + assetTypeName: item.asset_type_id + ? (assetTypeMap.get(item.asset_type_id) || "N/A") + : "N/A", + }; + }) + : resultData + ? [{ + id: resultData.id, + name: resultData.name || "", + description: resultData.description || "", + asset_type_id: resultData.asset_type_id || "", + assetTypeName: resultData.asset_type_id + ? (assetTypeMap.get(resultData.asset_type_id) || "N/A") + : "N/A", + }] + : []; + + setAssetPartData(normalizedData); + } catch (err: any) { + console.error("Error fetching asset parts:", err); + setAssetPartData([]); + } finally { + hideLoader(); + } + }; + + // Re-map asset part data when asset types are loaded + useEffect(() => { + if (assetTypesData && assetPartData.length > 0) { + const assetTypes = (assetTypesData as any)?.masterAssetTypes || []; + const assetTypeMap = new Map(assetTypes.map((at: any) => [at.id, at.name || ""])); + + const updatedData = assetPartData.map((item: any) => ({ + ...item, + assetTypeName: item.asset_type_id + ? (assetTypeMap.get(item.asset_type_id) || "N/A") + : "N/A", + })); + + // Only update if asset type names have changed + const hasChanges = updatedData.some((item: any, index: number) => + item.assetTypeName !== assetPartData[index]?.assetTypeName + ); + + if (hasChanges) { + setAssetPartData(updatedData); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [assetTypesData]); + + useEffect(() => { + getAssetPartDetails(); + }, []); + + const openAddModal = () => { + setModalMode("add"); + setSelectedAssetPart(null); + setAssetPartModalOpen(true); + }; + + const openEditModal = (assetPart: any) => { + setModalMode("edit"); + setSelectedAssetPart(assetPart); + setAssetPartModalOpen(true); + }; + + const handleDeleteAssetPart = async (assetPartId: any) => { + if (!assetPartId?.id) { + showToast({ + status: "error", + title: "Invalid asset part ID.", + }); + return; + } + + + + try { + showLoader(); + + const result = await deleteAssetPart({ + variables: { id: assetPartId.id }, + }); + + const deleteResult = (result.data as any)?.deleteMasterAssetPart; + if (deleteResult?.success) { + showToast({ + status: "success", + title: deleteResult.message || "Asset part deleted successfully.", + }); + + // Refresh table + getAssetPartDetails(); + } + } catch (error: any) { + console.error("Error deleting asset part:", error); + showToast({ + status: "error", + title: error?.message || "Failed to delete asset part. Please try again.", + }); + } finally { + hideLoader(); + } + }; + + + const columns: GridColDef[] = [ + { + field: "name", + headerName: "Name", + flex: 1, + minWidth: 160, + sortable: true, + }, + { + field: "description", + headerName: "Description", + flex: 1.5, + minWidth: 180, + sortable: true, + renderCell: (params: GridRenderCellParams) => ( + + {params.row.description || "NA"} + + ), + }, + { + field: "assetTypeName", + headerName: "Asset Type", + flex: 1, + minWidth: 160, + sortable: true, + valueGetter: (params) => params.row.assetTypeName || "N/A", + }, + { + field: "actions", + headerName: "Actions", + width: 160, + sortable: false, + filterable: false, + renderCell: (params: GridRenderCellParams) => ( + + openEditModal(params.row)} + size="small" + > + + + handleDeleteAssetPart(params.row)} + size="small" + > + + + + ), + }, + ]; + + const rows = useMemo( + () => + filteredData.map((row) => ({ + ...row, + id: row.id || row.name, + })), + [filteredData] + ); + + return ( + + +
+
+ setSearchTerm(e.target.value)} + size="small" + fullWidth + sx={{ maxWidth: { xs: "100%", sm: 200, md: 250 } }} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: searchTerm ? ( + + setSearchTerm("")} + edge="end" + > + + + + ) : undefined, + }} + /> + +
+
+ +
+
+ + { + setAssetPartModalOpen(false); + setSelectedAssetPart(null); + }} + title={modalMode === "add" ? "Add Asset Part" : "Edit Asset Part"} + > + {assetPartModalOpen && ( + { + setAssetPartModalOpen(false); + setSelectedAssetPart(null); + }} + /> + )} + +
+
+ ); +}; + +export default AssetParts; + diff --git a/src/pages/masterData/assignmentType/AssignmentType.tsx b/src/pages/masterData/assignmentType/AssignmentType.tsx new file mode 100644 index 000000000..428fd1637 --- /dev/null +++ b/src/pages/masterData/assignmentType/AssignmentType.tsx @@ -0,0 +1,349 @@ +import React, { useState, useEffect, useMemo } from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import IconButton from "@mui/material/IconButton"; +import InputAdornment from "@mui/material/InputAdornment"; +import TextField from "@mui/material/TextField"; +import { ThemeProvider } from "@mui/material/styles"; +import { + DataGrid, + GridColDef, + GridPaginationModel, + GridRenderCellParams, +} from "@mui/x-data-grid"; +import { Search, X, Trash2, Pencil, Plus } from "lucide-react"; +import { useLazyQuery, useMutation, useQuery } from "@apollo/client/react"; +import RightSideModal from "../../../components/RightSideModal"; +import AssignmentTypeModal from "./AssignmentTypeModal"; +import { showToast } from "../../../components/toastService"; +import { useLoader } from "../../../context/LoaderContext"; +import muiTheme from "../../../themes/muiTheme"; +import { ASSIGNMENTS_TYPES_QUERY, WORK_ORDERS_QUERY } from "../../../graphql/queries"; +import { DELETE_ASSIGNMENT_MUTATION } from "../../../graphql/mutations"; + +const AssignmentType = () => { + const [searchTerm, setSearchTerm] = useState(""); + const [assignmentTypeModalOpen, setAssignmentTypeModalOpen] = useState(false); + const [assignmentTypeData, setAssignmentTypeData] = useState([]); + const [modalMode, setModalMode] = useState<"add" | "edit">("add"); + const [selectedAssignmentType, setSelectedAssignmentType] = useState(null); + const [paginationModel, setPaginationModel] = useState({ + page: 0, + pageSize: 10, + }); + const { showLoader, hideLoader } = useLoader(); + const [fetchAssignmentTypes, { loading: queryLoading, refetch }] = useLazyQuery(ASSIGNMENTS_TYPES_QUERY); + const [hasFetched, setHasFetched] = useState(false); + const [deleteAssignment] = useMutation(DELETE_ASSIGNMENT_MUTATION, { + refetchQueries: [{ query: ASSIGNMENTS_TYPES_QUERY }], + }); + + // Fetch work orders to map workOrderId to work order name + const { data: workOrdersData } = useQuery(WORK_ORDERS_QUERY); + + const filteredData = useMemo( + () => + assignmentTypeData.filter((item) => + item.name?.toLowerCase().includes(searchTerm.toLowerCase()) + ), + [assignmentTypeData, searchTerm] + ); + + // Fetch assignment type data from GraphQL API + const getAssignmentTypeDetails = async () => { + try { + showLoader(); + + // Use refetch if query has been executed before, otherwise use fetchAssignmentTypes + let queryData, error; + if (hasFetched && refetch) { + const result = await refetch(); + queryData = result.data; + error = result.error; + } else { + const result = await fetchAssignmentTypes(); + queryData = result.data; + error = result.error; + setHasFetched(true); + } + + if (error) { + console.error("GraphQL error:", error); + setAssignmentTypeData([]); + return; + } + + const resultData = (queryData as any)?.workorderassignments; + + // Get work orders for mapping + const workOrders = (workOrdersData as any)?.workorders || []; + const workOrderMap = new Map( + workOrders.map((wo: any) => [wo.id, wo.name || wo.key || `Work Order ${wo.id}`]) + ); + + // Map assignments to display format + const normalizedData = Array.isArray(resultData) + ? resultData.map((item: any) => { + const workOrderName = item.workOrderId ? workOrderMap.get(item.workOrderId) || "N/A" : "N/A"; + return { + id: item.id, + name: item.assignmentType || "", + assignmentType: item.assignmentType || "", + workOrderId: item.workOrderId || "", + workOrderName: workOrderName, + userId: item.userId || "", + createdAt: item.createdAt, + updatedAt: item.updatedAt, + deletedAt: item.deletedAt, + }; + }) + : resultData + ? (() => { + const workOrderName = resultData.workOrderId ? workOrderMap.get(resultData.workOrderId) || "N/A" : "N/A"; + return [{ + id: resultData.id, + name: resultData.assignmentType || "", + assignmentType: resultData.assignmentType || "", + workOrderId: resultData.workOrderId || "", + workOrderName: workOrderName, + userId: resultData.userId || "", + createdAt: resultData.createdAt, + updatedAt: resultData.updatedAt, + deletedAt: resultData.deletedAt, + }]; + })() + : []; + + // Sort by createdAt descending (newest first) + const sortedData = [...normalizedData].sort((a: any, b: any) => { + const dateA = new Date(a.createdAt || 0).getTime(); + const dateB = new Date(b.createdAt || 0).getTime(); + return dateB - dateA; + }); + + setAssignmentTypeData(sortedData); + } catch (err: any) { + console.error("Error fetching assignment types:", err); + setAssignmentTypeData([]); + } finally { + hideLoader(); + } + }; + + useEffect(() => { + getAssignmentTypeDetails(); + }, []); + + // Re-map assignment data when work orders are loaded + useEffect(() => { + if (workOrdersData && assignmentTypeData.length > 0) { + const workOrders = (workOrdersData as any)?.workorders || []; + const workOrderMap = new Map( + workOrders.map((wo: any) => [wo.id, wo.name || wo.key || `Work Order ${wo.id}`]) + ); + + const updatedData = assignmentTypeData.map((item: any) => { + const workOrderName = item.workOrderId ? workOrderMap.get(item.workOrderId) || "N/A" : "N/A"; + return { + ...item, + workOrderName: workOrderName, + }; + }); + + setAssignmentTypeData(updatedData); + } + }, [workOrdersData]); + + const openAddModal = () => { + setModalMode("add"); + setSelectedAssignmentType(null); + setAssignmentTypeModalOpen(true); + }; + + const openEditModal = (assignmentType: any) => { + setModalMode("edit"); + setSelectedAssignmentType(assignmentType); + setAssignmentTypeModalOpen(true); + }; + + const handleDeleteAssignmentType = async (assignmentId: any) => { + if (!assignmentId?.id) { + showToast({ + status: "error", + title: "Invalid assignment ID.", + }); + return; + } + + try { + showLoader(); + + const result = await deleteAssignment({ + variables: { id: assignmentId.id }, + }); + + if (result.data) { + showToast({ + status: "success", + title: "Assignment deleted successfully.", + }); + + // Refresh table + getAssignmentTypeDetails(); + } + } catch (error: any) { + console.error("Error deleting assignment:", error); + showToast({ + status: "error", + title: error?.message || "Failed to delete assignment. Please try again.", + }); + } finally { + hideLoader(); + } + }; + + + const columns: GridColDef[] = [ + { + field: "name", + headerName: "Assignment Type", + flex: 1, + sortable: true, + }, + { + field: "workOrderName", + headerName: "Work Order", + flex: 1, + sortable: true, + renderCell: (params: GridRenderCellParams) => ( + + {params.row.workOrderName || "N/A"} + + ), + }, + { + field: "actions", + headerName: "Actions", + width: 160, + sortable: false, + filterable: false, + renderCell: (params: GridRenderCellParams) => ( + + openEditModal(params.row)} + size="small" + > + + + handleDeleteAssignmentType(params.row)} + size="small" + > + + + + ), + }, + ]; + + const rows = useMemo( + () => + filteredData.map((row) => ({ + ...row, + id: row.id || row.ID || row.name, + })), + [filteredData] + ); + + return ( + + +
+
+ setSearchTerm(e.target.value)} + size="small" + fullWidth + sx={{ maxWidth: { xs: "100%", sm: 200, md: 250 } }} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: searchTerm ? ( + + setSearchTerm("")} + edge="end" + > + + + + ) : undefined, + }} + /> + +
+
+ +
+
+ + { + setAssignmentTypeModalOpen(false); + setSelectedAssignmentType(null); + }} + title={modalMode === "add" ? "Add Assignment type" : "Edit Assignment type"} + > + {assignmentTypeModalOpen && ( + { + setAssignmentTypeModalOpen(false); + setSelectedAssignmentType(null); + }} + /> + )} + +
+
+ ); +}; + +export default AssignmentType; diff --git a/src/pages/masterData/assignmentType/AssignmentTypeModal.tsx b/src/pages/masterData/assignmentType/AssignmentTypeModal.tsx new file mode 100644 index 000000000..8caee0cbf --- /dev/null +++ b/src/pages/masterData/assignmentType/AssignmentTypeModal.tsx @@ -0,0 +1,238 @@ + +import React, { useEffect } from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import TextField from "@mui/material/TextField"; +import Stack from "@mui/material/Stack"; +import MenuItem from "@mui/material/MenuItem"; +import FormControl from "@mui/material/FormControl"; +import InputLabel from "@mui/material/InputLabel"; +import Select from "@mui/material/Select"; +import FormHelperText from "@mui/material/FormHelperText"; +import { useForm, Controller } from "react-hook-form"; +import { yupResolver } from "@hookform/resolvers/yup"; +import * as yup from "yup"; +import { useMutation, useQuery } from "@apollo/client/react"; +import { useLoader } from "../../../context/LoaderContext"; +import { showToast } from "../../../components/toastService"; +import { useAuth } from "../../../context/AuthContext"; +import { CREATE_ASSIGNMENT_MUTATION, UPDATE_ASSIGNMENT_MUTATION } from "../../../graphql/mutations"; +import { ASSIGNMENTS_TYPES_QUERY, WORK_ORDERS_QUERY } from "../../../graphql/queries"; + +type AssignmentFormInputs = { + assignmentType: string; + workOrderId: string; +}; + +const schema = yup.object({ + assignmentType: yup.string().required("Assignment Type is required").min(3, "Assignment Type must be at least 3 characters"), + workOrderId: yup.string().default(""), +}); + +type AssignmentTypeModalProps = { + mode: "add" | "edit"; + data?: any; + onClose: () => void; + getData: () => void; +}; + +const AssignmentTypeModal: React.FC = ({ + mode, + data, + onClose, + getData +}) => { + const { showLoader, hideLoader } = useLoader(); + const { user } = useAuth(); + const [createAssignment, { loading: createLoading }] = useMutation(CREATE_ASSIGNMENT_MUTATION, { + refetchQueries: [{ query: ASSIGNMENTS_TYPES_QUERY }], + }); + const [updateAssignment, { loading: updateLoading }] = useMutation(UPDATE_ASSIGNMENT_MUTATION, { + refetchQueries: [{ query: ASSIGNMENTS_TYPES_QUERY }], + }); + + // Fetch work orders for dropdown + const { data: workOrdersData, loading: workOrdersLoading } = useQuery(WORK_ORDERS_QUERY); + + const { + control, + handleSubmit, + reset, + formState: { errors }, + clearErrors, + trigger, + } = useForm({ + resolver: yupResolver(schema), + mode: "onSubmit", + reValidateMode: "onChange", + defaultValues: { + assignmentType: "", + workOrderId: "", + }, + }); + + useEffect(() => { + // Clear all errors and reset form when modal opens + clearErrors(); + + if (mode === "edit" && data) { + reset({ + assignmentType: data.assignmentType || data.name || "", + workOrderId: data.workOrderId || "", + }); + } else { + reset({ + assignmentType: "", + workOrderId: "", + }); + } + }, [mode, data, reset, clearErrors]); + + // Reset form when modal closes + const handleClose = () => { + reset({ + assignmentType: "", + workOrderId: "", + }); + clearErrors(); + onClose(); + }; + + const onSubmit = async (formData: AssignmentFormInputs) => { + // Trigger validation to ensure all fields are validated + const isValid = await trigger(); + if (!isValid) { + return; + } + + try { + showLoader(); + + // Get userId from AuthContext + const userId = user?.id || user?.userid || null; + if (!userId) { + showToast({ + status: "error", + title: "User ID not found. Please login again.", + }); + hideLoader(); + return; + } + + // Prepare input data + const input = { + assignmentType: formData.assignmentType || null, + workOrderId: formData.workOrderId || null, + userId: userId, + ...(mode === "edit" && data?.id ? { id: data.id } : {}), // Add id only in edit mode + }; + + let result; + if (mode === "edit") { + result = await updateAssignment({ + variables: { input }, + }); + } else { + result = await createAssignment({ + variables: { input }, + }); + } + + if (result.data) { + // Refetch data to update the grid + getData(); + const text = mode === "edit" + ? "Assignment details have been updated successfully." + : "Assignment has been added successfully."; + showToast({ status: "success", title: text }); + // Reset form after successful submission + reset({ + assignmentType: "", + workOrderId: "", + }); + clearErrors(); + handleClose(); + } + } catch (error: any) { + console.error("Error saving assignment:", error); + showToast({ + status: "error", + title: error?.message || "Something went wrong while saving the data.", + }); + } finally { + hideLoader(); + } + }; + + // Get work orders for dropdown + const workOrders = (workOrdersData as any)?.workorders || []; + + return ( + + + + ( + + )} + /> + + {/* ( + + Work Order + + {errors.workOrderId && ( + {errors.workOrderId.message} + )} + + )} + /> */} + + + + + + + ); +}; + +export default AssignmentTypeModal; diff --git a/src/pages/masterData/manufacturer/Manufacturer.tsx b/src/pages/masterData/manufacturer/Manufacturer.tsx new file mode 100644 index 000000000..537caafa0 --- /dev/null +++ b/src/pages/masterData/manufacturer/Manufacturer.tsx @@ -0,0 +1,354 @@ +import React, { useState, useEffect, useMemo } from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import IconButton from "@mui/material/IconButton"; +import InputAdornment from "@mui/material/InputAdornment"; +import TextField from "@mui/material/TextField"; +import { ThemeProvider } from "@mui/material/styles"; +import { + DataGrid, + GridColDef, + GridPaginationModel, + GridRenderCellParams, +} from "@mui/x-data-grid"; +import { Search, X, Pencil, Trash2, Plus } from "lucide-react"; +import { useLazyQuery, useMutation } from "@apollo/client/react"; +import ManufacturerDetailsModal from "./ManufacturerModal"; +import RightSideModal from "../../../components/RightSideModal"; +import { showToast } from "../../../components/toastService"; +import { useLoader } from "../../../context/LoaderContext"; +import muiTheme from "../../../themes/muiTheme"; +import { MANUFACTURERS_QUERY } from "../../../graphql/queries"; +import { DELETE_MANUFACTURER_MUTATION } from "../../../graphql/mutations"; + +const Manufacturer = () => { + const [searchTerm, setSearchTerm] = useState(""); + const [manufacturerModalOpen, setManufacturerModalOpen] = useState(false); + const [manufacturerData, setManufacturerData] = useState([]); + const [modalMode, setModalMode] = useState<"add" | "edit">("add"); + const [selectedManufacturer, setSelectedManufacturer] = useState(null); + const [paginationModel, setPaginationModel] = useState({ + page: 0, + pageSize: 10, + }); + const { showLoader, hideLoader } = useLoader(); + const [fetchManufacturers, { loading: queryLoading, refetch }] = useLazyQuery(MANUFACTURERS_QUERY); + const [hasFetched, setHasFetched] = useState(false); + const [deleteManufacturer] = useMutation(DELETE_MANUFACTURER_MUTATION, { + refetchQueries: [{ query: MANUFACTURERS_QUERY }], + }); + + const filteredData = useMemo( + () => + manufacturerData.filter((item) => { + const term = searchTerm.toLowerCase(); + return ( + item.name?.toLowerCase().includes(term) || + item.email?.toLowerCase().includes(term) || + item.website?.toLowerCase().includes(term) || + item.company_name?.toLowerCase().includes(term) || + item.contact_person?.toLowerCase().includes(term) + ); + }), + [manufacturerData, searchTerm] + ); + + // Fetch manufacturer data from GraphQL API + const getManufacturerDetails = async () => { + try { + showLoader(); + + // Use refetch if query has been executed before, otherwise use fetchManufacturers + let queryData, error; + if (hasFetched && refetch) { + const result = await refetch(); + queryData = result.data; + error = result.error; + } else { + const result = await fetchManufacturers(); + queryData = result.data; + error = result.error; + setHasFetched(true); + } + + if (error) { + console.error("GraphQL error:", error); + + setManufacturerData([]); + return; + } + + const resultData = (queryData as any)?.masterManufacturers; + const normalizedData = Array.isArray(resultData) + ? resultData.map((item: any) => ({ + ...item, + })) + : resultData + ? [resultData] + : []; + + // Sort by created_at descending (newest first) + const sortedData = [...normalizedData].sort((a: any, b: any) => { + const dateA = new Date(a.created_at || a.createdAt || 0).getTime(); + const dateB = new Date(b.created_at || b.createdAt || 0).getTime(); + return dateB - dateA; + }); + + setManufacturerData(sortedData); + } catch (err) { + console.error("Error fetching manufacturers:", err); + + setManufacturerData([]); + } finally { + hideLoader(); + } + }; + + useEffect(() => { + getManufacturerDetails(); + }, []); + + const openAddModal = () => { + setModalMode("add"); + setSelectedManufacturer(null); + setManufacturerModalOpen(true); + }; + + const openEditModal = (manufacturer: any) => { + setModalMode("edit"); + setSelectedManufacturer(manufacturer); + setManufacturerModalOpen(true); + }; + + // ------------------- DELETE FUNCTION ------------------- + const handleDelete = async (manufacturerId: string) => { + try { + showLoader(); + + const result = await deleteManufacturer({ + variables: { id: manufacturerId }, + }); + + const deleteResult = (result.data as any)?.deleteMasterManufacturer; + if (deleteResult?.success) { + showToast({ + status: "success", + title: deleteResult.message || "Manufacturer deleted successfully.", + }); + + // Refresh table + getManufacturerDetails(); + } + } catch (error: any) { + console.error("Error deleting manufacturer:", error); + showToast({ + status: "error", + title: error?.message || "Failed to delete the manufacturer.", + }); + } finally { + hideLoader(); + } + }; + // --------------------------------------------------------- + + const columns: GridColDef[] = [ + { + field: "name", + headerName: "Name", + flex: 1, + minWidth: 180, + valueGetter: (params) => params.row.name || "NA", + }, + { + field: "email", + headerName: "Email", + flex: 1, + minWidth: 200, + valueGetter: (params) => params.row.email || "NA", + }, + { + field: "company_name", + headerName: "Company Name", + flex: 1, + minWidth: 180, + valueGetter: (params) => params.row.company_name || "NA", + }, + { + field: "website", + headerName: "Website", + flex: 1, + minWidth: 160, + valueGetter: (params) => params.row.website || "NA", + }, + { + field: "address", + headerName: "Address", + flex: 1, + minWidth: 200, + valueGetter: (params) => params.row.address || "NA", + }, + { + field: "phone_number", + headerName: "Phone Number", + flex: 1, + minWidth: 160, + valueGetter: (params) => params.row.phone_number || "NA", + }, + { + field: "country_code", + headerName: "Country Code", + flex: 1, + minWidth: 140, + valueGetter: (params) => params.row.country_code || "NA", + }, + { + field: "contact_person", + headerName: "Contact Person", + flex: 1, + minWidth: 160, + valueGetter: (params) => params.row.contact_person || "NA", + }, + { + field: "actions", + headerName: "Actions", + width: 180, + sortable: false, + filterable: false, + renderCell: (params: GridRenderCellParams) => ( + + openEditModal(params.row)} + size="small" + > + + + handleDelete(params.row.id)} + size="small" + > + + + + ), + }, + ]; + + const rows = useMemo( + () => + filteredData.map((row) => ({ + ...row, + id: row.id || row.ID || row.email, + })), + [filteredData] + ); + + return ( + + +
+
+ setSearchTerm(e.target.value)} + size="small" + fullWidth + sx={{ maxWidth: { xs: "100%", sm: 240, md: 300 } }} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: searchTerm ? ( + + setSearchTerm("")} + edge="end" + > + + + + ) : undefined, + }} + /> + +
+
+ +
+
+ + setManufacturerModalOpen(false)} + title={modalMode === "add" ? "Add Manufacturer" : "Edit Manufacturer"} + > + setManufacturerModalOpen(false)} + /> + +
+
+ ); +}; + +export default Manufacturer; + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/pages/masterData/manufacturer/ManufacturerModal.tsx b/src/pages/masterData/manufacturer/ManufacturerModal.tsx new file mode 100644 index 000000000..cc3b5fd5e --- /dev/null +++ b/src/pages/masterData/manufacturer/ManufacturerModal.tsx @@ -0,0 +1,289 @@ +import React, { useState, useEffect } from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import TextField from "@mui/material/TextField"; +import Stack from "@mui/material/Stack"; +import { useMutation } from "@apollo/client/react"; +import { showToast } from "../../../components/toastService"; +import { useLoader } from "../../../context/LoaderContext"; +import { CREATE_MANUFACTURER_MUTATION, UPDATE_MANUFACTURER_MUTATION } from "../../../graphql/mutations"; +import { MANUFACTURERS_QUERY } from "../../../graphql/queries"; + +type ManufacturerDetailsModalProps = { + mode: "add" | "edit"; + data?: any; + onClose: () => void; + getData: () => void; +}; + +const ManufacturerDetailsModal: React.FC = ({ + mode, + data, + onClose, + getData +}) => { + const [formData, setFormData] = useState({ + name: '', + email: '', + company_name: '', + website: '', + address: '', + phone_number: '', + country_code: '', + contact_person: '', + }); + + const [errors, setErrors] = useState<{ name?: string }>({}); + const { showLoader, hideLoader } = useLoader(); + const [createManufacturer, { loading: createLoading }] = useMutation(CREATE_MANUFACTURER_MUTATION, { + refetchQueries: [{ query: MANUFACTURERS_QUERY }], + }); + const [updateManufacturer, { loading: updateLoading }] = useMutation(UPDATE_MANUFACTURER_MUTATION, { + refetchQueries: [{ query: MANUFACTURERS_QUERY }], + }); + + useEffect(() => { + if (mode === "edit" && data) { + setFormData({ + name: data.name || "", + email: data.email || "", + company_name: data.company_name || "", + website: data.website || "", + address: data.address || "", + phone_number: data.phone_number || "", + country_code: data.country_code || "", + contact_person: data.contact_person || "", + }); + } else { + setFormData({ + name: "", + email: "", + company_name: "", + website: "", + address: "", + phone_number: "", + country_code: "", + contact_person: "", + }); + } + }, [mode, data]); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + + // ✅ clear errors as user types + if (errors[name as keyof typeof errors]) { + setErrors((prev) => ({ ...prev, [name]: undefined })); + } + }; + + const handleUpdate = async () => { + const newErrors: { name?: string } = {}; + + if (!formData.name.trim()) { + newErrors.name = "Name is required."; + } + + if (Object.keys(newErrors).length > 0) { + setErrors(newErrors); + return; + } + + try { + showLoader(); + + // Helper function to convert empty strings to null + const toNullIfEmpty = (value: string | undefined) => { + if (!value) return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; + }; + + // Prepare input data with proper field mapping + const input: any = { + name: formData.name.trim(), + email: toNullIfEmpty(formData.email), + company_name: toNullIfEmpty(formData.company_name), + website: toNullIfEmpty(formData.website), + address: toNullIfEmpty(formData.address), + phone_number: toNullIfEmpty(formData.phone_number), + country_code: toNullIfEmpty(formData.country_code), + contact_person: toNullIfEmpty(formData.contact_person), + }; + + // Add id only in edit mode + if (mode === "edit" && data?.id) { + input.id = data.id; + } + + console.log("Sending mutation with input:", input); + console.log("Mode:", mode); + + let result; + if (mode === "edit") { + result = await updateManufacturer({ + variables: { input }, + }); + } else { + result = await createManufacturer({ + variables: { input }, + }); + } + + console.log("Mutation result:", result); + + // Check for the specific mutation response field + const responseData = mode === "edit" + ? (result.data as any)?.updateMasterManufacturer + : (result.data as any)?.createMasterManufacturer; + + if (responseData) { + // Refetch data to update the grid + getData(); + showToast({ + status: "success", + title: + mode === "edit" + ? "Manufacturer details updated successfully." + : "Manufacturer created successfully.", + }); + onClose(); + } else { + console.warn("Mutation completed but no data returned:", result); + showToast({ + status: "error", + title: "No data returned from the server. Please check the console for details.", + }); + } + } catch (error: any) { + console.error("Error saving manufacturer:", error); + console.error("Error details:", { + message: error?.message, + graphQLErrors: error?.graphQLErrors, + networkError: error?.networkError, + stack: error?.stack, + }); + + const errorMessage = error?.graphQLErrors?.[0]?.message + || error?.networkError?.message + || error?.message + || "Something went wrong while saving the data."; + + showToast({ + status: "error", + title: errorMessage, + }); + } finally { + hideLoader(); + } + }; + + return ( + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default ManufacturerDetailsModal; diff --git a/src/pages/masterData/serviceType/ServiceType.tsx b/src/pages/masterData/serviceType/ServiceType.tsx new file mode 100644 index 000000000..9ab9ee6f3 --- /dev/null +++ b/src/pages/masterData/serviceType/ServiceType.tsx @@ -0,0 +1,366 @@ +import React, { useState, useEffect, useMemo } from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import IconButton from "@mui/material/IconButton"; +import InputAdornment from "@mui/material/InputAdornment"; +import TextField from "@mui/material/TextField"; +import Typography from "@mui/material/Typography"; +import { ThemeProvider } from "@mui/material/styles"; +import { + DataGrid, + GridColDef, + GridRenderCellParams, + GridPaginationModel, +} from "@mui/x-data-grid"; +import { Search, X, Trash2, Pencil, Plus } from "lucide-react"; +import { useLazyQuery, useMutation, useQuery } from "@apollo/client/react"; +import RightSideModal from "../../../components/RightSideModal"; +import ServiceTypeModal from "./ServiceTypeModal"; +import { showToast } from "../../../components/toastService"; +import { useLoader } from "../../../context/LoaderContext"; +import muiTheme from "../../../themes/muiTheme"; +import { SERVICE_TYPE_QUERY, ASSET_CATEGORIES_QUERY } from "../../../graphql/queries"; +import { DELETE_SERVICE_TYPE_MUTATION } from "../../../graphql/mutations"; + +const ServiceType = () => { + const [searchTerm, setSearchTerm] = useState(""); + const [manufacturerModalOpen, setManufacturerModalOpen] = useState(false); + const [manufacturerData, setManufacturerData] = useState([]); + const [modalMode, setModalMode] = useState<"add" | "edit">("add"); + const [selectedManufacturer, setSelectedManufacturer] = useState(null); + const [paginationModel, setPaginationModel] = useState({ + page: 0, + pageSize: 10, + }); + const { showLoader, hideLoader } = useLoader(); + const [fetchServiceTypes, { loading: queryLoading, refetch }] = useLazyQuery(SERVICE_TYPE_QUERY); + const [hasFetched, setHasFetched] = useState(false); + const [deleteServiceType] = useMutation(DELETE_SERVICE_TYPE_MUTATION, { + refetchQueries: [{ query: SERVICE_TYPE_QUERY }], + }); + + // Fetch asset categories to map asset_category_ids to names + const { data: assetCategoriesData } = useQuery(ASSET_CATEGORIES_QUERY, { + fetchPolicy: 'cache-and-network', + }); + + // Create asset category map + const assetCategoryMap = useMemo(() => { + const assetCategories = (assetCategoriesData as any)?.masterAssetCategories || []; + return new Map(assetCategories.map((ac: any) => [ac.id, ac.name || ""])); + }, [assetCategoriesData]); + + const filteredData = useMemo( + () => + manufacturerData.filter((item) => + item.name?.toLowerCase().includes(searchTerm.toLowerCase()) || + item.description?.toLowerCase().includes(searchTerm.toLowerCase()) || + item.assetCategoryNames?.toLowerCase().includes(searchTerm.toLowerCase()) + ), + [manufacturerData, searchTerm] + ); + + // Fetch service type data from GraphQL API + const getServiceTypeDetails = async () => { + try { + showLoader(); + + // Use refetch if query has been executed before, otherwise use fetchServiceTypes + let queryData, error; + if (hasFetched && refetch) { + const result = await refetch(); + queryData = result.data; + error = result.error; + } else { + const result = await fetchServiceTypes(); + queryData = result.data; + error = result.error; + setHasFetched(true); + } + + if (error) { + console.error("GraphQL error:", error); + setManufacturerData([]); + return; + } + + const resultData = (queryData as any)?.masterAssetServiceTypes; + + // Get asset categories map for mapping IDs to names + const assetCategories = (assetCategoriesData as any)?.masterAssetCategories || []; + const categoryMap = new Map(assetCategories.map((ac: any) => [ac.id, ac.name || ""])); + + const normalizedData = Array.isArray(resultData) + ? resultData.map((item: any) => { + const categoryIds = item.asset_category_ids || []; + const categoryNames = categoryIds + .map((id: string) => categoryMap.get(id)) + .filter((name: string | undefined) => name) + .join(", "); + + return { + ...item, + assetCategoryNames: categoryNames || "N/A", + }; + }) + : resultData + ? [{ + ...resultData, + assetCategoryNames: (resultData.asset_category_ids || []) + .map((id: string) => categoryMap.get(id)) + .filter((name: string | undefined) => name) + .join(", ") || "N/A", + }] + : []; + + setManufacturerData(normalizedData); + } catch (err) { + console.error("Error fetching service types:", err); + setManufacturerData([]); + } finally { + hideLoader(); + } + }; + + // Re-map service type data when asset categories are loaded + useEffect(() => { + if (assetCategoriesData && manufacturerData.length > 0) { + const assetCategories = (assetCategoriesData as any)?.masterAssetCategories || []; + const assetCategoryMap = new Map(assetCategories.map((ac: any) => [ac.id, ac.name || ""])); + + const updatedData = manufacturerData.map((item: any) => { + const categoryIds = item.asset_category_ids || []; + const categoryNames = categoryIds + .map((id: string) => assetCategoryMap.get(id)) + .filter((name: string | undefined) => name) + .join(", "); + + return { + ...item, + assetCategoryNames: categoryNames || "N/A", + }; + }); + + // Only update if asset category names have changed + const hasChanges = updatedData.some((item: any, index: number) => + item.assetCategoryNames !== manufacturerData[index]?.assetCategoryNames + ); + + if (hasChanges) { + setManufacturerData(updatedData); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [assetCategoriesData]); + + useEffect(() => { + getServiceTypeDetails(); + }, []); + + const openAddModal = () => { + setModalMode("add"); + setSelectedManufacturer(null); + setManufacturerModalOpen(true); + }; + + const openEditModal = (manufacturer: any) => { + setModalMode("edit"); + setSelectedManufacturer(manufacturer); + setManufacturerModalOpen(true); + }; + const handleDeleteServiceType = async (workId: any) => { + try { + showLoader(); + + const result = await deleteServiceType({ + variables: { id: workId.id }, + }); + + const deleteResult = (result.data as any)?.deleteMasterAssetServiceType; + if (deleteResult) { + if (deleteResult.success) { + showToast({ + status: "success", + title: deleteResult.message || "Service type deleted successfully.", + }); + + // Refresh table + getServiceTypeDetails(); + } else { + showToast({ + status: "error", + title: deleteResult.message || "Failed to delete service type.", + }); + } + } + } catch (error: any) { + console.error("Error deleting service type:", error); + showToast({ + status: "error", + title: error?.message || "Failed to delete service type.", + }); + } finally { + hideLoader(); + } + }; + + + const columns: GridColDef[] = [ + { + field: "name", + headerName: "Name", + flex: 1, + minWidth: 160, + sortable: true, + }, + { + field: "description", + headerName: "Description", + flex: 1.5, + minWidth: 180, + sortable: true, + renderCell: (params: GridRenderCellParams) => ( + + {params.row.description || "NA"} + + ), + }, + { + field: "assetCategoryNames", + headerName: "Asset Category", + flex: 1, + minWidth: 160, + sortable: true, + renderCell: (params: GridRenderCellParams) => ( + + {params.row.assetCategoryNames || "N/A"} + + ), + }, + { + field: "actions", + headerName: "Actions", + width: 160, + sortable: false, + filterable: false, + renderCell: (params: GridRenderCellParams) => ( + + openEditModal(params.row)} + size="small" + > + + + handleDeleteServiceType(params.row)} + size="small" + > + + + + ), + }, + ]; + + const rows = useMemo( + () => + filteredData.map((row) => ({ + ...row, + id: row.id || row.ID || row.name, + })), + [filteredData] + ); + + return ( + + +
+
+ setSearchTerm(e.target.value)} + size="small" + fullWidth + sx={{ maxWidth: { xs: "100%", sm: 200, md: 250 } }} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: searchTerm ? ( + + setSearchTerm("")} + edge="end" + > + + + + ) : undefined, + }} + /> + +
+
+ +
+
+ + { + setManufacturerModalOpen(false); + setSelectedManufacturer(null); + }} + title={modalMode === "add" ? "Add Service Type" : "Edit Service Type"} + > + {manufacturerModalOpen && ( + { + setManufacturerModalOpen(false); + setSelectedManufacturer(null); + }} + /> + )} + +
+
+ ); +}; + +export default ServiceType; \ No newline at end of file diff --git a/src/pages/masterData/serviceType/ServiceTypeModal.tsx b/src/pages/masterData/serviceType/ServiceTypeModal.tsx new file mode 100644 index 000000000..9d5b0e2ba --- /dev/null +++ b/src/pages/masterData/serviceType/ServiceTypeModal.tsx @@ -0,0 +1,258 @@ + +import React, { useState, useEffect } from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import TextField from "@mui/material/TextField"; +import Stack from "@mui/material/Stack"; +import Autocomplete from "@mui/material/Autocomplete"; +import { useMutation, useQuery } from "@apollo/client/react"; +import { useLoader } from "../../../context/LoaderContext"; +import { showToast } from "../../../components/toastService"; +import { CREATE_SERVICE_TYPE_MUTATION, UPDATE_SERVICE_TYPE_MUTATION } from "../../../graphql/mutations"; +import { SERVICE_TYPE_QUERY, ASSET_CATEGORIES_QUERY } from "../../../graphql/queries"; + +type ServiceTypeModalProps = { + mode: "add" | "edit"; + data?: any; + onClose: () => void; + getData: () => void; +}; + +const ServiceTypeModal: React.FC = ({ + mode, + data, + onClose, + getData +}) => { + const [formData, setFormData] = useState({ + name: "", + description: "", + asset_category_ids: [] as string[] + }); + const [errors, setErrors] = useState<{ + name?: string; + description?: string; + asset_category_ids?: string; + }>({}); + const { showLoader, hideLoader } = useLoader(); + const [createServiceType, { loading: createLoading }] = useMutation(CREATE_SERVICE_TYPE_MUTATION, { + refetchQueries: [{ query: SERVICE_TYPE_QUERY }], + }); + const [updateServiceType, { loading: updateLoading }] = useMutation(UPDATE_SERVICE_TYPE_MUTATION, { + refetchQueries: [{ query: SERVICE_TYPE_QUERY }], + }); + + // Fetch asset categories for the dropdown + const { data: assetCategoriesData, loading: assetCategoriesLoading } = useQuery(ASSET_CATEGORIES_QUERY, { + fetchPolicy: 'cache-and-network', + }); + + const assetCategories = (assetCategoriesData as any)?.masterAssetCategories || []; + + useEffect(() => { + // Clear all errors when modal opens + setErrors({}); + + if (mode === "edit" && data) { + // Handle asset_category_ids - could be array or undefined + const categoryIds = Array.isArray(data.asset_category_ids) + ? data.asset_category_ids + : data.asset_category_ids + ? [data.asset_category_ids] + : []; + + setFormData({ + name: data.name || "", + description: data.description || "", + asset_category_ids: categoryIds, + }); + } else { + setFormData({ + name: "", + description: "", + asset_category_ids: [] + }); + } + }, [mode, data]); + + // Reset form when modal closes + const handleClose = () => { + setErrors({}); + setFormData({ + name: "", + description: "", + asset_category_ids: [] + }); + onClose(); + }; + +// const handleChange = (e: React.ChangeEvent) => { +// const { name, value } = e.target; +// setFormData((prev) => ({ ...prev, [name]: value })); +// }; +const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + setErrors((prev) => ({ ...prev, [name]: undefined })); + validateField(name, value); + }; + + const handleAssetCategoryChange = (event: any, newValue: any[]) => { + const categoryIds = newValue.map((category: any) => category.id); + setFormData((prev) => ({ ...prev, asset_category_ids: categoryIds })); + setErrors((prev) => ({ ...prev, asset_category_ids: undefined })); + }; + + const validateField = (name: string, value: string | string[]) => { + let error = ""; + + if (name === "name") { + if (typeof value === "string" && !value.trim()) { + error = "Name is required."; + } else if (typeof value === "string" && value.trim().length < 3) { + error = "Name must be at least 3 characters."; + } + } + + setErrors((prev) => ({ ...prev, [name]: error })); + }; + +// const validate = () => { +// const newErrors: { name?: string } = {}; +// if (!formData.name.trim()) { +// newErrors.name = "Name is required."; +// } else if (formData.name.trim().length < 3) { +// newErrors.name = "Name must be at least 3 characters."; +// } +// setErrors(newErrors); +// return Object.keys(newErrors).length === 0; +// }; + const validate = () => { + let valid = true; + if (!formData.name.trim() || formData.name.trim().length < 3) { + validateField("name", formData.name); + valid = false; + } + return valid; + }; + const handleUpdate = async () => { + if (!validate()) return; + + try { + showLoader(); + + // Prepare input data + const input: any = { + name: formData.name || null, + description: formData.description || null, + asset_category_ids: formData.asset_category_ids.length > 0 ? formData.asset_category_ids : null, + ...(mode === "edit" && data?.id ? { id: data.id } : {}), // Add id only in edit mode + }; + + let result; + if (mode === "edit") { + result = await updateServiceType({ + variables: { input }, + }); + } else { + result = await createServiceType({ + variables: { input }, + }); + } + + if (result.data) { + // Refetch data to update the grid + getData(); + const text = mode === "edit" + ? "Service type details have been updated successfully." + : "Service type has been added successfully."; + showToast({ status: "success", title: text }); + // Reset form after successful submission + setErrors({}); + setFormData({ + name: "", + description: "", + asset_category_ids: [] + }); + handleClose(); + } + } catch (error: any) { + console.error("Error saving service type:", error); + showToast({ + status: "error", + title: error?.message || "Something went wrong while saving the data.", + }); + } finally { + hideLoader(); + } + }; + + // Get selected asset categories for Autocomplete + const selectedAssetCategories = assetCategories.filter((category: any) => + formData.asset_category_ids.includes(category.id) + ); + + return ( + + + + + option.name || ""} + value={selectedAssetCategories} + onChange={handleAssetCategoryChange} + loading={assetCategoriesLoading} + size="small" + renderInput={(params) => ( + + )} + /> + + + + + + ); +}; + +export default ServiceTypeModal; diff --git a/src/pages/masterData/serviecCategory/ServiceCategory.tsx b/src/pages/masterData/serviecCategory/ServiceCategory.tsx new file mode 100644 index 000000000..30105d5dd --- /dev/null +++ b/src/pages/masterData/serviecCategory/ServiceCategory.tsx @@ -0,0 +1,281 @@ +import React, { useState, useEffect, useMemo } from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import IconButton from "@mui/material/IconButton"; +import InputAdornment from "@mui/material/InputAdornment"; +import TextField from "@mui/material/TextField"; +import { ThemeProvider } from "@mui/material/styles"; +import { + DataGrid, + GridColDef, + GridPaginationModel, + GridRenderCellParams, +} from "@mui/x-data-grid"; +import { useLazyQuery, useMutation } from "@apollo/client/react"; +import RightSideModal from "../../../components/RightSideModal"; +import ServiceCategoryModal from "./ServiceCategoryModal"; +import { showToast } from "../../../components/toastService"; +import { useLoader } from "../../../context/LoaderContext"; +import muiTheme from "../../../themes/muiTheme"; +import { Search, X, Pencil, Trash2, Plus } from "lucide-react"; +import { SERVICE_CATEGORIES_QUERY } from "../../../graphql/queries"; +import { DELETE_SERVICE_CATEGORY_MUTATION } from "../../../graphql/mutations"; + +const ServiceCategory = () => { + const [searchTerm, setSearchTerm] = useState(""); + const [serviceCategoryModalOpen, setServiceCategoryModalOpen] = useState(false); + const [serviceCategoryData, setServiceCategoryData] = useState([]); + const [modalMode, setModalMode] = useState<"add" | "edit">("add"); + const [selectedServiceCategory, setSelectedServiceCategory] = useState(null); + const [paginationModel, setPaginationModel] = useState({ + page: 0, + pageSize: 10, + }); + const { showLoader, hideLoader } = useLoader(); + const [fetchServiceCategories, { loading: queryLoading, refetch }] = useLazyQuery(SERVICE_CATEGORIES_QUERY); + const [hasFetched, setHasFetched] = useState(false); + const [deleteServiceCategory] = useMutation(DELETE_SERVICE_CATEGORY_MUTATION, { + refetchQueries: [{ query: SERVICE_CATEGORIES_QUERY }], + }); + + const filteredData = useMemo( + () => + serviceCategoryData.filter((item) => + item.name?.toLowerCase().includes(searchTerm.toLowerCase()) + ), + [serviceCategoryData, searchTerm] + ); + + // Fetch service category data from GraphQL API + const getServiceCategoryDetails = async () => { + try { + showLoader(); + + // Use refetch if query has been executed before, otherwise use fetchServiceCategories + let queryData, error; + if (hasFetched && refetch) { + const result = await refetch(); + queryData = result.data; + error = result.error; + } else { + const result = await fetchServiceCategories(); + queryData = result.data; + error = result.error; + setHasFetched(true); + } + + if (error) { + console.error("GraphQL error:", error); + // showToast({ + // status: "error", + // title: "Failed to load service categories. Please try again.", + // }); + setServiceCategoryData([]); + return; + } + + const resultData = (queryData as any)?.workorderservicecategories; + const normalizedData = Array.isArray(resultData) + ? resultData + : resultData + ? [resultData] + : []; + + // Sort by createdAt descending (newest first) + const sortedData = [...normalizedData].sort((a: any, b: any) => { + const dateA = new Date(a.createdAt || 0).getTime(); + const dateB = new Date(b.createdAt || 0).getTime(); + return dateB - dateA; + }); + + setServiceCategoryData(sortedData); + } catch (err: any) { + console.error("Error fetching service categories:", err); + // showToast({ + // status: "error", + // title: "Failed to load service categories. Please try again.", + // }); + setServiceCategoryData([]); + } finally { + hideLoader(); + } + }; + + useEffect(() => { + getServiceCategoryDetails(); + }, []); + + const openAddModal = () => { + setModalMode("add"); + setSelectedServiceCategory(null); + setServiceCategoryModalOpen(true); + }; + + const openEditModal = (serviceCategory: any) => { + setModalMode("edit"); + setSelectedServiceCategory(serviceCategory); + setServiceCategoryModalOpen(true); + }; + + const handleDeleteServiceCategory = async (serviceCategoryId: any) => { + if (!serviceCategoryId?.id) { + showToast({ + status: "error", + title: "Invalid service category ID.", + }); + return; + } + + try { + showLoader(); + + const result = await deleteServiceCategory({ + variables: { id: serviceCategoryId.id }, + }); + + if (result.data) { + showToast({ + status: "success", + title: "Service category deleted successfully.", + }); + + // Refresh table + getServiceCategoryDetails(); + } + } catch (error: any) { + console.error("Error deleting service category:", error); + showToast({ + status: "error", + title: error?.message || "Failed to delete service category. Please try again.", + }); + } finally { + hideLoader(); + } + }; + + + return ( + + +
+
+ setSearchTerm(e.target.value)} + size="small" + fullWidth + sx={{ maxWidth: { xs: "100%", sm: 240, md: 300 } }} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: searchTerm ? ( + + setSearchTerm("")} + edge="end" + > + + + + ) : undefined, + }} + /> + +
+
+ ({ + ...row, + id: row.id || row.ID || row.name, + }))} + columns={[ + { + field: "name", + headerName: "Name", + flex: 1, + minWidth: 160, + }, + { + field: "actions", + headerName: "Actions", + width: 180, + sortable: false, + filterable: false, + renderCell: (params: GridRenderCellParams) => ( + + openEditModal(params.row)} + size="small" + > + + + handleDeleteServiceCategory(params.row)} + size="small" + > + + + + ), + }, + ] as GridColDef[]} + disableRowSelectionOnClick + sortingOrder={["asc", "desc"]} + paginationModel={paginationModel} + pageSizeOptions={[5, 10, 25, 50]} + onPaginationModelChange={setPaginationModel} + disableColumnMenu + disableColumnFilter + localeText={{ + noRowsLabel: "No Data Found", + }} + /> +
+
+ + { + setServiceCategoryModalOpen(false); + setSelectedServiceCategory(null); + }} + title={modalMode === "add" ? "Add Service Category" : "Edit Service Category"} + > + {serviceCategoryModalOpen && ( + { + setServiceCategoryModalOpen(false); + setSelectedServiceCategory(null); + }} + /> + )} + +
+
+ ); +}; + +export default ServiceCategory; diff --git a/src/pages/masterData/serviecCategory/ServiceCategoryModal.tsx b/src/pages/masterData/serviecCategory/ServiceCategoryModal.tsx new file mode 100644 index 000000000..c8930f2cf --- /dev/null +++ b/src/pages/masterData/serviecCategory/ServiceCategoryModal.tsx @@ -0,0 +1,181 @@ + +import React, { useEffect } from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Stack from "@mui/material/Stack"; +import TextField from "@mui/material/TextField"; +import { useForm, Controller } from "react-hook-form"; +import { yupResolver } from "@hookform/resolvers/yup"; +import * as yup from "yup"; +import { useMutation } from "@apollo/client/react"; +import { useLoader } from "../../../context/LoaderContext"; +import { showToast } from "../../../components/toastService"; +import { CREATE_SERVICE_CATEGORY_MUTATION, UPDATE_SERVICE_CATEGORY_MUTATION } from "../../../graphql/mutations"; +import { SERVICE_CATEGORIES_QUERY } from "../../../graphql/queries"; + +type ServiceCategoryFormInputs = { + name: string; +}; + +const schema = yup.object({ + name: yup.string().required("Name is required").min(3, "Name must be at least 3 characters"), +}); + +type ServiceCategoryModalProps = { + mode: "add" | "edit"; + data?: any; + onClose: () => void; + getData: () => void; +}; + +const ServiceCategoryModal: React.FC = ({ + mode, + data, + onClose, + getData +}) => { + const { showLoader, hideLoader } = useLoader(); + const [createServiceCategory, { loading: createLoading }] = useMutation(CREATE_SERVICE_CATEGORY_MUTATION, { + refetchQueries: [{ query: SERVICE_CATEGORIES_QUERY }], + }); + const [updateServiceCategory, { loading: updateLoading }] = useMutation(UPDATE_SERVICE_CATEGORY_MUTATION, { + refetchQueries: [{ query: SERVICE_CATEGORIES_QUERY }], + }); + + const { + control, + handleSubmit, + reset, + formState: { errors }, + clearErrors, + trigger, + } = useForm({ + resolver: yupResolver(schema), + mode: "onSubmit", + reValidateMode: "onChange", + defaultValues: { + name: "", + }, + }); + + useEffect(() => { + // Clear all errors and reset form when modal opens + clearErrors(); + + if (mode === "edit" && data) { + reset({ + name: data.name || "", + }); + } else { + reset({ + name: "" + }); + } + }, [mode, data, reset, clearErrors]); + + // Reset form when modal closes + const handleClose = () => { + reset({ + name: "" + }); + clearErrors(); + onClose(); + }; + + const onSubmit = async (formData: ServiceCategoryFormInputs) => { + // Trigger validation to ensure all fields are validated + const isValid = await trigger(); + if (!isValid) { + return; + } + + try { + showLoader(); + + // Prepare input data + const input = { + name: formData.name || null, + ...(mode === "edit" && data?.id ? { id: data.id } : {}), // Add id only in edit mode + }; + + let result; + if (mode === "edit") { + result = await updateServiceCategory({ + variables: { input }, + }); + } else { + result = await createServiceCategory({ + variables: { input }, + }); + } + + if (result.data) { + // Refetch data to update the grid + getData(); + const text = mode === "edit" + ? "Service category details have been updated successfully." + : "Service category has been added successfully."; + showToast({ status: "success", title: text }); + // Reset form after successful submission + reset({ + name: "" + }); + clearErrors(); + handleClose(); + } + } catch (error: any) { + console.error("Error saving service category:", error); + showToast({ + status: "error", + title: error?.message || "Something went wrong while saving the data.", + }); + } finally { + hideLoader(); + } + }; + + return ( + + + + ( + + )} + /> + + + + + + + ); +}; + +export default ServiceCategoryModal; diff --git a/src/pages/masterData/vendor/Vendor.tsx b/src/pages/masterData/vendor/Vendor.tsx new file mode 100644 index 000000000..98c791cd3 --- /dev/null +++ b/src/pages/masterData/vendor/Vendor.tsx @@ -0,0 +1,336 @@ +import React, { useState, useEffect, useMemo } from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import IconButton from "@mui/material/IconButton"; +import InputAdornment from "@mui/material/InputAdornment"; +import TextField from "@mui/material/TextField"; +import Typography from "@mui/material/Typography"; +import { ThemeProvider } from "@mui/material/styles"; +import { + DataGrid, + GridColDef, + GridRenderCellParams, + GridPaginationModel, +} from "@mui/x-data-grid"; +import { useLazyQuery, useMutation } from "@apollo/client/react"; +import { Search, X, Pencil, Trash2, Plus } from "lucide-react"; +import RightSideModal from "../../../components/RightSideModal"; +import VendorModal from "./VendorModal"; +import { showToast } from "../../../components/toastService"; +import { useLoader } from "../../../context/LoaderContext"; +import muiTheme from "../../../themes/muiTheme"; +import { VENDORS_QUERY } from "../../../graphql/queries"; +import { DELETE_VENDOR_MUTATION } from "../../../graphql/mutations"; + +const Vendor = () => { + const [searchTerm, setSearchTerm] = useState(""); + const [vendorModalOpen, setVendorModalOpen] = useState(false); + const [vendorData, setVendorData] = useState([]); + const [modalMode, setModalMode] = useState("add"); + const [selectedVendor, setSelectedVendor] = useState(null); + const [paginationModel, setPaginationModel] = useState({ + page: 0, + pageSize: 10, + }); + const { showLoader, hideLoader } = useLoader(); + const [fetchVendors, { loading: queryLoading, refetch }] = useLazyQuery(VENDORS_QUERY); + const [hasFetched, setHasFetched] = useState(false); + const [deleteVendor] = useMutation(DELETE_VENDOR_MUTATION, { + refetchQueries: [{ query: VENDORS_QUERY }], + }); + + const filteredData = useMemo( + () => + vendorData.filter((item) => { + const term = searchTerm.toLowerCase(); + return ( + item.name?.toLowerCase().includes(term) || + item.email?.toLowerCase().includes(term) || + item.company_name?.toLowerCase().includes(term) || + item.website?.toLowerCase().includes(term) || + item.phone_number?.toLowerCase().includes(term) + ); + }), + [vendorData, searchTerm] + ); + + // Fetch vendor data from GraphQL API + const getVendorDetails = async () => { + try { + showLoader(); + + // Use refetch if query has been executed before, otherwise use fetchVendors + let queryData, error; + if (hasFetched && refetch) { + const result = await refetch(); + queryData = result.data; + error = result.error; + } else { + const result = await fetchVendors(); + queryData = result.data; + error = result.error; + setHasFetched(true); + } + + if (error) { + console.error("GraphQL error:", error); + setVendorData([]); + return; + } + + const resultData = (queryData as any)?.masterVendors; + const normalizedData = Array.isArray(resultData) + ? resultData.map((item: any) => ({ + ...item, + })) + : resultData + ? [resultData] + : []; + + // Sort by created_at descending (newest first) + const sortedData = [...normalizedData].sort((a: any, b: any) => { + const dateA = new Date(a.created_at || 0).getTime(); + const dateB = new Date(b.created_at || 0).getTime(); + return dateB - dateA; + }); + + setVendorData(sortedData); + } catch (err) { + console.error("Error fetching vendors:", err); + setVendorData([]); + } finally { + hideLoader(); + } + }; + + useEffect(() => { + getVendorDetails(); + }, []); + + const openAddModal = () => { + setModalMode("add"); + setSelectedVendor(null); + setVendorModalOpen(true); + }; + + const openEditModal = (vendor: any) => { + setModalMode("edit"); + setSelectedVendor(vendor); + setVendorModalOpen(true); + }; + + const handleDeleteVendor = async (vendor: any) => { + try { + showLoader(); + + const result = await deleteVendor({ + variables: { id: vendor.id }, + }); + + const deleteResult = (result.data as any)?.deleteMasterVendor; + if (deleteResult?.success) { + showToast({ + status: "success", + title: deleteResult.message || "Vendor deleted successfully.", + }); + + // Refresh table + getVendorDetails(); + } + } catch (error: any) { + console.error("Error deleting vendor:", error); + showToast({ + status: "error", + title: error?.message || "Failed to delete vendor.", + }); + } finally { + hideLoader(); + } + }; + + const getVendorTypeLabel = (type: string | null | undefined) => { + if (!type) return "NA"; + const typeMap: { [key: string]: string } = { + maintenance_provider: "Maintenance Provider", + procurement_partner: "Procurement Partner", + both: "Both", + }; + return typeMap[type] || type; + }; + + const columns: GridColDef[] = [ + { + field: "name", + headerName: "Name", + flex: 1, + minWidth: 180, + valueGetter: (params) => params.row.name || "NA", + }, + { + field: "email", + headerName: "Email", + flex: 1, + minWidth: 200, + valueGetter: (params) => params.row.email || "NA", + }, + { + field: "company_name", + headerName: "Company Name", + flex: 1, + minWidth: 180, + valueGetter: (params) => params.row.company_name || "NA", + }, + { + field: "website", + headerName: "Website", + flex: 1, + minWidth: 160, + valueGetter: (params) => params.row.website || "NA", + }, + { + field: "phone_number", + headerName: "Phone Number", + flex: 1, + minWidth: 160, + valueGetter: (params) => params.row.phone_number || "NA", + }, + { + field: "country_code", + headerName: "Country Code", + flex: 1, + minWidth: 140, + valueGetter: (params) => params.row.country_code || "NA", + }, + { + field: "vendor_type", + headerName: "Vendor Type", + flex: 1, + minWidth: 180, + valueGetter: (params) => getVendorTypeLabel(params.row.vendor_type), + }, + { + field: "actions", + headerName: "Actions", + width: 180, + sortable: false, + filterable: false, + renderCell: (params: GridRenderCellParams) => ( + + openEditModal(params.row)} + size="small" + > + + + handleDeleteVendor(params.row)} + size="small" + > + + + + ), + }, + ]; + + const rows = useMemo( + () => + filteredData.map((row) => ({ + ...row, + id: row.id || row.ID || row.email, + })), + [filteredData] + ); + + return ( + + +
+
+ setSearchTerm(e.target.value)} + size="small" + fullWidth + sx={{ maxWidth: { xs: "100%", sm: 240, md: 300 } }} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: searchTerm ? ( + + setSearchTerm("")} + edge="end" + > + + + + ) : undefined, + }} + /> + +
+
+ +
+
+ + { + setVendorModalOpen(false); + setSelectedVendor(null); + }} + title={modalMode === "add" ? "Add Vendor" : "Edit Vendor"} + > + {vendorModalOpen && ( + { + setVendorModalOpen(false); + setSelectedVendor(null); + }} + getData={getVendorDetails} + /> + )} + +
+
+ ); +}; + +export default Vendor; diff --git a/src/pages/masterData/vendor/VendorModal.tsx b/src/pages/masterData/vendor/VendorModal.tsx new file mode 100644 index 000000000..405426d8f --- /dev/null +++ b/src/pages/masterData/vendor/VendorModal.tsx @@ -0,0 +1,357 @@ +import React, { useState, useEffect } from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import TextField from "@mui/material/TextField"; +import MenuItem from "@mui/material/MenuItem"; +import Select from "@mui/material/Select"; +import FormControl from "@mui/material/FormControl"; +import InputLabel from "@mui/material/InputLabel"; +import FormHelperText from "@mui/material/FormHelperText"; +import Stack from "@mui/material/Stack"; +import { useForm, Controller } from "react-hook-form"; +import { yupResolver } from "@hookform/resolvers/yup"; +import * as yup from "yup"; +import { useMutation } from "@apollo/client/react"; +import { showToast } from "../../../components/toastService"; +import { useLoader } from "../../../context/LoaderContext"; +import { CREATE_VENDOR_MUTATION, UPDATE_VENDOR_MUTATION } from "../../../graphql/mutations"; +import { VENDORS_QUERY } from "../../../graphql/queries"; + +type VendorDetailsModalProps = { + mode: "add" | "edit"; + data?: any; + onClose: () => void; + getData: () => void; +}; + +const schema = yup.object({ + name: yup.string().required("Name is required"), + company_name: yup.string().required("Company name is required"), + email: yup.string().email("Invalid email format").optional().nullable().transform((value, originalValue) => { + return originalValue === "" ? null : value; + }), + website: yup.string().url("Invalid website URL").optional().nullable().transform((value, originalValue) => { + return originalValue === "" ? null : value; + }), + phone_number: yup.string().optional().nullable().transform((value, originalValue) => { + return originalValue === "" ? null : value; + }), + country_code: yup.string().optional().nullable().transform((value, originalValue) => { + return originalValue === "" ? null : value; + }), + vendor_type: yup.string().optional().nullable().transform((value, originalValue) => { + return originalValue === "" ? null : value; + }), +}); + +type VendorFormInputs = yup.InferType; + +const VendorModal: React.FC = ({ + mode, + data, + onClose, + getData, +}) => { + const { showLoader, hideLoader } = useLoader(); + const [createVendor, { loading: createLoading }] = useMutation(CREATE_VENDOR_MUTATION, { + refetchQueries: [{ query: VENDORS_QUERY }], + }); + const [updateVendor, { loading: updateLoading }] = useMutation(UPDATE_VENDOR_MUTATION, { + refetchQueries: [{ query: VENDORS_QUERY }], + }); + + const { + control, + handleSubmit, + formState: { errors, isSubmitting }, + reset, + clearErrors, + } = useForm({ + resolver: yupResolver(schema) as any, + mode: "onSubmit", + defaultValues: { + name: "", + email: "", + company_name: "", + website: "", + phone_number: "", + country_code: "", + vendor_type: "", + }, + }); + + useEffect(() => { + // Always clear errors first when modal opens + clearErrors(); + + if (mode === "edit" && data) { + reset({ + name: data.name || "", + email: data.email || "", + company_name: data.company_name || "", + website: data.website || "", + phone_number: data.phone_number || "", + country_code: data.country_code || "", + vendor_type: data.vendor_type || "", + }, { + keepErrors: false, + keepDirty: false, + keepIsSubmitted: false, + keepTouched: false, + keepIsValid: false, + keepSubmitCount: false, + }); + } else { + // Always reset to empty for add mode + reset({ + name: "", + email: "", + company_name: "", + website: "", + phone_number: "", + country_code: "", + vendor_type: "", + }, { + keepErrors: false, + keepDirty: false, + keepIsSubmitted: false, + keepTouched: false, + keepIsValid: false, + keepSubmitCount: false, + }); + } + }, [mode, data, reset, clearErrors]); + + // Reset form when modal closes + const handleClose = () => { + clearErrors(); + reset({ + name: "", + email: "", + company_name: "", + website: "", + phone_number: "", + country_code: "", + vendor_type: "", + }, { + keepErrors: false, + keepDirty: false, + keepIsSubmitted: false, + keepTouched: false, + keepIsValid: false, + keepSubmitCount: false, + }); + onClose(); + }; + + const handleUpdate = async (formData: VendorFormInputs) => { + try { + showLoader(); + + // Prepare input data with proper field mapping + const input: any = { + name: formData.name || null, + email: formData.email || null, + company_name: formData.company_name || null, + website: formData.website || null, + phone_number: formData.phone_number || null, + country_code: formData.country_code || null, + vendor_type: formData.vendor_type || null, + }; + + // Add id only in edit mode + if (mode === "edit" && data?.id) { + input.id = data.id; + } + + let result; + if (mode === "edit") { + result = await updateVendor({ + variables: { input }, + }); + } else { + result = await createVendor({ + variables: { input }, + }); + } + + if (result.data) { + // Refetch data to update the grid + getData(); + showToast({ + status: "success", + title: + mode === "edit" + ? "Vendor information has been updated." + : "Vendor has been created successfully.", + }); + // Reset form after successful submission + reset({ + name: "", + email: "", + company_name: "", + website: "", + phone_number: "", + country_code: "", + vendor_type: "", + }); + handleClose(); + } + } catch (error: any) { + console.error("Error saving vendor:", error); + showToast({ + status: "error", + title: error?.message || "Something went wrong while saving the data.", + }); + } finally { + hideLoader(); + } + }; + + return ( + + + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + + Vendor Type + + {errors.vendor_type && ( + {errors.vendor_type.message} + )} + + )} + /> + + + + + + ); +}; + +export default VendorModal; diff --git a/src/pages/masterData/workOrderType/WorkOrderModal1.tsx b/src/pages/masterData/workOrderType/WorkOrderModal1.tsx new file mode 100644 index 000000000..cad0e73ba --- /dev/null +++ b/src/pages/masterData/workOrderType/WorkOrderModal1.tsx @@ -0,0 +1,11 @@ +import React from 'react' + +type Props = {} + +const WorkOrderModal = (props: Props) => { + return ( +
WorkOrderModal
+ ) +} + +export default WorkOrderModal \ No newline at end of file diff --git a/src/pages/masterData/workOrderType/WorkOrderType.tsx b/src/pages/masterData/workOrderType/WorkOrderType.tsx new file mode 100644 index 000000000..3b29506ba --- /dev/null +++ b/src/pages/masterData/workOrderType/WorkOrderType.tsx @@ -0,0 +1,326 @@ +import React, { useState, useEffect } from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import IconButton from "@mui/material/IconButton"; +import InputAdornment from "@mui/material/InputAdornment"; +import TextField from "@mui/material/TextField"; +import Typography from "@mui/material/Typography"; +import { ThemeProvider } from "@mui/material/styles"; +import Paper from "@mui/material/Paper"; +import SvgIcon, { SvgIconProps } from "@mui/material/SvgIcon"; +import { DataGrid, GridColDef, GridRenderCellParams, GridPaginationModel } from "@mui/x-data-grid"; +import { useLazyQuery, useMutation } from "@apollo/client/react"; +// import ManufacturerDetailsModal from "./ManufacturerModal"; +import RightSideModal from "../../../components/RightSideModal"; +import WorkOrderTypeModal from "./WorkOrderTypeModal"; +import { showToast } from "../../../components/toastService"; +import { useLoader } from "../../../context/LoaderContext"; +import muiTheme from "../../../themes/muiTheme"; +import { Trash2, Pencil, Plus, X, Search } from 'lucide-react'; +import { WORKORDER_TYPES_QUERY } from "../../../graphql/queries"; +import { DELETE_WORK_ORDER_TYPE_MUTATION } from "../../../graphql/mutations"; + +const WorkOrderType = () => { + const [searchTerm, setSearchTerm] = useState(""); + const [paginationModel, setPaginationModel] = useState({ page: 0, pageSize: 10 }); + const [manufacturerModalOpen, setManufacturerModalOpen] = useState(false); + const [wordorderTypeData, setWorkorrdwrTypeData] = useState([]); + const [modalMode, setModalMode] = useState<"add" | "edit">("add"); + const [selectedManufacturer, setSelectedManufacturer] = useState(null); + const { showLoader, hideLoader } = useLoader(); + const [fetchWorkOrderTypes, { loading: queryLoading, refetch }] = useLazyQuery(WORKORDER_TYPES_QUERY); + const [hasFetched, setHasFetched] = useState(false); + const [deleteWorkOrderType] = useMutation(DELETE_WORK_ORDER_TYPE_MUTATION, { + refetchQueries: [{ query: WORKORDER_TYPES_QUERY }], + }); + + // Filter data based on search + const filteredData = wordorderTypeData.filter((item) => + item.name?.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + // Fetch work order type data from GraphQL API + const getWorkOrderTypesDetails = async () => { + try { + showLoader(); + + // Use refetch if query has been executed before, otherwise use fetchWorkOrderTypes + let queryData, error; + if (hasFetched && refetch) { + const result = await refetch(); + queryData = result.data; + error = result.error; + } else { + const result = await fetchWorkOrderTypes(); + queryData = result.data; + error = result.error; + setHasFetched(true); + } + + if (error) { + console.error("GraphQL error:", error); + + setWorkorrdwrTypeData([]); + return; + } + + const resultData = (queryData as any)?.workordertypes; + const normalizedData = Array.isArray(resultData) + ? resultData + : resultData + ? [resultData] + : []; + + // Sort by createdAt descending (newest first) + const sortedData = [...normalizedData].sort((a: any, b: any) => { + const dateA = new Date(a.createdAt || 0).getTime(); + const dateB = new Date(b.createdAt || 0).getTime(); + return dateB - dateA; + }); + + setWorkorrdwrTypeData(sortedData); + } catch (err: any) { + console.error("Error fetching work order types:", err); + + setWorkorrdwrTypeData([]); + } finally { + hideLoader(); + } + }; + + useEffect(() => { + getWorkOrderTypesDetails(); + }, []); + + const openAddModal = () => { + setModalMode("add"); + setSelectedManufacturer(null); + setManufacturerModalOpen(true); + }; + + const openEditModal = (manufacturer: any) => { + setModalMode("edit"); + setSelectedManufacturer(manufacturer); + setManufacturerModalOpen(true); + }; + const handleDeleteWorkOrder = async (workId: any) => { + if (!workId?.id) { + showToast({ + status: "error", + title: "Invalid work order type ID.", + }); + return; + } + + try { + showLoader(); + + const result = await deleteWorkOrderType({ + variables: { id: workId.id }, + }); + + if (result.data) { + showToast({ + status: "success", + title: "Work order type deleted successfully.", + }); + + // Refresh table + getWorkOrderTypesDetails(); + } + } catch (error: any) { + console.error("Error deleting work order type:", error); + showToast({ + status: "error", + title: error?.message || "Failed to delete work order type. Please try again.", + }); + } finally { + hideLoader(); + } + }; + + + const columns: GridColDef[] = [ + { + field: "name", + headerName: "Name", + flex: 1, + sortable: true, + }, + { + field: "actions", + headerName: "Actions", + width: 160, + sortable: false, + filterable: false, + renderCell: (params: GridRenderCellParams) => ( + + openEditModal(params.row)} + size="small" + + > + + + handleDeleteWorkOrder(params.row)} + size="small" + > + + + + + + ), + }, + ]; + + const rows = filteredData.map((row) => ({ ...row, id: row.id || row.ID || row.name })); + + return ( + + +
+
+ setSearchTerm(e.target.value)} + size="small" + fullWidth + sx={{ maxWidth: { xs: "100%", sm: 200, md: 250 } }} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: searchTerm ? ( + + setSearchTerm("")} + edge="end" + > + + + + ) : undefined, + }} + /> + +
+
+ +
+
+ + {/* Table */} + {/* + + + Workorder Type + + + + + + + + + + + + + */} + + {/* Pagination */} + {/* + + + */} + + {/* Modal */} + { + setManufacturerModalOpen(false); + setSelectedManufacturer(null); + }} + title={modalMode === "add" ? "Add Workorder Type" : "Edit Workorder Type"} + > + {manufacturerModalOpen && ( + { + setManufacturerModalOpen(false); + setSelectedManufacturer(null); + }} + /> + )} + +
+
+ ); +}; + +export default WorkOrderType; + diff --git a/src/pages/masterData/workOrderType/WorkOrderTypeModal.tsx b/src/pages/masterData/workOrderType/WorkOrderTypeModal.tsx new file mode 100644 index 000000000..5dd56ad39 --- /dev/null +++ b/src/pages/masterData/workOrderType/WorkOrderTypeModal.tsx @@ -0,0 +1,179 @@ +import React, { useEffect } from 'react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Stack from '@mui/material/Stack'; +import TextField from '@mui/material/TextField'; +import { useForm, Controller } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import * as yup from 'yup'; +import { useMutation } from '@apollo/client/react'; +import { useLoader } from '../../../context/LoaderContext'; +import { showToast } from '../../../components/toastService'; +import { CREATE_WORK_ORDER_TYPE_MUTATION, UPDATE_WORK_ORDER_TYPE_MUTATION } from '../../../graphql/mutations'; +import { WORKORDER_TYPES_QUERY } from '../../../graphql/queries'; + +type WorkOrderTypeFormInputs = { + name: string; +}; + +const schema = yup.object({ + name: yup.string().required("Name is required").min(3, "Name must be at least 3 characters"), +}); + +type WorkOrderTypeModalProps = { + mode: 'add' | 'edit'; + data?: any; + onClose: () => void; + getData: () => void; +}; + +const WorkOrderTypeModal: React.FC = ({ + mode, + data, + onClose, + getData +}) => { + const { showLoader, hideLoader } = useLoader(); + const [createWorkOrderType, { loading: createLoading }] = useMutation(CREATE_WORK_ORDER_TYPE_MUTATION, { + refetchQueries: [{ query: WORKORDER_TYPES_QUERY }], + }); + const [updateWorkOrderType, { loading: updateLoading }] = useMutation(UPDATE_WORK_ORDER_TYPE_MUTATION, { + refetchQueries: [{ query: WORKORDER_TYPES_QUERY }], + }); + + const { + control, + handleSubmit, + reset, + formState: { errors }, + clearErrors, + trigger, + } = useForm({ + resolver: yupResolver(schema), + mode: "onSubmit", + reValidateMode: "onChange", + defaultValues: { + name: "", + }, + }); + + useEffect(() => { + // Clear all errors and reset form when modal opens + clearErrors(); + + if (mode === 'edit' && data) { + reset({ + name: data.name || '', + }); + } else { + reset({ + name: '' + }); + } + }, [mode, data, reset, clearErrors]); + + // Reset form when modal closes + const handleClose = () => { + reset({ + name: '' + }); + clearErrors(); + onClose(); + }; + + const onSubmit = async (formData: WorkOrderTypeFormInputs) => { + // Trigger validation to ensure all fields are validated + const isValid = await trigger(); + if (!isValid) { + return; + } + + try { + showLoader(); + + // Prepare input data + const input = { + name: formData.name || null, + ...(mode === 'edit' && data?.id ? { id: data.id } : {}), // Add id only in edit mode + }; + + let result; + if (mode === 'edit') { + result = await updateWorkOrderType({ + variables: { input }, + }); + } else { + result = await createWorkOrderType({ + variables: { input }, + }); + } + + if (result.data) { + // Refetch data to update the grid + getData(); + const text = mode === 'edit' + ? 'Work order type details have been updated successfully.' + : 'Work order type has been added successfully.'; + showToast({ status: "success", title: text }); + // Reset form after successful submission + reset({ + name: '' + }); + clearErrors(); + handleClose(); + } + } catch (error: any) { + console.error("Error saving work order type:", error); + showToast({ + status: "error", + title: error?.message || 'Something went wrong while saving the data.', + }); + } finally { + hideLoader(); + } + }; + + return ( + + + + ( + + )} + /> + + + + + + + ); +}; + +export default WorkOrderTypeModal; diff --git a/src/pages/masterData/workorderStage/WorkOrderStage.tsx b/src/pages/masterData/workorderStage/WorkOrderStage.tsx new file mode 100644 index 000000000..f71eca154 --- /dev/null +++ b/src/pages/masterData/workorderStage/WorkOrderStage.tsx @@ -0,0 +1,268 @@ +import React, { useState, useEffect, useMemo } from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import IconButton from "@mui/material/IconButton"; +import InputAdornment from "@mui/material/InputAdornment"; +import TextField from "@mui/material/TextField"; +import { ThemeProvider } from "@mui/material/styles"; +import { DataGrid, GridColDef, GridRenderCellParams, GridPaginationModel } from "@mui/x-data-grid"; +import { useLazyQuery, useMutation } from "@apollo/client/react"; +import RightSideModal from "../../../components/RightSideModal"; +import WorkOrderStageModal from "./WorkOrderStageModal"; +import { showToast } from "../../../components/toastService"; +import { useLoader } from "../../../context/LoaderContext"; +import muiTheme from "../../../themes/muiTheme"; +import { Trash2, Pencil, Plus, X, Search } from 'lucide-react'; +import { WORKORDER_STAGES_QUERY } from "../../../graphql/queries"; +import { DELETE_WORK_ORDER_STAGE_MUTATION } from "../../../graphql/mutations"; + +const WorkOrderStage = () => { + const [searchTerm, setSearchTerm] = useState(""); + const [paginationModel, setPaginationModel] = useState({ page: 0, pageSize: 10 }); + const [manufacturerModalOpen, setManufacturerModalOpen] = useState(false); + const [manufacturerData, setManufacturerData] = useState([]); + const [modalMode, setModalMode] = useState<"add" | "edit">("add"); + const [selectedManufacturer, setSelectedManufacturer] = useState(null); + const { showLoader, hideLoader } = useLoader(); + const [fetchWorkOrderStages, { loading: queryLoading, refetch }] = useLazyQuery(WORKORDER_STAGES_QUERY); + const [hasFetched, setHasFetched] = useState(false); + const [deleteWorkOrderStage] = useMutation(DELETE_WORK_ORDER_STAGE_MUTATION, { + refetchQueries: [{ query: WORKORDER_STAGES_QUERY }], + }); + + // Filter data based on search + const filteredData = useMemo( + () => + manufacturerData.filter((item) => + item.name?.toLowerCase().includes(searchTerm.toLowerCase()) + ), + [manufacturerData, searchTerm] + ); + + // Fetch workorder stage data from GraphQL API + const getWorkOrderStageDetails = async () => { + try { + showLoader(); + + // Use refetch if query has been executed before, otherwise use fetchWorkOrderStages + let queryData, error; + if (hasFetched && refetch) { + const result = await refetch(); + queryData = result.data; + error = result.error; + } else { + const result = await fetchWorkOrderStages(); + queryData = result.data; + error = result.error; + setHasFetched(true); + } + + if (error) { + console.error("GraphQL error:", error); + setManufacturerData([]); + return; + } + + const resultData = (queryData as any)?.workorderstages; + const normalizedData = Array.isArray(resultData) + ? resultData + : resultData + ? [resultData] + : []; + + // Sort by createdAt descending (newest first) + const sortedData = [...normalizedData].sort((a: any, b: any) => { + const dateA = new Date(a.createdAt || 0).getTime(); + const dateB = new Date(b.createdAt || 0).getTime(); + return dateB - dateA; + }); + + setManufacturerData(sortedData); + } catch (err) { + console.error("Error fetching work order stages:", err); + setManufacturerData([]); + } finally { + hideLoader(); + } + }; + + useEffect(() => { + getWorkOrderStageDetails(); + }, []); + + const openAddModal = () => { + setModalMode("add"); + setSelectedManufacturer(null); + setManufacturerModalOpen(true); + }; + + const openEditModal = (manufacturer: any) => { + setModalMode("edit"); + setSelectedManufacturer(manufacturer); + setManufacturerModalOpen(true); + }; + + const handleDeleteWorkOrderStage = async (workId: any) => { + try { + showLoader(); + + const result = await deleteWorkOrderStage({ + variables: { id: workId.id }, + }); + + if (result.data) { + showToast({ + status: "success", + title: "Work order stage deleted successfully.", + }); + + // Refresh table + getWorkOrderStageDetails(); + } + } catch (error: any) { + console.error("Error deleting work order stage:", error); + showToast({ + status: "error", + title: error?.message || "Failed to delete work order stage.", + }); + } finally { + hideLoader(); + } + }; + + + const columns: GridColDef[] = [ + { + field: "name", + headerName: "Name", + flex: 1, + sortable: true, + }, + { + field: "actions", + headerName: "Actions", + width: 160, + sortable: false, + filterable: false, + renderCell: (params: GridRenderCellParams) => ( + + openEditModal(params.row)} + size="small" + > + + + handleDeleteWorkOrderStage(params.row)} + size="small" + > + + + + ), + }, + ]; + + const rows = useMemo( + () => + filteredData.map((row) => ({ + ...row, + id: row.id || row.ID || row.name, + })), + [filteredData] + ); + + return ( + + +
+
+ setSearchTerm(e.target.value)} + size="small" + fullWidth + sx={{ maxWidth: { xs: "100%", sm: 200, md: 250 } }} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: searchTerm ? ( + + setSearchTerm("")} + edge="end" + > + + + + ) : undefined, + }} + /> + +
+
+ +
+
+ + {/* Modal */} + { + setManufacturerModalOpen(false); + setSelectedManufacturer(null); + }} + title={modalMode === "add" ? "Add Workorder Stage" : "Edit Workorder Stage"} + > + {manufacturerModalOpen && ( + { + setManufacturerModalOpen(false); + setSelectedManufacturer(null); + }} + /> + )} + +
+
+ ); +}; + +export default WorkOrderStage; + diff --git a/src/pages/masterData/workorderStage/WorkOrderStageModal.tsx b/src/pages/masterData/workorderStage/WorkOrderStageModal.tsx new file mode 100644 index 000000000..f3e9627bd --- /dev/null +++ b/src/pages/masterData/workorderStage/WorkOrderStageModal.tsx @@ -0,0 +1,171 @@ +import React, { useState, useEffect } from 'react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Stack from '@mui/material/Stack'; +import TextField from '@mui/material/TextField'; +import { useMutation } from '@apollo/client/react'; +import { useLoader } from '../../../context/LoaderContext'; +import { showToast } from '../../../components/toastService'; +import { CREATE_WORK_ORDER_STAGE_MUTATION, UPDATE_WORK_ORDER_STAGE_MUTATION } from '../../../graphql/mutations'; +import { WORKORDER_STAGES_QUERY } from '../../../graphql/queries'; + +type WorkOrderStageModalProps = { + mode: 'add' | 'edit'; + data?: any; + onClose: () => void; + getData: () => void; +}; + +const WorkOrderStageModal: React.FC = ({ + mode, + data, + onClose, + getData +}) => { + const [formData, setFormData] = useState({ name: ''}); + const [errors, setErrors] = useState<{ name?: string }>({}); + const { showLoader, hideLoader } = useLoader(); + const [createWorkOrderStage, { loading: createLoading }] = useMutation(CREATE_WORK_ORDER_STAGE_MUTATION, { + refetchQueries: [{ query: WORKORDER_STAGES_QUERY }], + }); + const [updateWorkOrderStage, { loading: updateLoading }] = useMutation(UPDATE_WORK_ORDER_STAGE_MUTATION, { + refetchQueries: [{ query: WORKORDER_STAGES_QUERY }], + }); + + useEffect(() => { + // Clear all errors when modal opens + setErrors({}); + + if (mode === 'edit' && data) { + setFormData({ + name: data.name || '', + }); + } else { + setFormData({ + name: '' + }); + } + }, [mode, data]); + + // Reset form when modal closes + const handleClose = () => { + setErrors({}); + setFormData({ + name: '' + }); + onClose(); + }; + +const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + setErrors((prev) => ({ ...prev, [name]: undefined })); + validateField(name, value); + }; + + const validateField = (name: string, value: string) => { + let error = ""; + + if (name === "name") { + if (!value.trim()) { + error = "Name is required."; + } else if (value.trim().length < 3) { + error = "Name must be at least 3 characters."; + } + } + + setErrors((prev) => ({ ...prev, [name]: error })); + }; + + const validate = () => { + let valid = true; + if (!formData.name.trim() || formData.name.trim().length < 3) { + validateField("name", formData.name); + valid = false; + } + return valid; + }; + + const handleUpdate = async () => { + if (!validate()) return; + + try { + showLoader(); + + // Prepare input data + const input = { + name: formData.name || null, + color: data?.color || null, + ...(mode === 'edit' && data?.id ? { id: data.id } : {}), // Add id only in edit mode + }; + + let result; + if (mode === 'edit') { + result = await updateWorkOrderStage({ + variables: { input }, + }); + } else { + result = await createWorkOrderStage({ + variables: { input }, + }); + } + + if (result.data) { + // Refetch data to update the grid + getData(); + const text = mode === 'edit' + ? 'Work order stage details have been updated successfully.' + : 'Work order stage has been added successfully.'; + showToast({ status: "success", title: text }); + // Reset form after successful submission + setErrors({}); + setFormData({ + name: '' + }); + handleClose(); + } + } catch (error: any) { + console.error("Error saving work order stage:", error); + showToast({ + status: "error", + title: error?.message || 'Something went wrong while saving the data.', + }); + } finally { + hideLoader(); + } + }; + + return ( + + + + + + + + + + ); +}; + +export default WorkOrderStageModal; + diff --git a/src/pages/onBoard/Onboard.tsx b/src/pages/onBoard/Onboard.tsx new file mode 100644 index 000000000..dd6a26999 --- /dev/null +++ b/src/pages/onBoard/Onboard.tsx @@ -0,0 +1,1204 @@ +import { ReactNode, useEffect, useMemo, useState } from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Chip from "@mui/material/Chip"; +import CircularProgress from "@mui/material/CircularProgress"; +import Collapse from "@mui/material/Collapse"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import Divider from "@mui/material/Divider"; +import IconButton from "@mui/material/IconButton"; +import InputAdornment from "@mui/material/InputAdornment"; +import List from "@mui/material/List"; +import ListItemButton from "@mui/material/ListItemButton"; +import ListItemText from "@mui/material/ListItemText"; +import Paper from "@mui/material/Paper"; +import TextField from "@mui/material/TextField"; +import Typography from "@mui/material/Typography"; +import AddIcon from "@mui/icons-material/Add"; +import CloseIcon from "@mui/icons-material/Close"; +import DeleteIcon from "@mui/icons-material/Delete"; +import EditIcon from "@mui/icons-material/Edit"; +import ExpandLessIcon from "@mui/icons-material/ExpandLess"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import SearchIcon from "@mui/icons-material/Search"; +import RightSideModal from "../../components/RightSideModal"; +import LocationModal from "../../components/ModalContent/LocationModal"; +import FloorModal from "../../components/ModalContent/FloorModal"; +import ZoneModal from "../../components/ModalContent/ZoneModal"; +import RoomModal from "../../components/ModalContent/RoomModal"; +import AssetModal from "../../components/ModalContent/AssetModal"; +import { api } from "../../components/api"; +import { onboardData } from "./onboardData"; + +type HierarchyLevel = "location" | "floor" | "zone" | "room"; +type NodeType = HierarchyLevel | "asset"; + +type AssetContext = + | { + parentId: string; + parentType: HierarchyLevel; + } + | null; + +type TreeNode = { + nodeId: string; + id: string; + type: NodeType; + name: string; + data: any; + children: TreeNode[]; + parentChain: string[]; +}; + +type DetailField = { + key: string; + label: string; + multiline?: boolean; + placeholder?: string; +}; + +const LEVEL_LABELS: Record = { + location: "Location", + floor: "Floor", + zone: "Zone", + room: "Room", + asset: "Asset", +}; + +const LEVEL_STYLES: Record = { + location: { icon: "./images/location.svg", color: "#174899" }, + floor: { icon: "./images/floor.svg", color: "#D38400" }, + zone: { icon: "./images/zone.svg", color: "#009D6E" }, + room: { icon: "./images/room.svg", color: "#842BF6" }, + asset: { icon: "./images/asset.svg", color: "#1199D0" }, +}; + +const DETAIL_FIELDS: Record = { + location: [ + { key: "name", label: "Name" }, + { key: "description", label: "Description" }, + { key: "location_type", label: "Type" }, + { key: "address.address1", label: "Address 1" }, + { key: "address.address2", label: "Address 2" }, + { key: "address.city", label: "City" }, + { key: "address.state", label: "State" }, + { key: "address.zip", label: "Zip" }, + { key: "address.countryalpha2", label: "Country (Alpha-2)" }, + { key: "tags", label: "Tags" }, + ], + floor: [ + { key: "name", label: "Name" }, + { key: "description", label: "Description" }, + { key: "floorplan", label: "Floor plan" }, + { key: "type", label: "Type" }, + { key: "tags", label: "Tags" }, + ], + zone: [ + { key: "name", label: "Name" }, + { key: "description", label: "Description" }, + { key: "tags", label: "Tags" }, + ], + room: [ + { key: "name", label: "Name" }, + { key: "description", label: "Description" }, + { key: "tags", label: "Tags" }, + ], + asset: [ + { key: "name", label: "Name" }, + { key: "description", label: "Description" }, + { key: "assettypeid", label: "Asset Type" }, + { key: "maintenancestatus", label: "Maintenance Status" }, + { key: "tags", label: "Tags" }, + ], +}; + +const CHILD_KEY: Record = { + location: "ca_floor", + floor: "ca_zone", + zone: "ca_room", + room: "ca_asset", +}; + +const createNodeId = (type: NodeType, id?: string | null) => + `${type}:${id ?? Math.random().toString(36).slice(2)}`; + +const normalizeToArray = (value: T | T[] | null | undefined): T[] => { + if (!value) return []; + return Array.isArray(value) ? value : [value]; +}; + +const extractTags = (tags: any): string => { + if (!tags) return ""; + if (Array.isArray(tags)) { + return tags + .map((tag) => { + if (!tag) return ""; + if (typeof tag === "string") return tag; + return tag.name ?? ""; + }) + .filter(Boolean) + .join(", "); + } + if (typeof tags === "string") return tags; + return ""; +}; + +const buildTreeNodes = (locations: any[]): { roots: TreeNode[]; nodeMap: Map } => { + const nodeMap = new Map(); + + const buildHierarchyNode = ( + entity: any, + type: HierarchyLevel, + parentChain: string[] + ): TreeNode => { + const nodeId = createNodeId(type, entity.id); + const childParentChain = [...parentChain, nodeId]; + + const children: TreeNode[] = []; + + // Assets at this level + if (entity.ca_asset) { + normalizeToArray(entity.ca_asset).forEach((asset: any) => { + const assetNode: TreeNode = { + nodeId: createNodeId("asset", asset.id), + id: asset.id ?? "", + type: "asset", + name: asset.name || "Unnamed Asset", + data: asset, + children: [], + parentChain: childParentChain, + }; + children.push(assetNode); + nodeMap.set(assetNode.nodeId, assetNode); + }); + } + + // Child hierarchy + if (type !== "room") { + const childKey = CHILD_KEY[type]; + normalizeToArray(entity[childKey]).forEach((childEntity: any) => { + const childType = + type === "location" ? "floor" : type === "floor" ? "zone" : "room"; + const childNode = buildHierarchyNode(childEntity, childType, childParentChain); + children.push(childNode); + }); + } + + const node: TreeNode = { + nodeId, + id: entity.id ?? "", + type, + name: entity.name || "Untitled", + data: entity, + children, + parentChain, + }; + nodeMap.set(nodeId, node); + return node; + }; + + const roots = locations.map((location) => buildHierarchyNode(location, "location", [])); + return { roots, nodeMap }; +}; + +const renderLevelIcon = (type: NodeType) => ( + +); + +const buildDisplayValue = (node: TreeNode, fieldKey: string): string => { + const segments = fieldKey.split("."); + let value: any = node.data; + for (const segment of segments) { + if (value == null) break; + value = value[segment]; + } + + if (segments[segments.length - 1] === "tags") { + return extractTags(node.data.tags); + } + + if (value == null || value === "") return "-"; + if (Array.isArray(value)) return value.join(", "); + if (typeof value === "object") return JSON.stringify(value); + return String(value); +}; + +const deriveAssetContext = (asset: any): AssetContext => { + if (asset.roomid) return { parentType: "room", parentId: asset.roomid }; + if (asset.zoneid) return { parentType: "zone", parentId: asset.zoneid }; + if (asset.floorid) return { parentType: "floor", parentId: asset.floorid }; + if (asset.locationid) return { parentType: "location", parentId: asset.locationid }; + return null; +}; + +const Onboard = () => { + const [locationsData, setLocationsData] = useState([]); + const [loading, setLoading] = useState(false); + + const [addLocationModal, setAddLocationModal] = useState(false); + const [addFloorModal, setAddFloorModal] = useState(false); + const [addZoneModal, setAddZoneModal] = useState(false); + const [addRoomModal, setAddRoomModal] = useState(false); + const [addAssetModal, setAddAssetModal] = useState(false); + + const [locationModalData, setLocationModalData] = useState({}); + const [floorModalData, setFloorModalData] = useState({}); + const [zoneModalData, setZoneModalData] = useState({}); + const [roomModalData, setRoomModalData] = useState({}); + const [assetModalData, setAssetModalData] = useState(null); + + const [assetContext, setAssetContext] = useState(null); + const [parentId, setParentId] = useState(""); + + const [assetType, setAssetType] = useState([]); + + const [searchTerm, setSearchTerm] = useState(""); + const [selectedNodeId, setSelectedNodeId] = useState(null); + const [expandedIds, setExpandedIds] = useState([]); + const [matchedNodeIds, setMatchedNodeIds] = useState([]); + + const [confirmationModalShow, setConfirmationModalShow] = useState(false); + const [selectedDeleteData, setSelectedDeleteData] = useState({}); + + const getLocationPlansData = async () => { + try { + setLoading(true); + // const res = await api.patch("data/rest", { + // query: `ca_location{ca_floor{ca_zone{ca_room{ca_asset{}} ca_asset{}} ca_asset{}} ca_asset{}}`, + // }); + const res = onboardData; + if (res?.data?.data?.ca_location) { + const raw = res.data.data.ca_location; + setLocationsData(Array.isArray(raw) ? raw : [raw]); + } + } catch (error) { + console.error("Fetch error:", error); + } finally { + setLoading(false); + } + }; + + const getAssetType = async () => { + try { + const res = await api.patch("data/rest", { + query: `ca_asset_type{}`, + }); + if (res?.data?.data?.ca_asset_type) { + setAssetType(res.data.data.ca_asset_type); + } + } catch (error) { + console.error("Fetch asset type error:", error); + } + }; + + useEffect(() => { + getAssetType(); + getLocationPlansData(); + }, []); + + const { roots, nodeMap } = useMemo(() => buildTreeNodes(locationsData), [locationsData]); + + useEffect(() => { + if (!selectedNodeId && roots.length > 0) { + setSelectedNodeId(roots[0].nodeId); + } + + if (selectedNodeId && !nodeMap.has(selectedNodeId)) { + setSelectedNodeId(roots[0]?.nodeId ?? null); + } + }, [roots, nodeMap, selectedNodeId]); + + const selectedNode = useMemo( + () => (selectedNodeId ? nodeMap.get(selectedNodeId) ?? null : null), + [nodeMap, selectedNodeId] + ); + + useEffect(() => { + if (!selectedNode) { + setParentId(""); + return; + } + + switch (selectedNode.type) { + case "floor": + setParentId(selectedNode.data.locationid ?? ""); + break; + case "zone": + setParentId(selectedNode.data.floorid ?? ""); + break; + case "room": + setParentId(selectedNode.data.zoneid ?? ""); + break; + default: + setParentId(""); + } + + setExpandedIds((prev) => { + const next = new Set(prev); + selectedNode.parentChain.forEach((id) => next.add(id)); + next.add(selectedNode.nodeId); + return Array.from(next); + }); + }, [selectedNode]); + + useEffect(() => { + const term = searchTerm.trim().toLowerCase(); + if (!term) { + setMatchedNodeIds([]); + return; + } + + const matches = new Set(); + const expandSet = new Set(); + + const traverse = (node: TreeNode): boolean => { + const name = (node.name || "").toLowerCase(); + let hasMatch = name.includes(term); + + node.children.forEach((child) => { + if (traverse(child)) { + expandSet.add(node.nodeId); + hasMatch = true; + } + }); + + if (hasMatch) { + matches.add(node.nodeId); + node.parentChain.forEach((id) => expandSet.add(id)); + } + + return hasMatch; + }; + + roots.forEach(traverse); + + const matchesArray = Array.from(matches); + setMatchedNodeIds(matchesArray); + setExpandedIds((prev) => Array.from(new Set([...prev, ...Array.from(expandSet)]))); + + if ( + matchesArray.length > 0 && + (!selectedNodeId || !matches.has(selectedNodeId)) + ) { + setSelectedNodeId(matchesArray[0]); + } + }, [roots, searchTerm, selectedNodeId]); + + const submitLocationData = async (data: any, isEdit: boolean) => { + const payload: any = { + operations: [ + { + data: { + ca_location: [ + { + name: data.name, + description: data.description, + location_type: data.type, + address: { + address1: data.address1, + address2: data.address2, + city: data.city, + state: data.state, + zip: data.zip, + countryalpha2: data.countryalpha2, + }, + }, + ], + }, + }, + ], + }; + + if (isEdit) { + payload.operations[0].data.ca_location[0].id = data.id; + } + + try { + const action = isEdit ? api.put : api.post; + const res = await action("data/rest", payload); + if (res?.data) { + getLocationPlansData(); + setAddLocationModal(false); + setLocationModalData({}); + } + } catch (error) { + console.error("Error submitting location data:", error); + } + }; + + const submitFloorData = async (data: any, isEdit: boolean) => { + const payload: any = { + operations: [ + { + data: { + ca_floor: [ + { + name: data.name, + description: data.description, + locationid: parentId, + }, + ], + }, + }, + ], + }; + + if (isEdit) { + payload.operations[0].data.ca_floor[0].id = data.id; + } + + try { + const action = isEdit ? api.put : api.post; + const res = await action("data/rest", payload); + if (res?.data) { + getLocationPlansData(); + setAddFloorModal(false); + setFloorModalData({}); + } + } catch (error) { + console.error("Error submitting floor data:", error); + } + }; + + const submitZoneData = async (data: any, isEdit: boolean) => { + const payload: any = { + operations: [ + { + data: { + ca_zone: [ + { + name: data.name, + description: data.description, + floorid: parentId, + }, + ], + }, + }, + ], + }; + + if (isEdit) { + payload.operations[0].data.ca_zone[0].id = data.id; + } + + try { + const action = isEdit ? api.put : api.post; + const res = await action("data/rest", payload); + if (res?.data) { + getLocationPlansData(); + setAddZoneModal(false); + setZoneModalData({}); + } + } catch (error) { + console.error("Error submitting zone data:", error); + } + }; + + const submitRoomData = async (data: any, isEdit: boolean) => { + const payload: any = { + operations: [ + { + data: { + ca_room: [ + { + name: data.name, + description: data.description, + zoneid: parentId, + }, + ], + }, + }, + ], + }; + + if (isEdit) { + payload.operations[0].data.ca_room[0].id = data.id; + } + + try { + const action = isEdit ? api.put : api.post; + const res = await action("data/rest", payload); + if (res?.data) { + getLocationPlansData(); + setAddRoomModal(false); + setRoomModalData({}); + } + } catch (error) { + console.error("Error submitting room data:", error); + } + }; + + const submitAssetData = async ( + data: any, + isEdit: boolean, + contextOverride?: AssetContext + ) => { + const payload: any = { + operations: [ + { + data: { + ca_asset: [ + { + name: data.name, + assettypeid: data.assettypeid, + maintenancestatus: data.maintenancestatus, + }, + ], + }, + }, + ], + }; + + const context = contextOverride ?? assetContext; + if (context) { + const foreignKeyMap: Record = { + location: "locationid", + floor: "floorid", + zone: "zoneid", + room: "roomid", + }; + payload.operations[0].data.ca_asset[0][foreignKeyMap[context.parentType]] = + context.parentId; + } + + if (isEdit) { + payload.operations[0].data.ca_asset[0].id = data.id; + } + + try { + const action = isEdit ? api.put : api.post; + const res = await action("data/rest", payload); + if (res?.data) { + getLocationPlansData(); + setAddAssetModal(false); + setAssetModalData(null); + } + } catch (error) { + console.error("Error submitting asset data:", error); + } + }; + + const openEditModalForSelected = () => { + if (!selectedNode) return; + + switch (selectedNode.type) { + case "location": + setLocationModalData(selectedNode.data); + setAddLocationModal(true); + break; + case "floor": + setFloorModalData(selectedNode.data); + setParentId(selectedNode.data.locationid ?? ""); + setAddFloorModal(true); + break; + case "zone": + setZoneModalData(selectedNode.data); + setParentId(selectedNode.data.floorid ?? ""); + setAddZoneModal(true); + break; + case "room": + setRoomModalData(selectedNode.data); + setParentId(selectedNode.data.zoneid ?? ""); + setAddRoomModal(true); + break; + case "asset": + setAssetModalData(selectedNode.data); + setAssetContext(deriveAssetContext(selectedNode.data)); + setAddAssetModal(true); + break; + default: + break; + } + }; + + const handleAddAsset = (entity: any, level: HierarchyLevel) => { + setAssetContext({ parentId: entity.id, parentType: level }); + setAssetModalData(null); + setAddAssetModal(true); + }; + + const handleDelete = (data: any, level: NodeType) => { + setSelectedDeleteData({ ...data, levels: level }); + setConfirmationModalShow(true); + }; + + const deleteLevelsDataConfirmation = async () => { + setConfirmationModalShow(false); + const payload: any = { + operations: [ + { + data: {}, + }, + ], + }; + + switch (selectedDeleteData.levels) { + case "location": + payload.operations[0].data.ca_location = { id: selectedDeleteData.id }; + break; + case "floor": + payload.operations[0].data.ca_floor = { id: selectedDeleteData.id }; + break; + case "zone": + payload.operations[0].data.ca_zone = { id: selectedDeleteData.id }; + break; + case "room": + payload.operations[0].data.ca_room = { id: selectedDeleteData.id }; + break; + case "asset": + payload.operations[0].action = { + ca_asset: { action: "delete", deletechildren: true }, + }; + payload.operations[0].data.ca_asset = { id: selectedDeleteData.id }; + break; + default: + break; + } + + try { + const res = await api.delete("data/rest", { data: payload }); + if (res?.data) { + getLocationPlansData(); + setSelectedDeleteData({}); + } + } catch (error) { + console.error("Error deleting record:", error); + } + }; + + const handleDetailRemove = () => { + if (!selectedNode) return; + handleDelete(selectedNode.data, selectedNode.type); + }; + + const toggleExpanded = (nodeId: string) => { + setExpandedIds((prev) => + prev.includes(nodeId) ? prev.filter((id) => id !== nodeId) : [...prev, nodeId] + ); + }; + + const handleSelectNode = (nodeId: string) => { + setSelectedNodeId(nodeId); + }; + + const renderNavItems = (nodes: TreeNode[], depth = 0) => + nodes.map((node) => { + const isSelected = node.nodeId === selectedNodeId; + const isMatched = matchedNodeIds.includes(node.nodeId); + const hasChildren = node.type !== "asset" && node.children.length > 0; + const isExpanded = expandedIds.includes(node.nodeId); + + const handleClick = () => { + handleSelectNode(node.nodeId); + if (hasChildren) { + toggleExpanded(node.nodeId); + } + }; + + const handleExpandClick = (event: React.MouseEvent) => { + event.stopPropagation(); + toggleExpanded(node.nodeId); + }; + + return ( + + + {hasChildren && ( + + {isExpanded ? : } + + )} + {!hasChildren && } + {renderLevelIcon(node.type)} + + + {hasChildren && ( + + {renderNavItems(node.children, depth + 1)} + + )} + + ); + }); + + const renderDetailHeaderActions = () => { + if (!selectedNode) return null; + + const commonSx = { + textTransform: "none", + fontSize: 13, + px: 2.5, + borderRadius: 20, + }; + + const actions: ReactNode[] = []; + + const addButton = (label: string, handler: () => void, color: string) => ( + + ); + + const editButton = (label: string) => ( + + ); + + const deleteButton = ( + + ); + + switch (selectedNode.type) { + case "location": + actions.push( + addButton("New Floor", () => { + setParentId(selectedNode.data.id); + setFloorModalData({}); + setAddFloorModal(true); + }, "#5A58FF"), + addButton("New Asset", () => handleAddAsset(selectedNode.data, "location"), "#14b8a6"), + editButton("Edit Location"), + deleteButton + ); + break; + case "floor": + actions.push( + addButton("New Zone", () => { + setParentId(selectedNode.data.id); + setZoneModalData({}); + setAddZoneModal(true); + }, "#5A58FF"), + addButton("New Asset", () => handleAddAsset(selectedNode.data, "floor"), "#14b8a6"), + editButton("Edit Floor"), + deleteButton + ); + break; + case "zone": + actions.push( + addButton("New Room", () => { + setParentId(selectedNode.data.id); + setRoomModalData({}); + setAddRoomModal(true); + }, "#5A58FF"), + addButton("New Asset", () => handleAddAsset(selectedNode.data, "zone"), "#14b8a6"), + editButton("Edit Zone"), + deleteButton + ); + break; + case "room": + actions.push( + addButton("New Asset", () => handleAddAsset(selectedNode.data, "room"), "#14b8a6"), + editButton("Edit Room"), + deleteButton + ); + break; + case "asset": + actions.push(editButton("Edit Asset"), deleteButton); + break; + default: + break; + } + + return actions; + }; + + const renderDetailRows = () => { + if (!selectedNode) return null; + + return DETAIL_FIELDS[selectedNode.type].map((field) => ( + <> + {/* + + {field.label} + + {buildDisplayValue(selectedNode, field.key)} + */} +
+
{field.label}
+ {field.key !== "tags" && ( +

{buildDisplayValue(selectedNode, field.key)}

+ )} + {field.key === "tags" && ( + + {(() => { + const value = buildDisplayValue(selectedNode, field.key); + if (!value || value === "—") { + return

-

; + } + return value + .split(",") + .map((tag) => tag.trim()) + .filter(Boolean) + .map((tag) => ( + + )); + })()} +
+ )} + +
+ + )); + }; + + return ( + + {/* + + Onboard + + setSearchTerm(event.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: searchTerm ? ( + + setSearchTerm("")} aria-label="Clear search"> + + + + ) : undefined, + }} + sx={{ width: { xs: "100%", md: 280 } }} + /> + */} + + {loading && ( + + + + )} + + + + + + Locations + + + + + + setSearchTerm(event.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: searchTerm ? ( + + setSearchTerm("")} aria-label="Clear search"> + + + + ) : undefined, + }} + sx={{ width: { xs: "100%", md: 280 } }} + /> + + + {roots.length === 0 ? ( + + No locations found. Start by creating one. + + ) : ( + + {renderNavItems(roots)} + + )} + + + + {selectedNode ? ( + <> + + + + {selectedNode.name} Details + + + View & update details for the {LEVEL_LABELS[selectedNode.type].toLowerCase()}. + + + {renderDetailHeaderActions()} + + + +
+ {renderDetailRows()} +
+
+ + ) : ( + + + Select an item from the tree + + + Choose a location, floor, zone, room, or asset to view its details. + + + )} +
+
+ + setAddLocationModal(false)} + title={Object.keys(locationModalData || {}).length === 0 ? "Add Location" : "Edit Location"} + > + + + + setAddFloorModal(false)} + title={Object.keys(floorModalData || {}).length === 0 ? "Add Floor" : "Edit Floor"} + > + + + + setAddZoneModal(false)} + title={Object.keys(zoneModalData || {}).length === 0 ? "Add Zone" : "Edit Zone"} + > + + + + setAddRoomModal(false)} + title={Object.keys(roomModalData || {}).length === 0 ? "Add Room" : "Edit Room"} + > + + + + setAddAssetModal(false)} + title={assetModalData ? "Edit Asset" : "Add Asset"} + > + + + + setConfirmationModalShow(false)} + maxWidth="xs" + fullWidth + > + + + Are you sure you want to delete the following {selectedDeleteData.levels}?{" "} + + {selectedDeleteData.name} + + ? + + + + + + + +
+ ); +}; + +export default Onboard; + diff --git a/src/pages/onBoard/onboardData.js b/src/pages/onBoard/onboardData.js new file mode 100644 index 000000000..389baf3fa --- /dev/null +++ b/src/pages/onBoard/onboardData.js @@ -0,0 +1,1257 @@ +export const onboardData = { + "data":{ + "data": { + "ca_location": [ + { + "address": {}, + "ca_asset": { + "assettypeid": "01K47FSWPV7YWEM5WNSTXSQNB7", + "attributes": null, + "compliance_status": null, + "config": null, + "createdby": "olivia.morgan@criticalasset.com", + "createdon": "2025-08-28T07:33:41Z", + "deletedby": null, + "deletedon": null, + "description": "This is an HVAC duct asset in NSL Arena 2", + "fed_from": "01K3JPVXCKR7C8TKMHRZMPM077", + "fed_to": "01K3JPVXCN1PWA63HGGRE1YRGY", + "floorid": null, + "id": "01K3QSHFBZ42H19E5GEJS21QGV", + "locationid": "01K3KWWTB5FWAN7039A5JGPY8V", + "maintenancestatus": "Healthy", + "manufacturerid": "01K47P4XA16QZ322JNB4NTRHRY", + "name": "Location Asset", + "operational_metrics": null, + "operational_status": null, + "roomid": null, + "sop": "SOP: Operating, Maintenance, and Emergency Shut-Off for HVAC Duct Asset\nAsset: HVAC Duct Asset – NSL Arena 2\n\n1. PURPOSE\nTo provide standardized procedures for normal operation, preventive maintenance, and emergency shut-off of the HVAC duct asset located in NSL Arena 2, ensuring safe, reliable performance and rapid response during emergencies.\n\n2. SCOPE\nApplies to all personnel and contractors involved in the operation, inspection, testing, cleaning, and emergency management of the HVAC duct asset within NSL Arena 2. Includes dampers, actuators, associated controls, and upstream/downstream energy sources affecting this duct.\n\n3. DEFINITIONS\n- AHU: Air Handling Unit\n- Damper: Variable or manually operated control device that regulates airflow within the duct\n- Actuator: Motorized device that positions dampers\n- BMS/EMS: Building Management System / Energy Management System\n- LOTO: Lockout/Tagout procedure to isolate energy sources\n- PPE: Personal Protective Equipment\n\n4. ROLES AND RESPONSIBILITIES\n- Facility Manager: Overall accountability; approves access, scheduling, and emergency procedures.\n- MEP Supervisor: Ensures SOP adherence, coordinates maintenance, and acts as primary point of contact during emergencies.\n- Maintenance Technicians: Perform routine operation checks, inspections, cleaning, damper/actuator testing, and implement LOTO.\n- Control Room Operator (BMS/EMS): Monitors system status, initiates control actions in normal operation; supports emergency isolation through the BMS.\n- Security/Operations Staff: Assist in access control, area safety, and initiating alarms during emergencies.\n- Safety/EHS Representative: Ensures compliance with safety regulations and oversees LOTO compliance.\n\n5. REFERENCES\n- Building Management System (BMS) drawings and sequence of operations\n- Approved Preventive Maintenance (PM) schedule\n- Lockout/Tagout (LOTO) policy and procedure\n- NFPA 90A/NFPA 110 (as applicable to building mechanical systems)\n- NSL Arena 2 mechanical drawings and P&IDs\n- Cleaning and inspection guidelines for HVAC ducts (as defined by manufacturer and building standards)\n\n6. EQUIPMENT, TOOLS AND MATERIALS\n- Lockout/Tagout devices and tags\n- Energized disconnection points and service disconnects (as per electrical drawings)\n- Multimeter/voltmeter\n- Anemometer/airflow measuring device or equivalent (for validation)\n- Manometer or pressure differential gauge (if applicable)\n- Screwdrivers, wrenches, pliers, torque wrench (as required)\n- Flashlight or headlamp, PPE (hard hat, safety glasses, gloves, hearing protection as required, respiratory protection if dusty)\n- Intake/outlet damper position indicators and control wiring access\n- Access ladder or scissor lift (if needed for inspection)\n- Cleaning and inspection supplies for duct surfaces (brushes, vacuum, cloths) as approved by the PM schedule\n\n7. SAFETY PRECAUTIONS\n- Follow LOTO procedures before any electrical work or mechanical isolation.\n- Confirm clearance and obtain permits when working in confined spaces or elevated locations.\n- Wear required PPE: hard hat, eye protection, gloves, hearing protection, respiratory protection if dust, and fall protection when working at height.\n- Ensure area is cordoned off; post warning signs; maintain access to emergency egress.\n- Do not bypass or defeat damper actuators or safety interlocks.\n- Do not operate dampers or fans if abnormal vibration, smoke, or unusual odor is detected; evacuate if necessary and report.\n\n8. OPERATING PROCEDURES (NORMAL OPERATION)\n8.1 Pre-Operation Checks\n- Verify current status in the BMS: AHU status, fan running status, damper positions, supply/return pressures, temperature setpoints.\n- Confirm area around duct/workspace is clear; ensure access routes are unobstructed.\n- Check for any visible insulation damage, duct leaks, or corrosion; note issues for PM.\n\n8.2 Start-Up\n- If AHU and associated fans are not running, enable through BMS per approved sequence (start from the master controller, allow ramp time).\n- Confirm upstream and downstream dampers for the duct are aligned with design positions (as per S.O.P. or sequence of operations). Make adjustments only via BMS or manual controls if authorized.\n- Validate airflow in the duct segment with an airflow meter; ensure airflow direction matches design intent.\n- Check for abnormal noises, vibrations, or leaks; address as per PM guidelines.\n\n8.3 Normal Operation Monitoring\n- Monitor temperature differential, pressure differential, vibration levels, and noise during operation.\n- Record readings at prescribed intervals in the asset log.\n- If deviations occur, follow fault-handling steps in the Troubleshooting section or contact the MEP Supervisor.\n\n8.4 Daily/Weekly Logging\n- Log operation status, damper positions, any alarms, unusual conditions, and any corrective actions taken.\n- Ensure BMS alarms are acknowledged and routed to the control team.\n\n9. MAINTENANCE PROCEDURES\n9.1 Preventive Maintenance (PM) Schedule\n- Frequency: As per PM plan (e.g., monthly visual inspection; quarterly damper/actuator verification; semi-annual duct cleaning and insulation check; annual full system review).\n- Consider more frequent checks after major events (e.g., renovations, high dust periods).\n\n9.2 Visual Inspection\n- Inspect duct surfaces for corrosion, dents, loose insulation, holes, or sealant degradation.\n- Inspect hangers, supports, and vibration isolation for security and condition.\n- Inspect damper linkages, actuators, limit switches, and control wiring for wear or damage.\n\n9.3 Damper and Actuator Verification\n- Manually verify that dampers fully close and fully open. For motorized dampers, operate through full travel cycle via BMS or local switch.\n- Check actuator torque, travel time, and feedback signals; compare to manufacturer specifications.\n- Inspect limit switches and position indicators; recalibrate if out of spec.\n\n9.4 Duct Cleaning and Sealing\n- Schedule duct cleaning by qualified contractor per PM specification.\n- Inspect and replace seals/gaskets as needed; reseal joints showing leakage.\n- Insulate exposed duct sections if insulation degradation is evident.\n\n9.5 Insulation and Surface Integrity\n- Check insulation thickness, moisture intrusion, and surface condition; repair as required.\n- Treat any corrosion-prone areas per approved guidelines.\n\n9.6 Post-Maintenance Verification\n- Re-run BMS sequence; confirm airflow, damper positions, and system responsiveness.\n- Document maintenance actions, parts replaced, and any calibration corrections.\n\n9.7 Documentation\n- Record PM date, personnel, findings, corrective actions, and next PM due date.\n- Save updated drawings or control sequences if changes were made.\n\n10. EMERGENCY SHUT-OFF PROCEDURES\n10.1 Triggering Situations\n- Fire, smoke, gas leakage, significant duct or damper malfunction, electrical fault with risk to personnel, or any condition requiring immediate isolation of the duct asset.\n\n10.2 Immediate Actions (FAST RESPONSE)\n- Raise alarm and notify facilities management and security. If life safety is involved, call emergency services.\n- Evacuate area if required; ensure safe clearance from the immediate duct path.\n\n10.3 Isolation Steps\n- Identify and isolate energy sources:\n - Electrical: Access the electrical disconnects feeding the AHU and any associated fans serving this duct. Use LOTO to lock out and tag out the disconnects before proceeding.\n - Mechanical: Close all motorized dampers controlling the duct segment via the BMS or local control panels to the fully closed position.\n- If safe to do so, manually close any redundant dampers that feed into or out of the affected duct segment, ensuring safe access and minimal exposure.\n\n10.4 Verification of Isolation\n- Confirm there is no airflow in the duct segment using airflow measurement tools or BMS status indicators.\n- Verify that all energy sources are physically isolated and tagged.\n\n10.5 Communications and Safety\n- Notify control room, facilities management, and safety officer of the shutdown.\n- Post hazard signage and restrict access to the area until clearance is given.\n- If conditions indicate fire or hazardous atmosphere, follow fire/safety protocols and do not re-enter until cleared by fire or safety personnel.\n\n10.6 Post-Event actions\n- Document the incident: time, location, cause, actions taken, personnel involved, and any damages.\n- Notify relevant parties for a formal incident review.\n- Initiate corrective actions to prevent recurrence (repair, parts replacement, control sequence update).\n\n10.7 Recommissioning (After Hazard Clearance)\n- Only after clearance from safety and facilities must you perform a controlled re-energization.\n- Remove LOTO devices only after all work is complete and the area is safe.\n- Re-energize the AHU and associated fans in stages, verifying damper positions and airflow at each step.\n- Monitor for normal operation for a defined stabilization period; document results and return to normal operation logs.\n\n11. LOCKOUT/TAGOUT (LOTO) PROCEDURE\n- Obtain authorization from the MEP Supervisor before initiating LOTO.\n- Identify all energy sources feeding the HVAC duct asset (electrical, mechanical, pneumatic controls).\n- Apply Lockout devices and attach tags clearly indicating the asset, date, reason, and responsible person.\n- Verify isolation by attempting to re-energize controls and by measuring absence of voltage and absence of mechanical motion as applicable.\n- Keep LOTO devices in place until the asset is re-commissioned and all work is completed; remove only after supervisor approval.\n\n12. RECORDS AND DOCUMENTATION\n- Maintain an asset log with: operation status, PM dates, inspections, damper/actuator tests, cleaning actions, any deviations, LOTO events, and emergency shutdowns.\n- Attach drawing updates, sequence changes, and approved procedures to the asset record.\n- Retain incident reports and corrective action follow-ups for audit.\n\n13. TRAINING AND QUALIFICATIONS\n- All personnel must complete training on: normal operation, PM procedures, LOTO, confined spaces, fall protection (where applicable), using BMS controls, and emergency response.\n- Periodic refreshers and drills must be conducted per the security and safety schedule.\n\n14. AUDIT AND REVIEW\n- The SOP shall be reviewed annually or after significant changes to the duct asset, control sequences, or after an incident.\n- Updates must be approved by the Facility Manager and communicated to all affected personnel.\n\n15. ATTACHMENTS\n- Quick Reference Emergency Actions Sheet (damper closure steps, control panel disconnection points)\n- Contact List (Facilities, Safety, Security, Emergency Services)\n- PM Schedule and Checklists\n- LOTO Forms and Tag Templates\n- Damper/Actuator Commissioning and Verification Worksheets\n\nNote: The above SOP should be customized to reflect the specific equipment manufacturer instructions, the exact control sequences in the NSL Arena 2 BMS, and the internal NSL facilities policies. Ensure asset ID, exact disconnect locations, and damper coordinates are filled in where indicated.", + "status": null, + "tags": [ + { + "name": "Asset" + } + ], + "updatedby": "ava.turner@criticalasset.com", + "updatedon": "2025-09-03T13:45:24Z", + "vendorid": null, + "warranty_status": null, + "zoneid": null + }, + "ca_floor": [ + { + "ca_asset": { + "assettypeid": "01K3JN6V503FB1XER6A1DT6STQ", + "attributes": null, + "compliance_status": null, + "config": null, + "createdby": "olivia.morgan@criticalasset.com", + "createdon": "2025-08-28T07:36:25Z", + "deletedby": null, + "deletedon": null, + "description": null, + "fed_from": null, + "fed_to": null, + "floorid": "01K3P8DQ9Z733M92Y2Z9CCHWPW", + "id": "01K3QSPG3Y9RVV8B55HWDZFGXV", + "locationid": null, + "maintenancestatus": "Healthy", + "manufacturerid": null, + "name": "Floor Asset", + "operational_metrics": null, + "operational_status": null, + "roomid": null, + "sop": null, + "status": null, + "tags": null, + "updatedby": "olivia.morgan@criticalasset.com", + "updatedon": "2025-08-28T07:36:25Z", + "vendorid": null, + "warranty_status": null, + "zoneid": null + }, + "ca_zone": [ + { + "ca_asset": { + "assettypeid": "01K3JN6V52QQDC2HXNYQ3KKGA6", + "attributes": null, + "compliance_status": null, + "config": null, + "createdby": "olivia.morgan@criticalasset.com", + "createdon": "2025-08-28T07:37:29Z", + "deletedby": null, + "deletedon": null, + "description": null, + "fed_from": null, + "fed_to": null, + "floorid": null, + "id": "01K3QSRDS9G20PDR4Q07MF17NP", + "locationid": null, + "maintenancestatus": "Healthy", + "manufacturerid": null, + "name": "Zone Asset", + "operational_metrics": null, + "operational_status": null, + "roomid": null, + "sop": null, + "status": null, + "tags": null, + "updatedby": "olivia.morgan@criticalasset.com", + "updatedon": "2025-08-28T07:37:29Z", + "vendorid": null, + "warranty_status": null, + "zoneid": "01K3PDW9H22ZHSH6G8CE4Z3N6K" + }, + "ca_room": [ + { + "ca_asset": { + "assettypeid": "01K3JN6V4XTZQ1WVPVD9958JKZ", + "attributes": null, + "compliance_status": null, + "config": null, + "createdby": "olivia.morgan@criticalasset.com", + "createdon": "2025-08-28T07:43:13Z", + "deletedby": null, + "deletedon": null, + "description": null, + "fed_from": null, + "fed_to": null, + "floorid": null, + "id": "01K3QT2XMTSRW69ZYC60DGTFWW", + "locationid": null, + "maintenancestatus": "Healthy", + "manufacturerid": null, + "name": "Cabin Room", + "operational_metrics": null, + "operational_status": null, + "roomid": "01K3QK4EJSYVQ6DMS5DQ2GPXCC", + "sop": null, + "status": null, + "tags": null, + "updatedby": "olivia.morgan@criticalasset.com", + "updatedon": "2025-08-28T07:43:13Z", + "vendorid": null, + "warranty_status": null, + "zoneid": null + }, + "createdby": "olivia.morgan@criticalasset.com", + "createdon": "2025-08-28T05:41:43Z", + "deletedby": null, + "deletedon": null, + "description": "Cabin 5", + "id": "01K3QK4EJSYVQ6DMS5DQ2GPXCC", + "name": "Cabin 5", + "tags": null, + "updatedby": "olivia.morgan@criticalasset.com", + "updatedon": "2025-08-28T10:27:07Z", + "zoneid": "01K3PDW9H22ZHSH6G8CE4Z3N6K" + }, + { + "ca_asset": { + "assettypeid": "01K3JN6V503FB1XER6A1DT6STQ", + "attributes": null, + "compliance_status": null, + "config": null, + "createdby": "olivia.morgan@criticalasset.com", + "createdon": "2025-08-28T09:32:35Z", + "deletedby": null, + "deletedon": null, + "description": null, + "fed_from": null, + "fed_to": null, + "floorid": null, + "id": "01K3R0B5ZGMPEY9N2CR3N68X6D", + "locationid": null, + "maintenancestatus": "yes", + "manufacturerid": null, + "name": "testing asset", + "operational_metrics": null, + "operational_status": null, + "roomid": "01K3QK90JQH3JV0WK90RHK5TNB", + "sop": null, + "status": null, + "tags": null, + "updatedby": "olivia.morgan@criticalasset.com", + "updatedon": "2025-08-28T09:32:35Z", + "vendorid": null, + "warranty_status": null, + "zoneid": null + }, + "createdby": "olivia.morgan@criticalasset.com", + "createdon": "2025-08-28T05:44:12Z", + "deletedby": null, + "deletedon": null, + "description": "cabin 1", + "id": "01K3QK90JQH3JV0WK90RHK5TNB", + "name": "cabin 1", + "tags": null, + "updatedby": "olivia.morgan@criticalasset.com", + "updatedon": "2025-08-28T05:44:12Z", + "zoneid": "01K3PDW9H22ZHSH6G8CE4Z3N6K" + } + ], + "createdby": "olivia.morgan@criticalasset.com", + "createdon": "2025-08-27T18:50:38Z", + "deletedby": null, + "deletedon": null, + "description": "East Zone 4", + "floorid": "01K3P8DQ9Z733M92Y2Z9CCHWPW", + "id": "01K3PDW9H22ZHSH6G8CE4Z3N6K", + "name": "East Zone 4", + "tags": null, + "updatedby": "ava.turner@criticalasset.com", + "updatedon": "2025-08-31T10:17:14Z" + }, + { + "ca_asset": null, + "ca_room": { + "ca_asset": null, + "createdby": "olivia.morgan@criticalasset.com", + "createdon": "2025-08-28T05:41:02Z", + "deletedby": null, + "deletedon": null, + "description": "Cabin 1", + "id": "01K3QK36MJ7E0X7AQF8T5T9502", + "name": "Cabin 1", + "tags": null, + "updatedby": "olivia.morgan@criticalasset.com", + "updatedon": "2025-08-28T05:41:02Z", + "zoneid": "01K3PDZ34WE1R2MH5BWYPYDAVW" + }, + "createdby": "olivia.morgan@criticalasset.com", + "createdon": "2025-08-27T18:52:10Z", + "deletedby": null, + "deletedon": null, + "description": "West Zone", + "floorid": "01K3P8DQ9Z733M92Y2Z9CCHWPW", + "id": "01K3PDZ34WE1R2MH5BWYPYDAVW", + "name": "West Zone", + "tags": null, + "updatedby": "olivia.morgan@criticalasset.com", + "updatedon": "2025-08-27T18:52:10Z" + }, + { + "ca_asset": null, + "ca_room": null, + "createdby": "olivia.morgan@criticalasset.com", + "createdon": "2025-08-28T05:54:07Z", + "deletedby": null, + "deletedon": null, + "description": "zone1 ", + "floorid": "01K3P8DQ9Z733M92Y2Z9CCHWPW", + "id": "01K3QKV5YBB63CPGQNMF04DPJS", + "name": "Zone 1", + "tags": null, + "updatedby": "olivia.morgan@criticalasset.com", + "updatedon": "2025-08-28T05:54:07Z" + } + ], + "createdby": "olivia.morgan@criticalasset.com", + "createdon": "2025-08-27T17:15:18Z", + "createdusing": null, + "deletedby": null, + "deletedon": null, + "description": "Floor 1 for 12 seater cabins1", + "floorplan": null, + "id": "01K3P8DQ9Z733M92Y2Z9CCHWPW", + "locationid": "01K3KWWTB5FWAN7039A5JGPY8V", + "name": "Float Uno", + "tags": null, + "type": "", + "updatedby": "ava.turner@criticalasset.com", + "updatedon": "2025-08-31T10:14:36Z" + }, + { + "ca_asset": null, + "ca_zone": [ + { + "ca_asset": null, + "ca_room": { + "ca_asset": null, + "createdby": "ava.turner@criticalasset.com", + "createdon": "2025-08-29T16:17:52Z", + "deletedby": null, + "deletedon": null, + "description": "New room for Zone 1", + "id": "01K3V9Y0CXQ9FK6500FHT9JZE2", + "name": "Room 1", + "tags": null, + "updatedby": "ava.turner@criticalasset.com", + "updatedon": "2025-08-29T16:17:52Z", + "zoneid": "01K3V9SDMS8E0DJ37EA543BEKF" + }, + "createdby": "ava.turner@criticalasset.com", + "createdon": "2025-08-29T16:15:22Z", + "deletedby": null, + "deletedon": null, + "description": "First Zone for floor test1", + "floorid": "01K3P8KRDMW2V109FYW81BKGEY", + "id": "01K3V9SDMS8E0DJ37EA543BEKF", + "name": "Zone 1", + "tags": null, + "updatedby": "ava.turner@criticalasset.com", + "updatedon": "2025-08-29T16:15:22Z" + }, + { + "ca_asset": null, + "ca_room": null, + "createdby": "ava.turner@criticalasset.com", + "createdon": "2025-08-29T16:21:19Z", + "deletedby": null, + "deletedon": null, + "description": "Another zone for this floor", + "floorid": "01K3P8KRDMW2V109FYW81BKGEY", + "id": "01K3VA4APCVFGTQ92A2PBABYDM", + "name": "Zone 2", + "tags": null, + "updatedby": "ava.turner@criticalasset.com", + "updatedon": "2025-08-29T16:21:19Z" + } + ], + "createdby": "olivia.morgan@criticalasset.com", + "createdon": "2025-08-27T17:18:35Z", + "createdusing": null, + "deletedby": null, + "deletedon": null, + "description": "test1", + "floorplan": null, + "id": "01K3P8KRDMW2V109FYW81BKGEY", + "locationid": "01K3KWWTB5FWAN7039A5JGPY8V", + "name": "test1", + "tags": null, + "type": "", + "updatedby": "olivia.morgan@criticalasset.com", + "updatedon": "2025-08-27T17:18:35Z" + }, + { + "ca_asset": null, + "ca_zone": { + "ca_asset": null, + "ca_room": null, + "createdby": "olivia.morgan@criticalasset.com", + "createdon": "2025-08-28T10:36:33Z", + "deletedby": null, + "deletedon": null, + "description": "Zone uno ", + "floorid": "01K3R3YP1FNB9GY6NTH0DAJ2VD", + "id": "01K3R40AH41W1EM6HQCGCKQ9H5", + "name": "Zone dos", + "tags": null, + "updatedby": "olivia.morgan@criticalasset.com", + "updatedon": "2025-08-28T10:37:37Z" + }, + "createdby": "olivia.morgan@criticalasset.com", + "createdon": "2025-08-28T10:35:39Z", + "createdusing": null, + "deletedby": null, + "deletedon": null, + "description": "Floor Dos description", + "floorplan": null, + "id": "01K3R3YP1FNB9GY6NTH0DAJ2VD", + "locationid": "01K3KWWTB5FWAN7039A5JGPY8V", + "name": "Floor Dos", + "tags": [ + { + "name": "floor" + } + ], + "type": "", + "updatedby": "ava.turner@criticalasset.com", + "updatedon": "2025-09-01T12:54:19Z" + } + ], + "createdby": "olivia.morgan@criticalasset.com", + "createdon": "2025-08-26T19:15:21Z", + "deletedby": null, + "deletedon": null, + "description": "A multistory building for business1", + "id": "01K3KWWTB5FWAN7039A5JGPY8V", + "location_type": "building", + "name": "NSL arena 2", + "parentid": null, + "tags": [ + { + "name": "arena" + }, + { + "name": "nsl" + } + ], + "updatedby": "ava.turner@criticalasset.com", + "updatedon": "2025-08-31T10:15:59Z" + }, + { + "address": { + "address1": "Jubilee ", + "address2": "Enclave", + "city": "HYD", + "countryalpha2": "IN", + "state": "telanagan", + "zip": "500040" + }, + "ca_asset": { + "assettypeid": "01K3JN6V4XTZQ1WVPVD9958JKZ", + "attributes": null, + "compliance_status": null, + "config": null, + "createdby": "olivia.morgan@criticalasset.com", + "createdon": "2025-08-28T11:11:37Z", + "deletedby": null, + "deletedon": null, + "description": null, + "fed_from": null, + "fed_to": null, + "floorid": null, + "id": "01K3R60HCQ4NE22ZKQHPRKBNX6", + "locationid": "01K3R560084HPXWCAWM7PBG5MD", + "maintenancestatus": "on going", + "manufacturerid": null, + "name": "Location Asset 1 ", + "operational_metrics": null, + "operational_status": null, + "roomid": null, + "sop": null, + "status": null, + "tags": null, + "updatedby": "ava.turner@criticalasset.com", + "updatedon": "2025-08-31T12:24:06Z", + "vendorid": null, + "warranty_status": null, + "zoneid": null + }, + "ca_floor": [ + { + "ca_asset": { + "assettypeid": "01K3JN6V503FB1XER6A1DT6STQ", + "attributes": null, + "compliance_status": null, + "config": null, + "createdby": "olivia.morgan@criticalasset.com", + "createdon": "2025-08-28T11:12:20Z", + "deletedby": null, + "deletedon": null, + "description": null, + "fed_from": null, + "fed_to": null, + "floorid": "01K3R59SAH5XJ8EGE6DA5KHMK3", + "id": "01K3R61V8N7FMD5GRMJW8GBJN0", + "locationid": null, + "maintenancestatus": "on going", + "manufacturerid": null, + "name": "floor asset 1", + "operational_metrics": null, + "operational_status": null, + "roomid": null, + "sop": null, + "status": null, + "tags": null, + "updatedby": "olivia.morgan@criticalasset.com", + "updatedon": "2025-08-28T11:12:20Z", + "vendorid": null, + "warranty_status": null, + "zoneid": null + }, + "ca_zone": [ + { + "ca_asset": { + "assettypeid": "01K3JN6V4XTZQ1WVPVD9958JKZ", + "attributes": null, + "compliance_status": null, + "config": null, + "createdby": "olivia.morgan@criticalasset.com", + "createdon": "2025-08-28T11:12:47Z", + "deletedby": null, + "deletedon": null, + "description": null, + "fed_from": null, + "fed_to": null, + "floorid": null, + "id": "01K3R62N3A1G87NME8AKSPX5M5", + "locationid": null, + "maintenancestatus": "on going", + "manufacturerid": null, + "name": "zone asset 1 ", + "operational_metrics": null, + "operational_status": null, + "roomid": null, + "sop": null, + "status": null, + "tags": null, + "updatedby": "olivia.morgan@criticalasset.com", + "updatedon": "2025-08-28T11:12:47Z", + "vendorid": null, + "warranty_status": null, + "zoneid": "01K3R5V4HDASK3CF4PA35WPT6C" + }, + "ca_room": { + "ca_asset": { + "assettypeid": "01K3JN6V503FB1XER6A1DT6STQ", + "attributes": null, + "compliance_status": null, + "config": null, + "createdby": "olivia.morgan@criticalasset.com", + "createdon": "2025-08-28T11:13:14Z", + "deletedby": null, + "deletedon": null, + "description": null, + "fed_from": null, + "fed_to": null, + "floorid": null, + "id": "01K3R63FF7WN0GM3BM2C0D2PBV", + "locationid": null, + "maintenancestatus": "on going", + "manufacturerid": null, + "name": "room asset", + "operational_metrics": null, + "operational_status": null, + "roomid": "01K3R5WP5PRQKAAQM61RPHR407", + "sop": null, + "status": null, + "tags": null, + "updatedby": "olivia.morgan@criticalasset.com", + "updatedon": "2025-08-28T11:13:14Z", + "vendorid": null, + "warranty_status": null, + "zoneid": null + }, + "createdby": "olivia.morgan@criticalasset.com", + "createdon": "2025-08-28T11:09:31Z", + "deletedby": null, + "deletedon": null, + "description": "room one", + "id": "01K3R5WP5PRQKAAQM61RPHR407", + "name": "room one", + "tags": null, + "updatedby": "olivia.morgan@criticalasset.com", + "updatedon": "2025-08-28T11:10:01Z", + "zoneid": "01K3R5V4HDASK3CF4PA35WPT6C" + }, + "createdby": "olivia.morgan@criticalasset.com", + "createdon": "2025-08-28T11:08:40Z", + "deletedby": null, + "deletedon": null, + "description": "zone uno", + "floorid": "01K3R59SAH5XJ8EGE6DA5KHMK3", + "id": "01K3R5V4HDASK3CF4PA35WPT6C", + "name": "zone one", + "tags": null, + "updatedby": "olivia.morgan@criticalasset.com", + "updatedon": "2025-08-28T11:09:15Z" + }, + { + "ca_asset": null, + "ca_room": null, + "createdby": "olivia.morgan@criticalasset.com", + "createdon": "2025-08-28T11:09:02Z", + "deletedby": null, + "deletedon": null, + "description": "zone dos", + "floorid": "01K3R59SAH5XJ8EGE6DA5KHMK3", + "id": "01K3R5VSMEHD052AP130B0VRC0", + "name": "zone dos", + "tags": null, + "updatedby": "olivia.morgan@criticalasset.com", + "updatedon": "2025-08-28T11:09:02Z" + } + ], + "createdby": "olivia.morgan@criticalasset.com", + "createdon": "2025-08-28T10:59:12Z", + "createdusing": null, + "deletedby": null, + "deletedon": null, + "description": "Floor Uno Check", + "floorplan": null, + "id": "01K3R59SAH5XJ8EGE6DA5KHMK3", + "locationid": "01K3R560084HPXWCAWM7PBG5MD", + "name": "Floor One", + "tags": null, + "type": "", + "updatedby": "olivia.morgan@criticalasset.com", + "updatedon": "2025-08-28T11:08:18Z" + }, + { + "ca_asset": null, + "ca_zone": null, + "createdby": "olivia.morgan@criticalasset.com", + "createdon": "2025-08-28T11:07:19Z", + "createdusing": null, + "deletedby": null, + "deletedon": null, + "description": "Floor Dos", + "floorplan": null, + "id": "01K3R5RMWZC73SWV493FG8VBNA", + "locationid": "01K3R560084HPXWCAWM7PBG5MD", + "name": "Floor Dos", + "tags": null, + "type": "", + "updatedby": "olivia.morgan@criticalasset.com", + "updatedon": "2025-08-28T11:07:19Z" + }, + { + "ca_asset": null, + "ca_zone": null, + "createdby": "olivia.morgan@criticalasset.com", + "createdon": "2025-08-28T11:07:38Z", + "createdusing": null, + "deletedby": null, + "deletedon": null, + "description": "Floor Tres", + "floorplan": null, + "id": "01K3R5S7NPYMD9555CRJTR4R0S", + "locationid": "01K3R560084HPXWCAWM7PBG5MD", + "name": "Floor Tres", + "tags": null, + "type": "", + "updatedby": "ava.turner@criticalasset.com", + "updatedon": "2025-08-31T12:24:42Z" + } + ], + "createdby": "olivia.morgan@criticalasset.com", + "createdon": "2025-08-28T10:57:08Z", + "deletedby": null, + "deletedon": null, + "description": "NSL Check", + "id": "01K3R560084HPXWCAWM7PBG5MD", + "location_type": "building", + "name": "NSL Checking", + "parentid": null, + "tags": null, + "updatedby": "olivia.morgan@criticalasset.com", + "updatedon": "2025-08-28T11:07:58Z" + }, + { + "address": { + "address1": "101 Parkway", + "address2": "Central PArk", + "city": "New York", + "state": "New York", + "zip": "526600" + }, + "ca_asset": null, + "ca_floor": [ + { + "ca_asset": null, + "ca_zone": [ + { + "ca_asset": null, + "ca_room": [ + { + "ca_asset": { + "assettypeid": "01K3JN6V52QQDC2HXNYQ3KKGA6", + "attributes": null, + "compliance_status": null, + "config": null, + "createdby": "ethan.thompson@criticalasset.com", + "createdon": "2025-08-29T14:23:39Z", + "deletedby": null, + "deletedon": null, + "description": "A split AC which is taking the cool air from the central hvac system", + "fed_from": "01K3JPVXCGCD2HC144H9V0MQFF", + "fed_to": "01K3JPVXCKR7C8TKMHRZMPM077", + "floorid": null, + "id": "01K3V3CVYMM2SN3RX6PDJ3QFSV", + "locationid": null, + "maintenancestatus": "Active", + "manufacturerid": null, + "name": "SplitSL10001", + "operational_metrics": null, + "operational_status": null, + "roomid": "01K3RNK8XW09W423KZVSGRDTPM", + "sop": "Standard Operating Procedure (SOP)\nAsset: SplitSL10001\nDescription: A split air-conditioning terminal that delivers conditioned air drawn from the building’s central HVAC system.\n\n1. Purpose\n- Establish standardized operating, maintenance, and emergency shut-off procedures for SplitSL10001 to ensure safe, reliable cooling, avoid cross-contamination with the central system, and minimize downtime.\n\n2. Scope\n- Applies to all personnel responsible for operating, maintaining, testing, and emergency shutdown of SplitSL10001, including indoor unit (air handler) and associated outdoor unit and control interfaces, as connected to the building’s central HVAC system.\n\n3. Definitions\n- Central HVAC: Building-wide chilled-air production and distribution system supplying the SplitSL10001 intake.\n- BMS: Building Management System used to monitor and control the asset remotely.\n- LOTO: Lockout/Tagout procedures used to isolate energy sources during maintenance.\n- PPE: Personal Protective Equipment as required by task hazard assessment.\n\n4. Roles and Responsibilities\n- Building Operator:\n - Perform daily visual checks, record readings, and respond to alarms.\n - Initiate safe shutdown sequence if requested or in alarm condition.\n- HVAC Technician:\n - Conduct routine preventive maintenance, component replacements, tests, and corrective actions.\n - Perform refrigerant and coil work per regulations; verify system integrity after service.\n- Electrical Technician:\n - Inspect and service electrical connections, controls, fuses/ breakers, and protection devices.\n- Central Plant Operator:\n - Coordinate with SplitSL10001 when central HVAC status changes (on/off, reset, surge conditions).\n- Facility/Property Manager:\n - Ensure SOP adherence, maintain records, and authorize LOTO when required.\n\n5. Safety, PPE, and Precautions\n- PPE: safety glasses, gloves, hearing protection as required, flat-soled shoes, and fall protection where applicable.\n- Electrical safety: de-energize and lockout/tagout before accessing electrical panels or control circuits beyond normal operation.\n- Refrigerant safety: handle per local regulations; avoid inhalation; ensure proper ventilation; use leak detectors if applicable.\n- Work at height: use fall protection and approved access methods for outdoor unit when needed.\n- Do not operate the unit with visibly damaged wiring, panels, or insulation.\n- Do not bypass safety devices or disable alarms.\n- If central HVAC supply is off or restricted, confirm with Central Plant before attempting cooling operation.\n\n6. Tools, Equipment, and Materials\n- Multimeter, HVAC thermometer/temperature meter, manometer or differential pressure gauge\n- Refrigerant leak detector (if servicing refrigerant circuits)\n- Vacuum pump and micron gauge (for refrigerant work, if authorized)\n- Coil cleaner (non-caustic) and soft brushes\n- Drain cleaning equipment and tubing\n- Replacement filters compatible with SplitSL10001\n- Screwdrivers, torque wrench, nut drivers\n- Wrench set, pliers, cable ties\n- LOTO kit (padlocks, tags)\n- Cleaning rags, approved cleaning solutions\n- Manufacturer’s service manual for SplitSL10001\n- Personal protective equipment (PPE) per Task Hazard Analysis\n\n7. Operating Procedures\n\n7.1 Normal Operation (daily)\n- Verify central HVAC is supplying conditioned air to SplitSL10001 intake and that the outdoor unit is available/operational.\n- Ensure area around indoor unit and outdoor unit is unobstructed; verify intake and exhaust/return paths are clear.\n- Power: confirm unit is connected to the designated circuit and the BMS shows status ON.\n- Controls:\n - Set thermostat or BMS control to COOL mode.\n - Set desired temperature according to occupancy/comfort policy and design supply temperature range.\n - Set fan to Auto; avoid continuous high-speed operation unless required.\n- Airflow and filters:\n - Confirm intake and outlet dampers (if present) are in design positions and airflow is within expected range.\n - Check that air filters are clean and properly seated.\n- Condensate and drainage:\n - Verify condensate drain line is clear; observe for proper drainage.\n- Alarms and indicators:\n - Check BMS/local indicators for faults or abnormal readings (temperatures, pressures, voltage).\n- Documentation:\n - Record start-up time, setpoints, observed temperatures, and any deviations in the asset log.\n\n7.2 Control Interfaces (BMS/Local)\n- Use BMS to monitor unit status, setpoints, and energy usage where available.\n- If local controls are used, follow the manufacturer’s keypad/menu sequence to operate in COOL mode and monitor fault indicators.\n- Do not override alarm setpoints without authorization.\n\n7.3 Interfacing with Central HVAC\n- Confirm that central HVAC supply is compatible with SplitSL10001 requirements (temperature, airflow rate, humidity).\n- If central supply is reduced or unavailable, do not force operation; coordinate with Central Plant for reconfiguration or isolation.\n- When central supply is degraded, operate only within approved operating windows and monitor return air conditions.\n\n7.4 Start/Stop Sequencing\n- Start:\n - Verify safety and power isolation procedures are not engaged.\n - Initiate cooling on the thermostat/BMS; verify output within expected temperature range within 5–10 minutes.\n - Check for unobstructed airflow and normal noise levels.\n- Stop:\n - Set to OFF/CLOSED or disconnect via BMS or local controls.\n - If equipment is planned to remain off for an extended period, perform post-stop checks (drainage, filter condition) and log.\n\n7.5 Setpoints, Alarms, and Alarms Handling\n- Design setpoints should be tied to project/operational guidelines; deviations require approval.\n- Review active alarms; take corrective action or escalate per incident procedure.\n- Do not bypass alarms; document any temporary exceptions with rationale and duration.\n\n8. Maintenance Procedures\n\n8.1 Daily/Weekly\n- Visual check for leaks, unusual noises, odors, or ice formation on indoor coil.\n- Inspect condensate drain for blockages; ensure unobstructed drainage.\n- Check filter status and cleanliness; replace/clean as needed.\n\n8.2 Monthly\n- Replace or clean air filters according to manufacturer’s recommendation and daily air quality requirements.\n- Inspect electrical connections at the indoor and outdoor units; tighten as required per torque specs.\n- Inspect refrigerant lines for insulation integrity and signs of moisture or damage.\n\n8.3 Quarterly\n- Clean evaporator coil (gentle cleaning solution; avoid electrical components).\n- Inspect and test thermostat/BMS sensors for accuracy; calibrate if needed.\n- Check outdoor unit airflow paths and electrical components; clean debris.\n\n8.4 Annually\n- Full electrical inspection: inspect contactors, capacitors, wiring insulation, and disconnects.\n- Refrigerant system check: verify charge is within design specifications; perform leak check if permissible; complete required refrigerant system documentation.\n- Mechanical components: inspect fan assembly, bearings, belts (if belt-driven), and mounting hardware; lubricate moving parts if required by manufacturer.\n- Drainage system: verify condensate pans and lines are clean and free from growth; treat to prevent algae.\n\n8.5 Seasonal/Pre-Season Start-Up\n- Perform a comprehensive pre-season check: verify refrigerant pressures, sensor readings, and safety devices.\n- Confirm integration with central HVAC is functioning as expected before peak season.\n\n9. Emergency Shut-Off Procedures\n\n9.1 Alarm/Trip Scenarios\n- Central plant failure, refrigerant leak, electrical fault, water ingress, or fire alarm triggering.\n- If any high-risk condition is detected, initiate immediate shutdown of SplitSL10001 per the steps below and notify the Central Plant Operator and Building Management.\n\n9.2 Immediate Actions to Shut Off\n- Notify appropriate personnel per building SOP.\n- Disable power to SplitSL10001 at the local disconnect switch if safe to access.\n- If central HVAC is involved, coordinate with Central Plant to ensure the central supply is isolated and does not feed into the unit.\n- Use the Building Management System (BMS) to command OFF for the unit; ensure status is OFF and alarms are acknowledged.\n- Post a lockout/tagout if maintenance is required and the unit will be worked on (per LOTO procedures).\n\n9.3 Isolation and Lockout/Tagout (LOTO)\n- Implement LOTO for all energy sources (electrical, mechanical) before service work.\n- Attach a clearly visible tag with the date, time, location, and responsible person’s name.\n- Verify isolation by attempting to restart in a controlled test after work is completed.\n\n9.4 Fire, Flood, or Other Emergencies\n- Follow building fire/evacuation procedures.\n- If water ingress is detected, stop the unit, shut off power, and commence water-damage controls; coordinate with facilities for remediation.\n- After an emergency, perform safety inspection and restoration validation before re-energizing.\n\n9.5 Post-Emergency Restoration\n- Inspect for damage; assess thermal performance and safety before re-energizing.\n- Restore to normal operating parameters in compliance with design specifications.\n- Document event details and lessons learned in the maintenance log.\n\n10. Troubleshooting Guide (summary)\n- Unit not starting: Check power supply, circuit breakers, and BMS status; verify LOTO does not remain engaged.\n- No cooling or weak output: Verify central supply is delivering cold air to the intake; check filter; verify thermostat/BMS settings; check outdoor unit for obstruction; inspect coil condition.\n- Frost on evaporator/coil: Check air filter cleanliness; verify adequate airflow; inspect refrigerant system for charge issues (call service if suspected).\n- High noise or vibration: Inspect mounting hardware; check fan/blower alignment; verify no loose components; inspect bearings.\n- Water leakage: Check condensate drain line for blockage; inspect drain pan; ensure proper slope and wiring.\n- Electrical fault indicators: Check wiring insulation, connections, and protective devices; replace any failed components with approved parts.\n\n11. Documentation and Records\n- Maintain logs for: daily operation checks, maintenance actions (dates, tasks performed, parts replaced), calibration records, refrigerant charge and leak tests, alarm history, and post-emergency actions.\n- Update BMS trends and energy consumption data where applicable.\n\n12. Training and Competency\n- Operators and technicians must be trained on SplitSL10001 operation, safety procedures, BMS interface, LOTO procedures, and emergency protocols.\n- Refresher training at intervals defined by facility policy or after major system changes.\n\n13. Records Retention\n- Retain service reports, maintenance logs, test results, and incident reports per facility document control policy.\n\n14. References and Appendices\n- Manufacturer’s operation manual for SplitSL10001\n- Local codes and standards (electrical, HVAC, refrigerant handling, fire safety)\n- Lockout/Tagout policy and forms\n- As-built drawings and wiring diagrams for SplitSL10001 and related central HVAC interfaces\n\nEnd of SOP for Asset SplitSL10001.", + "status": null, + "tags": [ + { + "name": "hvac" + }, + { + "name": "splitac" + }, + { + "name": "monthly:maintenance" + } + ], + "updatedby": "ava.turner@criticalasset.com", + "updatedon": "2025-09-01T07:40:28Z", + "vendorid": null, + "warranty_status": null, + "zoneid": null + }, + "createdby": "ethan.thompson@criticalasset.com", + "createdon": "2025-08-28T15:44:00Z", + "deletedby": null, + "deletedon": null, + "description": "SL100", + "id": "01K3RNK8XW09W423KZVSGRDTPM", + "name": "SL100", + "tags": null, + "updatedby": "ethan.thompson@criticalasset.com", + "updatedon": "2025-08-29T14:28:34Z", + "zoneid": "01K3RNJ0EW138BKYJVJB3F1XWX" + }, + { + "ca_asset": null, + "createdby": "ethan.thompson@criticalasset.com", + "createdon": "2025-08-28T15:44:14Z", + "deletedby": null, + "deletedon": null, + "description": "SL200", + "id": "01K3RNKPSE0SFF6SXXDABNFY12", + "name": "SL200", + "tags": null, + "updatedby": "ethan.thompson@criticalasset.com", + "updatedon": "2025-08-29T14:28:49Z", + "zoneid": "01K3RNJ0EW138BKYJVJB3F1XWX" + } + ], + "createdby": "ethan.thompson@criticalasset.com", + "createdon": "2025-08-28T15:43:19Z", + "deletedby": null, + "deletedon": null, + "description": "South Lobby", + "floorid": "01K3RNFSNF5MGQD0ZKWZTVR0SX", + "id": "01K3RNJ0EW138BKYJVJB3F1XWX", + "name": "South Lobby", + "tags": null, + "updatedby": "ethan.thompson@criticalasset.com", + "updatedon": "2025-08-29T14:27:35Z" + }, + { + "ca_asset": null, + "ca_room": { + "ca_asset": { + "assettypeid": "01K3JN6V52QQDC2HXNYQ3KKGA6", + "attributes": null, + "compliance_status": null, + "config": null, + "createdby": "ethan.thompson@criticalasset.com", + "createdon": "2025-08-28T15:48:14Z", + "deletedby": null, + "deletedon": null, + "description": "This is hvac main component which gets cool air from the cooling tower and distributes to the split ac. It i son 5th floor of the building", + "fed_from": null, + "fed_to": null, + "floorid": null, + "id": "01K3RNV0QVVPXF2RAVVETP38F8", + "locationid": null, + "maintenancestatus": "Operational", + "manufacturerid": null, + "name": "SplitNL10001", + "operational_metrics": null, + "operational_status": null, + "roomid": "01K3RNM558DHG9WTD9VGPN5BQF", + "sop": "SOP: Operating, Maintenance, and Emergency Shut-off\nAsset: SplitNL10001\nLocation: 5th Floor, HVAC Main Component (air handling unit drawing cooled air from cooling tower and distributing to split units)\n\n1. Purpose\n- Define standardized procedures for safe operation, routine maintenance, and emergency shut-off of SplitNL10001.\n\n2. Scope\n- Applies to all personnel responsible for operation, maintenance, testing, and emergency response for SplitNL10001 on the 5th floor.\n\n3. Definitions\n- AHU: Air Handling Unit (SplitNL10001 as the central component)\n- BMS: Building Management System\n- LOTO: Lockout/Tagout procedures\n- MCC: Motor Control Center\n- VFD: Variable Frequency Drive\n- PPE: Personal Protective Equipment\n\n4. Roles and Responsibilities\n- Facility Manager: Approve SOP, ensure training, verify permits.\n- HVAC Operator/Technician: Conduct daily operation, perform start/stop, execute PM tasks, log data, initiate LOTO when required.\n- Maintenance Supervisor: Schedule and verify preventive maintenance, coordinate parts and labor, review outage procedures.\n- Safety Officer: Ensure compliance with safety protocols, conduct audits.\n- Security/Front Desk: Facilitate communications during emergencies.\n\n5. Safety and Compliance\n- Adhere to all applicable codes (e.g., NFPA, OSHA/local equivalents) and site-specific safety rules.\n- Always perform Lockout/Tagout before any maintenance that could energize components.\n- Wear appropriate PPE: safety glasses, gloves, hearing protection as required, hard hat if overhead hazards exist, and respiratory protection for chemical cleaning.\n- Be aware of hot surfaces, moving parts, electrical hazards, and confined space considerations.\n- Ensure clear access to emergency shut-off and fire/evacuation routes.\n\n6. Equipment and Tools\n- PPE: as above\n- Tools: insulated screwdrivers, digital multimeter, infrared thermometer, manometer/pressure gauge, vibration analyzer (optional), tachometer, torque wrench, ladder/scaffold as needed\n- Cleaning/maintenance supplies: coil cleaner, approved degreaser, non-ridgid brushes, rags, spare filters, level/line laser\n- Electrical/Controls: access to MS/SCADA/BMS, LOTO devices, spare fuses/capacitors as per OEM\n- Spare parts: filters, belts, gaskets, seals, motors/components per OEM recommendations\n\n7. Documentation and Records\n- Maintain a logbook or CMMS record for SplitNL10001 including:\n - Operating parameters (airflow, temperatures, pressures, motor amps)\n - Alarms and fault codes\n - Maintenance tasks completed (date, technician, parts used)\n - Any deviations or abnormal conditions\n - Emergency actions taken\n\n8. Pre-Operation Checks (Daily)\n- Verify cooling tower and related plant equipment are online and stable.\n- Confirm AHU supply and return dampers: set to Auto, position within design range.\n- Check air filters: condition and cleanliness; replace as required.\n- Inspect condensate drain pans and lines for blockages; ensure drainage unobstructed.\n- Inspect belts/pulleys for tension and wear; check for unusual noise or vibration.\n- Check electrical power to AHU/MCC and status on the BMS; verify interlocks are healthy.\n- Confirm setpoints on BMS: supply air temperature, outdoor-air ratio, ventilation rate.\n- Visual check for leaks, corrosion, and any abnormal condition.\n\n9. Normal Operation Procedures\n- Start-up sequence:\n 1) Verify LOTO is not active unless maintenance is planned; if maintenance, apply LOTO per procedure.\n 2) Confirm power is applied to MCC and AHU drives; verify BMS is in Run.\n 3) Ensure cooling tower is operating and water level/quality acceptable.\n 4) Set AHU to Auto; ensure damper and VFD controls are enabled.\n 5) Start AHU fans via MCC/VFD; verify airflow and motor current are within normal range.\n 6) Confirm supply air temperature reaches setpoint within expected timeframe; monitor coil temperature and humidity as applicable.\n 7) Observe alarms; acknowledge and address any alarms from BMS.\n- Normal operation monitoring:\n - Maintain documented setpoints for temperature, humidity, and airflow.\n - Monitor motor amperage, vibration (if available), and temperature of critical components.\n - Check filters, condensate drains, and dampers during shift changes.\n - Record daily operating data in the log/software.\n\n10. Shutdown Procedures (Normal)\n- Prepare for shutdown:\n - Notify affected occupants and secure the area if necessary.\n - Allow system to reach a stable, safe condition (temperatures approaching ambient, dampers in appropriate position).\n- Shutdown sequence:\n 1) Set AHU to Off via BMS or MCC.\n 2) Close dampers if required by building procedures.\n 3) Stop AHU fans and coil cooling (via VFD/MCC).\n 4) Isolate power to AHU and associated drives if maintenance or emergency requires.\n 5) Secure area and tag as required by LOTO.\n\n11. Emergency Shut-Off Procedures\n- Triggers: electrical fault, fire/smoke in equipment area, flooding, or other imminent danger.\n- Immediate actions:\n 1) Press the emergency stop or isolate at the MCC disconnect, then lockout/tagout as applicable.\n 2) Cut power to SplitNL10001 and associated drives; lockout at MCC and main control panel.\n 3) Notify Building Control/Operations and Fire Safety; initiate emergency communication per site protocol.\n 4) Evacuate as required; implement any building-specific shutdown sequence for HVAC zone if necessary to prevent spread of smoke or heat.\n 5) Do not re-energize until the area is safe, fault is diagnosed, and LOTO is removed by authorized personnel.\n- Post-event:\n - Conduct a safety briefing, document fault cause, notify engineering, and restore only after verification by authorized personnel.\n\n12. Lockout/Tagout (LOTO) Procedure for Maintenance\n- Prepare: obtain written permit, de-energize equipment, place LOTO tags with worker name, date, and reason.\n- Verify de-energization: test absence of voltage using appropriate test equipment.\n- Depower and secure: apply lock to MCC/disconnect, secure with tag.\n- Restore:\n - Remove tools and personnel from the area.\n - Verify function and restore power with supervisor approval.\n - Remove LOTO tags only after confirming safe restart.\n\n13. Maintenance Procedures (Preventive)\n- Schedule:\n - Daily: visual inspection, filter status, dampers, condensate drain flow, noise/ vibration check.\n - Weekly: inspect belts/pulleys; verify motor temperatures and vibration.\n - Monthly: clean/replace filters as per OEM guidelines; inspect coil condition; check refrigerant/airside cleanliness.\n - Quarterly: full electrical checks; verify sensor calibration; test control interlocks; verify BMS communications.\n - Yearly: complete PM with OEM-recommended overhaul; inspect bearings, seals, and structural components; verify refrigerant integrity if applicable; recalibrate controls.\n- Mechanical tasks:\n - Clean coils and blades; remove debris from intake/outlet.\n - Clean condensate drain and pan; ensure proper drainage.\n - Inspect and tighten belt tensions; replace worn belts.\n - Inspect bearings and lubrication as per OEM; lubricate if required (proper lubrication type and interval).\n - Check fans for balance and alignment; correct any misalignment.\n - Inspect dampers for smooth operation; lubricate moving parts.\n- Electrical and controls:\n - Inspect wiring, terminations, and insulation; tighten connections; replace damaged cables.\n - Check motor amperage against nameplate and operating range.\n - Inspect and calibrate temperature/pressure sensors; verify control sequences in BMS.\n - Update or verify software/firmware if applicable; backup control settings.\n- Water/ cooling system:\n - Monitor cooling tower water chemistry; adjust inhibitors/anti-corrosion chemicals per supplier spec.\n - Inspect water-side components for scale or corrosion; clean as needed.\n - Confirm condensate drainage is operational; verify condensate pump (if installed) operation.\n- Documentation:\n - Record PM tasks performed, parts replaced, readings, and any anomalies.\n - Schedule follow-up actions if required.\n\n14. Troubleshooting Guide (Common Issues)\n- No airflow or very low airflow:\n - Check power to AHU and MCC; verify VFD control; inspect damper position; examine blower belt tension; inspect filter clog.\n- High/low pressure or temperature anomalies:\n - Check coil cleanliness and refrigerant/water circuit; inspect sensors; verify valve positions; check for airflow restrictions.\n- Abnormal noise or vibration:\n - Inspect belt/pulley alignment; check bearing condition; inspect fan blades for damage; verify mounting hardware.\n- Drainage issues:\n - Inspect condensate pan and line; ensure trap is clear and pump (if present) operates.\n- Electrical faults/alarms:\n - Review alarm codes in BMS; perform lockout/tagout as needed; inspect wiring and connections; replace faulty components per OEM.\n\n15. Spare Parts and Consumables\n- Filters (as per OEM replacement schedule)\n- Belts, bearings, gaskets\n- Electrical components per OEM (fuses, capacitors, contactors)\n- Cleaning agents and coil cleaners approved for HVAC use\n- Lubricants and sealants per OEM recommendations\n- Replacement fans or motor components as listed by OEM\n\n16. Training and Competence\n- Personnel must be trained on:\n - AHU operation and safety\n - Lockout/Tagout procedures\n - BMS interface and alarm handling\n - Emergency response and evacuation procedures\n - Maintenance procedures and OEM guidelines\n- Refresher training at least annually or after major changes\n\n17. Appendices\n- Appendix A: Electrical schematic and sequence diagram for SplitNL10001\n- Appendix B: Control interlocks and alarm definitions\n- Appendix C: Sample LOTO form and permit-to-work\n- Appendix D: Daily/Weekly/Monthly checklists (editable templates)\n- Appendix E: BMS setpoints and sensor locations\n\nEnd of SOP", + "status": null, + "tags": null, + "updatedby": "ava.turner@criticalasset.com", + "updatedon": "2025-09-01T15:29:58Z", + "vendorid": null, + "warranty_status": null, + "zoneid": null + }, + "createdby": "ethan.thompson@criticalasset.com", + "createdon": "2025-08-28T15:44:29Z", + "deletedby": null, + "deletedon": null, + "description": "NL100", + "id": "01K3RNM558DHG9WTD9VGPN5BQF", + "name": "NL100", + "tags": null, + "updatedby": "ethan.thompson@criticalasset.com", + "updatedon": "2025-08-29T14:28:07Z", + "zoneid": "01K3RNJGMMX9GQRFCW95707KVZ" + }, + "createdby": "ethan.thompson@criticalasset.com", + "createdon": "2025-08-28T15:43:35Z", + "deletedby": null, + "deletedon": null, + "description": "North Lobby", + "floorid": "01K3RNFSNF5MGQD0ZKWZTVR0SX", + "id": "01K3RNJGMMX9GQRFCW95707KVZ", + "name": "North Lobby", + "tags": null, + "updatedby": "ethan.thompson@criticalasset.com", + "updatedon": "2025-08-29T14:27:48Z" + } + ], + "createdby": "ethan.thompson@criticalasset.com", + "createdon": "2025-08-28T15:42:06Z", + "createdusing": null, + "deletedby": null, + "deletedon": null, + "description": "Lobby Floor", + "floorplan": null, + "id": "01K3RNFSNF5MGQD0ZKWZTVR0SX", + "locationid": "01K3RNF6MFWA9FFFDF22XMQWAH", + "name": "Lobby", + "tags": null, + "type": "", + "updatedby": "ethan.thompson@criticalasset.com", + "updatedon": "2025-08-28T15:42:06Z" + }, + { + "ca_asset": null, + "ca_zone": { + "ca_asset": null, + "ca_room": null, + "createdby": "ethan.thompson@criticalasset.com", + "createdon": "2025-08-29T14:27:12Z", + "deletedby": null, + "deletedon": null, + "description": "South Zone", + "floorid": "01K3RNGJ093F575QA5K5BBHDSR", + "id": "01K3V3KC1GCCM7TAW0SNVDAVS6", + "name": "South", + "tags": null, + "updatedby": "ethan.thompson@criticalasset.com", + "updatedon": "2025-08-29T14:27:12Z" + }, + "createdby": "ethan.thompson@criticalasset.com", + "createdon": "2025-08-28T15:42:31Z", + "createdusing": null, + "deletedby": null, + "deletedon": null, + "description": "Mezzanine Floor", + "floorplan": null, + "id": "01K3RNGJ093F575QA5K5BBHDSR", + "locationid": "01K3RNF6MFWA9FFFDF22XMQWAH", + "name": "Mezzanine", + "tags": null, + "type": "", + "updatedby": "ethan.thompson@criticalasset.com", + "updatedon": "2025-08-28T15:42:31Z" + } + ], + "createdby": "ethan.thompson@criticalasset.com", + "createdon": "2025-08-28T15:41:47Z", + "deletedby": null, + "deletedon": null, + "description": "Location created by Anand", + "id": "01K3RNF6MFWA9FFFDF22XMQWAH", + "location_type": "building", + "name": "AnLocation", + "parentid": null, + "tags": [ + { + "name": "location" + }, + { + "name": "owned" + } + ], + "updatedby": "ava.turner@criticalasset.com", + "updatedon": "2025-09-01T07:36:09Z" + }, + { + "address": { + "address1": "hyderabad", + "address2": "hyderabad", + "city": "hyderabad", + "countryalpha1": "IN", + "state": "hyderabad", + "zip": "123456" + }, + "ca_asset": null, + "ca_floor": null, + "createdby": "ethan.thompson@criticalasset.com", + "createdon": "2025-09-03T11:33:26Z", + "deletedby": null, + "deletedon": null, + "description": "Arena", + "id": "01K47NMS4AGG3TC8337VE0NHQA", + "location_type": "building", + "name": "Arena", + "parentid": null, + "tags": [ + { + "name": "arena" + } + ], + "updatedby": "ethan.thompson@criticalasset.com", + "updatedon": "2025-09-03T11:33:26Z" + }, + { + "address": { + "address1": "Survey No- 1, 6, Uppal - Ramanthapur Rd", + "address2": "Uppal", + "city": "Hyderabad", + "countryalpha2": "IN", + "state": "Telangana", + "zip": "500039" + }, + "ca_asset": { + "assettypeid": "01K47YBT5RF70TAB9D0AZ5GEP8", + "attributes": null, + "compliance_status": null, + "config": null, + "createdby": "ava.turner@criticalasset.com", + "createdon": "2025-09-03T14:10:20Z", + "deletedby": null, + "deletedon": null, + "description": null, + "fed_from": null, + "fed_to": null, + "floorid": null, + "id": "01K47YM27Z55PHZKZ0YTE0T64Z", + "locationid": "01K47Y6QHFH1XV1M9W9B2EKXWV", + "maintenancestatus": "healthy", + "manufacturerid": null, + "name": "Compressor 1", + "operational_metrics": null, + "operational_status": null, + "roomid": null, + "sop": null, + "status": null, + "tags": null, + "updatedby": "ava.turner@criticalasset.com", + "updatedon": "2025-09-03T14:10:20Z", + "vendorid": null, + "warranty_status": null, + "zoneid": null + }, + "ca_floor": [ + { + "ca_asset": { + "assettypeid": "01K47YBT5N7P8DKDF82G8JMD4J", + "attributes": null, + "compliance_status": null, + "config": null, + "createdby": "ava.turner@criticalasset.com", + "createdon": "2025-09-03T14:10:20Z", + "deletedby": null, + "deletedon": null, + "description": null, + "fed_from": null, + "fed_to": null, + "floorid": "01K47Y8QEH080QQP8VHAWBH1VZ", + "id": "01K47YM283WJQPWB4PF2PXEY8M", + "locationid": null, + "maintenancestatus": "healthy", + "manufacturerid": null, + "name": "Duct Uno", + "operational_metrics": null, + "operational_status": null, + "roomid": null, + "sop": null, + "status": null, + "tags": null, + "updatedby": "ava.turner@criticalasset.com", + "updatedon": "2025-09-03T15:09:11Z", + "vendorid": null, + "warranty_status": null, + "zoneid": null + }, + "ca_zone": [ + { + "ca_asset": { + "assettypeid": "01K47YBT5N7P8DKDF82G8JMD4J", + "attributes": null, + "compliance_status": null, + "config": null, + "createdby": "ava.turner@criticalasset.com", + "createdon": "2025-09-03T14:10:20Z", + "deletedby": null, + "deletedon": null, + "description": null, + "fed_from": null, + "fed_to": null, + "floorid": null, + "id": "01K47YM287Y3ATNSM65WGB195H", + "locationid": null, + "maintenancestatus": "healthy", + "manufacturerid": null, + "name": "Duct Uno East", + "operational_metrics": null, + "operational_status": null, + "roomid": null, + "sop": null, + "status": null, + "tags": null, + "updatedby": "ava.turner@criticalasset.com", + "updatedon": "2025-09-03T14:10:20Z", + "vendorid": null, + "warranty_status": null, + "zoneid": "01K47Y98XA3W94Y9H28VRZ23JG" + }, + "ca_room": [ + { + "ca_asset": { + "assettypeid": "01K47YBT5T9K3HE9RFTQKEQT0J", + "attributes": null, + "compliance_status": null, + "config": null, + "createdby": "ava.turner@criticalasset.com", + "createdon": "2025-09-03T14:10:20Z", + "deletedby": null, + "deletedon": null, + "description": null, + "fed_from": null, + "fed_to": null, + "floorid": null, + "id": "01K47YM28C1DZ4V5YF25VBK64G", + "locationid": null, + "maintenancestatus": "healthy", + "manufacturerid": null, + "name": "Split Uno East Cabin 1", + "operational_metrics": null, + "operational_status": null, + "roomid": "01K47YACDT00G7JWV2CW5RC942", + "sop": null, + "status": null, + "tags": null, + "updatedby": "ava.turner@criticalasset.com", + "updatedon": "2025-09-03T14:10:20Z", + "vendorid": null, + "warranty_status": null, + "zoneid": null + }, + "createdby": "ava.turner@criticalasset.com", + "createdon": "2025-09-03T14:05:02Z", + "deletedby": null, + "deletedon": null, + "description": null, + "id": "01K47YACDT00G7JWV2CW5RC942", + "name": "Cabin 1", + "tags": null, + "updatedby": "ava.turner@criticalasset.com", + "updatedon": "2025-09-03T14:05:02Z", + "zoneid": "01K47Y98XA3W94Y9H28VRZ23JG" + }, + { + "ca_asset": null, + "createdby": "ava.turner@criticalasset.com", + "createdon": "2025-09-03T14:05:02Z", + "deletedby": null, + "deletedon": null, + "description": null, + "id": "01K47YACDZ0SPFWNBX377GG0KC", + "name": "Cabin 2", + "tags": null, + "updatedby": "ava.turner@criticalasset.com", + "updatedon": "2025-09-03T14:05:02Z", + "zoneid": "01K47Y98XA3W94Y9H28VRZ23JG" + } + ], + "createdby": "ava.turner@criticalasset.com", + "createdon": "2025-09-03T14:04:26Z", + "deletedby": null, + "deletedon": null, + "description": "zone towards east side spanning from edge to center", + "floorid": "01K47Y8QEH080QQP8VHAWBH1VZ", + "id": "01K47Y98XA3W94Y9H28VRZ23JG", + "name": "East Zone", + "tags": null, + "updatedby": "ava.turner@criticalasset.com", + "updatedon": "2025-09-03T14:04:26Z" + }, + { + "ca_asset": { + "assettypeid": "01K47YBT5N7P8DKDF82G8JMD4J", + "attributes": null, + "compliance_status": null, + "config": null, + "createdby": "ava.turner@criticalasset.com", + "createdon": "2025-09-03T14:10:20Z", + "deletedby": null, + "deletedon": null, + "description": null, + "fed_from": null, + "fed_to": null, + "floorid": null, + "id": "01K47YM28ACYYYEGJJZ9888D3V", + "locationid": null, + "maintenancestatus": "healthy", + "manufacturerid": null, + "name": "Duct Uno West", + "operational_metrics": null, + "operational_status": null, + "roomid": null, + "sop": null, + "status": null, + "tags": null, + "updatedby": "ava.turner@criticalasset.com", + "updatedon": "2025-09-03T14:10:20Z", + "vendorid": null, + "warranty_status": null, + "zoneid": "01K47Y98XD77CJCHAKSHAXP6QS" + }, + "ca_room": [ + { + "ca_asset": null, + "createdby": "ava.turner@criticalasset.com", + "createdon": "2025-09-03T14:05:02Z", + "deletedby": null, + "deletedon": null, + "description": null, + "id": "01K47YACE1TAXANDTRTFDNBNK1", + "name": "Cabin 3", + "tags": null, + "updatedby": "ava.turner@criticalasset.com", + "updatedon": "2025-09-03T14:05:02Z", + "zoneid": "01K47Y98XD77CJCHAKSHAXP6QS" + }, + { + "ca_asset": null, + "createdby": "ava.turner@criticalasset.com", + "createdon": "2025-09-03T14:05:02Z", + "deletedby": null, + "deletedon": null, + "description": null, + "id": "01K47YACE3NYZCEBJ3HBY7G98F", + "name": "Cabin 4", + "tags": null, + "updatedby": "ava.turner@criticalasset.com", + "updatedon": "2025-09-03T14:05:02Z", + "zoneid": "01K47Y98XD77CJCHAKSHAXP6QS" + } + ], + "createdby": "ava.turner@criticalasset.com", + "createdon": "2025-09-03T14:04:26Z", + "deletedby": null, + "deletedon": null, + "description": "zone towards west side spanning from edge to center", + "floorid": "01K47Y8QEH080QQP8VHAWBH1VZ", + "id": "01K47Y98XD77CJCHAKSHAXP6QS", + "name": "West Zone", + "tags": null, + "updatedby": "ava.turner@criticalasset.com", + "updatedon": "2025-09-03T14:04:26Z" + } + ], + "createdby": "ava.turner@criticalasset.com", + "createdon": "2025-09-03T14:04:08Z", + "createdusing": null, + "deletedby": null, + "deletedon": null, + "description": "Floor 1 for 12 seater cabins", + "floorplan": null, + "id": "01K47Y8QEH080QQP8VHAWBH1VZ", + "locationid": "01K47Y6QHFH1XV1M9W9B2EKXWV", + "name": "Flooat Uno", + "tags": null, + "type": "", + "updatedby": "ava.turner@criticalasset.com", + "updatedon": "2025-09-03T14:04:08Z" + }, + { + "ca_asset": { + "assettypeid": "01K47YBT5N7P8DKDF82G8JMD4J", + "attributes": null, + "compliance_status": null, + "config": null, + "createdby": "ava.turner@criticalasset.com", + "createdon": "2025-09-03T14:10:20Z", + "deletedby": null, + "deletedon": null, + "description": null, + "fed_from": null, + "fed_to": null, + "floorid": "01K47Y8QEQQMH72F5R9GNCVW2S", + "id": "01K47YM2858BTZ4HWCMAA8Q1RP", + "locationid": null, + "maintenancestatus": "healthy", + "manufacturerid": null, + "name": "Duct Dos", + "operational_metrics": null, + "operational_status": null, + "roomid": null, + "sop": null, + "status": null, + "tags": null, + "updatedby": "ava.turner@criticalasset.com", + "updatedon": "2025-09-03T15:08:56Z", + "vendorid": null, + "warranty_status": null, + "zoneid": null + }, + "ca_zone": null, + "createdby": "ava.turner@criticalasset.com", + "createdon": "2025-09-03T14:04:08Z", + "createdusing": null, + "deletedby": null, + "deletedon": null, + "description": "Floor 2 for 8 seater cabins", + "floorplan": null, + "id": "01K47Y8QEQQMH72F5R9GNCVW2S", + "locationid": "01K47Y6QHFH1XV1M9W9B2EKXWV", + "name": "Flooat Dos", + "tags": null, + "type": "", + "updatedby": "ava.turner@criticalasset.com", + "updatedon": "2025-09-03T14:04:08Z" + } + ], + "createdby": "ava.turner@criticalasset.com", + "createdon": "2025-09-03T14:03:03Z", + "deletedby": null, + "deletedon": null, + "description": "A multistory building for business", + "id": "01K47Y6QHFH1XV1M9W9B2EKXWV", + "location_type": "building", + "name": "NSL arena", + "parentid": null, + "tags": null, + "updatedby": "ava.turner@criticalasset.com", + "updatedon": "2025-09-03T14:03:03Z" + }, + { + "address": null, + "ca_asset": null, + "ca_floor": { + "ca_asset": null, + "ca_zone": null, + "createdby": "ethan.thompson@criticalasset.com", + "createdon": "2025-11-06T12:26:48Z", + "createdusing": null, + "deletedby": null, + "deletedon": null, + "description": "test123", + "floorplan": null, + "id": "01K9CJ4GNET3KW0E1Y39SWKJ3V", + "locationid": "01K47Y89H0WQRPQCVY9MPD7Z3C", + "name": "test", + "tags": null, + "type": "", + "updatedby": "ethan.thompson@criticalasset.com", + "updatedon": "2025-11-06T12:26:48Z" + }, + "createdby": "ava.turner@criticalasset.com", + "createdon": "2025-09-03T14:03:54Z", + "deletedby": null, + "deletedon": null, + "description": "Cafeteria for NSL arena", + "id": "01K47Y89H0WQRPQCVY9MPD7Z3C", + "location_type": "area", + "name": "NSL arena cafeteria", + "parentid": "01K47Y6QHFH1XV1M9W9B2EKXWV", + "tags": null, + "updatedby": "ava.turner@criticalasset.com", + "updatedon": "2025-09-03T14:03:54Z" + }, + { + "address": { + "address1": "test", + "address2": "test", + "city": "test", + "countryalpha2": "India", + "state": "test", + "zip": "123456" + }, + "ca_asset": null, + "ca_floor": null, + "createdby": "ethan.thompson@criticalasset.com", + "createdon": "2025-11-07T10:34:54Z", + "deletedby": null, + "deletedon": null, + "description": "test", + "id": "01K9EY4AVMBXBD5RPS9FVET8ET", + "location_type": "building", + "name": "NSL arena 3", + "parentid": null, + "tags": null, + "updatedby": "ethan.thompson@criticalasset.com", + "updatedon": "2025-11-07T10:34:54Z" + } + ] + } + } +} \ No newline at end of file diff --git a/src/pages/partners/EditPartnerModal.tsx b/src/pages/partners/EditPartnerModal.tsx new file mode 100644 index 000000000..8f9306aa2 --- /dev/null +++ b/src/pages/partners/EditPartnerModal.tsx @@ -0,0 +1,219 @@ +import React, { useEffect } from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import TextField from "@mui/material/TextField"; +import Stack from "@mui/material/Stack"; +import { useForm } from "react-hook-form"; +import * as yup from "yup"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { useMutation } from "@apollo/client/react"; +import { UPDATE_COMPANY_MUTATION } from "../../graphql/mutations"; +import { COMPANIES_QUERY } from "../../graphql/queries"; +import { showToast } from "../../components/toastService"; +import { useLoader } from "../../context/LoaderContext"; + +type EditPartnerModalProps = { + data: any; + onClose: () => void; + onSave: (partnerData: any) => void; +}; + +type EditPartnerFormInputs = { + firstName: string; + lastName: string; + email: string; + phone: string; +}; + +const schema = yup.object({ + firstName: yup.string().required("First name is required"), + lastName: yup.string().required("Last name is required"), + email: yup + .string() + .email("Invalid email format") + .required("Email is required"), + phone: yup + .string() + .required("Phone is required") + .test("phone-format", "Invalid phone number format", (value) => { + if (!value || value.trim() === "") return false; + // Accept formats like: +1234567890, (123) 456-7890, 123-456-7890, 1234567890 + const phoneRegex = /^[\+]?[(]?[0-9]{1,4}[)]?[-\s\.]?[(]?[0-9]{1,4}[)]?[-\s\.]?[0-9]{1,9}$/; + return phoneRegex.test(value); + }), +}) as yup.ObjectSchema; + +const EditPartnerModal: React.FC = ({ + data, + onClose, + onSave, +}) => { + const { showLoader, hideLoader } = useLoader(); + const [updateCompanyMutation, { loading: updateCompanyLoading }] = useMutation(UPDATE_COMPANY_MUTATION, { + refetchQueries: [{ query: COMPANIES_QUERY }], + awaitRefetchQueries: true, + }); + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + reset, + } = useForm({ + resolver: yupResolver(schema), + mode: "all", + defaultValues: { + firstName: "", + lastName: "", + email: "", + phone: "", + }, + }); + + const isEmailConfirmed = Boolean(data?.emailConfirmed); + + useEffect(() => { + if (data) { + reset({ + firstName: data.firstName || data.firstname || "", + lastName: data.lastName || data.lastname || "", + email: data.email || data.workemail || "", + phone: data.phone || data.phonenumber || "", + }); + } + }, [data, reset]); + + const onSubmit = async (formData: EditPartnerFormInputs) => { + try { + showLoader(); + + // Prepare GraphQL mutation input for company update + // Map partner form fields to company fields: + // firstName -> name (company name) + // email -> email + // phone -> phoneNumber + const input = { + id: data.id || null, + name: formData.firstName.trim() || null, // Partner firstName maps to company name + email: formData.email.trim() || null, + phoneNumber: formData.phone.trim() || null, + }; + + // Update company using GraphQL mutation + const result = await updateCompanyMutation({ + variables: { + input: input, + }, + }); + + if (result.data && typeof result.data === 'object' && 'updateCompany' in result.data) { + const companyData = (result.data as { updateCompany: any }).updateCompany; + + onSave(companyData); + + showToast({ + status: "success", + title: "Partner information has been updated.", + }); + + // Reset form + reset({ + firstName: "", + lastName: "", + email: "", + phone: "", + }); + + onClose(); + } + } catch (error: any) { + console.error("Failed to update partner", error); + const errorMessage = error?.graphQLErrors?.[0]?.message || error?.message || "Something went wrong while updating the data."; + showToast({ + status: "error", + title: errorMessage, + }); + } finally { + hideLoader(); + } + }; + + return ( + + + {/* FIRST NAME */} + + + {/* LAST NAME */} + + + {/* EMAIL - Disabled if emailConfirmed is true */} + + + {/* PHONE */} + + + + + + + ); +}; + +export default EditPartnerModal; + diff --git a/src/pages/partners/PartnerModal.tsx b/src/pages/partners/PartnerModal.tsx new file mode 100644 index 000000000..44b16972d --- /dev/null +++ b/src/pages/partners/PartnerModal.tsx @@ -0,0 +1,388 @@ +import React, { useEffect, useState } from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import TextField from "@mui/material/TextField"; +import MenuItem from "@mui/material/MenuItem"; +import Stack from "@mui/material/Stack"; +import IconButton from "@mui/material/IconButton"; +import InputAdornment from "@mui/material/InputAdornment"; +import { Visibility, VisibilityOff } from "@mui/icons-material"; +import { useForm, Controller } from "react-hook-form"; +import * as yup from "yup"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { useMutation, useLazyQuery } from "@apollo/client/react"; +import { CREATE_PARTNER_MUTATION } from "../../graphql/mutations"; +import { COMPANIES_QUERY } from "../../graphql/queries"; +import { showToast } from "../../components/toastService"; +import { useLoader } from "../../context/LoaderContext"; +import { useAuth } from "../../context/AuthContext"; + +type PartnerModalProps = { + companyData?: any[]; + onClose: () => void; + onSave: (partnerData: any) => void; +}; + +type PartnerFormInputs = { + firstName: string; + lastName: string; + phone: string; + name: string; // Company Name + jobTitle: string; + industry: string; + email: string; + password: string; +}; + +const schema = yup.object({ + firstName: yup.string().required("First Name is required"), + lastName: yup.string().required("Last Name is required"), + phone: yup + .string() + .required("Phone Number is required") + .matches(/^[0-9]{10}$/, "Phone number must be exactly 10 digits and contain only numbers"), + name: yup.string().required("Company is required"), + jobTitle: yup.string().required("Job Title is required"), + industry: yup.string().required("Industry is required"), + email: yup + .string() + .email("Invalid email format") + .required("Email is required"), + password: yup + .string() + .required("Password is required") + .min(8, "Password must be at least 8 characters") + .matches(/[a-zA-Z]/, "Password must contain at least one letter") + .matches(/[0-9]/, "Password must contain at least one number"), +}) as yup.ObjectSchema; + +const jobTitleOptions = [ + "CEO/President/Owner", + "CFO", + "CIO", + "COO", + "CTO", + "Building Owner", + "Others", +]; + +const industryOptions = [ + "Agriculture & Farming", + "Automotive & Transportation", + "Aviation & Aerospace", + "Commercial Real Estate & Property Management", + "Construction & Engineering", + "Energy", + "Others", +]; + +const PartnerModal: React.FC = ({ + companyData = [], + onClose, + onSave, +}) => { + const { showLoader, hideLoader } = useLoader(); + const { setTenantList } = useAuth(); + const [showPassword, setShowPassword] = useState(false); + const [createCompanyMutation, { loading: createCompanyLoading }] = useMutation(CREATE_PARTNER_MUTATION, { + refetchQueries: [{ query: COMPANIES_QUERY }], + awaitRefetchQueries: true, + }); + const [fetchCompanies] = useLazyQuery(COMPANIES_QUERY, { + fetchPolicy: 'network-only', + }); + + const { + register, + handleSubmit, + control, + formState: { errors, isSubmitting }, + reset, + } = useForm({ + resolver: yupResolver(schema), + mode: "all", + defaultValues: { + firstName: "", + lastName: "", + phone: "", + name: "", + jobTitle: "", + industry: "", + email: "", + password: "", + }, + }); + + const togglePasswordVisibility = () => { + setShowPassword((prev) => !prev); + }; + + // Reset form when modal opens + useEffect(() => { + reset({ + firstName: "", + lastName: "", + phone: "", + name: "", + jobTitle: "", + industry: "", + email: "", + password: "", + }); + setShowPassword(false); + }, [reset]); + + const onSubmit = async (formData: PartnerFormInputs) => { + try { + showLoader(); + + // Prepare GraphQL mutation input + const input = { + phoneNumber: formData.phone.trim(), + companyName: formData.name.trim(), // Company Name + jobTitle: formData.jobTitle, + industry: formData.industry, + email: formData.email.trim(), + password: formData.password, + // role: 'PARTNER_ADMIN', + firstName: formData.firstName.trim(), + lastName: formData.lastName.trim(), + }; + + // Create company and user using GraphQL mutation + const result = await createCompanyMutation({ + variables: { + input: input, + }, + }); + + if (result.data && typeof result.data === 'object' && 'partnerSignup' in result.data) { + // Fetch updated companies list and update tenantList + try { + const companiesResult = await fetchCompanies(); + if (companiesResult.data && typeof companiesResult.data === 'object' && 'companies' in companiesResult.data) { + const companies = (companiesResult.data as { companies: any[] }).companies; + // Map companies to Tenant format (same as Layout.tsx) + const tenants = companies.map((company: any) => ({ + id: company.id, + displayname: company.name || company.displayName || '', + slug: company.id, + ...company, // Include all company data + })); + setTenantList(tenants); + } + } catch (getError) { + console.error("Failed to fetch updated company list:", getError); + } + + showToast({ + status: "success", + title: "Partner has been created successfully.", + }); + + // Call onSave callback to notify parent component + onSave(result.data); + + // Reset form and close modal + handleClose(); + } + } catch (error: any) { + console.error("Failed to create partner", error); + const errorMessage = error?.graphQLErrors?.[0]?.message || error?.message || "Something went wrong while saving the data."; + showToast({ + status: "error", + title: errorMessage, + }); + } finally { + hideLoader(); + } + }; + + const handleClose = () => { + // Reset form when closing modal + reset({ + firstName: "", + lastName: "", + phone: "", + name: "", + jobTitle: "", + industry: "", + email: "", + password: "", + }); + setShowPassword(false); // Reset password visibility + onClose(); + }; + + return ( + + + {/* First Name - Required */} + + + {/* Last Name - Required */} + + + {/* Phone Number - Required */} + { + e.target.value = e.target.value.replace(/[^0-9]/g, ''); + }} + /> + + {/* Company Name - Required */} + + + {/* Job Title - Required */} + ( + + + Select Job Title + + {jobTitleOptions.map((option) => ( + + {option} + + ))} + + )} + /> + + {/* Industry - Required */} + ( + + + Select Industry + + {industryOptions.map((option) => ( + + {option} + + ))} + + )} + /> + + {/* Email - Required */} + + + {/* Password - Required */} + + + {showPassword ? : } + + + ), + }} + /> + + + + + + ); +}; + +export default PartnerModal; diff --git a/src/pages/partners/Partners.tsx b/src/pages/partners/Partners.tsx new file mode 100644 index 000000000..d82cad7d9 --- /dev/null +++ b/src/pages/partners/Partners.tsx @@ -0,0 +1,498 @@ +import React, { useState, useEffect, useMemo } from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import IconButton from "@mui/material/IconButton"; +import InputAdornment from "@mui/material/InputAdornment"; +import TextField from "@mui/material/TextField"; +import { ThemeProvider } from "@mui/material/styles"; +import { + DataGrid, + GridColDef, + GridPaginationModel, + GridRenderCellParams, +} from "@mui/x-data-grid"; +import { useMutation, useQuery } from "@apollo/client/react"; +import { COMPANIES_QUERY } from "../../graphql/queries"; +import { REMOVE_USER_MUTATION } from "../../graphql/mutations"; +import RightSideModal from "../../components/RightSideModal"; +import PartnerModal from "./PartnerModal"; +import EditPartnerModal from "./EditPartnerModal"; +import { showToast } from "../../components/toastService"; +import { useLoader } from "../../context/LoaderContext"; +import muiTheme from "../../themes/muiTheme"; +import { Search, X, Pencil, Trash2, Plus } from "lucide-react"; +import { useAuth } from "../../context/AuthContext"; + +const Partners = () => { + const { tenantList, setTenantList } = useAuth(); + const [searchTerm, setSearchTerm] = useState(""); + const [partnerModalOpen, setPartnerModalOpen] = useState(false); + const [editPartnerModalOpen, setEditPartnerModalOpen] = useState(false); + const [partnerData, setPartnerData] = useState([]); + const [selectedPartner, setSelectedPartner] = useState(null); + const [paginationModel, setPaginationModel] = useState({ + page: 0, + pageSize: 10, + }); + const { showLoader, hideLoader } = useLoader(); + const [removeUserMutation, { loading: removeUserLoading }] = useMutation(REMOVE_USER_MUTATION, { + refetchQueries: [{ query: COMPANIES_QUERY }], + awaitRefetchQueries: true, + }); + + // Query for companies - filter by role for partner table + const { data: companiesData, refetch: refetchCompanies } = useQuery(COMPANIES_QUERY, { + fetchPolicy: 'cache-and-network', + notifyOnNetworkStatusChange: true, + }); + + // Filter companies by "partner" role to get partners + const partnerCompanies = useMemo(() => { + if (!companiesData || !(companiesData as any)?.companies) return []; + return (companiesData as any).companies.filter((company: any) => { + return company.role?.toLowerCase() === 'partner'; + }); + }, [companiesData]); + + // Create company map from partner companies + const companyMap = useMemo(() => { + const map = new Map(); + partnerCompanies.forEach((company: any) => { + map.set(company.id, company.name || company.displayName || ''); + }); + return map; + }, [partnerCompanies]); + + // Update tenantList when companies data changes - filter by role for partners + useEffect(() => { + const tenants = partnerCompanies.map((company: any) => ({ + id: company.id, + displayname: company.name || company.displayName || '', + slug: company.id, + ...company, + })); + setTenantList(tenants); + }, [partnerCompanies, setTenantList]); + + const filteredData = useMemo( + () => + partnerData.filter((item) => { + const term = searchTerm.toLowerCase(); + const firstName = item.firstName || item.firstname || ""; + const lastName = item.lastName || item.lastname || ""; + const fullName = `${firstName} ${lastName}`.trim().toLowerCase(); + const email = item.email || item.workemail || ""; + const phone = item.phone || item.phonenumber || ""; + const role = item.role || ""; + const companyName = item.companyname || item.name || ""; + return ( + fullName.includes(term) || + email.toLowerCase().includes(term) || + phone.toLowerCase().includes(term) || + role.toLowerCase().includes(term) || + companyName.toLowerCase().includes(term) || + item.belongsToCompanyId?.toString().toLowerCase().includes(term) || + item.invitedBy?.toLowerCase().includes(term) + ); + }), + [partnerData, searchTerm] + ); + + // Transform partner companies to partner data format + useEffect(() => { + if (companiesData) { + // Transform companies with "partner" role to partner format + const transformedPartners = partnerCompanies.map((company: any) => ({ + id: company.id, + role: company.role || "partner", + workemail: company.email || "", + firstname: company.name || "", + lastname: "", + phonenumber: company.phoneNumber || "", + companyname: company.name || "", + belongsToCompanyId: company.id, + email: company.email || "", + firstName: company.name || "", + lastName: "", + phone: company.phoneNumber || "", + emailConfirmed: false, + phoneConfirmed: false, + twoFactorEnabled: false, + invitedBy: "", + createdAt: company.createdAt || "", + updatedAt: company.updatedAt || "", + // Keep original company data for reference + ...company, + })); + + // Sort by createdAt descending (newest first) + const sortedPartners = [...transformedPartners].sort((a: any, b: any) => { + const dateA = new Date(a.createdAt || 0).getTime(); + const dateB = new Date(b.createdAt || 0).getTime(); + return dateB - dateA; + }); + + setPartnerData(sortedPartners); + } else { + setPartnerData([]); + } + }, [partnerCompanies, companiesData]); + + const openAddModal = () => { + setSelectedPartner(null); + setPartnerModalOpen(true); + }; + + const openEditModal = (partner: any) => { + setSelectedPartner(partner); + setEditPartnerModalOpen(true); + }; + + const handleSavePartner = async (partnerData: any) => { + // Refetch companies list to get updated data with network-only to bypass cache + try { + await refetchCompanies({ fetchPolicy: 'network-only' }); + } catch (error) { + console.error("Failed to refresh data:", error); + } + // Close modal after save + setPartnerModalOpen(false); + setSelectedPartner(null); + }; + + const handleUpdatePartner = async (partnerData: any) => { + // Refetch companies list to get updated data with network-only to bypass cache + try { + await refetchCompanies({ fetchPolicy: 'network-only' }); + } catch (error) { + console.error("Failed to refresh data:", error); + } + // Close modal after save + setEditPartnerModalOpen(false); + setSelectedPartner(null); + }; + + const deletePartner = async (partnerId: any) => { + try { + showLoader(); + + // Delete partner using GraphQL mutation + const result = await removeUserMutation({ + variables: { + id: partnerId.id, + }, + }); + + if (result.data && typeof result.data === 'object' && 'removeUser' in result.data) { + const partnerName = `${partnerId.firstname || partnerId.firstName || partnerId.name || ""} ${partnerId.lastname || partnerId.lastName || ""}`.trim(); + const text = partnerName ? `Successfully deleted partner ${partnerName}` : "Successfully deleted partner"; + + // Refetch companies to get updated list (refetchQueries should handle this, but we'll also do it manually) + try { + await refetchCompanies({ fetchPolicy: 'network-only' }); + } catch (getError) { + console.error("Failed to fetch updated company list:", getError); + // Fallback: remove from partnerData manually + setPartnerData((prev) => prev.filter((partner) => partner.id !== partnerId.id)); + } + + showToast({ status: "success", title: text }); + } + } catch (error: any) { + console.error("Delete failed:", error); + const errorMessage = error?.graphQLErrors?.[0]?.message || error?.message || "Failed to delete partner"; + showToast({ status: "error", title: errorMessage }); + } finally { + hideLoader(); + } + }; + + const formatDate = (dateString: string | null | undefined) => { + if (!dateString) return ""; + try { + const date = new Date(dateString); + return date.toLocaleDateString(); + } catch { + return ""; + } + }; + + const columns: GridColDef[] = [ + { + field: "firstName", + headerName: "First Name", + flex: 1, + minWidth: 150, + sortable: true, + valueGetter: (params) => { + return params.row.firstName || params.row.firstname || ""; + }, + renderCell: (params: GridRenderCellParams) => ( + {params.row.firstName || params.row.firstname || "--"} + ), + }, + { + field: "lastName", + headerName: "Last Name", + flex: 1, + minWidth: 150, + sortable: true, + valueGetter: (params) => { + return params.row.lastName || params.row.lastname || ""; + }, + renderCell: (params: GridRenderCellParams) => ( + {params.row.lastName || params.row.lastname || "--"} + ), + }, + { + field: "email", + headerName: "Email", + flex: 1, + minWidth: 200, + sortable: true, + valueGetter: (params) => { + return params.row.email || params.row.workemail || ""; + }, + renderCell: (params: GridRenderCellParams) => ( + {params.row.email || params.row.workemail || "--"} + ), + }, + { + field: "belongsToCompanyId", + headerName: "Company", + flex: 1, + minWidth: 180, + sortable: true, + renderCell: (params: GridRenderCellParams) => { + const companyId = params.value; + if (!companyId) return --; + + // Use companyMap for instant lookup (same data as Select Company dropdown) + const companyName = companyMap.get(companyId); + + // Only show company name, never show the ID + return {companyName || "--"}; + }, + }, + { + field: "role", + headerName: "Role", + flex: 1, + minWidth: 120, + sortable: true, + renderCell: (params: GridRenderCellParams) => ( + {params.value || "--"} + ), + }, + { + field: "phone", + headerName: "Phone", + flex: 1, + minWidth: 140, + sortable: true, + valueGetter: (params) => { + return params.row.phone || params.row.phonenumber || ""; + }, + renderCell: (params: GridRenderCellParams) => ( + {params.row.phone || params.row.phonenumber || "--"} + ), + }, + + { + field: "emailConfirmed", + headerName: "Email Confirmed", + flex: 1, + minWidth: 140, + sortable: true, + renderCell: (params: GridRenderCellParams) => ( + {params.value === true ? "Yes" : params.value === false ? "No" : "--"} + ), + }, + { + field: "phoneConfirmed", + headerName: "Phone Confirmed", + flex: 1, + minWidth: 140, + sortable: true, + renderCell: (params: GridRenderCellParams) => ( + {params.value === true ? "Yes" : params.value === false ? "No" : "--"} + ), + }, + { + field: "twoFactorEnabled", + headerName: "Two Factor Enabled", + flex: 1, + minWidth: 160, + sortable: true, + renderCell: (params: GridRenderCellParams) => ( + {params.value === true ? "Yes" : params.value === false ? "No" : "--"} + ), + }, + + { + field: "invitedBy", + headerName: "Invited By", + flex: 1, + minWidth: 120, + sortable: true, + renderCell: (params: GridRenderCellParams) => ( + {params.value || "--"} + ), + }, + { + field: "createdAt", + headerName: "Created At", + flex: 1, + minWidth: 150, + sortable: true, + renderCell: (params: GridRenderCellParams) => ( + {formatDate(params.value)} + ), + }, + { + field: "updatedAt", + headerName: "Updated At", + flex: 1, + minWidth: 150, + sortable: true, + renderCell: (params: GridRenderCellParams) => ( + {formatDate(params.value)} + ), + }, + { + field: "actions", + headerName: "Actions", + width: 160, + sortable: false, + filterable: false, + renderCell: (params: GridRenderCellParams) => ( + + openEditModal(params.row)} + size="small" + > + + + deletePartner(params.row)} + size="small" + > + + + + ), + }, + ]; + + return ( + + +
+
+ setSearchTerm(e.target.value)} + size="small" + fullWidth + sx={{ maxWidth: { xs: "100%", sm: 240, md: 300 } }} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: searchTerm ? ( + + setSearchTerm("")} + edge="end" + > + + + + ) : undefined, + }} + /> + +
+
+ ({ + ...row, + id: row.id || row.ID || `${row.firstname}_${row.lastname}`, + }))} + columns={columns} + disableRowSelectionOnClick + sortingOrder={["asc", "desc"]} + paginationModel={paginationModel} + pageSizeOptions={[5, 10, 25, 50]} + onPaginationModelChange={setPaginationModel} + disableColumnMenu + disableColumnFilter + localeText={{ + noRowsLabel: "No Data Found", + }} + /> +
+
+ + { + setPartnerModalOpen(false); + setSelectedPartner(null); + }} + title="Create Partners" + > + {partnerModalOpen && ( + { + setPartnerModalOpen(false); + setSelectedPartner(null); + }} + /> + )} + + + { + setEditPartnerModalOpen(false); + setSelectedPartner(null); + }} + title="Edit Partners" + > + { + setEditPartnerModalOpen(false); + setSelectedPartner(null); + }} + /> + +
+
+ ); +}; + +export default Partners; + diff --git a/src/pages/plans/PlanForm.tsx b/src/pages/plans/PlanForm.tsx new file mode 100644 index 000000000..efb657b33 --- /dev/null +++ b/src/pages/plans/PlanForm.tsx @@ -0,0 +1,422 @@ +import React, { useEffect } from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import TextField from "@mui/material/TextField"; +import Stack from "@mui/material/Stack"; +import MenuItem from "@mui/material/MenuItem"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import Switch from "@mui/material/Switch"; +import Typography from "@mui/material/Typography"; +import { useForm, Controller } from "react-hook-form"; +import { yupResolver } from "@hookform/resolvers/yup"; +import * as yup from "yup"; +import { useMutation } from "@apollo/client/react"; +import { CREATE_PLAN_MUTATION, UPDATE_PLAN_MUTATION } from "../../graphql/mutations"; +import { PLANS_QUERY } from "../../graphql/queries"; +import { useLoader } from "../../context/LoaderContext"; +import { showToast } from "../../components/toastService"; + +const schema = yup.object().shape({ + name: yup.string().required("Name is required"), + description: yup.string().nullable().default(""), + price: yup.number().required("Price is required").min(0, "Price must be greater than or equal to 0"), + currency: yup.string().default("USD"), + interval: yup.string().oneOf(["month", "year"]).default("month"), + active: yup.boolean().default(true), + assetsLimit: yup.number().min(-1, "Assets Limit must be -1 or greater").default(-1).label("Assets Limit"), + locationsLimit: yup.number().min(-1, "Locations Limit must be -1 or greater").default(-1).label("Locations Limit"), + usersLimit: yup.number().min(-1, "Users Limit must be -1 or greater").default(-1).label("Users Limit"), + storageLimit: yup.number().min(-1, "Storage Limit must be -1 or greater").default(-1).label("Storage Limit (GB)"), +}); + +type PlanFormInputs = yup.InferType; + +type PlanFormProps = { + mode: "add" | "edit"; + data?: any; + onClose: () => void; + getData: () => void; +}; + +const PlanForm: React.FC = ({ + mode, + data, + onClose, + getData, +}) => { + const { showLoader, hideLoader } = useLoader(); + const [createPlan, { loading: createLoading }] = useMutation(CREATE_PLAN_MUTATION, { + refetchQueries: [{ query: PLANS_QUERY }], + }); + const [updatePlan, { loading: updateLoading }] = useMutation(UPDATE_PLAN_MUTATION, { + refetchQueries: [{ query: PLANS_QUERY }], + }); + + const { + control, + handleSubmit, + reset, + formState: { errors }, + clearErrors, + } = useForm({ + resolver: yupResolver(schema) as any, + mode: "onSubmit", + reValidateMode: "onChange", + defaultValues: { + name: "", + description: "", + price: 0, + currency: "USD", + interval: "month", + active: true, + assetsLimit: -1, + locationsLimit: -1, + usersLimit: -1, + storageLimit: -1, + }, + }); + + useEffect(() => { + clearErrors(); + + if (mode === "edit" && data) { + // Parse limits + const limits = typeof data.limits === 'string' ? JSON.parse(data.limits) : data.limits; + + reset({ + name: data.name || "", + description: data.description || "", + price: data.amount || 0, + currency: data.currency || "USD", + interval: data.interval || "month", + active: data.active !== undefined ? data.active : true, + assetsLimit: limits?.assets ?? -1, + locationsLimit: limits?.locations ?? -1, + usersLimit: limits?.users ?? -1, + storageLimit: limits?.storage ?? -1, + }); + } else { + reset({ + name: "", + description: "", + price: 0, + currency: "USD", + interval: "month", + active: true, + assetsLimit: -1, + locationsLimit: -1, + usersLimit: -1, + storageLimit: -1, + }); + } + }, [mode, data, reset, clearErrors]); + + const handleClose = () => { + clearErrors(); + reset({ + name: "", + description: "", + price: 0, + currency: "USD", + interval: "month", + active: true, + assetsLimit: -1, + locationsLimit: -1, + usersLimit: -1, + storageLimit: -1, + }); + onClose(); + }; + + const onSubmit = async (formData: PlanFormInputs) => { + try { + showLoader(); + + const limits = { + assets: formData.assetsLimit, + locations: formData.locationsLimit, + users: formData.usersLimit, + storage: formData.storageLimit, + }; + + const baseInput = { + name: formData.name, + description: formData.description || null, + amount: formData.price, + currency: formData.currency, + interval: formData.interval, + limits: limits, + }; + + let result; + if (mode === "edit") { + result = await updatePlan({ + variables: { + input: { + id: data?.id, + ...baseInput, + active: formData.active, + }, + }, + }); + } else { + result = await createPlan({ + variables: { + input: baseInput, + }, + }); + } + + if (result.data) { + // Refetch data to update the grid + getData(); + const text = mode === "edit" + ? "Plan updated successfully." + : "Plan created successfully."; + showToast({ status: "success", title: text }); + + // Reset form after successful submission + clearErrors(); + reset({ + name: "", + description: "", + price: 0, + currency: "USD", + interval: "month", + active: true, + assetsLimit: -1, + locationsLimit: -1, + usersLimit: -1, + storageLimit: -1, + }); + handleClose(); + } + } catch (error: any) { + console.error("Error saving plan:", error); + const errorMessage = error?.graphQLErrors?.[0]?.message + || error?.networkError?.message + || error?.message + || "Something went wrong while saving the plan."; + showToast({ + status: "error", + title: errorMessage, + }); + } finally { + hideLoader(); + } + }; + + return ( + + + + ( + + )} + /> + + ( + + )} + /> + + ( + { + const value = e.target.value === "" ? 0 : Number(e.target.value); + field.onChange(value); + }} + /> + )} + /> + + ( + + )} + /> + + ( + + Monthly + Yearly + + )} + /> + + + Limits (-1 for unlimited) + + + ( + + )} + /> + + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + + ( + + } + label="Active" + /> + )} + /> + + + + + + + ); +}; + +export default PlanForm; diff --git a/src/pages/plans/Plans.tsx b/src/pages/plans/Plans.tsx new file mode 100644 index 000000000..371257e79 --- /dev/null +++ b/src/pages/plans/Plans.tsx @@ -0,0 +1,438 @@ +import React, { useState, useEffect, useMemo } from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import IconButton from "@mui/material/IconButton"; +import InputAdornment from "@mui/material/InputAdornment"; +import TextField from "@mui/material/TextField"; +import Chip from "@mui/material/Chip"; +import Tooltip from "@mui/material/Tooltip"; +import { ThemeProvider } from "@mui/material/styles"; +import { + DataGrid, + GridColDef, + GridRenderCellParams, + GridPaginationModel, +} from "@mui/x-data-grid"; +import { Search, X, Trash2, Pencil, Plus, Users, Package, HardDrive, MapPin } from "lucide-react"; +import { useLazyQuery, useMutation } from "@apollo/client/react"; +import RightSideModal from "../../components/RightSideModal"; +import PlanForm from "./PlanForm"; +import { showToast } from "../../components/toastService"; +import { useLoader } from "../../context/LoaderContext"; +import muiTheme from "../../themes/muiTheme"; +import { PLANS_QUERY } from "../../graphql/queries"; +import { DELETE_PLAN_MUTATION } from "../../graphql/mutations"; + +const Plans = () => { + const [searchTerm, setSearchTerm] = useState(""); + const [planModalOpen, setPlanModalOpen] = useState(false); + const [planData, setPlanData] = useState([]); + const [modalMode, setModalMode] = useState<"add" | "edit">("add"); + const [selectedPlan, setSelectedPlan] = useState(null); + const [paginationModel, setPaginationModel] = useState({ + page: 0, + pageSize: 10, + }); + const { showLoader, hideLoader } = useLoader(); + const [fetchPlans, { loading: queryLoading, refetch }] = useLazyQuery(PLANS_QUERY); + const [hasFetched, setHasFetched] = useState(false); + const [deletePlan] = useMutation(DELETE_PLAN_MUTATION, { + refetchQueries: [{ query: PLANS_QUERY }], + }); + + const filteredData = useMemo( + () => + planData.filter((item) => + item.name?.toLowerCase().includes(searchTerm.toLowerCase()) || + item.description?.toLowerCase().includes(searchTerm.toLowerCase()) || + item.currency?.toLowerCase().includes(searchTerm.toLowerCase()) || + item.interval?.toLowerCase().includes(searchTerm.toLowerCase()) + ), + [planData, searchTerm] + ); + + // Fetch plans data from GraphQL API + const getPlanDetails = async () => { + try { + showLoader(); + + // Use refetch if query has been executed before, otherwise use fetchPlans + let queryData, error; + if (hasFetched && refetch) { + const result = await refetch(); + queryData = result.data; + error = result.error; + } else { + const result = await fetchPlans(); + queryData = result.data; + error = result.error; + setHasFetched(true); + } + + if (error) { + console.error("GraphQL error:", error); + setPlanData([]); + return; + } + + const resultData = (queryData as any)?.plans || []; + + const normalizedData = Array.isArray(resultData) + ? resultData.map((item: any) => ({ + ...item, + id: item.id, + })) + : []; + + setPlanData(normalizedData); + } catch (err) { + console.error("Error fetching plans:", err); + setPlanData([]); + } finally { + hideLoader(); + } + }; + + useEffect(() => { + getPlanDetails(); + }, []); + + const openAddModal = () => { + setModalMode("add"); + setSelectedPlan(null); + setPlanModalOpen(true); + }; + + const openEditModal = (plan: any) => { + setModalMode("edit"); + setSelectedPlan(plan); + setPlanModalOpen(true); + }; + + const handleDeletePlan = async (planId: any) => { + if (!window.confirm("Are you sure you want to delete this plan?")) { + return; + } + + try { + showLoader(); + + const result = await deletePlan({ + variables: { input: { id: planId.id } }, + }); + + const deleteResult = (result.data as any)?.deletePlan; + if (deleteResult) { + if (deleteResult.success) { + showToast({ + status: "success", + title: deleteResult.message || "Plan deleted successfully.", + }); + + // Refresh table + getPlanDetails(); + } else { + showToast({ + status: "error", + title: deleteResult.message || "Failed to delete plan.", + }); + } + } + } catch (error: any) { + console.error("Error deleting plan:", error); + showToast({ + status: "error", + title: error?.message || "Failed to delete plan.", + }); + } finally { + hideLoader(); + } + }; + + const parseLimits = (limits: any) => { + if (!limits) return null; + try { + const parsed = typeof limits === 'string' ? JSON.parse(limits) : limits; + // Check if object is empty + if (Object.keys(parsed).length === 0) return null; + return parsed; + } catch { + return null; + } + }; + + const LimitBadge = ({ + icon: Icon, + value, + label, + colorClass + }: { + icon: React.ElementType; + value: number; + label: string; + colorClass: string; + }) => { + const displayValue = value === -1 ? "∞" : value; + const tooltipText = `${label}: ${value === -1 ? "Unlimited" : value}`; + + return ( + + + + {displayValue} + + + ); + }; + + const columns: GridColDef[] = [ + { + field: "name", + headerName: "Name", + flex: 1, + minWidth: 160, + sortable: true, + renderCell: (params: GridRenderCellParams) => ( + + {params.row.name || "N/A"} + + ), + }, + { + field: "description", + headerName: "Description", + flex: 1, + minWidth: 160, + sortable: true, + renderCell: (params: GridRenderCellParams) => ( + + {params.row.description || "N/A"} + + ), + }, + { + field: "price", + headerName: "Price", + flex: 0.8, + minWidth: 120, + sortable: true, + renderCell: (params: GridRenderCellParams) => ( + + {params.row.currency?.toUpperCase() || "USD"} {params.row.amount || 0} + + ), + }, + { + field: "interval", + headerName: "Interval", + flex: 0.6, + minWidth: 100, + sortable: true, + renderCell: (params: GridRenderCellParams) => ( + + {params.row.interval || "N/A"} + + ), + }, + { + field: "active", + headerName: "Status", + flex: 0.6, + minWidth: 100, + sortable: true, + renderCell: (params: GridRenderCellParams) => ( + <> + {params.row.active ? "Active" : "Inactive"} + + ), + }, + { + field: "limits", + headerName: "Limits", + flex: 1.5, + minWidth: 200, + sortable: false, + renderCell: (params: GridRenderCellParams) => { + const limits = parseLimits(params.row.limits); + + if (!limits || Object.keys(limits).length === 0) { + return N/A; + } + + return ( + + {limits.users !== undefined && ( + + )} + {limits.assets !== undefined && ( + + )} + {limits.storage !== undefined && ( + + )} + {limits.locations !== undefined && ( + + )} + + ); + }, + }, + { + field: "actions", + headerName: "Actions", + width: 160, + sortable: false, + filterable: false, + renderCell: (params: GridRenderCellParams) => ( + + openEditModal(params.row)} + size="small" + > + + + handleDeletePlan(params.row)} + size="small" + > + + + + ), + }, + ]; + + const rows = useMemo( + () => + filteredData.map((row) => ({ + ...row, + id: row.id || row.name, + })), + [filteredData] + ); + + return ( + + +
+
+ setSearchTerm(e.target.value)} + size="small" + fullWidth + sx={{ maxWidth: { xs: "100%", sm: 200, md: 250 } }} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: searchTerm ? ( + + setSearchTerm("")} + edge="end" + > + + + + ) : undefined, + }} + /> + +
+
+ +
+
+ + { + setPlanModalOpen(false); + setSelectedPlan(null); + }} + title={modalMode === "add" ? "Add Plan" : "Edit Plan"} + > + {planModalOpen && ( + { + setPlanModalOpen(false); + setSelectedPlan(null); + }} + /> + )} + +
+
+ ); +}; + +export default Plans; diff --git a/src/pages/settings/Settings.tsx b/src/pages/settings/Settings.tsx new file mode 100644 index 000000000..9bf0ae0ed --- /dev/null +++ b/src/pages/settings/Settings.tsx @@ -0,0 +1,312 @@ +import React, { useState, useEffect, useMemo } from "react"; +import { + Box, + Button, + Paper, + TextField, + Typography, + Grid, + Divider, + CircularProgress, + Stack, +} from "@mui/material"; +import { useQuery, useMutation } from "@apollo/client/react"; +import { useForm, Controller } from "react-hook-form"; +import { yupResolver } from "@hookform/resolvers/yup"; +import * as yup from "yup"; +import { USER_QUERY, COMPANIES_QUERY, COMPANY_USERS_QUERY } from "../../graphql/queries"; +import { UPDATE_COMPANY_MUTATION, UPDATE_COMPANY_USER_MUTATION } from "../../graphql/mutations"; +import { useLoader } from "../../context/LoaderContext"; +import { showToast } from "../../components/toastService"; +import RightSideModal from "../../components/RightSideModal"; + +const schema = yup.object().shape({ + firstName: yup.string().required("First Name is required"), + lastName: yup.string().required("Last Name is required"), + companyName: yup.string().required("Company Name is required"), + industry: yup.string().required("Industry is required"), +}); + +const Settings = () => { + const [editModalOpen, setEditModalOpen] = useState(false); + const { showLoader, hideLoader } = useLoader(); + + const { data: userData, loading: userLoading, refetch: refetchUser } = useQuery(USER_QUERY); + const { data: companiesData, loading: companiesLoading, refetch: refetchCompanies } = useQuery(COMPANIES_QUERY); + const { data: companyUsersData, loading: companyUsersLoading, refetch: refetchCompanyUsers } = useQuery(COMPANY_USERS_QUERY); + + const [updateCompany] = useMutation(UPDATE_COMPANY_MUTATION); + const [updateCompanyUser] = useMutation(UPDATE_COMPANY_USER_MUTATION, { + refetchQueries: [{ query: COMPANY_USERS_QUERY }, { query: USER_QUERY }], + awaitRefetchQueries: true + }); + + const user = userData?.me; + const company = companiesData?.companies?.find((c: any) => c.id === user?.companyId); + + // Find current user from companyUsers by email + const currentUser = useMemo(() => { + if (!user?.email || !companyUsersData?.companyUsers) return null; + return companyUsersData.companyUsers.find((u: any) => u.email === user.email); + }, [user?.email, companyUsersData?.companyUsers]); + + const { control, handleSubmit, reset, formState: { errors, isSubmitting } } = useForm({ + resolver: yupResolver(schema), + defaultValues: { + firstName: "", + lastName: "", + companyName: "", + industry: "", + }, + }); + + useEffect(() => { + if (currentUser && company && editModalOpen) { + reset({ + firstName: currentUser.firstName || "", + lastName: currentUser.lastName || "", + companyName: company.name || "", + industry: company.industry || "", + }); + } + }, [currentUser, company, editModalOpen, reset]); + + const onSubmit = async (data: any) => { + if (!currentUser) { + showToast({ status: "error", title: "User not found" }); + return; + } + + showLoader(); + try { + // Update User + await updateCompanyUser({ + variables: { + input: { + userId: currentUser.id, + firstName: data.firstName, + lastName: data.lastName, + }, + }, + }); + + // Update Company + await updateCompany({ + variables: { + input: { + id: company.id, + name: data.companyName, + industry: data.industry, + }, + }, + }); + + await refetchUser(); + await refetchCompanies(); + await refetchCompanyUsers(); + setEditModalOpen(false); + showToast({ status: "success", title: "Settings updated successfully" }); + } catch (err: any) { + console.error("Error updating settings:", err); + showToast({ status: "error", title: err.message || "Error updating settings" }); + } finally { + hideLoader(); + } + }; + + const handleCloseModal = () => { + setEditModalOpen(false); + reset(); + }; + + if (userLoading || companiesLoading || companyUsersLoading) { + return ( + + + + ); + } + + // Use currentUser data if available, otherwise fallback to user from USER_QUERY + const displayUser = currentUser || user; + + return ( + + +
+
+ + Settings + + +
+
+ User Info +
+
+
+
First Name
+

{displayUser?.firstName || "---"}

+
+ +
+
+
+
Last Name
+

{displayUser?.lastName || "---"}

+
+ +
+
+
+
Email
+

{displayUser?.email || "---"}

+
+ +
+ +
+
+
Role
+

{displayUser?.role || "---"}

+
+
+
+ Company Info +
+
+
+
Company Name
+

{company?.name || "---"}

+
+
+
+
+
Subdomain
+

{company?.subdomain || "---"}

+
+
+
+
+
Industry
+

{company?.industry || "---"}

+
+
+
+
+
+ + + {/* Edit Modal - Right Side Modal like Teams */} + + + + ( + + )} + /> + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + + {/* */} + + + + +
+ ); +}; + +export default Settings; diff --git a/src/pages/subscription/SubscriptionOverview.tsx b/src/pages/subscription/SubscriptionOverview.tsx new file mode 100644 index 000000000..110acbf9e --- /dev/null +++ b/src/pages/subscription/SubscriptionOverview.tsx @@ -0,0 +1,370 @@ +import React, { useMemo, useState } from "react"; +import { + Alert, + Box, + Button, + Card, + CardActions, + CardContent, + CardHeader, + Chip, + Divider, + Grid, + IconButton, + Snackbar, + Stack, + TextField, + Typography, +} from "@mui/material"; +import CreditCardIcon from "@mui/icons-material/CreditCard"; +import SavingsIcon from "@mui/icons-material/Savings"; +import ReceiptLongIcon from "@mui/icons-material/ReceiptLong"; +import { Download } from "lucide-react"; +import SubscriptionPlanDialog from "./SubscriptionPlanDialog"; +import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; +import { subscriptionPlans } from "./subscriptionPlans"; + +type CardFormState = { + cardholder: string; + cardNumber: string; + expiry: string; + cvv: string; +}; + +const defaultCardForm: CardFormState = { + cardholder: "", + cardNumber: "", + expiry: "", + cvv: "", +}; + +const paymentHistory = [ + { id: "INV-009", date: "Nov 01, 2025", amount: "$299.00", status: "Paid" }, + { id: "INV-008", date: "Oct 01, 2025", amount: "$299.00", status: "Paid" }, + { id: "INV-007", date: "Sep 01, 2025", amount: "$299.00", status: "Paid" }, +]; + +const SubscriptionOverview: React.FC = () => { + const [dialogOpen, setDialogOpen] = useState(false); + const [cardForm, setCardForm] = useState(defaultCardForm); + const [savedCard, setSavedCard] = useState(null); + const [snackbarOpen, setSnackbarOpen] = useState(false); + + const maskedCardNumber = useMemo(() => { + if (!savedCard?.cardNumber) { + return ""; + } + const last4 = savedCard.cardNumber.slice(-4); + return `•••• •••• •••• ${last4}`; + }, [savedCard]); + + const selectedPlan = useMemo( + () => subscriptionPlans.find((plan) => plan.selected) ?? subscriptionPlans[0], + [] + ); + + const handleInputChange = + (field: keyof CardFormState) => (event: React.ChangeEvent) => { + setCardForm((prev) => ({ + ...prev, + [field]: event.target.value, + })); + }; + + const handleSaveCard = () => { + setSavedCard(cardForm); + setSnackbarOpen(true); + }; + + const handleCloseSnackbar = () => { + setSnackbarOpen(false); + }; + + const isSaveDisabled = + !cardForm.cardholder || !cardForm.cardNumber || !cardForm.expiry || !cardForm.cvv; + + const handleDownload = (invoiceId: string) => { + // Handle download action + console.log("Download invoice:", invoiceId); + // Add your download logic here + }; + + const columns: GridColDef[] = [ + { field: "id", headerName: "Invoice", flex: 1, minWidth: 120 }, + { field: "date", headerName: "Date", flex: 1, minWidth: 150 }, + { + field: "amount", + headerName: "Amount", + flex: 1, + minWidth: 120, + headerAlign: "center", + align: "center", + }, + { + field: "status", + headerName: "Status", + flex: 1, + minWidth: 140, + renderCell: (params) => {params.value as string} , + // , + }, + { + field: "actions", + headerName: "Actions", + width: 120, + sortable: false, + filterable: false, + align: "center", + headerAlign: "center", + renderCell: (params: GridRenderCellParams) => ( + + ), + }, + ]; + + return ( + + +
+
+
+
+
+ Current Plan + Your subscription details +
+
+ Active +
+
+
+ {selectedPlan && ( + +
+
+
+ + {(() => { + const IconComponent = selectedPlan.icon; + return ; + })()} + +
+ {selectedPlan.name} Plan + Professional features +
+
+ {selectedPlan.billedPrice && selectedPlan.period && ( + + + ${selectedPlan.price} + {/* / {selectedPlan.period} */}/month + + + + + billed at ${selectedPlan.billedPrice}/yr + + + )} +
+
+ Plan includes: +
+
+
+
Users
+

1

+
+
+
+
+
Locations
+

1

+
+
+
+
+
Assets
+

100

+
+
+
+
+
Assets Storage
+

100GB

+
+
+
+
+ + + +
+ )} +
+
+
+
+
+
+ Payment Method + Update your billing information +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + + Your payment information is encrypted and secure + +
+
+
+
+
+
+ Payment History + View and download your past invoices + +
+
+ +
+
+
+
+ + + {/* + + + + } + title={Payment History} + subheader="Recent invoices" + /> + + + + + + + + */} + + setDialogOpen(false)} + currentPlan={selectedPlan?.name || "Premium"} + /> + + + + Card details saved successfully. + + +
+ ); +}; + +export default SubscriptionOverview; diff --git a/src/pages/subscription/SubscriptionPlanDialog.tsx b/src/pages/subscription/SubscriptionPlanDialog.tsx new file mode 100644 index 000000000..2d586167d --- /dev/null +++ b/src/pages/subscription/SubscriptionPlanDialog.tsx @@ -0,0 +1,444 @@ +import React, { useState } from "react"; +import { + AppBar, + Box, + Button, + Card, + CardContent, + Dialog, + IconButton, + Slide, + Stack, + Toolbar, + Typography, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tooltip, + useTheme, + useMediaQuery, + Alert, +} from "@mui/material"; +import CloseIcon from "@mui/icons-material/Close"; +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import type { TransitionProps } from "@mui/material/transitions"; +import { Check, X, Phone, Mail } from "lucide-react"; +import { + subscriptionPlans, + planFeatureMeta, + featureRows, + PlanId, +} from "./subscriptionPlans"; + +type BillingCycle = "monthly" | "annual"; + +const Transition = React.forwardRef(function Transition( + props: TransitionProps & { children: React.ReactElement }, + ref: React.Ref +) { + return ; +}); + +type SubscriptionPlanDialogProps = { + open: boolean; + onClose: () => void; + currentPlan: string; +}; + +const SubscriptionPlanDialog: React.FC = ({ + open, + onClose, + currentPlan, +}) => { + const [billingCycle, setBillingCycle] = useState("monthly"); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + const isTablet = useMediaQuery(theme.breakpoints.down('md')); + + const renderFeatureValue = (value: boolean | string) => { + if (typeof value === "boolean") { + return value ? ( + + ) : ( + + ); + } + + return ( + + {value} + + ); + }; + return ( + + + + + + Choose a subscription plan + + + + + + + + + 14-day free trial for all plans. No risk! Upgrade, downgrade or cancel any time. + + {/* + + Stay aligned with your resilience objectives + + + Compare plans, adjust billing cycles, and select the coverage that matches your + portfolio. + + */} + + {/* + + + */} + +
+ {subscriptionPlans.map((plan) => { + const IconComponent = plan.icon; + return ( +
+ + + {plan.badge && ( + + + {plan.badge} + + + )} + + +
+ + + + +
+ {plan.name} Plan + {plan.subText} +
+
+ + {/* + + + + + {plan.name} + */} + + {plan.contactSales ? ( + + + Contact Sales + + + + + 833-744-1010 + + + + + + sales@criticalasset.com + + + {/* {plan.id === 'professional' && ( + + For Approved Contractors only + + )} */} + + ) : ( + + + ${plan.price} + / {plan.period} + + + + + billed at ${plan.billedPrice}/yr + + + )} + + + +
+ Plan includes: + {plan.features && ( + <> + {planFeatureMeta.map(({ key, label }) => ( +
+
+
{label}
+

{plan.features?.[key]}

+
+
+ ))} + + + )} +
+
+
+
+
+
+ ); + })} +
+ +
+
+
+ Feature Comparison +
+
+
+ + + + + + Features + + {subscriptionPlans.map((plan) => ( + + {plan.name} + + ))} + + + + {featureRows.map((row) => ( + + + + {row.label} + {row.tooltip && ( + + + + )} + + + {subscriptionPlans.map((plan) => ( + + + {renderFeatureValue(row.values[plan.id as PlanId])} + + + ))} + + ))} + +
+
+
+
+
+
+ +
+
+
+ ); +}; + +export default SubscriptionPlanDialog; \ No newline at end of file diff --git a/src/pages/subscription/subscriptionPlans.ts b/src/pages/subscription/subscriptionPlans.ts new file mode 100644 index 000000000..4e727ca92 --- /dev/null +++ b/src/pages/subscription/subscriptionPlans.ts @@ -0,0 +1,357 @@ +import { Briefcase, Crown, Medal, Sparkles } from "lucide-react"; + +export type PlanId = "starter" | "premium" | "ultimate" | "professional"; + +export type PlanFeatures = { + users: string; + locations: string; + assets: string; + storage: string; +}; + +export type SubscriptionPlan = { + id: PlanId; + name: string; + icon: typeof Sparkles; + price?: number; + billedPrice?: number; + period?: string; + badge?: string; + badgeBg?: string; + color: string; + contactSales?: boolean; + features: PlanFeatures; + selected?: boolean; + subText?: string; +}; + +export const subscriptionPlans: SubscriptionPlan[] = [ + { + id: "starter", + name: "Starter", + icon: Sparkles, + price: 99, + billedPrice: 1188, + period: "month", + subText: "Standard features", + color: "#fff", + badgeBg: "#fff", + features: { + users: "1", + locations: "1", + assets: "100", + storage: "100GB", + }, + selected: false, + }, + { + id: "premium", + name: "Premium", + icon: Medal, + price: 399, + billedPrice: 4788, + period: "month", + subText: "Advanced features", + badge: "Most Popular", + color: "#fff", + badgeBg: "#3b82f6", + features: { + users: "5", + locations: "5", + assets: "500", + storage: "500GB", + }, + selected: true, + }, + { + id: "ultimate", + name: "Ultimate", + icon: Crown, + price: 699, + billedPrice: 8388, + period: "month", + subText: "Professional features", + color: "#fff", + badgeBg: "transparent", + features: { + users: "10+", + locations: "10+", + assets: "2000+", + storage: "2TB+", + }, + selected: false, + }, + { + id: "professional", + name: "Professional/MSP", + icon: Briefcase, + contactSales: true, + color: "#fff", + subText: "All Inclusive features", + badgeBg: "#1F2937", + badge: "For Contractors", + features: { + users: "Unlimited", + locations: "Unlimited", + assets: "Unlimited", + storage: "5TB+", + }, + selected: false, + }, +]; + +export const planFeatureMeta: Array<{ key: keyof PlanFeatures; label: string }> = [ + { key: "users", label: "Users" }, + { key: "locations", label: "Locations" }, + { key: "assets", label: "Assets" }, + { key: "storage", label: "Assets Storage" }, +]; + +export type FeatureRow = { + label: string; + tooltip?: string; + values: Record; +}; + +export const featureRows: FeatureRow[] = [ + { + label: "Multi-company", + tooltip: "Add and manage additional companies.Perfect for contractors, subcontractors and licensed trade professionals who want to make jobs drastically easier by having instant access to customers`asset data and plans.", + values: { + starter: false, + premium: false, + ultimate: false, + professional: true, + }, + }, + { + label: "Electrical panel mapping", + tooltip: "Map your electrical panels so that you always know what each circuit is connected to.", + values: { + starter: true, + premium: true, + ultimate: true, + professional: true, + }, + }, + { + label: "Plumbing mapping", + tooltip: "Map your plumbing assets so that you always know what everything is connected to.", + values: { + starter: true, + premium: true, + ultimate: true, + professional: true, + }, + }, + { + label: "HVAC", + tooltip: "Manage your air conditioning, heating and other HVAC assets .", + values: { + starter: true, + premium: true, + ultimate: true, + professional: true, + }, + }, + { + label: "Mechanical", + tooltip: "Manage all your mechanical assets.", + values: { + starter: true, + premium: true, + ultimate: true, + professional: true, + }, + }, + { + label: "Fire-Life Safety", + tooltip: "Manage your fire extinguishers, smoke detectors and other fire-life safety assets.", + values: { + starter: true, + premium: true, + ultimate: true, + professional: true, + }, + }, + { + label: "Affected area views", + tooltip: "See what is affected when circuits, plumbing or other assets or break down, go offline or need to be shut down.", + values: { + starter: true, + premium: true, + ultimate: true, + professional: true, + }, + }, + { + label: "Service reminders", + tooltip: "Get notified by email and on your phone when assets are due for upcoming service.", + values: { + starter: true, + premium: true, + ultimate: true, + professional: true, + }, + }, + { + label: "In-app messaging", + tooltip: "Instantly communicate with your facilities team, HR, IT and any others involved in your facilities asset management and service process.", + values: { + starter: true, + premium: true, + ultimate: true, + professional: true, + }, + }, + { + label: "Reporting dashboard", + tooltip: "Instantly view important asset information such as upcoming and past due service requirements.", + values: { + starter: true, + premium: true, + ultimate: true, + professional: true, + }, + }, + { + label: "Mobile app", + tooltip: "Access your facilities data from your phone.", + values: { + starter: true, + premium: true, + ultimate: true, + professional: true, + }, + }, + { + label: "Offline mode", + tooltip: "Your data is always available, even when your Wi-Fi is not.", + values: { + starter: true, + premium: true, + ultimate: true, + professional: true, + }, + }, + { + label: "Visual asset mapping", + tooltip: "View all your assets on visual floor pans and building maps.", + values: { + starter: true, + premium: true, + ultimate: true, + professional: true, + }, + }, + { + label: "QR code labeling & scanning", + tooltip: "Print and scan QR codes to easily identify assets from your phone.", + values: { + starter: true, + premium: true, + ultimate: true, + professional: true, + }, + }, + { + label: "Data importing & exporting", + tooltip: "Easily import and export your asset data from and to Excel,Google docs or any other spreadsheet.", + values: { + starter: true, + premium: true, + ultimate: true, + professional: true, + }, + }, + { + label: "Definable roles & permissions", + tooltip: "Customize access for each member of your team and outside technicians.", + values: { + starter: true, + premium: true, + ultimate: true, + professional: true, + }, + }, + { + label: "Photo capture & annotation", + tooltip: "Upload pictures and add notes to share with your team.", + values: { + starter: true, + premium: true, + ultimate: true, + professional: true, + }, + }, + { + label: "Invite contactors & technicians", + tooltip: "Invite outside contractors and technicians to securely access your facilities data on a limited one-time or ongoing basis.", + values: { + starter: true, + premium: true, + ultimate: true, + professional: true, + }, + }, + { + label: "Email & phone support", + tooltip: "We love to help our customers!", + values: { + starter: true, + premium: true, + ultimate: true, + professional: true, + }, + }, + { + label: "Work orders (coming soon)", + tooltip: "Assign and track work orders.", + values: { + starter: "--", + premium: "--", + ultimate: "--", + professional: "--", + }, + }, + { + label: "Instant parts ordering (coming soon)", + tooltip: "When you need to order replacement parts, CriticalAsset makes it easy!.", + values: { + starter: "--", + premium: "--", + ultimate: "--", + professional: "--", + }, + }, + { + label: "Technician directory (coming soon)", + tooltip: "Instantly find qualified local technicians and schedule service calls.", + values: { + starter: "--", + premium: "--", + ultimate: "--", + professional: "--", + }, + }, + { + label: "IoT sensors (coming soon)", + tooltip: "Fix problems before they happen and track assets with CriticalAsset IoT sensors.", + values: { + starter: "--", + premium: "--", + ultimate: "--", + professional: "--", + }, + }, + { + label: "Integrations (coming soon)", + tooltip: "Use CMMS software and don't want to switch? No worries! CriticalAsset integrates with many CMMS and other software.", + values: { + starter: "--", + premium: "--", + ultimate: "--", + professional: "--", + }, + }, +]; diff --git a/src/pages/team/Team.tsx b/src/pages/team/Team.tsx new file mode 100644 index 000000000..a296412fd --- /dev/null +++ b/src/pages/team/Team.tsx @@ -0,0 +1,1153 @@ +import React, { useState, useMemo, useEffect } from "react"; +import { + Box, + Button, + Card, + CardContent, + Typography, + Avatar, + Chip, + IconButton, + Menu, + MenuItem, + TextField, + Select, + FormControl, + InputLabel, + Stack, + Snackbar, + Alert, + SelectChangeEvent, +} from "@mui/material"; +import { useForm, Controller } from "react-hook-form"; +import { yupResolver } from "@hookform/resolvers/yup"; +import * as yup from "yup"; +import MoreVertIcon from "@mui/icons-material/MoreVert"; +import PersonAddIcon from "@mui/icons-material/PersonAdd"; +import EditIcon from "@mui/icons-material/Edit"; +import DeleteIcon from "@mui/icons-material/Delete"; +import SendIcon from "@mui/icons-material/Send"; +import ExitToAppIcon from "@mui/icons-material/ExitToApp"; +import { useQuery, useMutation } from "@apollo/client/react"; +import { COMPANY_USERS_QUERY } from "../../graphql/queries"; +import { SEND_INVITATION_MUTATION, UPDATE_COMPANY_USER_MUTATION, DELETE_COMPANY_USER_MUTATION, RESEND_INVITATION_MUTATION } from "../../graphql/mutations"; +import RightSideModal from "../../components/RightSideModal"; +import { showToast } from "../../components/toastService"; +import { useLoader } from "../../context/LoaderContext"; +import { useAuth } from "../../context/AuthContext"; + +type Role = "superadmin" | "company_admin" | "team_member" | "partner_admin"|"vendor"; +type Employee = { + id: string; + name: string; + email: string; + role: Role; + invitationAccepted: boolean; + invitedDate?: string; + avatarColor?: string; + companyId?: string; + invitationStatus?: string | null; + invitedOn?: string | null; +}; + +// API Response Types +type Company = { + active: boolean; + address1: string | null; + address2: string | null; + assetCount: number; + businessType: string | null; + canAddLocation: boolean; + city: string | null; + config: any; + coordinates: any; + countryAlpha2: string | null; + createdAt: number; + createdById: string | null; + deletedAt: number | null; + deletedById: string | null; + email: string | null; + fileSizeTotal: number; + id: string; + industry: string | null; + isMainCompany: boolean; + name: string; + parentId: string | null; + phone: string | null; + planId: string | null; + planStripeCancelAt: number | null; + planStripeCurrentPeriodEnd: number | null; + planStripeEndedAt: number | null; + planStripeId: string | null; + planStripeStatus: string | null; + planStripeTrialEnd: number | null; + requestIP: string | null; + schemaName: string | null; + state: string | null; + stripeId: string | null; + updatedAt: number; + updatedById: string | null; + website: string | null; + zip: string | null; +}; + +type User = { + id: string; + email: string; + firstName: string | null; + lastName: string | null; + role: string | null; + companyId: string | null; + schema: string | null; + twoFactorEnabled: boolean; + invitationStatus: string | null; + invitedBy: string | null; + invitedOn: string | null; + belongsToCompanyId: string | null; + createdAt: number; + deletedAt: number | null; + displayName: string | null; + phone: string | null; + phoneConfirmed: boolean; + emailConfirmed: boolean; + updatedAt: number; + belongsToCompany: Company | null; +}; + +type CompanyUsersResponse = { + companyUsers: User[]; +}; + + +const roleColors: Record = { + superadmin: "#743ee4", + company_admin: "#3b82f6", + team_member: "#10b981", + partner_admin: "#3b82f6", + vendor: "#f59e0b", +}; + + +// Helper function to parse date strings like "Sep 10th, 2025" +const parseInviteDate = (dateStr: string): number => { + try { + // Remove ordinal suffixes (st, nd, rd, th) + const cleaned = dateStr.replace(/(\d+)(st|nd|rd|th)/, "$1"); + const date = new Date(cleaned); + return isNaN(date.getTime()) ? 0 : date.getTime(); + } catch { + return 0; + } +}; + +// Helper function to format date as "Sep 10th, 2025" +const formatInviteDate = (date: Date): string => { + const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + const day = date.getDate(); + const month = months[date.getMonth()]; + const year = date.getFullYear(); + + // Add ordinal suffix + const getOrdinalSuffix = (n: number) => { + const s = ["th", "st", "nd", "rd"]; + const v = n % 100; + return s[(v - 20) % 10] || s[v] || s[0]; + }; + + return `${month} ${day}${getOrdinalSuffix(day)}, ${year}`; +}; + +// Helper function to format timestamp to date string +const formatTimestampToDate = (timestamp: number): string => { + return formatInviteDate(new Date(timestamp)); +}; + +// Map API role to UI role +const mapApiRoleToUiRole = (apiRole: string | null): Role => { + if (!apiRole) return "team_member"; + const normalizedRole = apiRole.toLowerCase(); + if (normalizedRole === "superadmin" || normalizedRole === "super_admin") { + return "superadmin"; + } + if (normalizedRole === "company_admin" || normalizedRole === "companyadmin") { + return "company_admin"; + } + if (normalizedRole === "team_member" || normalizedRole === "teammember") { + return "team_member"; + } + if (normalizedRole === "partner_admin" || normalizedRole === "partneradmin") { + return "partner_admin"; + } + if (normalizedRole === "vendor") { + return "vendor"; + } + // Default to team_member if role doesn't match + return "team_member"; +}; + +// Map UI role back to API role (for API submissions) +const mapUiRoleToApiRole = (uiRole: Role): string => { + const roleMap: Record = { + superadmin: "super_admin", + company_admin: "company_admin", + team_member: "team_member", + partner_admin: "partner_admin", + vendor: "vendor", + }; + return roleMap[uiRole] || "team_member"; +}; + + +// Helper function to parse invitedOn date string (format: "2025-12-04 14:56:53.491091+00") +const parseInvitedOnDate = (dateStr: string | null | undefined): string | undefined => { + if (!dateStr) return undefined; + try { + // Parse the date string - handle various formats + // Replace space with 'T' and handle timezone + let isoDateStr = dateStr.replace(' ', 'T'); + // If it ends with +00, change to +00:00 for proper ISO format + if (isoDateStr.match(/\+\d{2}$/)) { + isoDateStr = isoDateStr + ':00'; + } + const date = new Date(isoDateStr); + if (isNaN(date.getTime())) { + // Try parsing without modification as fallback + const fallbackDate = new Date(dateStr); + if (isNaN(fallbackDate.getTime())) return undefined; + return formatInviteDate(fallbackDate); + } + return formatInviteDate(date); + } catch { + return undefined; + } +}; + +// Transform API response to Employee format +const transformUserToEmployee = (user: User): Employee => { + // Get name from displayName, or construct from firstName/lastName, or use email + let name = user.displayName || ""; + if (!name && (user.firstName || user.lastName)) { + name = [user.firstName, user.lastName].filter(Boolean).join(" "); + } + if (!name) { + name = user.email.split("@")[0]; + } + + // Determine invitation status - only "accepted" is considered accepted + const invitationAccepted = user.invitationStatus?.toLowerCase() === "accepted"; + + // Format invited date from invitedOn field if invitation is NOT accepted + const invitedDate = !invitationAccepted && user.invitedOn ? parseInvitedOnDate(user.invitedOn) : undefined; + + return { + id: user.id, + name, + email: user.email, + role: mapApiRoleToUiRole(user.role), + invitationAccepted, + invitedDate, + avatarColor: undefined, // Will be generated dynamically + companyId: user.companyId || user.belongsToCompanyId || undefined, + invitationStatus: user.invitationStatus, + invitedOn: user.invitedOn, + }; +}; + +const Team: React.FC = () => { + const [employees, setEmployees] = useState([]); + const { showLoader, hideLoader } = useLoader(); + const { user } = useAuth(); + + // Get logged-in user's role and determine if they are SUPER_ADMIN + const loggedInUserRole = user?.role || null; + const isSuperAdmin = loggedInUserRole && ( + loggedInUserRole.toUpperCase() === "SUPER_ADMIN" || + loggedInUserRole.toUpperCase() === "SUPERADMIN" + ); + + // Initialize filterRole to "All" to show all roles initially + const [filterRole, setFilterRole] = useState("All"); + + const [sortBy, setSortBy] = useState<"asc" | "desc" | "oldest" | "newest">("asc"); + const [anchorEl, setAnchorEl] = useState<{ [key: string]: HTMLElement | null }>({}); + const [isFormOpen, setIsFormOpen] = useState(false); + const [employeeBeingEdited, setEmployeeBeingEdited] = useState(null); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [selectedEmployee, setSelectedEmployee] = useState(null); + const [snackbarOpen, setSnackbarOpen] = useState(false); + const [snackbarMessage, setSnackbarMessage] = useState(""); + const [snackbarSeverity, setSnackbarSeverity] = useState<"success" | "error">("success"); + const superAdminRole=[ + { + role: "All", + label: "All Roles", + }, + { + role: "SUPER_ADMIN", + label: "Super Admin", + }, + { + role: "COMPANY_ADMIN", + label: "Company Admin", + }, + { + role: "TEAM_MEMBER", + label: "Team Member", + }, + { + role: "PARTNER_ADMIN", + label: "Partner Admin", + }, + { + role: "VENDOR", + label: "Vendor", + } + ]; + const partnerAdminRole=[ + { + role: "All", + label: "All Roles", + }, + { + role: "COMPANY_ADMIN", + label: "Company Admin", + }, + { + role: "TEAM_MEMBER", + label: "Team Member", + }, + { + role: "PARTNER_ADMIN", + label: "Partner Admin", + }, + { + role: "VENDOR", + label: "Vendor", + } + ]; + + // Fetch company users using GraphQL query + const { data: usersData, loading: usersLoading } = useQuery(COMPANY_USERS_QUERY); + + // Filter users based on filterRole (client-side filtering) + const filteredUsers = useMemo(() => { + if (!usersData?.companyUsers) return []; + + let filtered = usersData.companyUsers; + + // Apply role filter + if (filterRole !== "All") { + filtered = filtered.filter((user) => { + const userRole = mapApiRoleToUiRole(user.role); + return userRole === filterRole; + }); + } else { + // If "All" is selected, filter based on logged-in user's role + if (loggedInUserRole && !loggedInUserRole.toUpperCase().includes("SUPER")) { + // Partner admin users see only partner admin related roles + filtered = filtered.filter((user) => { + const userRole = user.role?.toUpperCase() || ""; + return userRole.includes("PARTNER"); + }); + } + // Super admin users see all users (no additional filtering) + } + + return filtered; + }, [usersData, filterRole, loggedInUserRole]); + + // Update employees when filtered users change + useEffect(() => { + if (filteredUsers.length > 0 || (usersData && filteredUsers.length === 0)) { + const transformedEmployees = filteredUsers.map(transformUserToEmployee); + setEmployees(transformedEmployees); + } else if (!usersData) { + setEmployees([]); + } + }, [filteredUsers, usersData]); + + // Update company user mutation + const [updateCompanyUserMutation, { loading: updateCompanyUserLoading }] = useMutation(UPDATE_COMPANY_USER_MUTATION, { + refetchQueries: [{ query: COMPANY_USERS_QUERY }], + awaitRefetchQueries: true + }); + + // Delete company user mutation + const [deleteCompanyUserMutation, { loading: deleteCompanyUserLoading }] = useMutation(DELETE_COMPANY_USER_MUTATION, { + refetchQueries: [{ query: COMPANY_USERS_QUERY }], + awaitRefetchQueries: true + }); + + // Send invitation mutation + const [sendInvitationMutation, { loading: sendInvitationLoading }] = useMutation(SEND_INVITATION_MUTATION, { + refetchQueries: [{ query: COMPANY_USERS_QUERY }], + awaitRefetchQueries: true + }); + + // Resend invitation mutation + const [resendInvitationMutation, { loading: resendInvitationLoading }] = useMutation(RESEND_INVITATION_MUTATION, { + refetchQueries: [{ query: COMPANY_USERS_QUERY }], + awaitRefetchQueries: true + }); + + // Show/hide loader based on loading states + useEffect(() => { + if (usersLoading || updateCompanyUserLoading || sendInvitationLoading || deleteCompanyUserLoading || resendInvitationLoading) { + showLoader(); + } else { + hideLoader(); + } + }, [usersLoading, updateCompanyUserLoading, sendInvitationLoading, deleteCompanyUserLoading, resendInvitationLoading, showLoader, hideLoader]); + + // Sort employees (filtering is now done at GraphQL level) + const sortedEmployees = useMemo(() => { + const sorted = [...employees]; + switch (sortBy) { + case "asc": + return sorted.sort((a, b) => a.name.localeCompare(b.name)); + case "desc": + return sorted.sort((a, b) => b.name.localeCompare(a.name)); + case "oldest": + return sorted.sort((a, b) => { + // For accepted invitations, sort by name (no date) + if (a.invitationAccepted && b.invitationAccepted) { + return a.name.localeCompare(b.name); + } + if (a.invitationAccepted) return 1; + if (b.invitationAccepted) return -1; + // For pending invitations, try to parse date or use name + const dateA = a.invitedDate ? parseInviteDate(a.invitedDate) : 0; + const dateB = b.invitedDate ? parseInviteDate(b.invitedDate) : 0; + if (dateA === 0 && dateB === 0) return a.name.localeCompare(b.name); + return dateA - dateB; + }); + case "newest": + return sorted.sort((a, b) => { + // For accepted invitations, sort by name (no date) + if (a.invitationAccepted && b.invitationAccepted) { + return a.name.localeCompare(b.name); + } + if (a.invitationAccepted) return 1; + if (b.invitationAccepted) return -1; + // For pending invitations, try to parse date or use name + const dateA = a.invitedDate ? parseInviteDate(a.invitedDate) : 0; + const dateB = b.invitedDate ? parseInviteDate(b.invitedDate) : 0; + if (dateA === 0 && dateB === 0) return a.name.localeCompare(b.name); + return dateB - dateA; + }); + default: + return sorted; + } + }, [employees, sortBy]); + + const handleMenuOpen = (event: React.MouseEvent, employeeId: string) => { + setAnchorEl({ [employeeId]: event.currentTarget }); + }; + + const handleMenuClose = (employeeId: string) => { + setAnchorEl({ [employeeId]: null }); + }; + + const handleEdit = (employee: Employee) => { + setEmployeeBeingEdited(employee); + setIsFormOpen(true); + handleMenuClose(employee.id); + }; + + const handleInviteClick = () => { + setEmployeeBeingEdited(null); + setIsFormOpen(true); + }; + + const handleModalClose = () => { + setEmployeeBeingEdited(null); + setIsFormOpen(false); + // Form reset will be handled by EmployeeForm component via useEffect + }; + + const handleDelete = (employee: Employee) => { + setSelectedEmployee(employee); + setDeleteDialogOpen(true); + handleMenuClose(employee.id); + }; + + const handleResendInvitation = async (employee: Employee) => { + try { + showLoader(); + + // Call resendInvitation API with email and role + const apiRole = mapUiRoleToApiRole(employee.role); + await resendInvitationMutation({ + variables: { + input: { + email: employee.email, + role: apiRole, + }, + }, + }); + + setSnackbarMessage(`Invitation resent to ${employee.email}`); + setSnackbarSeverity("success"); + setSnackbarOpen(true); + handleMenuClose(employee.id); + } catch (error: any) { + console.error("Failed to resend invitation:", error); + const errorMessage = error?.graphQLErrors?.[0]?.message || + error?.message || + "Failed to resend invitation"; + setSnackbarMessage(errorMessage); + setSnackbarSeverity("error"); + setSnackbarOpen(true); + } finally { + hideLoader(); + } + }; + + const handleLeaveCompany = async (employee: Employee) => { + try { + showLoader(); + await deleteCompanyUserMutation({ + variables: { + userId: employee.id + } + }); + // Refetch will update the employees list automatically + setSnackbarMessage(`${employee.name} has been removed from the company`); + setSnackbarSeverity("success"); + setSnackbarOpen(true); + handleMenuClose(employee.id); + } catch (error: any) { + console.error("Failed to remove from company:", error); + const errorMessage = error?.graphQLErrors?.[0]?.message || + error?.message || + "Failed to remove from company"; + setSnackbarMessage(errorMessage); + setSnackbarSeverity("error"); + setSnackbarOpen(true); + } finally { + hideLoader(); + } + }; + + const confirmDelete = async () => { + if (!selectedEmployee) return; + + try { + showLoader(); + await deleteCompanyUserMutation({ + variables: { + userId: selectedEmployee.id + } + }); + // Refetch will update the employees list automatically + setSnackbarMessage(`${selectedEmployee.name} has been deleted`); + setSnackbarSeverity("success"); + setSnackbarOpen(true); + setDeleteDialogOpen(false); + setSelectedEmployee(null); + } catch (error: any) { + console.error("Failed to delete user:", error); + const errorMessage = error?.graphQLErrors?.[0]?.message || + error?.message || + "Failed to delete user"; + setSnackbarMessage(errorMessage); + setSnackbarSeverity("error"); + setSnackbarOpen(true); + } finally { + hideLoader(); + } + }; + + const getInitials = (name: string) => { + const words = name.trim().split(/\s+/).filter(Boolean); + if (words.length >= 2) { + // Two or more words: take first letter of first two words + return (words[0][0] + words[1][0]).toUpperCase(); + } else if (words.length === 1) { + // Single word: take first two characters + return words[0].slice(0, 2).toUpperCase(); + } + return "??"; + }; + + console.log('filterRole',filterRole); + return ( + +
+
+
+
+ Team Members + +
+
+ + + Filter by Role + + + + + Sort By + + + + +
+ {sortedEmployees.map((employee) => ( +
+ + + +
+ + + {getInitials(employee.name)} + + + + {employee.name} + + + {employee.email} + + + + + +
+ + handleMenuOpen(e, employee.id)} + size="small" + > + + + + handleMenuClose(employee.id)} + anchorOrigin={{ vertical: "bottom", horizontal: "right" }} + transformOrigin={{ vertical: "top", horizontal: "right" }} + > + {employee.invitationAccepted || !employee.invitationStatus || !employee.invitedOn ? ( + <> + handleEdit(employee)}> + + Edit Member + + handleDelete(employee)} + // sx={{ color: "error.main" }} + > + + Delete Member + + + ) : ( + <> + handleResendInvitation(employee)}> + + Resend Invitation + + handleLeaveCompany(employee)} + > + + Leave Company + + + )} + +
+ + + // {roleIcons[employee.role]} + // + // } + /> + {employee.invitedDate && ( + + )} + +
+
+
+ ))} +
+ + {sortedEmployees.length === 0 && ( + + + No employees found + + + )} +
+
+
+
+ + {/* Employee Form Modal */} + + { + // Reset form and close modal + handleModalClose(); + }} + onCancel={handleModalClose} + /> + + + {/* Delete Confirmation Modal */} + { + setDeleteDialogOpen(false); + setSelectedEmployee(null); + }} + title="Delete Member" + > + + + Are you sure you want to delete{" "} + + {selectedEmployee?.name} + + ? This action cannot be undone. + + + + + + + + + {/* Snackbar */} + setSnackbarOpen(false)} + anchorOrigin={{ vertical: "bottom", horizontal: "right" }} + > + setSnackbarOpen(false)} + severity={snackbarSeverity} + variant="filled" + sx={{ width: "100%" }} + > + {snackbarMessage} + + +
+ ); +}; + +// Employee Form Component +type EmployeeFormProps = { + mode: "create" | "edit"; + employee: Employee | null; + onSuccess: (employee: Employee) => void; + onCancel: () => void; +}; + +type EmployeeFormInputs = { + email: string; + role: Role; +}; + +const employeeSchema = yup.object({ + email: yup + .string() + .required("Email is required") + .email("Invalid email format"), + role: yup + .mixed() + .oneOf(["company_admin", "team_member", "partner_admin", "vendor"]) + .required("Role is required"), +}) as yup.ObjectSchema; + +const EmployeeForm: React.FC = ({ + mode, + employee, + onSuccess, + onCancel, +}) => { + const { showLoader, hideLoader } = useLoader(); + const { user } = useAuth(); + const loggedInUserRole = user?.role || null; + const isSuperAdmin = loggedInUserRole && ( + loggedInUserRole.toUpperCase() === "SUPER_ADMIN" || + loggedInUserRole.toUpperCase() === "SUPERADMIN" + ); + + const [sendInvitationMutation] = useMutation(SEND_INVITATION_MUTATION, { + refetchQueries: [{ query: COMPANY_USERS_QUERY }], + awaitRefetchQueries: true + }); + + const [updateCompanyUserMutation] = useMutation(UPDATE_COMPANY_USER_MUTATION, { + refetchQueries: [{ query: COMPANY_USERS_QUERY }], + awaitRefetchQueries: true + }); + + const { + control, + handleSubmit, + reset, + watch, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: yupResolver(employeeSchema), + mode: "onBlur", + defaultValues: { + email: employee?.email || "", + role: employee?.role || "team_member", + }, + }); + + const watchedRole = watch("role"); + + useEffect(() => { + if (employee) { + // Edit mode + reset({ + email: employee.email, + role: employee.role, + }); + } else { + // Create mode - reset form + reset({ + email: "", + role: "team_member", + }); + } + }, [employee, mode, reset]); + + const onSubmit = async (data: EmployeeFormInputs) => { + if (mode === "edit" && !employee) return; + + showLoader(); + try { + if (mode === "create") { + // Send invitation using backend API + const apiRole = mapUiRoleToApiRole(data.role); + await sendInvitationMutation({ + variables: { + input: { + email: data.email.trim(), + role: apiRole + } + } + }); + // Refetch will update the employees list automatically + showToast({ status: "success", title: "Invitation sent successfully" }); + // Reset form before closing + reset({ + email: "", + role: "team_member", + }); + onSuccess({ id: "", name: "", email: data.email.trim(), role: data.role, invitationAccepted: false }); // Call onSuccess to close modal + } else { + // Edit mode - update company user using updateCompanyUser API + if (!employee) { + showToast({ status: "error", title: "User not found" }); + return; + } + const apiRole = mapUiRoleToApiRole(data.role); + await updateCompanyUserMutation({ + variables: { + input: { + userId: employee.id, + role: apiRole, + } + } + }); + // Refetch will update the employees list automatically + showToast({ status: "success", title: "User updated successfully" }); + onSuccess(employee); // Call onSuccess to close modal + } + } catch (error: any) { + console.error(`Failed to ${mode === "create" ? "send invitation" : "update user"}:`, error); + const errorMessage = error?.graphQLErrors?.[0]?.message || + error?.message || + `Failed to ${mode === "create" ? "send invitation" : "update user"}`; + showToast({ + status: "error", + title: errorMessage + }); + } finally { + hideLoader(); + } + }; + + const handleCancel = () => { + // Reset form when canceling + reset({ + email: "", + role: "team_member", + }); + onCancel(); + }; + + const actionLabel = mode === "edit" ? "Save Changes" : "Send Invitation"; + + return ( + + + ( + + )} + /> + ( + + Role + + {errors.role && ( + + {errors.role.message} + + )} + + )} + /> + {watchedRole === "company_admin" && ( + + Has administrative access with company admin privileges. + + )} + {watchedRole === "team_member" && ( + + Has user-level access with team member privileges. + + )} + {watchedRole === "partner_admin" && ( + + Has administrative access with partner admin privileges. + + )} + {watchedRole === "vendor" && ( + + Has vendor-level access with vendor privileges. + + )} + + + + + + + ); +}; + +export default Team; + diff --git a/src/pages/ticketing/TicketForm.tsx b/src/pages/ticketing/TicketForm.tsx new file mode 100644 index 000000000..306610d4e --- /dev/null +++ b/src/pages/ticketing/TicketForm.tsx @@ -0,0 +1,410 @@ +import React, { useEffect, useMemo } from "react"; +import { + Autocomplete, + Box, + Button, + Chip, + MenuItem, + Stack, + TextField, +} from "@mui/material"; +import { useForm, Controller, type Resolver } from "react-hook-form"; +import { yupResolver } from "@hookform/resolvers/yup"; +import * as yup from "yup"; +import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import { DatePicker } from "@mui/x-date-pickers/DatePicker"; +import dayjs, { type Dayjs } from "dayjs"; +import { useQuery } from "@apollo/client/react"; +import { COMPANY_USERS_QUERY } from "../../graphql/queries"; +import { useAuth } from "../../context/AuthContext"; +import type { + ColumnConfig, + TicketFormState, + TicketPriority, + TicketCategory, +} from "./TicketingBoard"; + +type TicketFormProps = { + mode: "create" | "edit"; + initialValues: TicketFormState; + statusOptions: ColumnConfig[]; + priorityOptions: TicketPriority[]; + categoryOptions: TicketCategory[]; + tagOptions: string[]; + onSubmit: (values: TicketFormState) => void; + onCancel: () => void; +}; + +type User = { + id: string; + firstName?: string; + lastName?: string; + displayName?: string; + email?: string; + role?: string; +}; + +type CompanyUsersResponse = { + companyUsers: User[]; +}; + +const TicketForm: React.FC = ({ + mode, + initialValues, + statusOptions, + priorityOptions, + categoryOptions, + tagOptions, + onSubmit, + onCancel, +}) => { + const { user: loggedInUser } = useAuth(); + + // Fetch company users for assignee dropdown + const { data: usersData, loading: usersLoading } = useQuery(COMPANY_USERS_QUERY); + + // Transform users to options for dropdown + const assigneeOptions = useMemo(() => { + if (!usersData?.companyUsers) return []; + + // Filter users based on logged-in user's role (client-side filtering) + const loggedInUserRole = loggedInUser?.role || null; + let filteredUsers = usersData.companyUsers; + + if (loggedInUserRole && !loggedInUserRole.toUpperCase().includes("SUPER")) { + // Partner admin users see only partner admin related roles + filteredUsers = usersData.companyUsers.filter((user) => { + const userRole = user.role?.toUpperCase() || ""; + return userRole.includes("PARTNER"); + }); + } + // Super admin users see all users (no filtering needed) + + return filteredUsers.map((user) => ({ + id: user.id, + label: user.displayName || + `${user.firstName || ""} ${user.lastName || ""}`.trim() || + user.email || + "Unknown User", + })); + }, [usersData, loggedInUser]); + + const validationSchema = useMemo( + () => + yup.object({ + title: yup.string().trim().required("Title is required"), + description: yup.string().trim(), + status: mode === "edit" + ? yup + .mixed() + .oneOf(statusOptions.map((option) => option.id)) + .required("Status is required") + : yup + .mixed() + .oneOf(statusOptions.map((option) => option.id)) + .optional(), + priority: yup + .mixed() + .oneOf(priorityOptions) + .required("Priority is required"), + category: yup + .mixed() + .oneOf(categoryOptions) + .required("Category is required"), + // requester: yup.string().trim().required("Requester is required"), + assignee: yup.string().trim().required("Assignee is required"), + dueDate: yup.string().trim(), + // tags: yup.array().of(yup.string().trim()), + comments: yup.string().trim(), + }), + [priorityOptions, statusOptions, categoryOptions, mode] + ); + + const resolver = useMemo( + () => yupResolver(validationSchema) as unknown as Resolver, + [validationSchema] + ); + + const { + control, + handleSubmit, + reset, + formState: { errors, isSubmitting }, + } = useForm({ + defaultValues: initialValues, + resolver, + }); + + useEffect(() => { + reset(initialValues); + }, [initialValues, reset]); + + const actionLabel = useMemo( + () => (mode === "edit" ? "Save Changes" : "Create Ticket"), + [mode] + ); + + return ( + + onSubmit(data))} + className="modal-form-sec" + noValidate + > + + {/* Title */} + ( + + )} + /> + + {/* Description */} + ( + + )} + /> + + {/* Status + Priority */} + + {/* Status field - only show in edit mode */} + {mode === "edit" && ( + ( + + {statusOptions.map((column) => ( + + {column.title} + + ))} + + )} + /> + )} + + ( + + {priorityOptions.map((priority) => ( + + {priority.charAt(0).toUpperCase() + priority.slice(1)} + + ))} + + )} + /> + + + {/* Category */} + ( + + {categoryOptions.map((category) => ( + + {category.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())} + + ))} + + )} + /> + + {/* Requester + Assignee */} + + {/* Requester field commented out */} + {/* ( + + )} + /> */} + + ( + + {assigneeOptions.map((option) => ( + + {option.label} + + ))} + + )} + /> + + + {/* Due Date */} + + ( + + field.onChange( + newValue && dayjs(newValue).isValid() + ? dayjs(newValue).format("YYYY-MM-DD") + : "" + ) + } + renderInput={(params) => ( + + {params.InputProps?.endAdornment} + + ), + }} + /> + )} + /> + )} + /> + + + {/* Tags - commented out */} + {/* ( + field.onChange(newValue)} + renderTags={(value: string[], getTagProps) => + value.map((option: string, index: number) => ( + + )) + } + renderInput={(params) => ( + + )} + /> + )} + /> */} + + + {/* Submit */} + + + +
+ + ); +}; + +export default TicketForm; diff --git a/src/pages/ticketing/TicketingBoard.tsx b/src/pages/ticketing/TicketingBoard.tsx new file mode 100644 index 000000000..2cf7a4969 --- /dev/null +++ b/src/pages/ticketing/TicketingBoard.tsx @@ -0,0 +1,949 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { + DragDropContext, + Draggable, + Droppable, + DropResult, +} from "@hello-pangea/dnd"; +import { + Box, + Button, + Chip, + Paper, + Stack, + Tooltip, + Typography, + useTheme, +} from "@mui/material"; +import { CalendarDays, MessageCircleMore, Plus } from "lucide-react"; +import { useQuery, useMutation } from "@apollo/client/react"; +import dayjs from "dayjs"; +import RightSideModal from "../../components/RightSideModal"; +import TicketForm from "./TicketForm"; +import { GET_TICKETING_QUERY } from "../../graphql/queries"; +import { UPDATE_TICKET_MUTATION, CREATE_TICKET_MUTATION } from "../../graphql/mutations"; +import { useAuth } from "../../context/AuthContext"; +import { showToast } from "../../components/toastService"; + +export type TicketStatus = "open" | "in_progress" | "waiting_on_customer" | "waiting_on_team" | "resolved" | "closed"; + +export type TicketPriority = "low" | "medium" | "high" | "critical"; + +export type TicketCategory = "technical" | "billing" | "feature_request" | "bug_report" | "general_inquiry"; + +export type Ticket = { + id: string; + originalId: string; // Store the original GraphQL ID for mutations + title: string; + description: string; + status: TicketStatus; + priority: TicketPriority; + requester: string; + assignee?: string; + dueDate?: string; + tags?: string[]; + comments?: number; + updatedAt?: number; // For sorting by most recent + }; + +type TicketsByStatus = Record; + +type GraphQLTicket = { + id: string; + short_code?: string; + parent_ticket?: string; + company_id?: string; + created_by?: string; + title?: string; + description?: string; + priority?: string; + category?: string; + sub_category?: string; + status?: string; + estimated_time?: number; + start_date?: string; + end_date?: string; + assigned_to?: string; + assigned_by?: string; + assigned_at?: string; + resolved_at?: string; + closed_at?: string; + created_at?: string; + updated_at?: string; + deleted_at?: string; +}; + +type TicketsQueryResponse = { + tickets: GraphQLTicket[]; +}; + +export type TicketFormState = { + title: string; + description?: string; + status: TicketStatus; + priority: TicketPriority; + category: TicketCategory; + requester: string; + assignee?: string; + dueDate?: string; + tags: string[]; + comments?: string; + }; + +export type ColumnConfig = { + id: TicketStatus; + title: string; + subtitle: string; + accent: string; + }; + +const columnConfig: ColumnConfig[] = [ + { + id: "open", + title: "Open", + subtitle: "Freshly submitted tickets", + accent: "gray", + }, + { + id: "in_progress", + title: "In Progress", + subtitle: "Active work underway", + accent: "blue", + }, + { + id: "waiting_on_customer", + title: "Waiting on Customer", + subtitle: "Awaiting customer response", + accent: "purple", + }, + { + id: "waiting_on_team", + title: "Waiting on Team", + subtitle: "Awaiting team response", + accent: "purple", + }, + { + id: "resolved", + title: "Resolved", + subtitle: "Tickets that have been resolved", + accent: "orange", + }, + { + id: "closed", + title: "Closed", + subtitle: "Tickets that have been closed", + accent: "green", + }, + ]; + +const accentColorMap: Record< + ColumnConfig["accent"], + "default" | "primary" | "secondary" | "success" | "warning" +> = { + gray: "default", + purple: "secondary", + blue: "primary", + orange: "warning", + green: "success", +}; + +// Transform GraphQL ticket data to Ticket type +const transformGraphQLTicket = (ticket: GraphQLTicket): Ticket => { + // Map status from GraphQL to TicketStatus + const mapStatus = (status: string | undefined): TicketStatus => { + const statusLower = status?.toLowerCase() || ""; + if (statusLower === "open" || statusLower === "new") return "open"; + if (statusLower === "in_progress" || statusLower === "inprogress") return "in_progress"; + if (statusLower === "waiting_on_customer") return "waiting_on_customer"; + if (statusLower === "waiting_on_team") return "waiting_on_team"; + if (statusLower === "resolved") return "resolved"; + if (statusLower === "closed") return "closed"; + return "open"; // default + }; + + // Map priority from GraphQL to TicketPriority + const mapPriority = (priority: string | undefined): TicketPriority => { + const priorityLower = priority?.toLowerCase() || ""; + if (priorityLower === "critical") return "critical"; + if (priorityLower === "high") return "high"; + if (priorityLower === "medium") return "medium"; + if (priorityLower === "low") return "low"; + return "medium"; // default + }; + + // Build tags from category and sub_category + const tags: string[] = []; + if (ticket.category) tags.push(ticket.category); + if (ticket.sub_category) tags.push(ticket.sub_category); + + // Get requester name (from created_by) + const requesterName = ticket.created_by + ? `User ${ticket.created_by}` + : "Unknown"; + + // Get assignee name (assigned_to is just an ID, not an object) + // We'll display the ID for now, or could fetch user details separately + const assigneeName = ticket.assigned_to || undefined; + + // Store the original ID for mutations + const originalId = ticket.id; + + // Format ticket ID for display (use short_code if available, otherwise format from id) + const ticketId = ticket.short_code + ? ticket.short_code + : ticket.id?.startsWith("TCK-") + ? ticket.id + : `TCK-${ticket.id?.slice(-3) || "000"}`; + + // Parse dates for sorting (convert to timestamp) + const parseDate = (dateStr: string | undefined): number => { + if (!dateStr) return 0; + // Handle both timestamp strings and ISO date strings + const timestamp = /^\d+$/.test(dateStr) ? parseInt(dateStr, 10) : dateStr; + const date = new Date(timestamp); + return isNaN(date.getTime()) ? 0 : date.getTime(); + }; + + // Use end_date as dueDate if available - format as YYYY-MM-DD for DatePicker + // Handle timestamp strings (e.g., "1765218600000") and ISO date strings + const dueDate = ticket.end_date + ? (() => { + // Check if it's a timestamp string (all digits) + const timestamp = /^\d+$/.test(ticket.end_date) + ? parseInt(ticket.end_date, 10) + : ticket.end_date; + return dayjs(timestamp).format("YYYY-MM-DD"); + })() + : undefined; + + return { + id: ticketId, + originalId: originalId, // Keep original ID for GraphQL mutations + title: ticket.title || "Untitled Ticket", + description: ticket.description || "", + status: mapStatus(ticket.status), + priority: mapPriority(ticket.priority), + requester: requesterName, + assignee: assigneeName, + dueDate: dueDate, + tags: tags.length > 0 ? tags : undefined, + comments: 0, // GraphQL doesn't have comments count + updatedAt: parseDate(ticket.updated_at) || parseDate(ticket.created_at) || 0, // For sorting by most recent + }; +}; + +const priorityOptions: TicketPriority[] = ["low", "medium", "high", "critical"]; + +const categoryOptions: TicketCategory[] = ["technical", "billing", "feature_request", "bug_report", "general_inquiry"]; + +const priorityRanking: Record = { + critical: 1, + high: 2, + medium: 3, + low: 4, + }; + +const sortTickets = (tickets: Ticket[]) => + [...tickets].sort((a, b) => { + // Sort by most recently updated/created first + const updatedAtA = a.updatedAt || 0; + const updatedAtB = b.updatedAt || 0; + if (updatedAtA !== updatedAtB) { + return updatedAtB - updatedAtA; // Newest first + } + // Then by priority + const priorityDelta = + priorityRanking[a.priority] - priorityRanking[b.priority]; + if (priorityDelta !== 0) { + return priorityDelta; + } + return a.title.localeCompare(b.title); + }); + +const getNextTicketId = (tickets: TicketsByStatus) => { + const allTickets = Object.values(tickets).flat(); + const highestNumber = allTickets.reduce((acc, ticket) => { + const match = ticket.id.match(/(\d+)$/); + if (!match) return acc; + const num = parseInt(match[1], 10); + return Number.isNaN(num) ? acc : Math.max(acc, num); + }, 100); + const nextNumber = String(highestNumber + 1).padStart(3, "0"); + return `TCK-${nextNumber}`; + }; + +export const emptyFormState: TicketFormState = { + title: "", + description: "", + status: "open", + priority: "medium", + category: "general_inquiry", + requester: "", + assignee: "", + dueDate: "", + tags: [], + comments: "", + }; + +const buildTicketMap = (tickets: Ticket[]): TicketsByStatus => { + return columnConfig.reduce((acc, column) => { + acc[column.id] = sortTickets( + tickets.filter((ticket) => ticket.status === column.id) + ); + return acc; + }, {} as TicketsByStatus); + }; + +const TicketPriorityTag = ({ priority }: { priority: TicketPriority }) => { + const normalizedPriority = priority.toLowerCase(); + const displayPriority = priority.charAt(0).toUpperCase() + priority.slice(1); + + return ( + + {displayPriority} + + ); +}; + +const TicketCard = ({ + ticket, + index, + onEdit, + }: { + ticket: Ticket; + index: number; + onEdit: (ticket: Ticket) => void; + }) => { + const theme = useTheme(); + const isUnassigned = !ticket.assignee; + const displayName = ticket.assignee || "Not Assigned"; + const avatarLetter = ticket.assignee ? ticket.assignee.charAt(0).toUpperCase() : "?"; + + return ( + + {(provided, snapshot) => ( + { + if (!snapshot.isDragging) { + onEdit(ticket); + } + }} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onEdit(ticket); + } + }} + > + + + + {ticket.id} + + + + + {ticket.title} + + + {ticket.description} + + {ticket.tags && ticket.tags.length > 0 ? ( + + {ticket.tags.map((tag) => ( + + ))} + + ) : null} + + + + + {avatarLetter} + + + + + +
+ {ticket.dueDate && ( + + + + Due {dayjs(ticket.dueDate).format("MMM DD, YYYY")} + + + )} + + + + + {ticket?.comments ?? 0} + {/* {ticket.comments > 1 ? "s" : ""} */} + + +
+ +
+ +
+
+ )} +
+ ); + }; + +const TicketColumn = ({ + column, + tickets, + onEditTicket, + }: { + column: ColumnConfig; + tickets: Ticket[]; + onEditTicket: (ticket: Ticket) => void; + }) => { + const theme = useTheme(); + const borderColor = theme.palette.divider; + const accentColor = accentColorMap[column.accent] ?? "default"; + + return ( + + + + + + {column.title} + + + {column.subtitle} + + + {/* */} + + + + + + {(provided, snapshot) => ( + + {tickets.map((ticket, index) => ( + + ))} + {provided.placeholder} + + )} + + + ); + }; + +const TicketingBoard = () => { + const { user } = useAuth(); + + // Fetch tickets from GraphQL + const { data: ticketsData, loading: ticketsLoading, refetch: refetchTickets } = useQuery(GET_TICKETING_QUERY, { + fetchPolicy: 'cache-and-network', + }); + + // Store original GraphQL tickets for mutation mapping + const originalTickets = useMemo(() => { + if (!ticketsData?.tickets) return new Map(); + const map = new Map(); + ticketsData.tickets.forEach((ticket) => { + map.set(ticket.id, ticket); + }); + return map; + }, [ticketsData]); + + // Transform GraphQL tickets to Ticket type + const tickets = useMemo(() => { + if (!ticketsData?.tickets) return []; + return ticketsData.tickets.map(transformGraphQLTicket); + }, [ticketsData]); + + // Local state for optimistic updates (for instant drag-and-drop feedback) + const [localTicketsByStatus, setLocalTicketsByStatus] = useState(null); + + // Server-based tickets map + const serverTicketsByStatus = useMemo(() => { + return buildTicketMap(tickets); + }, [tickets]); + + // Build tickets map by status (use local state if available for optimistic updates) + const ticketsByStatus = localTicketsByStatus || serverTicketsByStatus; + + // Mutations + const [updateTicketMutation] = useMutation(UPDATE_TICKET_MUTATION); + + const [createTicketMutation] = useMutation(CREATE_TICKET_MUTATION, { + refetchQueries: [{ query: GET_TICKETING_QUERY }], + awaitRefetchQueries: true, + }); + + const [ticketBeingEdited, setTicketBeingEdited] = useState( + null + ); + const [isFormOpen, setIsFormOpen] = useState(false); + + const handleModalClose = useCallback(() => { + setTicketBeingEdited(null); + setIsFormOpen(false); + }, []); + + const handleAddTicketClick = useCallback(() => { + setTicketBeingEdited(null); + setIsFormOpen(true); + }, []); + + const handleEditTicket = useCallback((ticket: Ticket) => { + setTicketBeingEdited(ticket); + setIsFormOpen(true); + }, []); + + const totals = useMemo(() => { + return columnConfig.map((column) => ({ + id: column.id, + count: ticketsByStatus[column.id]?.length ?? 0, + title: column.title, + accent: column.accent, + })); + }, [ticketsByStatus]); + + const tagOptions = useMemo(() => { + const tagsSet = new Set(); + Object.values(ticketsByStatus).forEach((ticketList) => { + ticketList.forEach((ticket) => { + ticket.tags?.forEach((tag) => tagsSet.add(tag)); + }); + }); + return Array.from(tagsSet).sort((a, b) => a.localeCompare(b)); + }, [ticketsByStatus]); + +const formInitialValues = useMemo(() => { + if (!ticketBeingEdited) { + return { ...emptyFormState }; + } + + // Use originalId directly to find the original ticket + const originalTicket = originalTickets.get(ticketBeingEdited.originalId); + // Map assigned_to (snake_case from query) to assigneeId (camelCase for mutation) + const assigneeId = originalTicket?.assigned_to || ""; + + // Map category from GraphQL ticket + const mapCategory = (): TicketCategory => { + const category = originalTicket?.category?.toLowerCase() || ""; + if (category === "technical") return "technical"; + if (category === "billing") return "billing"; + if (category === "feature_request") return "feature_request"; + if (category === "bug_report") return "bug_report"; + if (category === "general_inquiry") return "general_inquiry"; + return "general_inquiry"; // default value + }; + + return { + title: ticketBeingEdited.title, + description: ticketBeingEdited.description, + status: ticketBeingEdited.status, + priority: ticketBeingEdited.priority, + category: mapCategory(), + requester: ticketBeingEdited.requester, + assignee: assigneeId, + dueDate: ticketBeingEdited.dueDate ?? "", + tags: ticketBeingEdited.tags ? [...ticketBeingEdited.tags] : [], + comments: ticketBeingEdited.comments?.toString() ?? "", + }; +}, [ticketBeingEdited, originalTickets]); + + // Helper function to find original ticket by UI ticket ID + const findOriginalTicket = (uiTicketId: string): GraphQLTicket | undefined => { + // Try direct lookup first + let originalTicket = originalTickets.get(uiTicketId); + if (originalTicket) return originalTicket; + + // Try without TCK- prefix + const idWithoutPrefix = uiTicketId.replace(/^TCK-/, ""); + originalTicket = originalTickets.get(idWithoutPrefix); + if (originalTicket) return originalTicket; + + // Try with TCK- prefix + const idWithPrefix = uiTicketId.startsWith("TCK-") ? uiTicketId : `TCK-${uiTicketId}`; + originalTicket = originalTickets.get(idWithPrefix); + if (originalTicket) return originalTicket; + + // Try to find by matching the last part of the ID + let foundTicket: GraphQLTicket | undefined; + originalTickets.forEach((ticket, key) => { + if (key.endsWith(idWithoutPrefix) || key === idWithoutPrefix) { + foundTicket = ticket; + } + }); + + return foundTicket; + }; + + // Map UI status to GraphQL status format + const mapStatusToGraphQL = (status: TicketStatus): string => { + // GraphQL expects lowercase enum values + return status.toLowerCase(); + }; + + // Map UI priority to GraphQL priority format + const mapPriorityToGraphQL = (priority: TicketPriority): string => { + // GraphQL expects lowercase enum values + return priority.toLowerCase(); + }; + + const handleDragEnd = useCallback(async (result: DropResult) => { + const { destination, source } = result; + if (!destination) return; + if ( + destination.droppableId === source.droppableId && + destination.index === source.index + ) { + return; + } + + const sourceStatus = source.droppableId as TicketStatus; + const destinationStatus = destination.droppableId as TicketStatus; + + const sourceTickets = Array.from(ticketsByStatus[sourceStatus]); + const movedTicket = sourceTickets[source.index]; + + if (!movedTicket) { + return; + } + + // Use the originalId stored in the ticket for direct lookup + const originalTicket = originalTickets.get(movedTicket.originalId); + + if (!originalTicket) { + console.error("Original ticket data not found for:", movedTicket.originalId); + showToast({ status: "error", title: "Failed to find ticket data." }); + return; + } + + // OPTIMISTIC UPDATE: Update local state immediately for instant feedback + const updatedTicket = { ...movedTicket, status: destinationStatus }; + + // Create new state based on current state (local or server) + const currentState = localTicketsByStatus || serverTicketsByStatus; + const newTicketsByStatus: TicketsByStatus = { + open: [...currentState.open], + in_progress: [...currentState.in_progress], + waiting_on_customer: [...currentState.waiting_on_customer], + waiting_on_team: [...currentState.waiting_on_team], + resolved: [...currentState.resolved], + closed: [...currentState.closed], + }; + + // Remove from source column + newTicketsByStatus[sourceStatus] = newTicketsByStatus[sourceStatus].filter( + (t) => t.id !== movedTicket.id + ); + + // Add to destination column at the correct position + newTicketsByStatus[destinationStatus].splice(destination.index, 0, updatedTicket); + + // Apply optimistic update immediately + setLocalTicketsByStatus(newTicketsByStatus); + + // Make API call in background (don't await) + updateTicketMutation({ + variables: { + input: { + id: movedTicket.originalId, + status: mapStatusToGraphQL(destinationStatus), + assigned_to: originalTicket.assigned_to || null, // Map snake_case to camelCase for mutation + }, + }, + }) + .then(async () => { + // Refetch first, then clear local state to prevent data vanishing + await refetchTickets(); + setLocalTicketsByStatus(null); + showToast({ status: "success", title: "Ticket status updated." }); + }) + .catch((error) => { + console.error("Failed to update ticket status:", error); + // Revert optimistic update on error - go back to server state + setLocalTicketsByStatus(null); + showToast({ status: "error", title: "Failed to update ticket status. Please try again." }); + }); + }, [localTicketsByStatus, serverTicketsByStatus, originalTickets, updateTicketMutation, refetchTickets]); + + const handleFormSubmit = useCallback( + async (values: TicketFormState) => { + const title = values.title.trim(); + // const requester = (values.requester ?? "").trim(); // Commented out + const description = (values.description ?? "").trim(); + const assigneeValue = (values.assignee ?? "").trim(); // This is now a user ID + const dueDateValue = (values.dueDate ?? "").trim(); + const categoryValue = values.category; // Use category from form + + // Tags are commented out - set subCategory to null + // const trimmedTags = values.tags + // .map((tag) => tag.trim()) + // .filter((tag) => Boolean(tag)); + // const subCategory = trimmedTags.length > 1 ? trimmedTags[1] : null; + const subCategory = null; + + if (!title) { + showToast({ status: "error", title: "Title is required." }); + return; + } + + try { + if (ticketBeingEdited) { + // Update existing ticket using originalId directly + const assignedToId = assigneeValue || null; + const endDateISO = dueDateValue ? dayjs(dueDateValue).toISOString() : null; + await updateTicketMutation({ + variables: { + input: { + id: ticketBeingEdited.originalId, // Use originalId directly + title, + description: description || null, + status: mapStatusToGraphQL(values.status), + priority: mapPriorityToGraphQL(values.priority), + category: categoryValue || null, + sub_category: subCategory || null, + assigned_to: assignedToId, + end_date: endDateISO, + }, + }, + }); + + // Refetch to get updated assignee details + await refetchTickets(); + + showToast({ status: "success", title: "Ticket updated." }); + } else { + // Create new ticket + // Convert dueDate to ISO format for end_date, use same for start_date if provided + const endDateISO = dueDateValue ? new Date(dueDateValue).toISOString() : null; + const startDateISO = dueDateValue ? new Date().toISOString() : null; + + await createTicketMutation({ + variables: { + input: { + title, + description: description || null, + priority: mapPriorityToGraphQL(values.priority), + category: categoryValue || null, + sub_category: subCategory || null, + assigned_to: assigneeValue || null, + estimated_time: null, + start_date: startDateISO, + end_date: endDateISO, + }, + }, + }); + + showToast({ status: "success", title: "Ticket created." }); + } + + handleModalClose(); + } catch (error: any) { + console.error("Failed to save ticket:", error); + const errorMessage = error?.graphQLErrors?.[0]?.message || error?.message || "Failed to save ticket. Please try again."; + showToast({ status: "error", title: errorMessage }); + } + }, + [handleModalClose, ticketBeingEdited, originalTickets, updateTicketMutation, createTicketMutation, user] + ); + + return ( + <> + + + + + Ticket Flow Overview + + {/* + Drag, drop, and update tickets to keep facilities work coordinated. + */} + + {/* */} + + + + + {/* + {totals.map((column) => ( + + {column.title}: {column.count} + + } + sx={{ mr: 1, mb: 1 }} + /> + ))} + */} + + + + {columnConfig.map((column) => ( + + ))} + + + + + + + + + + ); + }; + + export default TicketingBoard; diff --git a/src/pages/ticketing/index.ts b/src/pages/ticketing/index.ts new file mode 100644 index 000000000..8b4ba1209 --- /dev/null +++ b/src/pages/ticketing/index.ts @@ -0,0 +1,2 @@ +export { default } from "./TicketingBoard"; + diff --git a/src/react-app-env.d.ts b/src/react-app-env.d.ts new file mode 100644 index 000000000..6431bc5fc --- /dev/null +++ b/src/react-app-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/src/reportWebVitals.ts b/src/reportWebVitals.ts new file mode 100644 index 000000000..49a2a16e0 --- /dev/null +++ b/src/reportWebVitals.ts @@ -0,0 +1,15 @@ +import { ReportHandler } from 'web-vitals'; + +const reportWebVitals = (onPerfEntry?: ReportHandler) => { + if (onPerfEntry && onPerfEntry instanceof Function) { + import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { + getCLS(onPerfEntry); + getFID(onPerfEntry); + getFCP(onPerfEntry); + getLCP(onPerfEntry); + getTTFB(onPerfEntry); + }); + } +}; + +export default reportWebVitals; diff --git a/src/setupTests.ts b/src/setupTests.ts new file mode 100644 index 000000000..8f2609b7b --- /dev/null +++ b/src/setupTests.ts @@ -0,0 +1,5 @@ +// jest-dom adds custom jest matchers for asserting on DOM nodes. +// allows you to do things like: +// expect(element).toHaveTextContent(/react/i) +// learn more: https://github.com/testing-library/jest-dom +import '@testing-library/jest-dom'; diff --git a/src/themes/colors.ts b/src/themes/colors.ts new file mode 100644 index 000000000..357927868 --- /dev/null +++ b/src/themes/colors.ts @@ -0,0 +1,22 @@ +// themes/colors.ts +export const roleColors:any = { + user: { + background: "#f5f8ff", + cardBg: "#ffffff", + cardBorder: "#cbd5e0", + text: "#1a202c", + }, + admin: { + background: "#1a202c", + cardBg: "#2d3748", + cardBorder: "#4a5568", + text: "#f7fafc", + }, + manager: { + background: "#064e3b", + cardBg: "#065f46", + cardBorder: "#047857", + text: "#ecfdf5", + }, + }; + \ No newline at end of file diff --git a/src/themes/muiTheme.ts b/src/themes/muiTheme.ts new file mode 100644 index 000000000..107db0fea --- /dev/null +++ b/src/themes/muiTheme.ts @@ -0,0 +1,362 @@ +import { createTheme, ThemeOptions } from "@mui/material/styles"; +import type {} from "@mui/x-data-grid/themeAugmentation"; + +export const themeOptions: ThemeOptions = { + palette: { + mode: "light", + primary: { + main: "#3b82f6", + }, + secondary: { + main: "#743ee4", + }, + success: { + main: "#10b981", + }, + warning: { + main: "#f59e0b", + }, + }, + typography: { + fontFamily: "Inter, sans-serif", + htmlFontSize: 14, + h1: { color: "#6b7280", fontSize: "16px",fontWeight: 700 }, + h2: { color: "#6b7280",fontSize: "14px",fontWeight: 700 }, + h3: { color: "#6b7280" }, + h4: { color: "#6b7280" }, + h5: { color: "#6b7280" }, + h6: { color: "#6b7280", fontSize: "12px",fontWeight: 400 }, + body1: { color: "#6b7280" }, // default for

+ body2: { color: "#6b7280" }, // secondary body style + }, + components: { + MuiDataGrid: { + styleOverrides: { + root: { + borderRadius: 8, + border: "1px solid #E2E8F0", + fontSize: "14px", + }, + columnSeparator: { + display: "none", // remove separator + }, + columnHeaders: { + fontSize: "12px", + fontWeight: 700, + backgroundColor: "#F8FAFC", + color: "#6b7280", + minHeight: 36, + maxHeight: 36, + borderBottom: "1px solid rgba(0, 0, 0, 0.1)", + + '&:focus': { + outline: "none", + }, + "& .MuiDataGrid-sortIcon": { + fontSize: "14px", + marginLeft: "2px", + }, + }, + row: { + fontSize: "12px", + fontWeight: 400, + minHeight: 36, + borderBottom: "1px solid rgba(0, 0, 0, 0.1)", + '&.MuiDataGrid-row--lastVisible': { + borderBottom: "none", + }, + '&:hover': { + backgroundColor: "#f5f5f5", + boxShadow: "0 2px 6px rgba(0,0,0,0.05)", + // 0 0 12px #76867926 + }, + }, + cell: { + paddingTop: 12, + paddingBottom: 12, + color: "#6b7280", + }, + }, + defaultProps: { + rowHeight: 36, + columnHeaderHeight: 36, + }, + }, + MuiTablePagination: { + styleOverrides: { + root: { + '& .MuiTablePagination-root': { + minHeight: "36px", + height: "36px", + padding: 0, + margin: 0, + }, + }, + actions: { + margin: 0, + padding: 0, // remove padding + '& .MuiIconButton-root': { + padding: 4, // smaller button padding + margin: 0, + }, + '& .MuiIconButton-root svg': { + fontSize: 16, // smaller arrow icons + }, + }, + toolbar: { + fontSize: "12px", // entire pagination toolbar text + color: "#6b7280", + minHeight: "36px", + }, + selectLabel: { + fontSize: "12px", // "Rows per page:" label + color: "#6b7280", + }, + displayedRows: { + fontSize: "12px", // "1–10 of 50" + color: "#6b7280", + }, + select: { + fontSize: "12px !important", // dropdown number (10, 25, 50) + color: "#6b7280", + minWidth:'35px !important', + }, + }, + }, + + MuiTextField: { + styleOverrides: { + root: { + fontSize: "12px", // affects outer wrapper (for label) + }, + }, + }, + + // 🔹 Controls input font size, placeholder, and paddings + MuiInputBase: { + styleOverrides: { + input: { + fontSize: "12px", // 👈 input text size + padding: "8px 12px", + "::placeholder": { + fontSize: "12px", // 👈 placeholder font size + opacity: 0.4, // optional styling + }, + }, + }, + }, + + // 🔹 Controls Outlined variant borders, label, and font + MuiOutlinedInput: { + styleOverrides: { + input: { + padding: "0px 8px !important", + }, + root: { + fontSize: "12px", + padding: "8px 8px", + }, + notchedOutline: { + borderColor: "#cbd5e1", + }, + }, + }, + // 🔹 Label styling (the floating label) + MuiInputLabel: { + styleOverrides: { + root: { + fontSize: "12px", + }, + }, + }, + + // 🔹 Applies to Select fields (uses same InputBase) + MuiSelect: { + styleOverrides: { + select: { + padding: "0px 8px !important", // FIX EXTRA SPACE + display: "flex", + alignItems: "center", + }, + icon: { + fontSize: "14px", // Decrease dropdown icon size + }, + }, + }, + MuiMenuItem: { + styleOverrides: { + root: { + fontSize: "12px", // font size of dropdown options + paddingTop: 6, + paddingBottom: 6, + }, + }, + }, + MuiAutocomplete: { + styleOverrides: { + option: { + fontSize: "12px", + minHeight: "28px", + }, + noOptions: { + fontSize: "12px", + }, + popupIndicator: { + fontSize: "14px", + // padding: "0 4px", + "& svg": { + fontSize: "14px", + }, + }, + clearIndicator: { + fontSize: "14px", + // padding: "0 4px", + "& svg": { + fontSize: "14px", + }, + }, + }, + }, + MuiListItemText: { + styleOverrides: { + primary: { + fontSize: "14px", + }, + }, + }, + MuiButton: { + styleOverrides: { + contained: { + borderRadius: "8px", + fontSize: "14px", + textTransform: "none", + // fontWeight: 700, + // "&:hover": { + // backgroundColor: "transparent", + // color: "#743ee4", + // border: "1px solid #743ee4", + // }, + }, + outlined: { + borderRadius: "8px", + fontSize: "14px", + textTransform: "none", + // fontWeight: 700, + // "&:hover": { + // backgroundColor: "#743ee4", + // color: "#ffffff", + // }, + }, + }, + }, + MuiFormControlLabel: { + styleOverrides: { + label: { + fontSize: "12px", + color: "#6b7280", + paddingLeft: "4px", // Add small padding between radio/checkbox and label + }, + root: { + marginLeft: 0, // optional tighter spacing + marginRight: 0, + } + }, + }, + MuiFormHelperText: { + styleOverrides: { + root: { + fontSize: "12px", + marginLeft: "0px", + color: "#ef4444 !important", + }, + }, + }, + MuiAlert: { + styleOverrides: { + standardInfo: { + borderRadius: "8px", + backgroundColor: "#eff5ff", + fontSize: "14px", + color: "#3b82f6", + padding: "4px 16px", + // border: "1px solid #7dd3fc", + "& .MuiAlert-icon": { + color: "#3b82f6", + }, + }, + + }, + }, + // 🔹 Calendar icon size for DatePicker/DateTimePicker + MuiInputAdornment: { + styleOverrides: { + root: { + "& .MuiIconButton-root": { + // padding: "4px", + "& svg": { + fontSize: "14px", // Decrease calendar icon size + }, + }, + }, + }, + }, + // 🔹 Chip size styling + MuiChip: { + styleOverrides: { + root: { + height: "20px", // Decrease chip height + fontSize: "12px", // Decrease chip text size + "& .MuiChip-label": { + padding: "0 8px", // Decrease label padding + }, + "& .MuiChip-icon": { + fontSize: "14px", // Decrease icon size in chip + marginLeft: "4px", + marginRight: "-4px", + }, + "& .MuiChip-deleteIcon": { + fontSize: "14px", // Decrease delete icon size + marginLeft: "4px", + marginRight: "4px", + }, + }, + sizeSmall: { + height: "20px", + fontSize: "12px", + "& .MuiChip-label": { + padding: "0 6px", + }, + "& .MuiChip-icon": { + fontSize: "12px", + }, + "& .MuiChip-deleteIcon": { + fontSize: "12px", + }, + }, + }, + }, + // 🔹 RadioGroup and Radio size styling + MuiRadio: { + styleOverrides: { + root: { + padding: "4px", // Decrease radio button padding + "& svg": { + fontSize: "18px", // Decrease radio icon size (default is 20px) + }, + }, + sizeSmall: { + padding: "2px", + "& svg": { + fontSize: "16px", + }, + }, + }, + }, + + }, + +}; + +const muiTheme = createTheme(themeOptions); + +export default muiTheme; diff --git a/src/themes/theme.tsx b/src/themes/theme.tsx new file mode 100644 index 000000000..bccc38f4d --- /dev/null +++ b/src/themes/theme.tsx @@ -0,0 +1,108 @@ +// themes/baseTheme.ts +import { border, extendTheme } from "@chakra-ui/react"; +import { roleColors } from "./colors"; + +export const getRoleTheme = (role: string = 'user') => { + const colors = roleColors[role] || roleColors["user"]; + + return extendTheme({ + textStyles: { + xxl: { fontSize: "24px", fontWeight: "700" }, + xl: { fontSize: "18px", fontWeight: "700" }, + lg: { fontSize: "16px", fontWeight: "700" }, + md: { fontSize: "14px", fontWeight: "700" }, + sm: { fontSize: "12px", fontWeight: "400" }, + xs: { fontSize: "11px", fontWeight: "400" }, + }, + styles: { + global: { + body: { + bg: colors.background, + // color: colors.text, + color: "#6b7280", + }, + ".chakra-card": { + // backgroundColor: "#f9fafb", + // border: "1px solid #e2e8f0", + borderRadius: "8px !important", + boxShadow: "0 2px 6px rgba(0,0,0,0.05)", + fontFamily: "Inter, sans-serif", + }, + // Card Header + ".chakra-card__header": { + padding: "8px 16px !important", + // backgroundColor: "#edf2f7", + borderBottom: "1px solid #e2e8f0", + fontWeight: "700", + fontSize: "18px", + color: "#6b7280", + borderRadius:'8px 8px 0 0 !important' + }, + // Card Body + ".chakra-card__body": { + padding: "16px !important", + fontSize: "14px", + color: "#4a5568", + }, + // Card Footer + // ".chakra-card__footer": { + // padding: "6px 16px !important", + + // }, + + }, + }, + colors: { + role: { + background: colors.background, + cardBg: colors.cardBg, + text: colors.text, + }, + }, + components: { + Card: { + baseStyle: { + bg: colors.cardBg, + borderColor: colors.cardBorder, + borderWidth: "1px", + borderRadius: "md", + boxShadow: "sm", + }, + }, + Tabs: { + variants: { + "soft-rounded": { + tab: { + borderRadius: "8px", + fontWeight: "400", + color:'#6b7280', + fontSize: "14px", + _selected: { + bg: "#1e3a8a", + color: "white", + }, + }, + }, + }, + }, + Heading: { + baseStyle: { + color: "#6b7280", + }, + sizes: { + xxl: { fontSize: "24px", fontWeight: "700" }, // H1 style + xl: { fontSize: "18px", fontWeight: "700" }, // H2 style + lg: { fontSize: "16px", fontWeight: "700" }, // H3 style + md: { fontSize: "14px", fontWeight: "700" }, // H4 style + sm: { fontSize: "12px", fontWeight: "400" }, // H5 style + xs: { fontSize: "11px", fontWeight: "400" }, // H6 style + }, + + }, + }, + fonts: { + heading: `'Inter', sans-serif`, + body: `'Inter', sans-serif`, + }, + }); +}; diff --git a/src/tinycolor2.d.ts b/src/tinycolor2.d.ts new file mode 100644 index 000000000..3effdd289 --- /dev/null +++ b/src/tinycolor2.d.ts @@ -0,0 +1,13 @@ +declare module "tinycolor2" { + interface TinyColor { + isDark(): boolean; + isLight(): boolean; + toHex(): string; + toRgb(): { r: number; g: number; b: number; a?: number }; + toHsl(): { h: number; s: number; l: number; a?: number }; + } + + function tinycolor(color?: string | { r: number; g: number; b: number }): TinyColor; + export = tinycolor; +} + From 6753e0ed4844a2860a99686af35c7675c9000a08 Mon Sep 17 00:00:00 2001 From: Laljanibasha Shaik <98688990+laljohnny@users.noreply.github.com> Date: Mon, 8 Dec 2025 17:49:05 +0530 Subject: [PATCH 21/25] Revise Node.js CI workflow for multiple versions Updated Node.js CI workflow to support multiple Node.js versions and modified job steps. --- .github/workflows/node.js.yml | 74 ++++++++++++++++------------------- 1 file changed, 33 insertions(+), 41 deletions(-) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index c1102ee6c..bc1662849 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -1,59 +1,51 @@ -# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs - -name: Node.js CI +name: critical-asset-admin-frontend-CI on: push: - branches: [ "master" ] + branches: + - release tags: - "v*" + pull_request: - branches: [ "master" ] + branches: + - release tags: - "v*" + release: + types: [created, published] + jobs: build: - runs-on: ubuntu-latest + environment: envdev strategy: matrix: - node-version: [22.x] - # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ + node-version: [22.x, 25.x] steps: - - uses: actions/checkout@v4 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - cache: 'npm' - - run: npm ci - - run: npm run build - - - uses: actions/upload-artifact@v4 - with: - name: my-build-output - path: . - - - name: Aqua Security Trivy Scan - uses: aquasecurity/trivy-action@0.33.1 - with: - scan-type: fs - scan-ref: . - severity: HIGH,CRITICAL - ignore-unfixed: true - vuln-type: os,library - exit-code: 0 - format: table - output: trivy-report.txt - hide-progress: true - - - name: Upload Trivy Report - uses: actions/upload-artifact@v4 - with: - name: trivy-report - path: trivy-report.txt + - name: Checkout code + uses: actions/checkout@v4 + +# - name: Create .env file from secret +# run: echo "${{ secrets.ENV_FILE }}" > .env + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Install dependencies + run: npm install + + - name: Build the dependencies + run: npm run build + - name: upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: critical-asset-admin-frontend-$date + path: buiid + From 4434d119d763e7f2f196d95350d464fceadef802 Mon Sep 17 00:00:00 2001 From: Laljanibasha Shaik <98688990+laljohnny@users.noreply.github.com> Date: Mon, 8 Dec 2025 17:57:00 +0530 Subject: [PATCH 22/25] Remove Node.js version 25 from CI workflow --- .github/workflows/node.js.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index bc1662849..98c3693d4 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -23,7 +23,7 @@ jobs: strategy: matrix: - node-version: [22.x, 25.x] + node-version: [22.x] steps: - name: Checkout code From 98692fbf4255d10385bd4424d8f8c7ec3f402fa0 Mon Sep 17 00:00:00 2001 From: Laljanibasha Shaik <98688990+laljohnny@users.noreply.github.com> Date: Mon, 8 Dec 2025 18:07:41 +0530 Subject: [PATCH 23/25] Replace npm install with linting command Change the dependency installation step to run linting instead. --- .github/workflows/node.js.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 98c3693d4..28a2dbbe6 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -38,7 +38,7 @@ jobs: node-version: ${{ matrix.node-version }} - name: Install dependencies - run: npm install + run: npm run lint -- --quiet || true - name: Build the dependencies run: npm run build From f0727ed0b1497a190a6a58b4e5f7eac0422128dc Mon Sep 17 00:00:00 2001 From: Laljanibasha Shaik <98688990+laljohnny@users.noreply.github.com> Date: Mon, 8 Dec 2025 18:12:25 +0530 Subject: [PATCH 24/25] Update Node.js workflow to install dependencies --- .github/workflows/node.js.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 28a2dbbe6..367076a05 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -38,10 +38,11 @@ jobs: node-version: ${{ matrix.node-version }} - name: Install dependencies - run: npm run lint -- --quiet || true + run: npm install - name: Build the dependencies - run: npm run build + #run: npm run build + run: react-scripts build - name: upload build artifacts uses: actions/upload-artifact@v4 From 6d96dde50c81fc088fcdf2e778159ec185cd7c95 Mon Sep 17 00:00:00 2001 From: Laljanibasha Shaik <98688990+laljohnny@users.noreply.github.com> Date: Mon, 8 Dec 2025 18:17:46 +0530 Subject: [PATCH 25/25] Delete .github/workflows/node.js.yml --- .github/workflows/node.js.yml | 52 ----------------------------------- 1 file changed, 52 deletions(-) delete mode 100644 .github/workflows/node.js.yml diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml deleted file mode 100644 index 367076a05..000000000 --- a/.github/workflows/node.js.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: critical-asset-admin-frontend-CI - -on: - push: - branches: - - release - tags: - - "v*" - - pull_request: - branches: - - release - tags: - - "v*" - - release: - types: [created, published] - -jobs: - build: - runs-on: ubuntu-latest - environment: envdev - - strategy: - matrix: - node-version: [22.x] - - steps: - - name: Checkout code - uses: actions/checkout@v4 - -# - name: Create .env file from secret -# run: echo "${{ secrets.ENV_FILE }}" > .env - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - - - name: Install dependencies - run: npm install - - - name: Build the dependencies - #run: npm run build - run: react-scripts build - - - name: upload build artifacts - uses: actions/upload-artifact@v4 - with: - name: critical-asset-admin-frontend-$date - path: buiid -