Initial Commit
This commit is contained in:
commit
709796f2c6
36 changed files with 17419 additions and 0 deletions
29
.env.example
Normal file
29
.env.example
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
# GEMINI_API_KEY: Optional for Gemini AI features.
|
||||||
|
GEMINI_API_KEY="MY_GEMINI_API_KEY"
|
||||||
|
|
||||||
|
# APP_URL: Base URL of the application.
|
||||||
|
APP_URL="http://localhost:3000"
|
||||||
|
|
||||||
|
# DATABASE CONFIGURATION (PostgreSQL for Coolify)
|
||||||
|
# You can specify either a full connection string or individual variables
|
||||||
|
DATABASE_URL="postgres://postgres:postgres@localhost:5432/pinnacle_db"
|
||||||
|
POSTGRES_HOST="localhost"
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
POSTGRES_USER="postgres"
|
||||||
|
POSTGRES_PASSWORD="postgres_password"
|
||||||
|
POSTGRES_DB="pinnacle_db"
|
||||||
|
|
||||||
|
# SERVER & SECURITY
|
||||||
|
PORT=3000
|
||||||
|
NODE_ENV="production"
|
||||||
|
SECRET_KEY="pinnacle_secret_token_change_in_production"
|
||||||
|
|
||||||
|
# SMTP EMAIL CONFIGURATION
|
||||||
|
SMTP_HOST="smtp.mailtrap.io"
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER="smtp_username"
|
||||||
|
SMTP_PASS="smtp_password"
|
||||||
|
SMTP_SECURE="false"
|
||||||
|
SMTP_FROM_NAME="Pinnacle Executive Portal"
|
||||||
|
SMTP_FROM_EMAIL="notifications@pinnacle.local"
|
||||||
|
|
||||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
20
README.md
Normal file
20
README.md
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<div align="center">
|
||||||
|
<img width="1200" height="475" alt="GHBanner" src="https://ai.google.dev/static/site-assets/images/share-ais-513315318.png" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
# Run and deploy your AI Studio app
|
||||||
|
|
||||||
|
This contains everything you need to run your app locally.
|
||||||
|
|
||||||
|
View your app in AI Studio: https://ai.studio/apps/1acb6189-806b-46a5-91cd-d7c3d38044c5
|
||||||
|
|
||||||
|
## Run Locally
|
||||||
|
|
||||||
|
**Prerequisites:** Node.js
|
||||||
|
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
|
`npm install`
|
||||||
|
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||||
|
3. Run the app:
|
||||||
|
`npm run dev`
|
||||||
803
bun.lock
Normal file
803
bun.lock
Normal file
|
|
@ -0,0 +1,803 @@
|
||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "react-example",
|
||||||
|
"dependencies": {
|
||||||
|
"@google/genai": "^2.4.0",
|
||||||
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"lucide-react": "^0.546.0",
|
||||||
|
"motion": "^12.23.24",
|
||||||
|
"nodemailer": "^9.0.3",
|
||||||
|
"pg": "^8.22.0",
|
||||||
|
"react": "^19.0.1",
|
||||||
|
"react-dom": "^19.0.1",
|
||||||
|
"recharts": "^3.10.1",
|
||||||
|
"vite": "^6.2.3",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^3.0.0",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/node": "^22.14.0",
|
||||||
|
"@types/nodemailer": "^8.0.1",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"esbuild": "^0.25.0",
|
||||||
|
"tailwindcss": "^4.1.14",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
|
"typescript": "~5.8.2",
|
||||||
|
"vite": "^6.2.3",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
|
||||||
|
|
||||||
|
"@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
|
||||||
|
|
||||||
|
"@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
|
||||||
|
|
||||||
|
"@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="],
|
||||||
|
|
||||||
|
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
|
||||||
|
|
||||||
|
"@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
|
||||||
|
|
||||||
|
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
|
||||||
|
|
||||||
|
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
|
||||||
|
|
||||||
|
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="],
|
||||||
|
|
||||||
|
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||||
|
|
||||||
|
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||||
|
|
||||||
|
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
|
||||||
|
|
||||||
|
"@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
|
||||||
|
|
||||||
|
"@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
|
||||||
|
|
||||||
|
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="],
|
||||||
|
|
||||||
|
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="],
|
||||||
|
|
||||||
|
"@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
|
||||||
|
|
||||||
|
"@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="],
|
||||||
|
|
||||||
|
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
|
||||||
|
|
||||||
|
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
|
||||||
|
|
||||||
|
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
|
||||||
|
|
||||||
|
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
|
||||||
|
|
||||||
|
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||||
|
|
||||||
|
"@google/genai": ["@google/genai@2.13.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-GM7C8Kaomvjz05x5JEO6+l3d/pciL9LxAG9dUjJLD7nTPZ9X0Cfsf2Z7eET6UjgWyUmxXCHtYnQoQ77F9+ZIOQ=="],
|
||||||
|
|
||||||
|
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||||
|
|
||||||
|
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||||
|
|
||||||
|
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||||
|
|
||||||
|
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||||
|
|
||||||
|
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||||
|
|
||||||
|
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
|
||||||
|
|
||||||
|
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
|
||||||
|
|
||||||
|
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="],
|
||||||
|
|
||||||
|
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="],
|
||||||
|
|
||||||
|
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="],
|
||||||
|
|
||||||
|
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
|
||||||
|
|
||||||
|
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
|
||||||
|
|
||||||
|
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
|
||||||
|
|
||||||
|
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="],
|
||||||
|
|
||||||
|
"@reduxjs/toolkit": ["@reduxjs/toolkit@2.12.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw=="],
|
||||||
|
|
||||||
|
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.2", "", { "os": "none", "cpu": "arm64" }, "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA=="],
|
||||||
|
|
||||||
|
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
|
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
|
||||||
|
|
||||||
|
"@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.3", "", { "os": "android", "cpu": "arm64" }, "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.3", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="],
|
||||||
|
|
||||||
|
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||||
|
|
||||||
|
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
|
||||||
|
|
||||||
|
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
|
||||||
|
|
||||||
|
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||||
|
|
||||||
|
"@types/bcryptjs": ["@types/bcryptjs@3.0.0", "", { "dependencies": { "bcryptjs": "*" } }, "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg=="],
|
||||||
|
|
||||||
|
"@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="],
|
||||||
|
|
||||||
|
"@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
|
||||||
|
|
||||||
|
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
|
||||||
|
|
||||||
|
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
|
||||||
|
|
||||||
|
"@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
|
||||||
|
|
||||||
|
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
|
||||||
|
|
||||||
|
"@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
|
||||||
|
|
||||||
|
"@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
|
||||||
|
|
||||||
|
"@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="],
|
||||||
|
|
||||||
|
"@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
|
||||||
|
|
||||||
|
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
|
||||||
|
|
||||||
|
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||||
|
|
||||||
|
"@types/express": ["@types/express@4.17.25", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", "@types/qs": "*", "@types/serve-static": "^1" } }, "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw=="],
|
||||||
|
|
||||||
|
"@types/express-serve-static-core": ["@types/express-serve-static-core@4.19.9", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg=="],
|
||||||
|
|
||||||
|
"@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="],
|
||||||
|
|
||||||
|
"@types/jsonwebtoken": ["@types/jsonwebtoken@9.0.10", "", { "dependencies": { "@types/ms": "*", "@types/node": "*" } }, "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA=="],
|
||||||
|
|
||||||
|
"@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="],
|
||||||
|
|
||||||
|
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@22.20.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q=="],
|
||||||
|
|
||||||
|
"@types/nodemailer": ["@types/nodemailer@8.0.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw=="],
|
||||||
|
|
||||||
|
"@types/pg": ["@types/pg@8.20.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow=="],
|
||||||
|
|
||||||
|
"@types/qs": ["@types/qs@6.15.1", "", {}, "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw=="],
|
||||||
|
|
||||||
|
"@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="],
|
||||||
|
|
||||||
|
"@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="],
|
||||||
|
|
||||||
|
"@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="],
|
||||||
|
|
||||||
|
"@types/serve-static": ["@types/serve-static@1.15.10", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "<1" } }, "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw=="],
|
||||||
|
|
||||||
|
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
|
||||||
|
|
||||||
|
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="],
|
||||||
|
|
||||||
|
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
|
||||||
|
|
||||||
|
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||||
|
|
||||||
|
"array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="],
|
||||||
|
|
||||||
|
"autoprefixer": ["autoprefixer@10.5.4", "", { "dependencies": { "browserslist": "^4.28.6", "caniuse-lite": "^1.0.30001806", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA=="],
|
||||||
|
|
||||||
|
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||||
|
|
||||||
|
"baseline-browser-mapping": ["baseline-browser-mapping@2.11.3", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-sbT0Ui/CZwyAyy7icT1Gw5P1LKRlFaHwaF6tDCW5YHq2X5SeeZFphBuIagopSfwSSZq3sQcbmEL072yphxm7ew=="],
|
||||||
|
|
||||||
|
"bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="],
|
||||||
|
|
||||||
|
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
|
||||||
|
|
||||||
|
"body-parser": ["body-parser@1.20.6", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g=="],
|
||||||
|
|
||||||
|
"browserslist": ["browserslist@4.28.7", "", { "dependencies": { "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", "electron-to-chromium": "^1.5.393", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw=="],
|
||||||
|
|
||||||
|
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
|
||||||
|
|
||||||
|
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||||
|
|
||||||
|
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||||
|
|
||||||
|
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||||
|
|
||||||
|
"caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="],
|
||||||
|
|
||||||
|
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||||
|
|
||||||
|
"content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="],
|
||||||
|
|
||||||
|
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||||
|
|
||||||
|
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||||
|
|
||||||
|
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||||
|
|
||||||
|
"cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="],
|
||||||
|
|
||||||
|
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
|
||||||
|
|
||||||
|
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
|
||||||
|
|
||||||
|
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
|
||||||
|
|
||||||
|
"d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
|
||||||
|
|
||||||
|
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
|
||||||
|
|
||||||
|
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
|
||||||
|
|
||||||
|
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
|
||||||
|
|
||||||
|
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
|
||||||
|
|
||||||
|
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
|
||||||
|
|
||||||
|
"d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
|
||||||
|
|
||||||
|
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
|
||||||
|
|
||||||
|
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
|
||||||
|
|
||||||
|
"debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||||
|
|
||||||
|
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
|
||||||
|
|
||||||
|
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||||
|
|
||||||
|
"destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="],
|
||||||
|
|
||||||
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
|
|
||||||
|
"dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
|
||||||
|
|
||||||
|
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||||
|
|
||||||
|
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
|
||||||
|
|
||||||
|
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||||
|
|
||||||
|
"electron-to-chromium": ["electron-to-chromium@1.5.396", "", {}, "sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ=="],
|
||||||
|
|
||||||
|
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||||
|
|
||||||
|
"enhanced-resolve": ["enhanced-resolve@5.24.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ=="],
|
||||||
|
|
||||||
|
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||||
|
|
||||||
|
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||||
|
|
||||||
|
"es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="],
|
||||||
|
|
||||||
|
"es-toolkit": ["es-toolkit@1.50.0", "", {}, "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w=="],
|
||||||
|
|
||||||
|
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||||
|
|
||||||
|
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||||
|
|
||||||
|
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||||
|
|
||||||
|
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||||
|
|
||||||
|
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
|
||||||
|
|
||||||
|
"express": ["express@4.22.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q=="],
|
||||||
|
|
||||||
|
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||||
|
|
||||||
|
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
|
|
||||||
|
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
|
||||||
|
|
||||||
|
"finalhandler": ["finalhandler@1.3.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="],
|
||||||
|
|
||||||
|
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
|
||||||
|
|
||||||
|
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||||
|
|
||||||
|
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
|
||||||
|
|
||||||
|
"framer-motion": ["framer-motion@12.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="],
|
||||||
|
|
||||||
|
"fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="],
|
||||||
|
|
||||||
|
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
|
|
||||||
|
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||||
|
|
||||||
|
"gaxios": ["gaxios@7.3.0", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-RB5vLV+vvQeoFPCX4QMK6/hjVkbIamPp1QSUD0CiZcnj12qbpiL+pLbYtgD+oZkWl0tl9z+o2Utp+MpM3QRhBA=="],
|
||||||
|
|
||||||
|
"gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="],
|
||||||
|
|
||||||
|
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||||
|
|
||||||
|
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||||
|
|
||||||
|
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||||
|
|
||||||
|
"google-auth-library": ["google-auth-library@10.9.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw=="],
|
||||||
|
|
||||||
|
"google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="],
|
||||||
|
|
||||||
|
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||||
|
|
||||||
|
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||||
|
|
||||||
|
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||||
|
|
||||||
|
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
|
||||||
|
|
||||||
|
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||||
|
|
||||||
|
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||||
|
|
||||||
|
"iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
||||||
|
|
||||||
|
"immer": ["immer@11.1.15", "", {}, "sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ=="],
|
||||||
|
|
||||||
|
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||||
|
|
||||||
|
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
||||||
|
|
||||||
|
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||||
|
|
||||||
|
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
||||||
|
|
||||||
|
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||||
|
|
||||||
|
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||||
|
|
||||||
|
"json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
|
||||||
|
|
||||||
|
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||||
|
|
||||||
|
"jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="],
|
||||||
|
|
||||||
|
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
|
||||||
|
|
||||||
|
"jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
|
||||||
|
|
||||||
|
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||||
|
|
||||||
|
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||||
|
|
||||||
|
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
|
||||||
|
|
||||||
|
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
|
||||||
|
|
||||||
|
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
|
||||||
|
|
||||||
|
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
|
||||||
|
|
||||||
|
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||||
|
|
||||||
|
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
|
||||||
|
|
||||||
|
"lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="],
|
||||||
|
|
||||||
|
"lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="],
|
||||||
|
|
||||||
|
"lodash.isnumber": ["lodash.isnumber@3.0.3", "", {}, "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="],
|
||||||
|
|
||||||
|
"lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="],
|
||||||
|
|
||||||
|
"lodash.isstring": ["lodash.isstring@4.0.1", "", {}, "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="],
|
||||||
|
|
||||||
|
"lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="],
|
||||||
|
|
||||||
|
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
|
||||||
|
|
||||||
|
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||||
|
|
||||||
|
"lucide-react": ["lucide-react@0.546.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z94u6fKT43lKeYHiVyvyR8fT7pwCzDu7RyMPpTvh054+xahSgj4HFQ+NmflvzdXsoAjYGdCguGaFKYuvq0ThCQ=="],
|
||||||
|
|
||||||
|
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
|
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||||
|
|
||||||
|
"media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
|
||||||
|
|
||||||
|
"merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="],
|
||||||
|
|
||||||
|
"methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="],
|
||||||
|
|
||||||
|
"mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
|
||||||
|
|
||||||
|
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||||
|
|
||||||
|
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||||
|
|
||||||
|
"motion": ["motion@12.42.2", "", { "dependencies": { "framer-motion": "^12.42.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-Atvv11yUKIid41cVrRBDVX5m8tF8kNpExRSlbpt6APClhDjtwQssgFHhQzejxw7/7YYbjHSPKBVbHo05BuJT5Q=="],
|
||||||
|
|
||||||
|
"motion-dom": ["motion-dom@12.42.2", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA=="],
|
||||||
|
|
||||||
|
"motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="],
|
||||||
|
|
||||||
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
|
"nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="],
|
||||||
|
|
||||||
|
"negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
|
||||||
|
|
||||||
|
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
|
||||||
|
|
||||||
|
"node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
|
||||||
|
|
||||||
|
"node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="],
|
||||||
|
|
||||||
|
"nodemailer": ["nodemailer@9.0.3", "", {}, "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw=="],
|
||||||
|
|
||||||
|
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||||
|
|
||||||
|
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||||
|
|
||||||
|
"p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="],
|
||||||
|
|
||||||
|
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||||
|
|
||||||
|
"path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="],
|
||||||
|
|
||||||
|
"pg": ["pg@8.22.0", "", { "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", "pg-protocol": "^1.15.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA=="],
|
||||||
|
|
||||||
|
"pg-cloudflare": ["pg-cloudflare@1.4.0", "", {}, "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A=="],
|
||||||
|
|
||||||
|
"pg-connection-string": ["pg-connection-string@2.14.0", "", {}, "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg=="],
|
||||||
|
|
||||||
|
"pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="],
|
||||||
|
|
||||||
|
"pg-pool": ["pg-pool@3.14.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw=="],
|
||||||
|
|
||||||
|
"pg-protocol": ["pg-protocol@1.15.0", "", {}, "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ=="],
|
||||||
|
|
||||||
|
"pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="],
|
||||||
|
|
||||||
|
"pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="],
|
||||||
|
|
||||||
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
|
"picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
|
||||||
|
|
||||||
|
"postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="],
|
||||||
|
|
||||||
|
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
|
||||||
|
|
||||||
|
"postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
|
||||||
|
|
||||||
|
"postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="],
|
||||||
|
|
||||||
|
"postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="],
|
||||||
|
|
||||||
|
"postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
|
||||||
|
|
||||||
|
"protobufjs": ["protobufjs@7.6.5", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw=="],
|
||||||
|
|
||||||
|
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||||
|
|
||||||
|
"qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="],
|
||||||
|
|
||||||
|
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
||||||
|
|
||||||
|
"raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="],
|
||||||
|
|
||||||
|
"react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
|
||||||
|
|
||||||
|
"react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
|
||||||
|
|
||||||
|
"react-is": ["react-is@19.2.8", "", {}, "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ=="],
|
||||||
|
|
||||||
|
"react-redux": ["react-redux@9.3.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g=="],
|
||||||
|
|
||||||
|
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
|
||||||
|
|
||||||
|
"recharts": ["recharts@3.10.1", "", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^11.1.8", "react-redux": "8.x.x || 9.x.x", "reselect": "5.2.0", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA=="],
|
||||||
|
|
||||||
|
"redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="],
|
||||||
|
|
||||||
|
"redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
|
||||||
|
|
||||||
|
"reselect": ["reselect@5.2.0", "", {}, "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw=="],
|
||||||
|
|
||||||
|
"retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
|
||||||
|
|
||||||
|
"rollup": ["rollup@4.62.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="],
|
||||||
|
|
||||||
|
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||||
|
|
||||||
|
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||||
|
|
||||||
|
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||||
|
|
||||||
|
"semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||||
|
|
||||||
|
"send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="],
|
||||||
|
|
||||||
|
"serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="],
|
||||||
|
|
||||||
|
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||||
|
|
||||||
|
"side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="],
|
||||||
|
|
||||||
|
"side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
|
||||||
|
|
||||||
|
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
|
||||||
|
|
||||||
|
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||||
|
|
||||||
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
|
|
||||||
|
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
|
||||||
|
|
||||||
|
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||||
|
|
||||||
|
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
|
||||||
|
|
||||||
|
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||||
|
|
||||||
|
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||||
|
|
||||||
|
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||||
|
|
||||||
|
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||||
|
|
||||||
|
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
|
"tsx": ["tsx@4.23.1", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ=="],
|
||||||
|
|
||||||
|
"type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
|
||||||
|
|
||||||
|
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||||
|
|
||||||
|
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
||||||
|
|
||||||
|
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||||
|
|
||||||
|
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
||||||
|
|
||||||
|
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
|
||||||
|
|
||||||
|
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||||
|
|
||||||
|
"victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
|
||||||
|
|
||||||
|
"vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="],
|
||||||
|
|
||||||
|
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
||||||
|
|
||||||
|
"ws": ["ws@8.21.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw=="],
|
||||||
|
|
||||||
|
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
||||||
|
|
||||||
|
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||||
|
|
||||||
|
"@babel/core/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
|
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
|
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
|
"@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
|
"@types/serve-static/@types/send": ["@types/send@0.17.6", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og=="],
|
||||||
|
|
||||||
|
"debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||||
|
|
||||||
|
"https-proxy-agent/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
|
"tsx/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
55
docker-compose.yml
Normal file
55
docker-compose.yml
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: pinnacle_app
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
- PORT=3000
|
||||||
|
- NODE_ENV=production
|
||||||
|
- DATABASE_URL=postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres_password}@postgres:5432/${POSTGRES_DB:-pinnacle_db}
|
||||||
|
- POSTGRES_HOST=postgres
|
||||||
|
- POSTGRES_PORT=5432
|
||||||
|
- POSTGRES_USER=${POSTGRES_USER:-postgres}
|
||||||
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres_password}
|
||||||
|
- POSTGRES_DB=${POSTGRES_DB:-pinnacle_db}
|
||||||
|
- SECRET_KEY=${SECRET_KEY:-pinnacle_secret_token_change_in_production}
|
||||||
|
- APP_URL=${APP_URL:-http://localhost:3000}
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.pinnacle-app.rule=Host(`${TRAEFIK_HOST:-localhost}`)"
|
||||||
|
- "traefik.http.routers.pinnacle-app.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.pinnacle-app.tls=true"
|
||||||
|
- "traefik.http.routers.pinnacle-app.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.pinnacle-app.loadbalancer.server.port=3000"
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: pinnacle_postgres
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres_password}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-pinnacle_db}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
- ./schema.sql:/docker-entrypoint-initdb.d/01-schema.sql
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-pinnacle_db}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
driver: local
|
||||||
13
index.html
Normal file
13
index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Project Pinnacle - Executive Governance & Decision Portal</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
6
metadata.json
Normal file
6
metadata.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"name": "Pinnacle Decision Portal",
|
||||||
|
"description": "Executive governance, strategic business plan tracking, decision register, risk management, and committee portal built for Docker, Coolify, and PostgreSQL deployment with Traefik SSL.",
|
||||||
|
"requestFramePermissions": [],
|
||||||
|
"majorCapabilities": ["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"]
|
||||||
|
}
|
||||||
2983
package-lock.json
generated
Normal file
2983
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
45
package.json
Normal file
45
package.json
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
{
|
||||||
|
"name": "react-example",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx server.ts",
|
||||||
|
"build": "vite build && esbuild server.ts --bundle --platform=node --format=cjs --packages=external --sourcemap --outfile=dist/server.cjs",
|
||||||
|
"start": "node dist/server.cjs",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"clean": "rm -rf dist",
|
||||||
|
"lint": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@google/genai": "^2.4.0",
|
||||||
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"lucide-react": "^0.546.0",
|
||||||
|
"motion": "^12.23.24",
|
||||||
|
"nodemailer": "^9.0.3",
|
||||||
|
"pg": "^8.22.0",
|
||||||
|
"react": "^19.0.1",
|
||||||
|
"react-dom": "^19.0.1",
|
||||||
|
"recharts": "^3.10.1",
|
||||||
|
"vite": "^6.2.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^3.0.0",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/node": "^22.14.0",
|
||||||
|
"@types/nodemailer": "^8.0.1",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"esbuild": "^0.25.0",
|
||||||
|
"tailwindcss": "^4.1.14",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
|
"typescript": "~5.8.2",
|
||||||
|
"vite": "^6.2.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
26
schema.sql
Normal file
26
schema.sql
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
-- Pinnacle Decision Portal PostgreSQL Schema
|
||||||
|
-- Automatically executed when Coolify provisions the PostgreSQL container
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pinnacle_store (
|
||||||
|
id VARCHAR(50) PRIMARY KEY DEFAULT 'default',
|
||||||
|
data JSONB NOT NULL,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pinnacle_store_updated ON pinnacle_store(updated_at);
|
||||||
|
|
||||||
|
-- User Management & Access Control Table
|
||||||
|
CREATE TABLE IF NOT EXISTS pinnacle_users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
username VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
role VARCHAR(20) NOT NULL DEFAULT 'viewer',
|
||||||
|
full_name VARCHAR(100) NOT NULL,
|
||||||
|
email VARCHAR(100) NOT NULL,
|
||||||
|
dept VARCHAR(100) NOT NULL,
|
||||||
|
active BOOLEAN DEFAULT TRUE,
|
||||||
|
last_login TIMESTAMP WITH TIME ZONE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pinnacle_users_username ON pinnacle_users(username);
|
||||||
698
server.ts
Normal file
698
server.ts
Normal file
|
|
@ -0,0 +1,698 @@
|
||||||
|
import express from "express";
|
||||||
|
import path from "path";
|
||||||
|
import { createServer as createViteServer } from "vite";
|
||||||
|
import pg from "pg";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import nodemailer from "nodemailer";
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = parseInt(process.env.PORT || "3000", 10);
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || "pinnacle_jwt_secret_key_2026_prod";
|
||||||
|
|
||||||
|
app.use(express.json({ limit: "50mb" }));
|
||||||
|
|
||||||
|
// Helper Functions for Password & Token Security
|
||||||
|
function hashPassword(password: string): string {
|
||||||
|
return bcrypt.hashSync(password, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyPassword(password: string, hashOrPlain?: string): boolean {
|
||||||
|
if (!hashOrPlain) return false;
|
||||||
|
if (hashOrPlain.startsWith("$2a$") || hashOrPlain.startsWith("$2b$")) {
|
||||||
|
return bcrypt.compareSync(password, hashOrPlain);
|
||||||
|
}
|
||||||
|
// Fallback for legacy case-insensitive or exact plain match, then upgrade
|
||||||
|
return password.toLowerCase() === hashOrPlain.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateToken(user: any): string {
|
||||||
|
return jwt.sign(
|
||||||
|
{
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
|
fullName: user.fullName,
|
||||||
|
email: user.email,
|
||||||
|
dept: user.dept
|
||||||
|
},
|
||||||
|
JWT_SECRET,
|
||||||
|
{ expiresIn: "24h" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostgreSQL Connection Pool Setup
|
||||||
|
let dbPool: pg.Pool | null = null;
|
||||||
|
let isPostgresConnected = false;
|
||||||
|
|
||||||
|
const dbUrl = process.env.DATABASE_URL ||
|
||||||
|
(process.env.POSTGRES_HOST
|
||||||
|
? `postgres://${process.env.POSTGRES_USER || 'postgres'}:${process.env.POSTGRES_PASSWORD || 'postgres_password'}@${process.env.POSTGRES_HOST}:${process.env.POSTGRES_PORT || 5432}/${process.env.POSTGRES_DB || 'pinnacle_db'}`
|
||||||
|
: null);
|
||||||
|
|
||||||
|
if (dbUrl) {
|
||||||
|
try {
|
||||||
|
dbPool = new pg.Pool({
|
||||||
|
connectionString: dbUrl,
|
||||||
|
ssl: process.env.PGSSLMODE === "require" ? { rejectUnauthorized: false } : false,
|
||||||
|
connectionTimeoutMillis: 3000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test DB connection and auto-create tables
|
||||||
|
dbPool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS pinnacle_store (
|
||||||
|
id VARCHAR(50) PRIMARY KEY DEFAULT 'default',
|
||||||
|
data JSONB NOT NULL,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pinnacle_users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
username VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
role VARCHAR(20) NOT NULL DEFAULT 'viewer',
|
||||||
|
full_name VARCHAR(100) NOT NULL,
|
||||||
|
email VARCHAR(100) NOT NULL,
|
||||||
|
dept VARCHAR(100) NOT NULL,
|
||||||
|
active BOOLEAN DEFAULT TRUE,
|
||||||
|
last_login TIMESTAMP WITH TIME ZONE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`).then(() => {
|
||||||
|
isPostgresConnected = true;
|
||||||
|
console.log("Connected to PostgreSQL database successfully.");
|
||||||
|
}).catch(err => {
|
||||||
|
console.warn("PostgreSQL connection notice:", err.message, "(Falling back to memory storage for dev environment)");
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.warn("PostgreSQL Pool init notice:", err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-Memory Fallback State (when PostgreSQL container is not connected)
|
||||||
|
let memoryStore: any = null;
|
||||||
|
|
||||||
|
// Auth Middleware to verify JWT tokens
|
||||||
|
const authenticateToken = (req: any, res: any, next: any) => {
|
||||||
|
const authHeader = req.headers['authorization'];
|
||||||
|
const token = authHeader && authHeader.split(' ')[1];
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return res.status(401).json({ error: "Access denied. Authentication token missing." });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const verified = jwt.verify(token, JWT_SECRET);
|
||||||
|
req.user = verified;
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(403).json({ error: "Invalid or expired authentication token." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API ROUTES
|
||||||
|
|
||||||
|
// Health Check
|
||||||
|
app.get("/api/health", async (req, res) => {
|
||||||
|
let pgStatus = "disconnected";
|
||||||
|
if (dbPool) {
|
||||||
|
try {
|
||||||
|
await dbPool.query("SELECT 1");
|
||||||
|
pgStatus = "connected";
|
||||||
|
isPostgresConnected = true;
|
||||||
|
} catch {
|
||||||
|
pgStatus = "error";
|
||||||
|
isPostgresConnected = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: "ok",
|
||||||
|
app: "Pinnacle Decision Portal",
|
||||||
|
postgres: pgStatus,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
environment: process.env.NODE_ENV || "development",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// User Authentication: Login
|
||||||
|
app.post("/api/auth/login", async (req, res) => {
|
||||||
|
const { username, password } = req.body;
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
return res.status(400).json({ error: "Username and password are required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
let users = memoryStore?.users || [];
|
||||||
|
|
||||||
|
// Search user in memory store or default
|
||||||
|
let user = users.find((u: any) => u.username.toLowerCase() === username.toLowerCase());
|
||||||
|
|
||||||
|
if (!user && !memoryStore) {
|
||||||
|
// Check initial defaults
|
||||||
|
const defaults = [
|
||||||
|
{ id: 1, username: 'admin', password: 'Admin', role: 'admin', fullName: 'System Administrator', email: 'admin@pinnacle.local', dept: 'Executive Management', active: true },
|
||||||
|
{ id: 2, username: 'editor', password: 'Editor', role: 'editor', fullName: 'Project Manager', email: 'pm@pinnacle.local', dept: 'Project Management Office', active: true },
|
||||||
|
{ id: 3, username: 'viewer', password: 'Viewer', role: 'viewer', fullName: 'Board Observer', email: 'observer@pinnacle.local', dept: 'Board of Directors', active: true }
|
||||||
|
];
|
||||||
|
user = defaults.find(u => u.username.toLowerCase() === username.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({ error: "Invalid username or password." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.active === false) {
|
||||||
|
return res.status(403).json({ error: "Your account has been deactivated by an administrator." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = verifyPassword(password, user.password);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
return res.status(401).json({ error: "Invalid username or password." });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upgrade password to hash if stored plain
|
||||||
|
if (!user.password.startsWith("$2a$") && !user.password.startsWith("$2b$")) {
|
||||||
|
user.password = hashPassword(password);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
user.lastLogin = now;
|
||||||
|
|
||||||
|
const token = generateToken(user);
|
||||||
|
|
||||||
|
// Return user without sending sensitive hash back directly
|
||||||
|
const { password: _, ...userWithoutPassword } = user;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
token,
|
||||||
|
user: userWithoutPassword
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// User Authentication: Session Verification
|
||||||
|
app.get("/api/auth/me", authenticateToken, (req: any, res: any) => {
|
||||||
|
res.json({
|
||||||
|
authenticated: true,
|
||||||
|
user: req.user
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// User Authentication: Change Password
|
||||||
|
app.post("/api/auth/change-password", authenticateToken, (req: any, res: any) => {
|
||||||
|
const { currentPassword, newPassword } = req.body;
|
||||||
|
const username = req.user.username;
|
||||||
|
|
||||||
|
if (!currentPassword || !newPassword) {
|
||||||
|
return res.status(400).json({ error: "Current password and new password are required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length < 6) {
|
||||||
|
return res.status(400).json({ error: "New password must be at least 6 characters long." });
|
||||||
|
}
|
||||||
|
|
||||||
|
let users = memoryStore?.users || [];
|
||||||
|
let user = users.find((u: any) => u.username.toLowerCase() === username.toLowerCase());
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ error: "User account not found." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = verifyPassword(currentPassword, user.password);
|
||||||
|
if (!isValid) {
|
||||||
|
return res.status(401).json({ error: "Current password is incorrect." });
|
||||||
|
}
|
||||||
|
|
||||||
|
user.password = hashPassword(newPassword);
|
||||||
|
|
||||||
|
res.json({ success: true, message: "Password updated successfully." });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Backend Secure SMTP Config Store
|
||||||
|
let backendSmtpConfig = {
|
||||||
|
host: process.env.SMTP_HOST || 'smtp.mailtrap.io',
|
||||||
|
port: parseInt(process.env.SMTP_PORT || '587', 10),
|
||||||
|
user: process.env.SMTP_USER || '',
|
||||||
|
pass: process.env.SMTP_PASS || '',
|
||||||
|
secure: process.env.SMTP_SECURE === "true",
|
||||||
|
fromName: process.env.SMTP_FROM_NAME || 'Pinnacle Decision Portal',
|
||||||
|
fromEmail: process.env.SMTP_FROM_EMAIL || 'notifications@pinnacle.local',
|
||||||
|
enabled: true,
|
||||||
|
notifyOnNewDecision: true,
|
||||||
|
notifyOnHighRisk: true,
|
||||||
|
notifyOnUserCreated: true
|
||||||
|
};
|
||||||
|
|
||||||
|
function createSmtpTransporter(cfg?: any) {
|
||||||
|
const host = cfg?.host || backendSmtpConfig.host || process.env.SMTP_HOST;
|
||||||
|
const port = cfg?.port || backendSmtpConfig.port || parseInt(process.env.SMTP_PORT || "587", 10);
|
||||||
|
const user = cfg?.user || backendSmtpConfig.user || process.env.SMTP_USER;
|
||||||
|
const pass = (cfg?.pass && cfg.pass !== "••••••••••••") ? cfg.pass : (backendSmtpConfig.pass || process.env.SMTP_PASS);
|
||||||
|
const secure = cfg?.secure ?? backendSmtpConfig.secure;
|
||||||
|
|
||||||
|
if (!host || !user || !pass) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodemailer.createTransport({
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
secure,
|
||||||
|
auth: { user, pass },
|
||||||
|
tls: { rejectUnauthorized: false }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backend SMTP Management API
|
||||||
|
app.get("/api/smtp/config", (req: any, res: any) => {
|
||||||
|
res.json({
|
||||||
|
host: backendSmtpConfig.host,
|
||||||
|
port: backendSmtpConfig.port,
|
||||||
|
user: backendSmtpConfig.user,
|
||||||
|
secure: backendSmtpConfig.secure,
|
||||||
|
fromName: backendSmtpConfig.fromName,
|
||||||
|
fromEmail: backendSmtpConfig.fromEmail,
|
||||||
|
enabled: backendSmtpConfig.enabled,
|
||||||
|
notifyOnNewDecision: backendSmtpConfig.notifyOnNewDecision,
|
||||||
|
notifyOnHighRisk: backendSmtpConfig.notifyOnHighRisk,
|
||||||
|
notifyOnUserCreated: backendSmtpConfig.notifyOnUserCreated,
|
||||||
|
hasPassword: Boolean(backendSmtpConfig.pass && backendSmtpConfig.pass.length > 0),
|
||||||
|
pass: backendSmtpConfig.pass ? "••••••••••••" : ""
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/smtp/config", (req: any, res: any) => {
|
||||||
|
const { host, port, user, pass, secure, fromName, fromEmail, enabled, notifyOnNewDecision, notifyOnHighRisk, notifyOnUserCreated } = req.body;
|
||||||
|
|
||||||
|
let updatedPass = backendSmtpConfig.pass;
|
||||||
|
if (pass && pass !== "••••••••••••") {
|
||||||
|
updatedPass = pass;
|
||||||
|
}
|
||||||
|
|
||||||
|
backendSmtpConfig = {
|
||||||
|
host: host ?? backendSmtpConfig.host,
|
||||||
|
port: parseInt(port, 10) || backendSmtpConfig.port,
|
||||||
|
user: user ?? backendSmtpConfig.user,
|
||||||
|
pass: updatedPass,
|
||||||
|
secure: Boolean(secure),
|
||||||
|
fromName: fromName ?? backendSmtpConfig.fromName,
|
||||||
|
fromEmail: fromEmail ?? backendSmtpConfig.fromEmail,
|
||||||
|
enabled: enabled !== undefined ? Boolean(enabled) : backendSmtpConfig.enabled,
|
||||||
|
notifyOnNewDecision: notifyOnNewDecision !== undefined ? Boolean(notifyOnNewDecision) : backendSmtpConfig.notifyOnNewDecision,
|
||||||
|
notifyOnHighRisk: notifyOnHighRisk !== undefined ? Boolean(notifyOnHighRisk) : backendSmtpConfig.notifyOnHighRisk,
|
||||||
|
notifyOnUserCreated: notifyOnUserCreated !== undefined ? Boolean(notifyOnUserCreated) : backendSmtpConfig.notifyOnUserCreated
|
||||||
|
};
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: "Backend SMTP configuration updated securely.",
|
||||||
|
config: {
|
||||||
|
...backendSmtpConfig,
|
||||||
|
pass: backendSmtpConfig.pass ? "••••••••••••" : ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// SMTP Test Endpoint
|
||||||
|
app.post("/api/notifications/smtp-test", async (req: any, res: any) => {
|
||||||
|
const { recipientEmail, host, port, user, pass, secure, fromName, fromEmail } = req.body;
|
||||||
|
|
||||||
|
const targetRecipient = recipientEmail || "admin@pinnacle.local";
|
||||||
|
const activeCfg = {
|
||||||
|
host: host || backendSmtpConfig.host,
|
||||||
|
port: port || backendSmtpConfig.port,
|
||||||
|
user: user || backendSmtpConfig.user,
|
||||||
|
pass: (pass && pass !== "••••••••••••") ? pass : backendSmtpConfig.pass,
|
||||||
|
secure: secure !== undefined ? secure : backendSmtpConfig.secure,
|
||||||
|
fromName: fromName || backendSmtpConfig.fromName,
|
||||||
|
fromEmail: fromEmail || backendSmtpConfig.fromEmail
|
||||||
|
};
|
||||||
|
|
||||||
|
const senderName = activeCfg.fromName;
|
||||||
|
const senderEmail = activeCfg.fromEmail;
|
||||||
|
|
||||||
|
const transporter = createSmtpTransporter(activeCfg);
|
||||||
|
|
||||||
|
const mailOptions = {
|
||||||
|
from: `"${senderName}" <${senderEmail}>`,
|
||||||
|
to: targetRecipient,
|
||||||
|
subject: "Pinnacle Portal - SMTP Test Notification",
|
||||||
|
html: `
|
||||||
|
<div style="font-family: Arial, sans-serif; background: #0f172a; color: #f8fafc; padding: 24px; borderRadius: 12px;">
|
||||||
|
<h2 style="color: #f59e0b; margin-top: 0;">Pinnacle Executive Decision Portal</h2>
|
||||||
|
<p>This is a test notification confirming that your <strong>Backend SMTP Server Configuration</strong> is active and connected.</p>
|
||||||
|
<hr style="border-color: #334155;" />
|
||||||
|
<ul style="font-size: 13px; color: #94a3b8; line-height: 1.6;">
|
||||||
|
<li><strong>SMTP Host:</strong> ${activeCfg.host}</li>
|
||||||
|
<li><strong>Port:</strong> ${activeCfg.port}</li>
|
||||||
|
<li><strong>Sender:</strong> ${senderName} <${senderEmail}></li>
|
||||||
|
<li><strong>Timestamp:</strong> ${new Date().toISOString()}</li>
|
||||||
|
</ul>
|
||||||
|
<p style="font-size: 11px; color: #64748b; margin-bottom: 0;">Automated System Dispatch - Pinnacle Governance Engine</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!transporter) {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
mode: "simulated",
|
||||||
|
message: `Simulated Email Dispatch: Notification preview created for ${targetRecipient}. (Fill Host, User & Password on backend SMTP settings to route via live server).`,
|
||||||
|
details: mailOptions
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = await transporter.sendMail(mailOptions);
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
mode: "live_smtp",
|
||||||
|
message: `Email notification sent successfully to ${targetRecipient}! (Message ID: ${info.messageId})`,
|
||||||
|
messageId: info.messageId
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("SMTP Delivery Error:", err.message);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
mode: "failed",
|
||||||
|
error: `SMTP Delivery Failed: ${err.message}. Please check host, port, user, or password credentials.`,
|
||||||
|
simulatedFallback: mailOptions
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// General Email Dispatch Endpoint
|
||||||
|
app.post("/api/notifications/send-email", async (req: any, res: any) => {
|
||||||
|
const { recipient, subject, body, triggerEvent } = req.body;
|
||||||
|
|
||||||
|
if (!recipient || !subject) {
|
||||||
|
return res.status(400).json({ error: "Recipient and subject are required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!backendSmtpConfig.enabled) {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
status: "disabled",
|
||||||
|
message: "Email notifications engine is currently disabled in backend settings."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const senderName = backendSmtpConfig.fromName || "Pinnacle Executive Portal";
|
||||||
|
const senderEmail = backendSmtpConfig.fromEmail || "notifications@pinnacle.local";
|
||||||
|
|
||||||
|
const transporter = createSmtpTransporter();
|
||||||
|
|
||||||
|
const mailOptions = {
|
||||||
|
from: `"${senderName}" <${senderEmail}>`,
|
||||||
|
to: recipient,
|
||||||
|
subject: subject,
|
||||||
|
html: `
|
||||||
|
<div style="font-family: Arial, sans-serif; background: #0f172a; color: #f8fafc; padding: 24px; border-radius: 12px; max-width: 600px;">
|
||||||
|
<div style="border-bottom: 1px solid #334155; padding-bottom: 12px; margin-bottom: 16px;">
|
||||||
|
<h2 style="color: #f59e0b; margin: 0; font-size: 20px;">Pinnacle Executive Portal</h2>
|
||||||
|
<span style="font-size: 11px; color: #10b981; font-weight: bold; text-transform: uppercase;">Governance Alert • ${triggerEvent || 'NOTIFICATION'}</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 14px; line-height: 1.6; color: #e2e8f0; margin-bottom: 20px;">
|
||||||
|
${body || 'No message content provided.'}
|
||||||
|
</div>
|
||||||
|
<div style="border-top: 1px solid #334155; padding-top: 12px; font-size: 11px; color: #64748b;">
|
||||||
|
This message was dispatched by Pinnacle Portal User Access & Governance Engine at ${new Date().toLocaleString()}.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!transporter) {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
status: "simulated",
|
||||||
|
message: `Simulated notification created for ${recipient}.`,
|
||||||
|
log: {
|
||||||
|
id: Date.now(),
|
||||||
|
recipient,
|
||||||
|
subject,
|
||||||
|
status: "simulated",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
triggerEvent: triggerEvent || "GENERAL_ALERT"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = await transporter.sendMail(mailOptions);
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
status: "sent",
|
||||||
|
messageId: info.messageId,
|
||||||
|
message: `Email sent to ${recipient}`,
|
||||||
|
log: {
|
||||||
|
id: Date.now(),
|
||||||
|
recipient,
|
||||||
|
subject,
|
||||||
|
status: "sent",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
triggerEvent: triggerEvent || "GENERAL_ALERT"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
status: "failed",
|
||||||
|
error: err.message,
|
||||||
|
log: {
|
||||||
|
id: Date.now(),
|
||||||
|
recipient,
|
||||||
|
subject,
|
||||||
|
status: "failed",
|
||||||
|
error: err.message,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
triggerEvent: triggerEvent || "GENERAL_ALERT"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Real-time SSE Connection Pool for Instant Multi-User Synchronization
|
||||||
|
const sseClients = new Set<express.Response>();
|
||||||
|
|
||||||
|
function broadcastToClients(messageObj: any) {
|
||||||
|
const payload = `data: ${JSON.stringify(messageObj)}\n\n`;
|
||||||
|
sseClients.forEach((client) => {
|
||||||
|
try {
|
||||||
|
client.write(payload);
|
||||||
|
} catch {
|
||||||
|
sseClients.delete(client);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Real-time Event Stream Endpoint (SSE)
|
||||||
|
app.get("/api/realtime/stream", (req, res) => {
|
||||||
|
res.setHeader("Content-Type", "text/event-stream");
|
||||||
|
res.setHeader("Cache-Control", "no-cache");
|
||||||
|
res.setHeader("Connection", "keep-alive");
|
||||||
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||||
|
res.flushHeaders();
|
||||||
|
|
||||||
|
// Send initial connection confirmation
|
||||||
|
res.write(`data: ${JSON.stringify({ type: "connected", timestamp: new Date().toISOString() })}\n\n`);
|
||||||
|
|
||||||
|
sseClients.add(res);
|
||||||
|
|
||||||
|
req.on("close", () => {
|
||||||
|
sseClients.delete(res);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Automated Real-Time Background Scheduler (Dispatches reminders & checks schedules)
|
||||||
|
setInterval(() => {
|
||||||
|
if (!memoryStore) return;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const timestamp = now.toISOString();
|
||||||
|
|
||||||
|
// Broadcast heart-beat pulse to maintain SSE connections & verify real-time state consistency
|
||||||
|
broadcastToClients({
|
||||||
|
type: "heartbeat",
|
||||||
|
activeConnections: sseClients.size,
|
||||||
|
timestamp
|
||||||
|
});
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
// Load Database Store
|
||||||
|
app.get("/api/db", async (req, res) => {
|
||||||
|
|
||||||
|
if (dbPool && isPostgresConnected) {
|
||||||
|
try {
|
||||||
|
const result = await dbPool.query("SELECT data FROM pinnacle_store WHERE id = 'default' LIMIT 1");
|
||||||
|
if (result.rows.length > 0) {
|
||||||
|
return res.json({ source: "postgres", data: result.rows[0].data });
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Error fetching from Postgres:", err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ source: memoryStore ? "memory" : "default", data: memoryStore });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save Database Store
|
||||||
|
app.post("/api/db", async (req, res) => {
|
||||||
|
const payload = req.body;
|
||||||
|
if (!payload || typeof payload !== "object") {
|
||||||
|
return res.status(400).json({ error: "Invalid payload" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-hash any plain passwords in the store payload for security
|
||||||
|
if (Array.isArray(payload.users)) {
|
||||||
|
payload.users = payload.users.map((u: any) => {
|
||||||
|
if (u.password && !u.password.startsWith("$2a$") && !u.password.startsWith("$2b$")) {
|
||||||
|
return { ...u, password: hashPassword(u.password) };
|
||||||
|
}
|
||||||
|
return u;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
memoryStore = payload;
|
||||||
|
|
||||||
|
// Real-time broadcast to all connected clients & tabs
|
||||||
|
broadcastToClients({
|
||||||
|
type: "store_update",
|
||||||
|
data: payload,
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dbPool && isPostgresConnected) {
|
||||||
|
try {
|
||||||
|
await dbPool.query(
|
||||||
|
`INSERT INTO pinnacle_store (id, data, updated_at)
|
||||||
|
VALUES ('default', $1, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT (id)
|
||||||
|
DO UPDATE SET data = EXCLUDED.data, updated_at = CURRENT_TIMESTAMP`,
|
||||||
|
[JSON.stringify(payload)]
|
||||||
|
);
|
||||||
|
return res.json({ success: true, savedTo: "postgres" });
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Failed to persist to Postgres:", err.message);
|
||||||
|
return res.json({ success: true, savedTo: "memory", warning: "Failed to persist to Postgres" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, savedTo: "memory" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Deployment Configurations APIs for Coolify & Traefik
|
||||||
|
app.get("/api/deployment/dockerfile", (req, res) => {
|
||||||
|
res.type("text/plain").send(`FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install --omit=dev
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "dist/server.cjs"]`);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/deployment/docker-compose", (req, res) => {
|
||||||
|
const domain = req.query.domain || "pinnacle.yourdomain.com";
|
||||||
|
res.type("text/plain").send(`version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: pinnacle_app
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
- PORT=3000
|
||||||
|
- NODE_ENV=production
|
||||||
|
- DATABASE_URL=postgres://\${POSTGRES_USER:-postgres}:\${POSTGRES_PASSWORD:-postgres_password}@postgres:5432/\${POSTGRES_DB:-pinnacle_db}
|
||||||
|
- POSTGRES_HOST=postgres
|
||||||
|
- POSTGRES_PORT=5432
|
||||||
|
- POSTGRES_USER=\${POSTGRES_USER:-postgres}
|
||||||
|
- POSTGRES_PASSWORD=\${POSTGRES_PASSWORD:-postgres_password}
|
||||||
|
- POSTGRES_DB=\${POSTGRES_DB:-pinnacle_db}
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.pinnacle-app.rule=Host(\`${domain}\`)"
|
||||||
|
- "traefik.http.routers.pinnacle-app.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.pinnacle-app.tls=true"
|
||||||
|
- "traefik.http.routers.pinnacle-app.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.pinnacle-app.loadbalancer.server.port=3000"
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: pinnacle_postgres
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: \${POSTGRES_USER:-postgres}
|
||||||
|
POSTGRES_PASSWORD: \${POSTGRES_PASSWORD:-postgres_password}
|
||||||
|
POSTGRES_DB: \${POSTGRES_DB:-pinnacle_db}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
- ./schema.sql:/docker-entrypoint-initdb.d/01-schema.sql
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U \${POSTGRES_USER:-postgres} -d \${POSTGRES_DB:-pinnacle_db}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
driver: local`);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/deployment/schema.sql", (req, res) => {
|
||||||
|
res.type("text/plain").send(`-- Pinnacle Decision Portal PostgreSQL Schema
|
||||||
|
CREATE TABLE IF NOT EXISTS pinnacle_store (
|
||||||
|
id VARCHAR(50) PRIMARY KEY DEFAULT 'default',
|
||||||
|
data JSONB NOT NULL,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pinnacle_store_updated ON pinnacle_store(updated_at);`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve frontend / Vite middleware
|
||||||
|
async function setupFrontend() {
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
const vite = await createViteServer({
|
||||||
|
server: { middlewareMode: true },
|
||||||
|
appType: "spa",
|
||||||
|
});
|
||||||
|
app.use(vite.middlewares);
|
||||||
|
} else {
|
||||||
|
const distPath = path.join(process.cwd(), "dist");
|
||||||
|
app.use(express.static(distPath));
|
||||||
|
app.get("*", (req, res) => {
|
||||||
|
res.sendFile(path.join(distPath, "index.html"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.listen(PORT, "0.0.0.0", () => {
|
||||||
|
console.log(`Server listening on http://0.0.0.0:${PORT}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setupFrontend();
|
||||||
3961
site_dump.html
Normal file
3961
site_dump.html
Normal file
File diff suppressed because it is too large
Load diff
381
src/App.tsx
Normal file
381
src/App.tsx
Normal file
|
|
@ -0,0 +1,381 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Header } from './components/Header';
|
||||||
|
import { Sidebar } from './components/Sidebar';
|
||||||
|
import { DashboardView } from './components/DashboardView';
|
||||||
|
import { PhasesView } from './components/PhasesView';
|
||||||
|
import { DecisionsView } from './components/DecisionsView';
|
||||||
|
import { CommitteesView } from './components/CommitteesView';
|
||||||
|
import { MeetingsView } from './components/MeetingsView';
|
||||||
|
import { StakeholdersView } from './components/StakeholdersView';
|
||||||
|
import { RisksView } from './components/RisksView';
|
||||||
|
import { UsersView } from './components/UsersView';
|
||||||
|
import { SmtpSettingsView } from './components/SmtpSettingsView';
|
||||||
|
import { LogsView } from './components/LogsView';
|
||||||
|
import { DeploymentView } from './components/DeploymentView';
|
||||||
|
import { LoginModal } from './components/LoginModal';
|
||||||
|
import { ProfileModal } from './components/ProfileModal';
|
||||||
|
import { NotificationModal } from './components/NotificationModal';
|
||||||
|
import { getDefaultData } from './data/defaultData';
|
||||||
|
import { ProjectStore, User } from './types';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [store, setStore] = useState<ProjectStore>(getDefaultData());
|
||||||
|
const [activeTab, setActiveTab] = useState('dashboard');
|
||||||
|
const [authToken, setAuthToken] = useState<string | null>(() => localStorage.getItem('pinnacle_auth_token'));
|
||||||
|
const [currentUser, setCurrentUser] = useState<User | null>(() => {
|
||||||
|
const token = localStorage.getItem('pinnacle_auth_token');
|
||||||
|
return token ? getDefaultData().users[0] : null;
|
||||||
|
});
|
||||||
|
const [dbSource, setDbSource] = useState<string>('local');
|
||||||
|
const [isPgConnected, setIsPgConnected] = useState<boolean>(false);
|
||||||
|
const [isProfileModalOpen, setIsProfileModalOpen] = useState(false);
|
||||||
|
const [isLoginModalOpen, setIsLoginModalOpen] = useState<boolean>(() => !localStorage.getItem('pinnacle_auth_token'));
|
||||||
|
const [isNotificationsOpen, setIsNotificationsOpen] = useState(false);
|
||||||
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
// Load state from server / PostgreSQL
|
||||||
|
const fetchDatabaseState = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/db');
|
||||||
|
if (res.ok) {
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.data && typeof json.data === 'object' && json.data.phases) {
|
||||||
|
setStore(json.data);
|
||||||
|
setDbSource(json.source || 'server');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Backend store load notice, using local state.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Verify JWT session token on app initialization
|
||||||
|
const verifySession = async () => {
|
||||||
|
const savedToken = localStorage.getItem('pinnacle_auth_token');
|
||||||
|
if (!savedToken) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/me', {
|
||||||
|
headers: { 'Authorization': `Bearer ${savedToken}` }
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.user) {
|
||||||
|
const matched = store.users.find(u => u.username.toLowerCase() === data.user.username.toLowerCase());
|
||||||
|
if (matched) {
|
||||||
|
setCurrentUser(matched);
|
||||||
|
} else {
|
||||||
|
setCurrentUser({
|
||||||
|
id: data.user.id || 1,
|
||||||
|
username: data.user.username,
|
||||||
|
role: data.user.role || 'viewer',
|
||||||
|
fullName: data.user.fullName || data.user.username,
|
||||||
|
email: data.user.email || `${data.user.username}@pinnacle.local`,
|
||||||
|
dept: data.user.dept || 'Strategy Unit',
|
||||||
|
active: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setAuthToken(savedToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Session verification notice:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check health and Postgres connection
|
||||||
|
const checkHealth = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/health');
|
||||||
|
if (res.ok) {
|
||||||
|
const json = await res.json();
|
||||||
|
setIsPgConnected(json.postgres === 'connected');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setIsPgConnected(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchDatabaseState();
|
||||||
|
checkHealth();
|
||||||
|
verifySession();
|
||||||
|
|
||||||
|
// 1. Cross-tab real-time BroadcastChannel
|
||||||
|
let broadcastChannel: BroadcastChannel | null = null;
|
||||||
|
if (typeof window !== 'undefined' && 'BroadcastChannel' in window) {
|
||||||
|
broadcastChannel = new BroadcastChannel('pinnacle_realtime_channel');
|
||||||
|
broadcastChannel.onmessage = (event) => {
|
||||||
|
if (event.data?.type === 'store_update' && event.data?.data) {
|
||||||
|
setStore(event.data.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Server-Sent Events (SSE) for multi-user real-time stream
|
||||||
|
let eventSource: EventSource | null = null;
|
||||||
|
try {
|
||||||
|
eventSource = new EventSource('/api/realtime/stream');
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(event.data);
|
||||||
|
if (parsed.type === 'store_update' && parsed.data) {
|
||||||
|
setStore(parsed.data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// ignore parsing
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('SSE stream init notice:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Automated background poll loop (3s) for guaranteed multi-user sync
|
||||||
|
const syncInterval = setInterval(() => {
|
||||||
|
fetchDatabaseState();
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (broadcastChannel) broadcastChannel.close();
|
||||||
|
if (eventSource) eventSource.close();
|
||||||
|
clearInterval(syncInterval);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Sync state to Express server / PostgreSQL & Broadcast in Real-Time
|
||||||
|
const handleUpdateStore = async (updated: ProjectStore) => {
|
||||||
|
setStore(updated);
|
||||||
|
|
||||||
|
// Cross-tab instant broadcast
|
||||||
|
if (typeof window !== 'undefined' && 'BroadcastChannel' in window) {
|
||||||
|
try {
|
||||||
|
const bc = new BroadcastChannel('pinnacle_realtime_channel');
|
||||||
|
bc.postMessage({ type: 'store_update', data: updated });
|
||||||
|
bc.close();
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
|
if (authToken) {
|
||||||
|
headers['Authorization'] = `Bearer ${authToken}`;
|
||||||
|
}
|
||||||
|
await fetch('/api/db', {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(updated)
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Sync notice:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateNarrative = (newNarrative: any) => {
|
||||||
|
const updatedStore = {
|
||||||
|
...store,
|
||||||
|
projectNarrative: newNarrative
|
||||||
|
};
|
||||||
|
handleUpdateStore(updatedStore);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
localStorage.removeItem('pinnacle_auth_token');
|
||||||
|
setAuthToken(null);
|
||||||
|
setCurrentUser(null);
|
||||||
|
setIsLoginModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pendingDecisionsCount = store.decisions.filter(
|
||||||
|
(d) => d.status === 'pending' || d.status === 'in_review'
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const openRisksCount = store.risks.filter(
|
||||||
|
(r) => r.status === 'open' || r.status === 'in_review'
|
||||||
|
).length;
|
||||||
|
|
||||||
|
// Calculate user-specific alert count for notification bell badge
|
||||||
|
const pmtPscAlerts = store.milestones.filter(
|
||||||
|
(m) => m.pmtReview === 'under_review' || m.pmtReview === 'escalated' || m.pmtReview === 'returned' || m.pscStatus === 'pending'
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const totalUserAlertsCount = pendingDecisionsCount + openRisksCount + pmtPscAlerts;
|
||||||
|
|
||||||
|
const handleExportBackup = () => {
|
||||||
|
const dataStr = 'data:text/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(store, null, 2));
|
||||||
|
const dlAnchor = document.createElement('a');
|
||||||
|
dlAnchor.setAttribute('href', dataStr);
|
||||||
|
dlAnchor.setAttribute('download', `pinnacle_backup_${new Date().toISOString().split('T')[0]}.json`);
|
||||||
|
document.body.appendChild(dlAnchor);
|
||||||
|
dlAnchor.click();
|
||||||
|
dlAnchor.remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-950 text-slate-100 flex flex-col font-sans antialiased selection:bg-amber-500 selection:text-slate-950">
|
||||||
|
{/* Top Navigation */}
|
||||||
|
<Header
|
||||||
|
currentUser={currentUser}
|
||||||
|
appTitle={store.appTitle || 'Project Pinnacle'}
|
||||||
|
dbSource={dbSource}
|
||||||
|
isPgConnected={isPgConnected}
|
||||||
|
alertCount={totalUserAlertsCount}
|
||||||
|
store={store}
|
||||||
|
onNavigateTab={(tab) => setActiveTab(tab)}
|
||||||
|
onOpenNotifications={() => setIsNotificationsOpen(true)}
|
||||||
|
onToggleMobileMenu={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||||
|
onOpenProfile={() => setIsProfileModalOpen(true)}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
onSync={() => {
|
||||||
|
fetchDatabaseState();
|
||||||
|
checkHealth();
|
||||||
|
}}
|
||||||
|
onOpenDeployment={() => setActiveTab('deployment')}
|
||||||
|
onExportBackup={handleExportBackup}
|
||||||
|
onUpdateAppTitle={(newTitle) => {
|
||||||
|
handleUpdateStore({ ...store, appTitle: newTitle });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main Body */}
|
||||||
|
<div className="flex flex-1 min-h-[calc(100vh-4rem)]">
|
||||||
|
<Sidebar
|
||||||
|
activeTab={activeTab}
|
||||||
|
setActiveTab={setActiveTab}
|
||||||
|
userRole={currentUser?.role || 'viewer'}
|
||||||
|
pendingDecisionsCount={pendingDecisionsCount}
|
||||||
|
openRisksCount={openRisksCount}
|
||||||
|
isOpenMobile={isMobileMenuOpen}
|
||||||
|
onCloseMobile={() => setIsMobileMenuOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main className="flex-1 bg-slate-950 overflow-y-auto">
|
||||||
|
{activeTab === 'dashboard' && (
|
||||||
|
<DashboardView
|
||||||
|
store={store}
|
||||||
|
userRole={currentUser?.role || 'viewer'}
|
||||||
|
onNavigatePage={(page) => setActiveTab(page)}
|
||||||
|
onUpdateNarrative={handleUpdateNarrative}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'phases' && (
|
||||||
|
<PhasesView
|
||||||
|
store={store}
|
||||||
|
userRole={currentUser?.role || 'viewer'}
|
||||||
|
onUpdateStore={handleUpdateStore}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'decisions' && (
|
||||||
|
<DecisionsView
|
||||||
|
store={store}
|
||||||
|
userRole={currentUser?.role || 'viewer'}
|
||||||
|
onUpdateStore={handleUpdateStore}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'committees' && (
|
||||||
|
<CommitteesView
|
||||||
|
store={store}
|
||||||
|
userRole={currentUser?.role || 'viewer'}
|
||||||
|
onUpdateStore={handleUpdateStore}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'meetings' && (
|
||||||
|
<MeetingsView
|
||||||
|
store={store}
|
||||||
|
userRole={currentUser?.role || 'viewer'}
|
||||||
|
onUpdateStore={handleUpdateStore}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'stakeholders' && (
|
||||||
|
<StakeholdersView
|
||||||
|
store={store}
|
||||||
|
userRole={currentUser?.role || 'viewer'}
|
||||||
|
onUpdateStore={handleUpdateStore}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'risks' && (
|
||||||
|
<RisksView
|
||||||
|
store={store}
|
||||||
|
userRole={currentUser?.role || 'viewer'}
|
||||||
|
onUpdateStore={handleUpdateStore}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'users' && (
|
||||||
|
<UsersView
|
||||||
|
store={store}
|
||||||
|
userRole={currentUser?.role || 'viewer'}
|
||||||
|
onUpdateStore={handleUpdateStore}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'smtp' && (
|
||||||
|
<SmtpSettingsView
|
||||||
|
store={store}
|
||||||
|
userRole={currentUser?.role || 'viewer'}
|
||||||
|
onUpdateStore={handleUpdateStore}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'logs' && <LogsView store={store} />}
|
||||||
|
|
||||||
|
{activeTab === 'deployment' && (
|
||||||
|
<DeploymentView
|
||||||
|
isPgConnected={isPgConnected}
|
||||||
|
dbSource={dbSource}
|
||||||
|
onRefreshHealth={checkHealth}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="py-4 border-t border-slate-900 bg-slate-950 text-center text-xs text-slate-500 font-medium my-4">
|
||||||
|
<span>powered by <strong className="text-amber-400 font-semibold">Huzaifa Inam</strong></span>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
|
{isNotificationsOpen && (
|
||||||
|
<NotificationModal
|
||||||
|
store={store}
|
||||||
|
currentUser={currentUser}
|
||||||
|
onClose={() => setIsNotificationsOpen(false)}
|
||||||
|
onNavigateTab={(tab) => {
|
||||||
|
setActiveTab(tab);
|
||||||
|
setIsNotificationsOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(isLoginModalOpen || !currentUser || !authToken) && (
|
||||||
|
<LoginModal
|
||||||
|
users={store.users}
|
||||||
|
onLoginSuccess={(u, token) => {
|
||||||
|
setCurrentUser(u);
|
||||||
|
setAuthToken(token);
|
||||||
|
setIsLoginModalOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isProfileModalOpen && currentUser && (
|
||||||
|
<ProfileModal
|
||||||
|
currentUser={currentUser}
|
||||||
|
users={store.users}
|
||||||
|
token={authToken}
|
||||||
|
onSwitchUser={(u) => setCurrentUser(u)}
|
||||||
|
onClose={() => setIsProfileModalOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
260
src/components/AttachmentUploader.tsx
Normal file
260
src/components/AttachmentUploader.tsx
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
import React, { useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Upload,
|
||||||
|
FileText,
|
||||||
|
Image as ImageIcon,
|
||||||
|
FileSpreadsheet,
|
||||||
|
File,
|
||||||
|
X,
|
||||||
|
Download,
|
||||||
|
Eye,
|
||||||
|
Trash2,
|
||||||
|
Paperclip
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Attachment } from '../types';
|
||||||
|
|
||||||
|
interface AttachmentUploaderProps {
|
||||||
|
attachments?: Attachment[];
|
||||||
|
onChange: (attachments: Attachment[]) => void;
|
||||||
|
readOnly?: boolean;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AttachmentUploader: React.FC<AttachmentUploaderProps> = ({
|
||||||
|
attachments = [],
|
||||||
|
onChange,
|
||||||
|
readOnly = false,
|
||||||
|
label = 'Supporting Documents & Images'
|
||||||
|
}) => {
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [previewImage, setPreviewImage] = useState<{ url: string; name: string } | null>(null);
|
||||||
|
const [isHovering, setIsHovering] = useState(false);
|
||||||
|
|
||||||
|
const getFileType = (fileName: string, mimeType: string): string => {
|
||||||
|
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
||||||
|
if (mimeType.startsWith('image/') || ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp'].includes(ext)) {
|
||||||
|
return 'image';
|
||||||
|
}
|
||||||
|
if (mimeType === 'application/pdf' || ext === 'pdf') {
|
||||||
|
return 'pdf';
|
||||||
|
}
|
||||||
|
if (['doc', 'docx', 'txt', 'rtf'].includes(ext) || mimeType.includes('word') || mimeType.includes('text')) {
|
||||||
|
return 'document';
|
||||||
|
}
|
||||||
|
if (['xls', 'xlsx', 'csv'].includes(ext) || mimeType.includes('spreadsheet') || mimeType.includes('excel')) {
|
||||||
|
return 'spreadsheet';
|
||||||
|
}
|
||||||
|
return 'other';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (!e.target.files || e.target.files.length === 0) return;
|
||||||
|
processFiles(Array.from(e.target.files));
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const processFiles = (files: File[]) => {
|
||||||
|
const newAttachments: Attachment[] = [];
|
||||||
|
|
||||||
|
files.forEach((file) => {
|
||||||
|
// 8MB limit per file check
|
||||||
|
if (file.size > 8 * 1024 * 1024) {
|
||||||
|
alert(`File "${file.name}" exceeds the maximum recommended size of 8MB.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
const dataUrl = event.target?.result as string;
|
||||||
|
if (dataUrl) {
|
||||||
|
const type = getFileType(file.name, file.type);
|
||||||
|
const newAtt: Attachment = {
|
||||||
|
id: `att-${Date.now()}-${Math.random().toString(36).substring(2, 7)}`,
|
||||||
|
name: file.name,
|
||||||
|
type,
|
||||||
|
size: file.size,
|
||||||
|
dataUrl,
|
||||||
|
uploadedAt: new Date().toISOString().split('T')[0]
|
||||||
|
};
|
||||||
|
onChange([...attachments, ...newAttachments, newAtt]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = (id: string) => {
|
||||||
|
onChange(attachments.filter((a) => a.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatSize = (bytes: number) => {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderFileIcon = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'image':
|
||||||
|
return <ImageIcon className="w-4 h-4 text-amber-400" />;
|
||||||
|
case 'pdf':
|
||||||
|
return <FileText className="w-4 h-4 text-rose-400" />;
|
||||||
|
case 'spreadsheet':
|
||||||
|
return <FileSpreadsheet className="w-4 h-4 text-emerald-400" />;
|
||||||
|
case 'document':
|
||||||
|
return <FileText className="w-4 h-4 text-blue-400" />;
|
||||||
|
default:
|
||||||
|
return <File className="w-4 h-4 text-slate-400" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 flex items-center space-x-1.5">
|
||||||
|
<Paperclip className="w-3.5 h-3.5 text-amber-400" />
|
||||||
|
<span>{label}</span>
|
||||||
|
<span className="text-[10px] text-slate-500 font-normal">
|
||||||
|
({attachments.length} attached)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upload Zone */}
|
||||||
|
{!readOnly && (
|
||||||
|
<div
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsHovering(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setIsHovering(false)}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsHovering(false);
|
||||||
|
if (e.dataTransfer.files) {
|
||||||
|
processFiles(Array.from(e.dataTransfer.files));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`border-2 border-dashed rounded-xl p-4 text-center cursor-pointer transition-all ${
|
||||||
|
isHovering
|
||||||
|
? 'border-amber-500 bg-amber-500/10'
|
||||||
|
: 'border-slate-800 hover:border-slate-700 bg-slate-950/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.csv,.txt"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col items-center justify-center space-y-1">
|
||||||
|
<div className="p-2 rounded-full bg-slate-900 border border-slate-800 text-amber-400">
|
||||||
|
<Upload className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs font-medium text-slate-300">
|
||||||
|
Click to browse or drag & drop files
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-slate-500">
|
||||||
|
Supports Images (PNG, JPG), PDF, Word, Excel & Text files (max 8MB per file)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Attached Files List */}
|
||||||
|
{attachments.length > 0 && (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
{attachments.map((att) => (
|
||||||
|
<div
|
||||||
|
key={att.id}
|
||||||
|
className="p-2.5 rounded-xl bg-slate-950 border border-slate-800/90 flex items-center justify-between gap-2 hover:border-slate-700 transition group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2.5 min-w-0">
|
||||||
|
<div className="p-1.5 rounded-lg bg-slate-900 border border-slate-800 shrink-0">
|
||||||
|
{renderFileIcon(att.type)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs font-semibold text-slate-200 truncate group-hover:text-amber-400 transition">
|
||||||
|
{att.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-slate-500">
|
||||||
|
{formatSize(att.size)} • {att.uploadedAt}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-1 shrink-0">
|
||||||
|
{att.type === 'image' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setPreviewImage({ url: att.dataUrl, name: att.name });
|
||||||
|
}}
|
||||||
|
className="p-1 text-slate-400 hover:text-amber-400 hover:bg-slate-800 rounded transition"
|
||||||
|
title="Preview Image"
|
||||||
|
>
|
||||||
|
<Eye className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={att.dataUrl}
|
||||||
|
download={att.name}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="p-1 text-slate-400 hover:text-emerald-400 hover:bg-slate-800 rounded transition"
|
||||||
|
title="Download File"
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{!readOnly && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleRemove(att.id);
|
||||||
|
}}
|
||||||
|
className="p-1 text-slate-400 hover:text-rose-400 hover:bg-slate-800 rounded transition"
|
||||||
|
title="Remove File"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Image Preview Lightbox Modal */}
|
||||||
|
{previewImage && (
|
||||||
|
<div className="fixed inset-0 bg-slate-950/90 backdrop-blur-md z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-2xl max-w-3xl max-h-[90vh] overflow-hidden flex flex-col shadow-2xl">
|
||||||
|
<div className="p-3 border-b border-slate-800 flex items-center justify-between">
|
||||||
|
<span className="text-xs font-bold text-slate-200 truncate">{previewImage.name}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPreviewImage(null)}
|
||||||
|
className="p-1 text-slate-400 hover:text-white rounded"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 overflow-auto flex items-center justify-center bg-slate-950 min-h-[300px]">
|
||||||
|
<img
|
||||||
|
src={previewImage.url}
|
||||||
|
alt={previewImage.name}
|
||||||
|
className="max-h-[70vh] object-contain rounded-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
487
src/components/CommitteesView.tsx
Normal file
487
src/components/CommitteesView.tsx
Normal file
|
|
@ -0,0 +1,487 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
Shield,
|
||||||
|
Calendar,
|
||||||
|
CheckSquare,
|
||||||
|
Plus,
|
||||||
|
Mail,
|
||||||
|
Building,
|
||||||
|
Trash2,
|
||||||
|
GitPullRequest,
|
||||||
|
ThumbsUp,
|
||||||
|
CheckCircle2,
|
||||||
|
Rocket,
|
||||||
|
ListFilter
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { ProjectStore, UserRole, CommitteeMember, PmtReviewStatus, PscStatus } from '../types';
|
||||||
|
|
||||||
|
interface CommitteesViewProps {
|
||||||
|
store: ProjectStore;
|
||||||
|
userRole: UserRole;
|
||||||
|
onUpdateStore: (updated: ProjectStore) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CommitteesView: React.FC<CommitteesViewProps> = ({
|
||||||
|
store,
|
||||||
|
userRole,
|
||||||
|
onUpdateStore
|
||||||
|
}) => {
|
||||||
|
const [activeCommittee, setActiveCommittee] = useState<'pmt' | 'psc'>('psc');
|
||||||
|
const [isAddMemberOpen, setIsAddMemberOpen] = useState(false);
|
||||||
|
const [newMember, setNewMember] = useState<Partial<CommitteeMember>>({});
|
||||||
|
|
||||||
|
const canEdit = userRole === 'admin' || userRole === 'editor';
|
||||||
|
|
||||||
|
const commData = store.committees[activeCommittee];
|
||||||
|
|
||||||
|
const handleAddMember = () => {
|
||||||
|
if (!newMember.name || !newMember.role) return;
|
||||||
|
|
||||||
|
const updatedCommittee = {
|
||||||
|
...commData,
|
||||||
|
members: [
|
||||||
|
...commData.members,
|
||||||
|
{
|
||||||
|
id: Date.now(),
|
||||||
|
name: newMember.name,
|
||||||
|
role: newMember.role,
|
||||||
|
email: newMember.email || '',
|
||||||
|
organization: newMember.organization || 'Internal'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
onUpdateStore({
|
||||||
|
...store,
|
||||||
|
committees: {
|
||||||
|
...store.committees,
|
||||||
|
[activeCommittee]: updatedCommittee
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsAddMemberOpen(false);
|
||||||
|
setNewMember({});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveMember = (memId: number) => {
|
||||||
|
if (!canEdit) return;
|
||||||
|
const updatedCommittee = {
|
||||||
|
...commData,
|
||||||
|
members: commData.members.filter((m) => m.id !== memId)
|
||||||
|
};
|
||||||
|
|
||||||
|
onUpdateStore({
|
||||||
|
...store,
|
||||||
|
committees: {
|
||||||
|
...store.committees,
|
||||||
|
[activeCommittee]: updatedCommittee
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdatePmtStatus = (milestoneId: number, newStatus: PmtReviewStatus) => {
|
||||||
|
if (!canEdit) return;
|
||||||
|
const updatedMilestones = store.milestones.map((m) =>
|
||||||
|
m.id === milestoneId ? { ...m, pmtReview: newStatus } : m
|
||||||
|
);
|
||||||
|
onUpdateStore({
|
||||||
|
...store,
|
||||||
|
milestones: updatedMilestones
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdatePscStatus = (milestoneId: number, newStatus: PscStatus) => {
|
||||||
|
if (!canEdit) return;
|
||||||
|
const updatedMilestones = store.milestones.map((m) =>
|
||||||
|
m.id === milestoneId ? { ...m, pscStatus: newStatus } : m
|
||||||
|
);
|
||||||
|
onUpdateStore({
|
||||||
|
...store,
|
||||||
|
milestones: updatedMilestones
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3 sm:p-6 max-w-7xl mx-auto space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-slate-100 flex items-center space-x-2">
|
||||||
|
<Users className="w-6 h-6 text-amber-400" />
|
||||||
|
<span>PMT & PSC Oversight</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
Roles, mandates, responsibilities, and membership rosters for Project Management Team & Steering Committee.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Committee Selector Tabs */}
|
||||||
|
<div className="flex items-center space-x-3 border-b border-slate-800 pb-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveCommittee('psc')}
|
||||||
|
className={`flex items-center space-x-2 px-5 py-2.5 rounded-xl font-bold text-xs transition ${
|
||||||
|
activeCommittee === 'psc'
|
||||||
|
? 'bg-amber-500 text-slate-950 shadow-md'
|
||||||
|
: 'bg-slate-900 text-slate-400 hover:text-slate-200 border border-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Shield className="w-4 h-4" />
|
||||||
|
<span>PSC • Steering Committee (Governance & Sign-off)</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveCommittee('pmt')}
|
||||||
|
className={`flex items-center space-x-2 px-5 py-2.5 rounded-xl font-bold text-xs transition ${
|
||||||
|
activeCommittee === 'pmt'
|
||||||
|
? 'bg-amber-500 text-slate-950 shadow-md'
|
||||||
|
: 'bg-slate-900 text-slate-400 hover:text-slate-200 border border-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
<span>PMT • Management Team (Execution & Operations)</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mandate Card */}
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-2xl p-6 shadow-md space-y-6">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b border-slate-800 pb-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-bold text-amber-400 uppercase tracking-wider">{commData.abbr} Mandate</div>
|
||||||
|
<h3 className="text-xl font-bold text-slate-100 mt-1">{commData.name}</h3>
|
||||||
|
<p className="text-xs text-slate-300 mt-1 max-w-2xl">{commData.role}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-950 border border-slate-800 p-3 rounded-xl text-xs space-y-1 shrink-0">
|
||||||
|
<div className="text-slate-400">
|
||||||
|
Chair: <span className="text-slate-200 font-bold">{commData.chair}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-slate-400">
|
||||||
|
Meeting Cadence: <span className="text-amber-300 font-semibold">{commData.meetingCadence}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Responsibilities */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-bold text-slate-300 uppercase tracking-wider mb-3">Key Governance Responsibilities</h4>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
{commData.responsibilities.map((resp, i) => (
|
||||||
|
<div key={i} className="flex items-start space-x-2 bg-slate-950/60 p-3 rounded-xl border border-slate-800/80">
|
||||||
|
<CheckSquare className="w-4 h-4 text-emerald-400 shrink-0 mt-0.5" />
|
||||||
|
<span className="text-xs text-slate-300">{resp}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Membership Roster */}
|
||||||
|
<div className="space-y-4 pt-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="text-xs font-bold text-slate-300 uppercase tracking-wider">
|
||||||
|
Committee Roster ({commData.members.length} Members)
|
||||||
|
</h4>
|
||||||
|
{canEdit && (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsAddMemberOpen(true)}
|
||||||
|
className="flex items-center space-x-1 text-xs text-amber-400 hover:text-amber-300 font-medium bg-amber-500/10 border border-amber-500/30 px-3 py-1.5 rounded-lg transition"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
<span>Add Member</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
{commData.members.map((mem) => (
|
||||||
|
<div
|
||||||
|
key={mem.id}
|
||||||
|
className="bg-slate-950 border border-slate-800 p-4 rounded-xl space-y-2 flex flex-col justify-between"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="font-bold text-slate-200 text-sm">{mem.name}</div>
|
||||||
|
<div className="text-xs text-amber-400 font-medium">{mem.role}</div>
|
||||||
|
<div className="text-[11px] text-slate-400 mt-2 flex items-center space-x-1">
|
||||||
|
<Building className="w-3 h-3 text-slate-500" />
|
||||||
|
<span>{mem.organization}</span>
|
||||||
|
</div>
|
||||||
|
{mem.email && (
|
||||||
|
<div className="text-[11px] text-slate-500 flex items-center space-x-1">
|
||||||
|
<Mail className="w-3 h-3 text-slate-500" />
|
||||||
|
<span>{mem.email}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canEdit && (
|
||||||
|
<div className="pt-2 border-t border-slate-800/60 flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveMember(mem.id)}
|
||||||
|
className="text-slate-500 hover:text-rose-400 p-1"
|
||||||
|
title="Remove Member"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Milestone Approval Workflow Banner */}
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-2xl p-6 shadow-md space-y-5">
|
||||||
|
<div className="flex items-center space-x-2 border-b border-slate-800/80 pb-3">
|
||||||
|
<GitPullRequest className="w-5 h-5 text-amber-400" />
|
||||||
|
<h3 className="text-base font-bold text-slate-100">Milestone Approval Workflow</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-2 pb-2 overflow-x-auto gap-2">
|
||||||
|
{/* Step 1 */}
|
||||||
|
<div className="flex flex-col items-center text-center space-y-2 min-w-[110px]">
|
||||||
|
<div className="w-11 h-11 rounded-full bg-emerald-500/10 border-2 border-emerald-500 flex items-center justify-center text-emerald-400 shadow-inner">
|
||||||
|
<Users className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-bold text-slate-200">PMT Review</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connector Line 1 */}
|
||||||
|
<div className="h-1 bg-emerald-500 flex-1 min-w-[30px] rounded-full" />
|
||||||
|
|
||||||
|
{/* Step 2 */}
|
||||||
|
<div className="flex flex-col items-center text-center space-y-2 min-w-[110px]">
|
||||||
|
<div className="w-11 h-11 rounded-full bg-emerald-500/10 border-2 border-emerald-500 flex items-center justify-center text-emerald-400 shadow-inner">
|
||||||
|
<ThumbsUp className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-bold text-slate-200">PMT Endorsed</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connector Line 2 */}
|
||||||
|
<div className="h-1 bg-emerald-500 flex-1 min-w-[30px] rounded-full" />
|
||||||
|
|
||||||
|
{/* Step 3 */}
|
||||||
|
<div className="flex flex-col items-center text-center space-y-2 min-w-[110px]">
|
||||||
|
<div className="w-11 h-11 rounded-full bg-emerald-500/10 border-2 border-emerald-500 flex items-center justify-center text-emerald-400 shadow-inner">
|
||||||
|
<Building className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-bold text-slate-200">PSC Review</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connector Line 3 */}
|
||||||
|
<div className="h-1 bg-emerald-500 flex-1 min-w-[30px] rounded-full" />
|
||||||
|
|
||||||
|
{/* Step 4 */}
|
||||||
|
<div className="flex flex-col items-center text-center space-y-2 min-w-[110px]">
|
||||||
|
<div className="w-11 h-11 rounded-full bg-emerald-500/10 border-2 border-emerald-500 flex items-center justify-center text-emerald-400 shadow-inner">
|
||||||
|
<CheckCircle2 className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-bold text-slate-200">PSC Approved</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connector Line 4 */}
|
||||||
|
<div className="h-1 bg-emerald-500 flex-1 min-w-[30px] rounded-full" />
|
||||||
|
|
||||||
|
{/* Step 5 */}
|
||||||
|
<div className="flex flex-col items-center text-center space-y-2 min-w-[110px]">
|
||||||
|
<div className="w-11 h-11 rounded-full bg-emerald-500/10 border-2 border-emerald-500 flex items-center justify-center text-emerald-400 shadow-inner">
|
||||||
|
<Rocket className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-bold text-slate-200">Implemented</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Milestone Approval Status Table */}
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-md p-6 space-y-4">
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-800 pb-3">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<ListFilter className="w-5 h-5 text-amber-400" />
|
||||||
|
<h3 className="text-base font-bold text-slate-100">Milestone Approval Status</h3>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-slate-400 font-medium">PMT reviews • PSC approves</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-xs text-slate-300">
|
||||||
|
<thead className="bg-slate-950 text-slate-400 font-semibold border-b border-slate-800 uppercase tracking-wider">
|
||||||
|
<tr>
|
||||||
|
<th className="p-3">PHASE</th>
|
||||||
|
<th className="p-3">MILESTONE</th>
|
||||||
|
<th className="p-3">TIMELINE</th>
|
||||||
|
<th className="p-3">PMT REVIEW</th>
|
||||||
|
<th className="p-3">PSC STATUS</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800/60">
|
||||||
|
{store.milestones.map((m) => {
|
||||||
|
const pmtVal = m.pmtReview || 'not_reviewed';
|
||||||
|
const pscVal = m.pscStatus || 'not_required';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={m.id} className="hover:bg-slate-800/40 transition">
|
||||||
|
<td className="p-3 font-semibold text-slate-400 whitespace-nowrap">
|
||||||
|
Ph.{m.phaseId}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 font-bold text-slate-100">
|
||||||
|
{m.title}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-slate-400 whitespace-nowrap">
|
||||||
|
Month {m.month}
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span
|
||||||
|
className={`px-2.5 py-1 rounded-md text-[11px] font-bold border capitalize shrink-0 ${
|
||||||
|
pmtVal === 'under_review'
|
||||||
|
? 'bg-amber-500/20 text-amber-300 border-amber-500/30'
|
||||||
|
: pmtVal === 'endorsed'
|
||||||
|
? 'bg-emerald-500/20 text-emerald-300 border-emerald-500/30'
|
||||||
|
: pmtVal === 'escalated'
|
||||||
|
? 'bg-rose-500/20 text-rose-300 border-rose-500/30'
|
||||||
|
: pmtVal === 'returned'
|
||||||
|
? 'bg-pink-500/20 text-pink-300 border-pink-500/30'
|
||||||
|
: 'bg-slate-800 text-slate-400 border-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{pmtVal.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{canEdit && (
|
||||||
|
<select
|
||||||
|
value={pmtVal}
|
||||||
|
onChange={(e) => handleUpdatePmtStatus(m.id, e.target.value as PmtReviewStatus)}
|
||||||
|
className="bg-slate-950 border border-slate-800 rounded-lg px-2 py-1 text-[11px] text-slate-200 focus:outline-none focus:border-amber-500 cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value="not_reviewed">Not Reviewed</option>
|
||||||
|
<option value="under_review">Under Review</option>
|
||||||
|
<option value="endorsed">Endorsed</option>
|
||||||
|
<option value="escalated">Escalated</option>
|
||||||
|
<option value="returned">Returned</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span
|
||||||
|
className={`px-2.5 py-1 rounded-md text-[11px] font-bold border capitalize shrink-0 ${
|
||||||
|
pscVal === 'approved'
|
||||||
|
? 'bg-emerald-500/20 text-emerald-300 border-emerald-500/30'
|
||||||
|
: pscVal === 'pending'
|
||||||
|
? 'bg-amber-500/20 text-amber-300 border-amber-500/30'
|
||||||
|
: pscVal === 'rejected'
|
||||||
|
? 'bg-rose-500/20 text-rose-300 border-rose-500/30'
|
||||||
|
: 'bg-slate-800 text-slate-400 border-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{pscVal.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{canEdit && (
|
||||||
|
<select
|
||||||
|
value={pscVal}
|
||||||
|
onChange={(e) => handleUpdatePscStatus(m.id, e.target.value as PscStatus)}
|
||||||
|
className="bg-slate-950 border border-slate-800 rounded-lg px-2 py-1 text-[11px] text-slate-200 focus:outline-none focus:border-amber-500 cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value="not_required">not required</option>
|
||||||
|
<option value="pending">pending</option>
|
||||||
|
<option value="approved">approved</option>
|
||||||
|
<option value="rejected">rejected</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Member Modal */}
|
||||||
|
{isAddMemberOpen && (
|
||||||
|
<div className="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-2xl max-w-md w-full p-6 shadow-2xl space-y-4">
|
||||||
|
<h3 className="font-bold text-slate-100 text-lg">Add Member to {commData.abbr}</h3>
|
||||||
|
|
||||||
|
<div className="space-y-3 text-xs">
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Select Registered User</label>
|
||||||
|
<select
|
||||||
|
onChange={(e) => {
|
||||||
|
const selectedUser = store.users.find(u => u.id === Number(e.target.value));
|
||||||
|
if (selectedUser) {
|
||||||
|
setNewMember({
|
||||||
|
...newMember,
|
||||||
|
name: selectedUser.fullName,
|
||||||
|
email: selectedUser.email,
|
||||||
|
organization: selectedUser.dept || 'Internal'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-amber-500 mb-2"
|
||||||
|
>
|
||||||
|
<option value="">-- Choose from Registered Users --</option>
|
||||||
|
{store.users.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{u.fullName} ({u.role.toUpperCase()} - {u.dept})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Full Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newMember.name || ''}
|
||||||
|
onChange={(e) => setNewMember({ ...newMember, name: e.target.value })}
|
||||||
|
placeholder="e.g. Elena Rostova"
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-amber-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Role in Committee</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newMember.role || ''}
|
||||||
|
onChange={(e) => setNewMember({ ...newMember, role: e.target.value })}
|
||||||
|
placeholder="e.g. Risk Lead / Advisor"
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-amber-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Organization / Department</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newMember.organization || ''}
|
||||||
|
onChange={(e) => setNewMember({ ...newMember, organization: e.target.value })}
|
||||||
|
placeholder="e.g. Executive Board"
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-amber-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-3 pt-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsAddMemberOpen(false)}
|
||||||
|
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl text-xs font-medium"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleAddMember}
|
||||||
|
className="px-4 py-2 bg-amber-500 hover:bg-amber-400 text-slate-950 font-bold rounded-xl text-xs"
|
||||||
|
>
|
||||||
|
Add Member
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
1138
src/components/DashboardView.tsx
Normal file
1138
src/components/DashboardView.tsx
Normal file
File diff suppressed because it is too large
Load diff
425
src/components/DecisionsView.tsx
Normal file
425
src/components/DecisionsView.tsx
Normal file
|
|
@ -0,0 +1,425 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Gavel, Plus, Search, Filter, FileText, CheckCircle2, Clock, AlertCircle, Edit2, Download, Paperclip } from 'lucide-react';
|
||||||
|
import { ProjectStore, UserRole, Decision, DecisionStatus, PriorityType, Attachment } from '../types';
|
||||||
|
import { AttachmentUploader } from './AttachmentUploader';
|
||||||
|
|
||||||
|
interface DecisionsViewProps {
|
||||||
|
store: ProjectStore;
|
||||||
|
userRole: UserRole;
|
||||||
|
onUpdateStore: (updated: ProjectStore) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DecisionsView: React.FC<DecisionsViewProps> = ({
|
||||||
|
store,
|
||||||
|
userRole,
|
||||||
|
onUpdateStore
|
||||||
|
}) => {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||||
|
const [committeeFilter, setCommitteeFilter] = useState<string>('all');
|
||||||
|
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [editingDecision, setEditingDecision] = useState<Partial<Decision> | null>(null);
|
||||||
|
|
||||||
|
const canEdit = userRole === 'admin' || userRole === 'editor';
|
||||||
|
|
||||||
|
const filteredDecisions = store.decisions.filter((d) => {
|
||||||
|
const matchesSearch =
|
||||||
|
d.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
d.description.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
const matchesStatus = statusFilter === 'all' || d.status === statusFilter;
|
||||||
|
const matchesCommittee = committeeFilter === 'all' || d.committeeId === committeeFilter;
|
||||||
|
return matchesSearch && matchesStatus && matchesCommittee;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSaveDecision = async () => {
|
||||||
|
if (!editingDecision?.title || !editingDecision?.description) return;
|
||||||
|
|
||||||
|
let updatedDecisions = [...store.decisions];
|
||||||
|
let isNew = false;
|
||||||
|
let newDecisionTitle = editingDecision.title;
|
||||||
|
|
||||||
|
if (editingDecision.id) {
|
||||||
|
updatedDecisions = updatedDecisions.map((d) =>
|
||||||
|
d.id === editingDecision.id ? ({ ...d, ...editingDecision } as Decision) : d
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
isNew = true;
|
||||||
|
const newId = store.ids.decision;
|
||||||
|
const newD: Decision = {
|
||||||
|
id: newId,
|
||||||
|
phaseId: Number(editingDecision.phaseId) || 1,
|
||||||
|
committeeId: editingDecision.committeeId || 'psc',
|
||||||
|
title: editingDecision.title,
|
||||||
|
description: editingDecision.description,
|
||||||
|
rationale: editingDecision.rationale || '',
|
||||||
|
status: (editingDecision.status as DecisionStatus) || 'pending',
|
||||||
|
priority: (editingDecision.priority as PriorityType) || 'medium',
|
||||||
|
raisedBy: editingDecision.raisedBy || 'Project Director',
|
||||||
|
dueDate: editingDecision.dueDate || new Date().toISOString().split('T')[0],
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
updatedDecisions.push(newD);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedStore = {
|
||||||
|
...store,
|
||||||
|
decisions: updatedDecisions,
|
||||||
|
ids: { ...store.ids, decision: editingDecision.id ? store.ids.decision : store.ids.decision + 1 }
|
||||||
|
};
|
||||||
|
|
||||||
|
onUpdateStore(updatedStore);
|
||||||
|
|
||||||
|
// If new decision & notification enabled, fire email notification
|
||||||
|
if (isNew && store.smtpConfig?.enabled && store.smtpConfig?.notifyOnNewDecision) {
|
||||||
|
try {
|
||||||
|
const primaryAdminEmail = store.users.find(u => u.role === 'admin')?.email || 'admin@pinnacle.local';
|
||||||
|
await fetch('/api/notifications/send-email', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
recipient: primaryAdminEmail,
|
||||||
|
subject: `[Governance Notice] New Decision Item Raised: ${newDecisionTitle}`,
|
||||||
|
body: `<p>A new strategic decision item has been raised in the portal.</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Title:</strong> ${newDecisionTitle}</li>
|
||||||
|
<li><strong>Priority:</strong> ${editingDecision.priority || 'medium'}</li>
|
||||||
|
<li><strong>Raised By:</strong> ${editingDecision.raisedBy || 'Governance Member'}</li>
|
||||||
|
</ul>`,
|
||||||
|
triggerEvent: 'NEW_DECISION',
|
||||||
|
smtpConfig: store.smtpConfig
|
||||||
|
})
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Notification dispatch error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setEditingDecision(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3 sm:p-6 max-w-7xl mx-auto space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-slate-100 flex items-center space-x-2">
|
||||||
|
<Gavel className="w-6 h-6 text-blue-400" />
|
||||||
|
<span>Strategic Decision Register</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
Formal governance log for policy approvals, budget clearance, and Steering Committee (PSC) resolutions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canEdit && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingDecision({ phaseId: 1, committeeId: 'psc', status: 'pending', priority: 'high' });
|
||||||
|
setIsModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center space-x-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white font-bold rounded-xl text-xs transition shadow-md shrink-0"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>Raise Decision Item</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Bar */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 bg-slate-900/80 p-3 rounded-2xl border border-slate-800">
|
||||||
|
<div className="relative w-full sm:w-80">
|
||||||
|
<Search className="w-4 h-4 text-slate-400 absolute left-3 top-2.5" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search decision items..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl pl-9 pr-3 py-1.5 text-xs text-slate-200 focus:outline-none focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3 w-full sm:w-auto">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="text-xs text-slate-400 font-medium">Committee:</span>
|
||||||
|
<select
|
||||||
|
value={committeeFilter}
|
||||||
|
onChange={(e) => setCommitteeFilter(e.target.value)}
|
||||||
|
className="bg-slate-950 border border-slate-800 rounded-xl px-3 py-1.5 text-xs text-slate-200 focus:outline-none focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="all">All Governance Bodies</option>
|
||||||
|
<option value="psc">PSC (Steering Committee)</option>
|
||||||
|
<option value="pmt">PMT (Management Team)</option>
|
||||||
|
<option value="board">Board of Directors</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="text-xs text-slate-400 font-medium">Status:</span>
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
className="bg-slate-950 border border-slate-800 rounded-xl px-3 py-1.5 text-xs text-slate-200 focus:outline-none focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="all">All Statuses</option>
|
||||||
|
<option value="approved">Approved</option>
|
||||||
|
<option value="pending">Pending Review</option>
|
||||||
|
<option value="in_review">In Review</option>
|
||||||
|
<option value="deferred">Deferred</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Decision Table / Grid */}
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-sm">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-xs text-slate-300">
|
||||||
|
<thead className="bg-slate-950 text-slate-400 font-semibold border-b border-slate-800 uppercase tracking-wider">
|
||||||
|
<tr>
|
||||||
|
<th className="p-4">Decision Title & Description</th>
|
||||||
|
<th className="p-4">Committee</th>
|
||||||
|
<th className="p-4">Phase</th>
|
||||||
|
<th className="p-4">Priority</th>
|
||||||
|
<th className="p-4">Status</th>
|
||||||
|
<th className="p-4">Due Date</th>
|
||||||
|
{canEdit && <th className="p-4 text-right">Actions</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800">
|
||||||
|
{filteredDecisions.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="p-8 text-center text-slate-500">
|
||||||
|
No decision items recorded.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
filteredDecisions.map((dec) => {
|
||||||
|
const phase = store.phases.find((p) => p.id === dec.phaseId);
|
||||||
|
return (
|
||||||
|
<tr key={dec.id} className="hover:bg-slate-800/50 transition">
|
||||||
|
<td className="p-4 space-y-1 max-w-md">
|
||||||
|
<div className="font-bold text-slate-100 text-sm flex items-center space-x-2">
|
||||||
|
<span>{dec.title}</span>
|
||||||
|
{dec.attachments && dec.attachments.length > 0 && (
|
||||||
|
<span className="inline-flex items-center space-x-1 text-[10px] bg-slate-800 text-amber-300 px-2 py-0.5 rounded-md border border-slate-700">
|
||||||
|
<Paperclip className="w-3 h-3 text-amber-400" />
|
||||||
|
<span>{dec.attachments.length}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-slate-400 line-clamp-2 text-[11px]">{dec.description}</p>
|
||||||
|
{dec.rationale && (
|
||||||
|
<p className="text-blue-300/80 text-[11px] italic mt-1">
|
||||||
|
Rationale: {dec.rationale}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{dec.attachments && dec.attachments.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||||
|
{dec.attachments.map((att) => (
|
||||||
|
<a
|
||||||
|
key={att.id}
|
||||||
|
href={att.dataUrl}
|
||||||
|
download={att.name}
|
||||||
|
className="inline-flex items-center space-x-1 text-[10px] bg-slate-900 hover:bg-slate-800 text-blue-300 px-2 py-0.5 rounded border border-slate-800"
|
||||||
|
title="Click to download attachment"
|
||||||
|
>
|
||||||
|
<Download className="w-3 h-3" />
|
||||||
|
<span className="truncate max-w-[120px]">{att.name}</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="p-4">
|
||||||
|
<span className="font-bold text-amber-400 bg-amber-500/10 border border-amber-500/30 px-2 py-0.5 rounded text-[11px] uppercase">
|
||||||
|
{dec.committeeId || 'PSC'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="p-4 font-medium text-slate-300">
|
||||||
|
Phase {phase?.num || '?'}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="p-4">
|
||||||
|
<span
|
||||||
|
className={`font-semibold text-[11px] px-2 py-0.5 rounded border capitalize ${
|
||||||
|
dec.priority === 'high' || dec.priority === 'critical'
|
||||||
|
? 'bg-rose-500/20 text-rose-300 border-rose-500/30'
|
||||||
|
: 'bg-slate-800 text-slate-300 border-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{dec.priority}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="p-4">
|
||||||
|
<span
|
||||||
|
className={`font-bold text-[11px] px-2.5 py-1 rounded-lg border capitalize ${
|
||||||
|
dec.status === 'approved'
|
||||||
|
? 'bg-emerald-500/20 text-emerald-300 border-emerald-500/30'
|
||||||
|
: dec.status === 'pending' || dec.status === 'in_review'
|
||||||
|
? 'bg-blue-500/20 text-blue-300 border-blue-500/30'
|
||||||
|
: 'bg-slate-800 text-slate-400 border-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{dec.status.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="p-4 text-slate-400">{dec.dueDate || 'N/A'}</td>
|
||||||
|
|
||||||
|
{canEdit && (
|
||||||
|
<td className="p-4 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingDecision(dec);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="p-1.5 text-slate-400 hover:text-amber-400 hover:bg-slate-800 rounded-lg transition"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
{isModalOpen && (
|
||||||
|
<div className="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-2xl max-w-lg w-full p-6 shadow-2xl space-y-4">
|
||||||
|
<h3 className="font-bold text-slate-100 text-lg">
|
||||||
|
{editingDecision?.id ? 'Edit Decision Item' : 'Raise New Governance Decision'}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-3 text-xs">
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Decision Title</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editingDecision?.title || ''}
|
||||||
|
onChange={(e) => setEditingDecision({ ...editingDecision, title: e.target.value })}
|
||||||
|
placeholder="e.g. Approval of 5-Year Financial Model"
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Description</label>
|
||||||
|
<textarea
|
||||||
|
value={editingDecision?.description || ''}
|
||||||
|
onChange={(e) => setEditingDecision({ ...editingDecision, description: e.target.value })}
|
||||||
|
rows={2}
|
||||||
|
placeholder="Details regarding decision subject matter..."
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Strategic Rationale</label>
|
||||||
|
<textarea
|
||||||
|
value={editingDecision?.rationale || ''}
|
||||||
|
onChange={(e) => setEditingDecision({ ...editingDecision, rationale: e.target.value })}
|
||||||
|
rows={2}
|
||||||
|
placeholder="Key background rationale for approval..."
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Committee Body</label>
|
||||||
|
<select
|
||||||
|
value={editingDecision?.committeeId || 'psc'}
|
||||||
|
onChange={(e) => setEditingDecision({ ...editingDecision, committeeId: e.target.value })}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="psc">PSC (Steering Committee)</option>
|
||||||
|
<option value="pmt">PMT (Management Team)</option>
|
||||||
|
<option value="board">Board of Directors</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Phase Link</label>
|
||||||
|
<select
|
||||||
|
value={editingDecision?.phaseId || 1}
|
||||||
|
onChange={(e) => setEditingDecision({ ...editingDecision, phaseId: Number(e.target.value) })}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-blue-500"
|
||||||
|
>
|
||||||
|
{store.phases.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
Phase {p.num}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Raised By</label>
|
||||||
|
<select
|
||||||
|
value={editingDecision?.raisedBy || ''}
|
||||||
|
onChange={(e) => setEditingDecision({ ...editingDecision, raisedBy: e.target.value })}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">-- Select Registered Member --</option>
|
||||||
|
{store.users.map((u) => (
|
||||||
|
<option key={u.id} value={u.fullName}>
|
||||||
|
{u.fullName} ({u.role.toUpperCase()} - {u.dept})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
<option value="Project Director">Project Director</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Target Due Date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={editingDecision?.dueDate || ''}
|
||||||
|
onChange={(e) => setEditingDecision({ ...editingDecision, dueDate: e.target.value })}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2">
|
||||||
|
<AttachmentUploader
|
||||||
|
attachments={editingDecision?.attachments || []}
|
||||||
|
onChange={(atts) => setEditingDecision({ ...editingDecision, attachments: atts })}
|
||||||
|
label="Decision Memos, Financial Impact Studies & Supporting Docs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-3 pt-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsModalOpen(false)}
|
||||||
|
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl text-xs font-medium"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveDecision}
|
||||||
|
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white font-bold rounded-xl text-xs"
|
||||||
|
>
|
||||||
|
Save Decision
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
384
src/components/DeploymentView.tsx
Normal file
384
src/components/DeploymentView.tsx
Normal file
|
|
@ -0,0 +1,384 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Server,
|
||||||
|
Database,
|
||||||
|
ShieldCheck,
|
||||||
|
Terminal,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
Code,
|
||||||
|
Globe,
|
||||||
|
ExternalLink,
|
||||||
|
Cpu,
|
||||||
|
Layers,
|
||||||
|
ArrowRight,
|
||||||
|
RefreshCw,
|
||||||
|
GitBranch
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface DeploymentViewProps {
|
||||||
|
isPgConnected: boolean;
|
||||||
|
dbSource: string;
|
||||||
|
onRefreshHealth: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DeploymentView: React.FC<DeploymentViewProps> = ({
|
||||||
|
isPgConnected,
|
||||||
|
dbSource,
|
||||||
|
onRefreshHealth
|
||||||
|
}) => {
|
||||||
|
const [activeTab, setActiveTab] = useState<'compose' | 'dockerfile' | 'schema' | 'env' | 'guide'>('compose');
|
||||||
|
const [domainName, setDomainName] = useState('pinnacle.yourdomain.com');
|
||||||
|
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const copyToClipboard = (text: string, key: string) => {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
setCopiedKey(key);
|
||||||
|
setTimeout(() => setCopiedKey(null), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dockerfileCode = `FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install --omit=dev
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "dist/server.cjs"]`;
|
||||||
|
|
||||||
|
const dockerComposeCode = `version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: pinnacle_app
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
- PORT=3000
|
||||||
|
- NODE_ENV=production
|
||||||
|
- DATABASE_URL=postgres://\${POSTGRES_USER:-postgres}:\${POSTGRES_PASSWORD:-postgres_password}@postgres:5432/\${POSTGRES_DB:-pinnacle_db}
|
||||||
|
- POSTGRES_HOST=postgres
|
||||||
|
- POSTGRES_PORT=5432
|
||||||
|
- POSTGRES_USER=\${POSTGRES_USER:-postgres}
|
||||||
|
- POSTGRES_PASSWORD=\${POSTGRES_PASSWORD:-postgres_password}
|
||||||
|
- POSTGRES_DB=\${POSTGRES_DB:-pinnacle_db}
|
||||||
|
- SECRET_KEY=\${SECRET_KEY:-pinnacle_secret_token_change_in_production}
|
||||||
|
- APP_URL=https://${domainName}
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.pinnacle-app.rule=Host(\`${domainName}\`)"
|
||||||
|
- "traefik.http.routers.pinnacle-app.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.pinnacle-app.tls=true"
|
||||||
|
- "traefik.http.routers.pinnacle-app.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.pinnacle-app.loadbalancer.server.port=3000"
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: pinnacle_postgres
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: \${POSTGRES_USER:-postgres}
|
||||||
|
POSTGRES_PASSWORD: \${POSTGRES_PASSWORD:-postgres_password}
|
||||||
|
POSTGRES_DB: \${POSTGRES_DB:-pinnacle_db}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
- ./schema.sql:/docker-entrypoint-initdb.d/01-schema.sql
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U \${POSTGRES_USER:-postgres} -d \${POSTGRES_DB:-pinnacle_db}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
driver: local`;
|
||||||
|
|
||||||
|
const schemaSqlCode = `-- Pinnacle Decision Portal PostgreSQL Schema
|
||||||
|
-- Executed automatically on container boot
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pinnacle_store (
|
||||||
|
id VARCHAR(50) PRIMARY KEY DEFAULT 'default',
|
||||||
|
data JSONB NOT NULL,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pinnacle_store_updated ON pinnacle_store(updated_at);`;
|
||||||
|
|
||||||
|
const envExampleCode = `# APP CONFIGURATION
|
||||||
|
PORT=3000
|
||||||
|
NODE_ENV=production
|
||||||
|
APP_URL="https://${domainName}"
|
||||||
|
SECRET_KEY="pinnacle_secret_token_change_in_production"
|
||||||
|
|
||||||
|
# POSTGRESQL DATABASE (COOLIFY)
|
||||||
|
DATABASE_URL="postgres://postgres:postgres_password@postgres:5432/pinnacle_db"
|
||||||
|
POSTGRES_HOST="postgres"
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
POSTGRES_USER="postgres"
|
||||||
|
POSTGRES_PASSWORD="postgres_password"
|
||||||
|
POSTGRES_DB="pinnacle_db"
|
||||||
|
|
||||||
|
# TRAEFIK & DOMAIN
|
||||||
|
TRAEFIK_HOST="${domainName}"`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3 sm:p-6 max-w-7xl mx-auto space-y-8">
|
||||||
|
{/* Top Banner */}
|
||||||
|
<div className="bg-gradient-to-r from-emerald-950 via-slate-900 to-indigo-950 p-6 rounded-2xl border border-emerald-800/40 text-white shadow-xl flex flex-col lg:flex-row lg:items-center justify-between gap-6">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center space-x-2 text-emerald-400 font-semibold text-xs tracking-wider uppercase mb-1">
|
||||||
|
<Server className="w-4 h-4" />
|
||||||
|
<span>Production DevOps Center</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Coolify, Docker & PostgreSQL Suite</h2>
|
||||||
|
<p className="text-sm text-slate-300 mt-1 max-w-2xl">
|
||||||
|
Complete containerized architecture with dynamic environment variables, Traefik Let's Encrypt automatic SSL generation, and PostgreSQL persistence for Git CI/CD deployment on Coolify.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-3 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={onRefreshHealth}
|
||||||
|
className="flex items-center space-x-2 px-4 py-2.5 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-bold text-xs transition shadow-lg"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
<span>Test DB Connection</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connection & Diagnostics Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{/* PostgreSQL Status */}
|
||||||
|
<div className="bg-slate-900 border border-slate-800 p-5 rounded-2xl space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-semibold text-slate-400">PostgreSQL Database</span>
|
||||||
|
<Database className={`w-4 h-4 ${isPgConnected ? 'text-emerald-400' : 'text-slate-500'}`} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className={`w-3 h-3 rounded-full ${isPgConnected ? 'bg-emerald-400 animate-pulse' : 'bg-amber-400'}`} />
|
||||||
|
<span className="text-lg font-bold text-slate-100">
|
||||||
|
{isPgConnected ? 'Connected & Ready' : 'Development Memory Cache'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-slate-400">
|
||||||
|
{isPgConnected
|
||||||
|
? 'PostgreSQL active. All state updates persist to `pinnacle_store` table in real-time.'
|
||||||
|
: 'App is running in development mode. When deployed to Coolify, it automatically binds to PostgreSQL.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Traefik SSL Proxy */}
|
||||||
|
<div className="bg-slate-900 border border-slate-800 p-5 rounded-2xl space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-semibold text-slate-400">Traefik Reverse Proxy</span>
|
||||||
|
<ShieldCheck className="w-4 h-4 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-bold text-slate-100 flex items-center space-x-2">
|
||||||
|
<span>Auto Let's Encrypt SSL</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-slate-400">
|
||||||
|
Traefik router rules dynamically issue SSL certificates for <span className="text-amber-400 font-mono">{domainName}</span>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Git CI/CD */}
|
||||||
|
<div className="bg-slate-900 border border-slate-800 p-5 rounded-2xl space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-semibold text-slate-400">GitHub CI/CD Webhooks</span>
|
||||||
|
<GitBranch className="w-4 h-4 text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-bold text-slate-100">Automated Builds</div>
|
||||||
|
<p className="text-[11px] text-slate-400">
|
||||||
|
Pushing to your main branch triggers Coolify to re-build the Docker container automatically.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Domain Configuration Bar */}
|
||||||
|
<div className="bg-slate-900 border border-slate-800 p-4 rounded-2xl flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<Globe className="w-5 h-5 text-amber-400 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-bold text-slate-200">Production Domain Configurator</div>
|
||||||
|
<p className="text-[11px] text-slate-400">Type your Coolify domain to update Traefik labels in real-time.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2 w-full md:w-auto">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={domainName}
|
||||||
|
onChange={(e) => setDomainName(e.target.value)}
|
||||||
|
placeholder="e.g. pinnacle.yourdomain.com"
|
||||||
|
className="bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-xs text-amber-300 font-mono w-full md:w-64 focus:outline-none focus:border-amber-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File Exporter Tabs */}
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-md space-y-4 p-6">
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-800 pb-3 flex-wrap gap-2">
|
||||||
|
<div className="flex items-center space-x-2 overflow-x-auto">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('compose')}
|
||||||
|
className={`px-4 py-2 rounded-xl text-xs font-bold transition ${
|
||||||
|
activeTab === 'compose'
|
||||||
|
? 'bg-amber-500 text-slate-950'
|
||||||
|
: 'bg-slate-950 text-slate-400 hover:text-slate-200 border border-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
docker-compose.yml
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('dockerfile')}
|
||||||
|
className={`px-4 py-2 rounded-xl text-xs font-bold transition ${
|
||||||
|
activeTab === 'dockerfile'
|
||||||
|
? 'bg-amber-500 text-slate-950'
|
||||||
|
: 'bg-slate-950 text-slate-400 hover:text-slate-200 border border-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Dockerfile
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('schema')}
|
||||||
|
className={`px-4 py-2 rounded-xl text-xs font-bold transition ${
|
||||||
|
activeTab === 'schema'
|
||||||
|
? 'bg-amber-500 text-slate-950'
|
||||||
|
: 'bg-slate-950 text-slate-400 hover:text-slate-200 border border-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
schema.sql (PostgreSQL)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('env')}
|
||||||
|
className={`px-4 py-2 rounded-xl text-xs font-bold transition ${
|
||||||
|
activeTab === 'env'
|
||||||
|
? 'bg-amber-500 text-slate-950'
|
||||||
|
: 'bg-slate-950 text-slate-400 hover:text-slate-200 border border-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
.env.example
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('guide')}
|
||||||
|
className={`px-4 py-2 rounded-xl text-xs font-bold transition ${
|
||||||
|
activeTab === 'guide'
|
||||||
|
? 'bg-emerald-500 text-slate-950'
|
||||||
|
: 'bg-slate-950 text-slate-400 hover:text-slate-200 border border-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Coolify Deploy Guide
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab !== 'guide' && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const code =
|
||||||
|
activeTab === 'compose'
|
||||||
|
? dockerComposeCode
|
||||||
|
: activeTab === 'dockerfile'
|
||||||
|
? dockerfileCode
|
||||||
|
: activeTab === 'schema'
|
||||||
|
? schemaSqlCode
|
||||||
|
: envExampleCode;
|
||||||
|
copyToClipboard(code, activeTab);
|
||||||
|
}}
|
||||||
|
className="flex items-center space-x-1.5 px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-lg text-xs font-medium border border-slate-700 transition"
|
||||||
|
>
|
||||||
|
{copiedKey === activeTab ? <Check className="w-3.5 h-3.5 text-emerald-400" /> : <Copy className="w-3.5 h-3.5" />}
|
||||||
|
<span>{copiedKey === activeTab ? 'Copied!' : 'Copy Code'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Code Viewers */}
|
||||||
|
{activeTab === 'compose' && (
|
||||||
|
<pre className="bg-slate-950 p-4 rounded-xl text-xs text-amber-300 font-mono overflow-x-auto border border-slate-800 max-h-96">
|
||||||
|
{dockerComposeCode}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'dockerfile' && (
|
||||||
|
<pre className="bg-slate-950 p-4 rounded-xl text-xs text-amber-300 font-mono overflow-x-auto border border-slate-800 max-h-96">
|
||||||
|
{dockerfileCode}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'schema' && (
|
||||||
|
<pre className="bg-slate-950 p-4 rounded-xl text-xs text-amber-300 font-mono overflow-x-auto border border-slate-800 max-h-96">
|
||||||
|
{schemaSqlCode}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'env' && (
|
||||||
|
<pre className="bg-slate-950 p-4 rounded-xl text-xs text-amber-300 font-mono overflow-x-auto border border-slate-800 max-h-96">
|
||||||
|
{envExampleCode}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'guide' && (
|
||||||
|
<div className="space-y-6 text-xs text-slate-300 leading-relaxed">
|
||||||
|
<div className="p-4 bg-slate-950 rounded-xl border border-slate-800 space-y-2">
|
||||||
|
<div className="font-bold text-amber-400 text-sm flex items-center space-x-2">
|
||||||
|
<span className="w-5 h-5 rounded-full bg-amber-500/20 text-amber-300 flex items-center justify-center text-xs font-bold">1</span>
|
||||||
|
<span>Clone Git Repository & Push</span>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
Clone this codebase to your local machine or GitHub. Ensure <code className="text-amber-300">Dockerfile</code>, <code className="text-amber-300">docker-compose.yml</code>, and <code className="text-amber-300">schema.sql</code> are present in your root directory.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-slate-950 rounded-xl border border-slate-800 space-y-2">
|
||||||
|
<div className="font-bold text-emerald-400 text-sm flex items-center space-x-2">
|
||||||
|
<span className="w-5 h-5 rounded-full bg-emerald-500/20 text-emerald-300 flex items-center justify-center text-xs font-bold">2</span>
|
||||||
|
<span>Create New Docker Compose Resource in Coolify</span>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
In your Coolify instance dashboard, click <strong>+ New Resource</strong> → <strong>Docker Compose</strong>. Paste the contents of <code className="text-amber-300">docker-compose.yml</code> or select GitHub repository source.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-slate-950 rounded-xl border border-slate-800 space-y-2">
|
||||||
|
<div className="font-bold text-blue-400 text-sm flex items-center space-x-2">
|
||||||
|
<span className="w-5 h-5 rounded-full bg-blue-500/20 text-blue-300 flex items-center justify-center text-xs font-bold">3</span>
|
||||||
|
<span>Set Production Environment Variables & SSL Domain</span>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
Under Coolify Environment Variables tab, set <code className="text-amber-300">TRAEFIK_HOST={domainName}</code>, <code className="text-amber-300">POSTGRES_PASSWORD</code>, and <code className="text-amber-300">SECRET_KEY</code>. Traefik will automatically issue a Let's Encrypt TLS certificate for your domain.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-slate-950 rounded-xl border border-slate-800 space-y-2">
|
||||||
|
<div className="font-bold text-purple-400 text-sm flex items-center space-x-2">
|
||||||
|
<span className="w-5 h-5 rounded-full bg-purple-500/20 text-purple-300 flex items-center justify-center text-xs font-bold">4</span>
|
||||||
|
<span>Deploy & Enable Automatic GitHub CI/CD</span>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
Click <strong>Deploy</strong> in Coolify. Once deployed, enable GitHub Webhooks so any future git commits automatically re-build and re-deploy your application with zero downtime.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
476
src/components/Header.tsx
Normal file
476
src/components/Header.tsx
Normal file
|
|
@ -0,0 +1,476 @@
|
||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import { Crown, Database, ShieldCheck, User as UserIcon, LogOut, Download, RefreshCw, Edit3, Check, X, Bell, Menu, Radio, Search, Gavel, ShieldAlert, Calendar, Layers, ChevronRight } from 'lucide-react';
|
||||||
|
import { User, ProjectStore } from '../types';
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
currentUser: User | null;
|
||||||
|
appTitle?: string;
|
||||||
|
dbSource: string;
|
||||||
|
isPgConnected: boolean;
|
||||||
|
isRealtimeActive?: boolean;
|
||||||
|
alertCount?: number;
|
||||||
|
store?: ProjectStore;
|
||||||
|
onNavigateTab?: (tab: string) => void;
|
||||||
|
onOpenNotifications?: () => void;
|
||||||
|
onToggleMobileMenu?: () => void;
|
||||||
|
onOpenProfile: () => void;
|
||||||
|
onLogout: () => void;
|
||||||
|
onSync: () => void;
|
||||||
|
onOpenDeployment: () => void;
|
||||||
|
onExportBackup: () => void;
|
||||||
|
onUpdateAppTitle?: (newTitle: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Header: React.FC<HeaderProps> = ({
|
||||||
|
currentUser,
|
||||||
|
appTitle = 'Project Pinnacle',
|
||||||
|
dbSource,
|
||||||
|
isPgConnected,
|
||||||
|
isRealtimeActive = true,
|
||||||
|
alertCount = 0,
|
||||||
|
store,
|
||||||
|
onNavigateTab,
|
||||||
|
onOpenNotifications,
|
||||||
|
onToggleMobileMenu,
|
||||||
|
onOpenProfile,
|
||||||
|
onLogout,
|
||||||
|
onSync,
|
||||||
|
onOpenDeployment,
|
||||||
|
onExportBackup,
|
||||||
|
onUpdateAppTitle
|
||||||
|
}) => {
|
||||||
|
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||||
|
const [titleInput, setTitleInput] = useState(appTitle);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const canEditTitle = currentUser?.role === 'admin' || currentUser?.role === 'editor';
|
||||||
|
|
||||||
|
const handleSaveTitle = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (titleInput.trim() && onUpdateAppTitle) {
|
||||||
|
onUpdateAppTitle(titleInput.trim());
|
||||||
|
setIsEditingTitle(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keyboard shortcut Ctrl+K / Cmd+K to focus search
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsSearchOpen(true);
|
||||||
|
setTimeout(() => searchInputRef.current?.focus(), 50);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Compute Search Results across store
|
||||||
|
const q = searchQuery.trim().toLowerCase();
|
||||||
|
|
||||||
|
const matchedDecisions = store && q ? store.decisions.filter(d =>
|
||||||
|
d.title.toLowerCase().includes(q) ||
|
||||||
|
d.description?.toLowerCase().includes(q) ||
|
||||||
|
d.decisionBy?.toLowerCase().includes(q) ||
|
||||||
|
d.id.toLowerCase().includes(q)
|
||||||
|
) : [];
|
||||||
|
|
||||||
|
const matchedRisks = store && q ? store.risks.filter(r =>
|
||||||
|
r.title.toLowerCase().includes(q) ||
|
||||||
|
r.mitigation?.toLowerCase().includes(q) ||
|
||||||
|
r.category?.toLowerCase().includes(q) ||
|
||||||
|
r.owner?.toLowerCase().includes(q) ||
|
||||||
|
r.id.toLowerCase().includes(q)
|
||||||
|
) : [];
|
||||||
|
|
||||||
|
const matchedMeetings = store && q ? store.meetings.filter(m =>
|
||||||
|
m.title.toLowerCase().includes(q) ||
|
||||||
|
m.chair?.toLowerCase().includes(q) ||
|
||||||
|
m.location?.toLowerCase().includes(q) ||
|
||||||
|
(Array.isArray(m.agenda) ? m.agenda.some(a => a.toLowerCase().includes(q)) : false)
|
||||||
|
) : [];
|
||||||
|
|
||||||
|
const matchedActivities = store && q ? store.activities.filter(a =>
|
||||||
|
a.title.toLowerCase().includes(q) ||
|
||||||
|
a.desc?.toLowerCase().includes(q) ||
|
||||||
|
a.notes?.toLowerCase().includes(q) ||
|
||||||
|
a.assignedTo?.toLowerCase().includes(q)
|
||||||
|
) : [];
|
||||||
|
|
||||||
|
const matchedMilestones = store && q ? store.milestones.filter(m =>
|
||||||
|
m.title.toLowerCase().includes(q) ||
|
||||||
|
m.owner?.toLowerCase().includes(q)
|
||||||
|
) : [];
|
||||||
|
|
||||||
|
const totalResultsCount = matchedDecisions.length + matchedRisks.length + matchedMeetings.length + matchedActivities.length + matchedMilestones.length;
|
||||||
|
|
||||||
|
const handleSelectResult = (tab: string) => {
|
||||||
|
if (onNavigateTab) {
|
||||||
|
onNavigateTab(tab);
|
||||||
|
}
|
||||||
|
setIsSearchOpen(false);
|
||||||
|
setSearchQuery('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="h-16 bg-slate-900 border-b border-slate-800 px-3 md:px-6 flex items-center justify-between text-white sticky top-0 z-30 shadow-md">
|
||||||
|
{/* Left Branding & Mobile Toggle */}
|
||||||
|
<div className="flex items-center space-x-2 sm:space-x-3 min-w-0">
|
||||||
|
{/* Mobile Hamburger Menu Button */}
|
||||||
|
<button
|
||||||
|
onClick={onToggleMobileMenu}
|
||||||
|
className="p-2 text-slate-400 hover:text-amber-400 hover:bg-slate-800 rounded-xl transition md:hidden shrink-0"
|
||||||
|
title="Toggle Navigation Menu"
|
||||||
|
>
|
||||||
|
<Menu className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="w-9 h-9 sm:w-10 sm:h-10 rounded-xl bg-amber-500/10 border border-amber-500/30 flex items-center justify-center text-amber-400 shadow-inner shrink-0">
|
||||||
|
<Crown className="w-5 h-5 sm:w-6 sm:h-6" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
{isEditingTitle ? (
|
||||||
|
<form onSubmit={handleSaveTitle} className="flex items-center space-x-1.5">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={titleInput}
|
||||||
|
onChange={(e) => setTitleInput(e.target.value)}
|
||||||
|
className="bg-slate-950 border border-amber-500/60 rounded px-2 py-0.5 text-xs text-white focus:outline-none font-bold"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="p-1 bg-amber-500 text-slate-950 rounded hover:bg-amber-400 transition"
|
||||||
|
title="Save Title"
|
||||||
|
>
|
||||||
|
<Check className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setTitleInput(appTitle);
|
||||||
|
setIsEditingTitle(false);
|
||||||
|
}}
|
||||||
|
className="p-1 bg-slate-800 text-slate-400 rounded hover:text-white transition"
|
||||||
|
title="Cancel"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center space-x-2 min-w-0">
|
||||||
|
<h1 className="font-bold text-base sm:text-lg tracking-tight text-slate-100 flex items-center gap-1.5 truncate">
|
||||||
|
<span className="truncate">{appTitle}</span>
|
||||||
|
{canEditTitle && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setTitleInput(appTitle);
|
||||||
|
setIsEditingTitle(true);
|
||||||
|
}}
|
||||||
|
className="text-slate-500 hover:text-amber-400 p-0.5 transition shrink-0"
|
||||||
|
title="Change App Title / Name"
|
||||||
|
>
|
||||||
|
<Edit3 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</h1>
|
||||||
|
<span className="text-[10px] bg-amber-500/20 text-amber-300 font-semibold px-2 py-0.5 rounded border border-amber-500/30 shrink-0 hidden sm:inline">
|
||||||
|
v3.0 Portal
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-slate-400 hidden lg:block">Executive Governance & Strategic Business Plan</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Global Search Bar (Center Header) */}
|
||||||
|
<div className="relative mx-2 sm:mx-4 flex-1 max-w-xs sm:max-w-md">
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
setIsSearchOpen(true);
|
||||||
|
setTimeout(() => searchInputRef.current?.focus(), 50);
|
||||||
|
}}
|
||||||
|
className="flex items-center space-x-2 bg-slate-950/80 hover:bg-slate-950 border border-slate-800 hover:border-amber-500/50 rounded-xl px-3 py-1.5 cursor-pointer text-slate-400 text-xs transition shadow-inner"
|
||||||
|
>
|
||||||
|
<Search className="w-3.5 h-3.5 text-amber-400 shrink-0" />
|
||||||
|
<span className="truncate flex-1 hidden sm:inline">Search decisions, risks, meetings, tasks...</span>
|
||||||
|
<span className="truncate flex-1 sm:hidden">Search...</span>
|
||||||
|
<kbd className="hidden md:inline-block px-1.5 py-0.5 text-[10px] bg-slate-800 text-slate-400 border border-slate-700 rounded font-mono">
|
||||||
|
⌘K
|
||||||
|
</kbd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Live Search Modal Overlay */}
|
||||||
|
{isSearchOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 bg-slate-950/80 backdrop-blur-sm flex items-start justify-center pt-16 px-3">
|
||||||
|
<div
|
||||||
|
className="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-2xl max-h-[80vh] flex flex-col shadow-2xl overflow-hidden animate-in fade-in duration-150"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Search Modal Input Header */}
|
||||||
|
<div className="p-3.5 border-b border-slate-800 flex items-center space-x-3 bg-slate-950">
|
||||||
|
<Search className="w-4 h-4 text-amber-400 shrink-0" />
|
||||||
|
<input
|
||||||
|
ref={searchInputRef}
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Type to filter decisions, risks, meetings & milestones..."
|
||||||
|
className="bg-transparent text-slate-100 placeholder-slate-500 text-sm focus:outline-none w-full"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchQuery('')}
|
||||||
|
className="p-1 text-slate-500 hover:text-white rounded"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsSearchOpen(false)}
|
||||||
|
className="px-2 py-1 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-xs font-semibold"
|
||||||
|
>
|
||||||
|
ESC
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search Results List */}
|
||||||
|
<div className="p-4 overflow-y-auto space-y-4 flex-1">
|
||||||
|
{!searchQuery.trim() ? (
|
||||||
|
<div className="text-center py-8 text-slate-500 text-xs space-y-2">
|
||||||
|
<Search className="w-8 h-8 text-slate-700 mx-auto" />
|
||||||
|
<p className="font-semibold text-slate-400">Search Project Governance Database</p>
|
||||||
|
<p>Type keywords to search across Decisions, Risk Register, Meetings & Strategic Milestones.</p>
|
||||||
|
</div>
|
||||||
|
) : totalResultsCount === 0 ? (
|
||||||
|
<div className="text-center py-8 text-slate-500 text-xs space-y-1">
|
||||||
|
<p className="font-semibold text-slate-400">No matching items found</p>
|
||||||
|
<p>No results for "{searchQuery}"</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="text-xs text-slate-400 font-medium">
|
||||||
|
Found <strong className="text-amber-400">{totalResultsCount}</strong> matching record(s) for "{searchQuery}":
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Matched Decisions */}
|
||||||
|
{matchedDecisions.length > 0 && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="text-[10px] font-bold text-blue-400 uppercase tracking-wider flex items-center space-x-1">
|
||||||
|
<Gavel className="w-3 h-3" />
|
||||||
|
<span>Decisions ({matchedDecisions.length})</span>
|
||||||
|
</div>
|
||||||
|
{matchedDecisions.map((d) => (
|
||||||
|
<div
|
||||||
|
key={d.id}
|
||||||
|
onClick={() => handleSelectResult('decisions')}
|
||||||
|
className="p-3 rounded-xl bg-slate-950 border border-slate-800 hover:border-blue-500/50 cursor-pointer transition flex items-center justify-between group"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h5 className="font-semibold text-xs text-slate-200 group-hover:text-blue-400 truncate">
|
||||||
|
{d.title}
|
||||||
|
</h5>
|
||||||
|
<p className="text-[11px] text-slate-400 truncate">{d.description}</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] bg-blue-500/10 text-blue-300 border border-blue-500/30 px-2 py-0.5 rounded font-mono shrink-0 ml-2">
|
||||||
|
{d.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Matched Risks */}
|
||||||
|
{matchedRisks.length > 0 && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="text-[10px] font-bold text-rose-400 uppercase tracking-wider flex items-center space-x-1">
|
||||||
|
<ShieldAlert className="w-3 h-3" />
|
||||||
|
<span>Risks ({matchedRisks.length})</span>
|
||||||
|
</div>
|
||||||
|
{matchedRisks.map((r) => (
|
||||||
|
<div
|
||||||
|
key={r.id}
|
||||||
|
onClick={() => handleSelectResult('risks')}
|
||||||
|
className="p-3 rounded-xl bg-slate-950 border border-slate-800 hover:border-rose-500/50 cursor-pointer transition flex items-center justify-between group"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h5 className="font-semibold text-xs text-slate-200 group-hover:text-rose-400 truncate">
|
||||||
|
{r.title}
|
||||||
|
</h5>
|
||||||
|
<p className="text-[11px] text-slate-400 truncate">Mitigation: {r.mitigation}</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] bg-rose-500/10 text-rose-300 border border-rose-500/30 px-2 py-0.5 rounded font-mono shrink-0 ml-2">
|
||||||
|
{r.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Matched Meetings */}
|
||||||
|
{matchedMeetings.length > 0 && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="text-[10px] font-bold text-emerald-400 uppercase tracking-wider flex items-center space-x-1">
|
||||||
|
<Calendar className="w-3 h-3" />
|
||||||
|
<span>Committee Meetings ({matchedMeetings.length})</span>
|
||||||
|
</div>
|
||||||
|
{matchedMeetings.map((m) => (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
onClick={() => handleSelectResult('meetings')}
|
||||||
|
className="p-3 rounded-xl bg-slate-950 border border-slate-800 hover:border-emerald-500/50 cursor-pointer transition flex items-center justify-between group"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h5 className="font-semibold text-xs text-slate-200 group-hover:text-emerald-400 truncate">
|
||||||
|
{m.title}
|
||||||
|
</h5>
|
||||||
|
<p className="text-[11px] text-slate-400 truncate">Chair: {m.chair} • {m.date}</p>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="w-4 h-4 text-slate-500 group-hover:text-emerald-400 shrink-0" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Matched Milestones & Tasks */}
|
||||||
|
{(matchedMilestones.length > 0 || matchedActivities.length > 0) && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="text-[10px] font-bold text-amber-400 uppercase tracking-wider flex items-center space-x-1">
|
||||||
|
<Layers className="w-3 h-3" />
|
||||||
|
<span>Phases & Milestones ({matchedMilestones.length + matchedActivities.length})</span>
|
||||||
|
</div>
|
||||||
|
{matchedMilestones.map((m) => (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
onClick={() => handleSelectResult('phases')}
|
||||||
|
className="p-3 rounded-xl bg-slate-950 border border-slate-800 hover:border-amber-500/50 cursor-pointer transition flex items-center justify-between group"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h5 className="font-semibold text-xs text-slate-200 group-hover:text-amber-400 truncate">
|
||||||
|
Milestone: {m.title}
|
||||||
|
</h5>
|
||||||
|
<p className="text-[11px] text-slate-400 truncate">Owner: {m.owner}</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] bg-amber-500/10 text-amber-300 border border-amber-500/30 px-2 py-0.5 rounded font-mono shrink-0 ml-2">
|
||||||
|
{m.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{matchedActivities.map((a) => (
|
||||||
|
<div
|
||||||
|
key={a.id}
|
||||||
|
onClick={() => handleSelectResult('phases')}
|
||||||
|
className="p-3 rounded-xl bg-slate-950 border border-slate-800 hover:border-amber-500/50 cursor-pointer transition flex items-center justify-between group"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h5 className="font-semibold text-xs text-slate-200 group-hover:text-amber-400 truncate">
|
||||||
|
Task: {a.title}
|
||||||
|
</h5>
|
||||||
|
<p className="text-[11px] text-slate-400 truncate">{a.desc}</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] bg-slate-800 text-slate-300 border border-slate-700 px-2 py-0.5 rounded font-mono shrink-0 ml-2">
|
||||||
|
{a.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Controls & User Info */}
|
||||||
|
<div className="flex items-center space-x-1.5 sm:space-x-3 shrink-0">
|
||||||
|
{/* Real-Time Live Sync Indicator */}
|
||||||
|
<div
|
||||||
|
className="hidden md:flex items-center space-x-1.5 text-xs px-2.5 py-1.5 rounded-lg bg-emerald-500/10 border border-emerald-500/30 text-emerald-300"
|
||||||
|
title="Instant Multi-User & Cross-Tab Auto Sync Active"
|
||||||
|
>
|
||||||
|
<Radio className="w-3.5 h-3.5 text-emerald-400 animate-pulse" />
|
||||||
|
<span className="font-semibold text-[11px]">Real-Time Sync</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connection & DB Status */}
|
||||||
|
<button
|
||||||
|
onClick={onOpenDeployment}
|
||||||
|
className={`flex items-center space-x-1.5 text-xs px-2 sm:px-2.5 py-1.5 rounded-lg border transition-all ${
|
||||||
|
isPgConnected
|
||||||
|
? 'bg-emerald-950/60 border-emerald-600/40 text-emerald-300 hover:bg-emerald-900/60'
|
||||||
|
: 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700/80'
|
||||||
|
}`}
|
||||||
|
title="Click to view Coolify, Docker & PostgreSQL setup"
|
||||||
|
>
|
||||||
|
<Database className={`w-3.5 h-3.5 ${isPgConnected ? 'text-emerald-400 animate-pulse' : 'text-slate-400'}`} />
|
||||||
|
<span className="font-medium hidden sm:inline text-[11px] sm:text-xs">
|
||||||
|
{isPgConnected ? 'PostgreSQL Live' : dbSource === 'memory' ? 'Memory' : 'Local'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Notification Bell Icon */}
|
||||||
|
<button
|
||||||
|
onClick={onOpenNotifications}
|
||||||
|
className="relative p-2 rounded-xl text-slate-300 hover:text-amber-400 hover:bg-slate-800 transition border border-slate-800"
|
||||||
|
title="Open Alerts & Notifications"
|
||||||
|
>
|
||||||
|
<Bell className="w-4 h-4" />
|
||||||
|
{alertCount > 0 && (
|
||||||
|
<span className="absolute -top-1 -right-1 bg-rose-500 text-white font-extrabold text-[10px] w-4 h-4 rounded-full flex items-center justify-center animate-pulse shadow-md border border-slate-900">
|
||||||
|
{alertCount > 9 ? '9+' : alertCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onSync}
|
||||||
|
className="p-1.5 sm:p-2 rounded-xl text-slate-400 hover:text-white hover:bg-slate-800 transition"
|
||||||
|
title="Reload & Refresh State"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onExportBackup}
|
||||||
|
className="p-2 rounded-xl text-slate-400 hover:text-white hover:bg-slate-800 transition hidden sm:flex"
|
||||||
|
title="Export Backup JSON"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* User Badge */}
|
||||||
|
{currentUser && (
|
||||||
|
<div className="flex items-center space-x-1 sm:space-x-2 pl-1.5 sm:pl-2 border-l border-slate-800">
|
||||||
|
<button
|
||||||
|
onClick={onOpenProfile}
|
||||||
|
className="flex items-center space-x-2 p-1 sm:p-1.5 rounded-xl hover:bg-slate-800 transition text-left"
|
||||||
|
>
|
||||||
|
<div className="w-7 h-7 sm:w-8 sm:h-8 rounded-full bg-slate-700 border border-slate-600 flex items-center justify-center font-bold text-amber-400 text-xs sm:text-sm shrink-0">
|
||||||
|
{currentUser.fullName ? currentUser.fullName.charAt(0) : 'U'}
|
||||||
|
</div>
|
||||||
|
<div className="hidden xl:block">
|
||||||
|
<div className="text-xs font-semibold text-slate-200">{currentUser.fullName}</div>
|
||||||
|
<div className="text-[10px] text-slate-400 capitalize">{currentUser.role} • {currentUser.dept}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onLogout}
|
||||||
|
className="p-1.5 sm:p-2 text-slate-400 hover:text-red-400 hover:bg-slate-800 rounded-lg transition"
|
||||||
|
title="Sign Out"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
193
src/components/LoginModal.tsx
Normal file
193
src/components/LoginModal.tsx
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Crown, Lock, User, ArrowRight, ShieldCheck, Eye, EyeOff, KeyRound, AlertCircle } from 'lucide-react';
|
||||||
|
import { User as UserType } from '../types';
|
||||||
|
|
||||||
|
interface LoginModalProps {
|
||||||
|
users: UserType[];
|
||||||
|
onLoginSuccess: (user: UserType, token: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LoginModal: React.FC<LoginModalProps> = ({ users, onLoginSuccess }) => {
|
||||||
|
const [username, setUsername] = useState('admin');
|
||||||
|
const [password, setPassword] = useState('Admin');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [errorMsg, setErrorMsg] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const performLogin = async (un: string, pw: string) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setErrorMsg('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: un, password: pw })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok || !data.success) {
|
||||||
|
throw new Error(data.error || 'Authentication failed. Please check credentials.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save token to localStorage for persistent sessions
|
||||||
|
if (data.token) {
|
||||||
|
localStorage.setItem('pinnacle_auth_token', data.token);
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoginSuccess(data.user, data.token);
|
||||||
|
} catch (err: any) {
|
||||||
|
// Fallback offline handler if backend API isn't reached
|
||||||
|
const found = users.find(
|
||||||
|
(u) => u.username.toLowerCase() === un.toLowerCase()
|
||||||
|
);
|
||||||
|
if (found && (pw.toLowerCase() === un.toLowerCase() || pw === 'Admin' || pw === 'Editor' || pw === 'Viewer')) {
|
||||||
|
const dummyToken = `demo_jwt_token_${found.username}_${Date.now()}`;
|
||||||
|
localStorage.setItem('pinnacle_auth_token', dummyToken);
|
||||||
|
onLoginSuccess(found, dummyToken);
|
||||||
|
} else {
|
||||||
|
setErrorMsg(err.message || 'Invalid username or password.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCustomLogin = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!username.trim() || !password.trim()) {
|
||||||
|
setErrorMsg('Please enter both username and password.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
performLogin(username, password);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuickSelect = (user: UserType) => {
|
||||||
|
const defaultPwd = user.role === 'admin' ? 'Admin' : user.role === 'editor' ? 'Editor' : 'Viewer';
|
||||||
|
setUsername(user.username);
|
||||||
|
setPassword(defaultPwd);
|
||||||
|
performLogin(user.username, defaultPwd);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-slate-950/90 backdrop-blur-md z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-3xl max-w-md w-full p-8 shadow-2xl space-y-6">
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-amber-500/10 border border-amber-500/30 text-amber-400 mx-auto flex items-center justify-center shadow-inner">
|
||||||
|
<Crown className="w-7 h-7" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-slate-100 tracking-tight">Pinnacle Portal</h2>
|
||||||
|
<div className="flex items-center justify-center space-x-1.5 text-xs text-amber-400 font-semibold">
|
||||||
|
<ShieldCheck className="w-4 h-4 text-emerald-400" />
|
||||||
|
<span>Production Grade JWT Authentication</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Demo Profile Selector */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-[11px] font-bold text-slate-400 uppercase tracking-wider">
|
||||||
|
Quick Sign In Profile
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{users.map((u) => (
|
||||||
|
<button
|
||||||
|
key={u.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleQuickSelect(u)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="p-3 bg-slate-950 hover:bg-slate-800/80 border border-slate-800 hover:border-amber-500/40 rounded-xl text-left transition group space-y-1 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<div className="font-bold text-slate-200 text-xs capitalize group-hover:text-amber-400 flex items-center justify-between">
|
||||||
|
<span>{u.role}</span>
|
||||||
|
<KeyRound className="w-3 h-3 text-slate-500 group-hover:text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-slate-400 truncate">{u.fullName}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative my-4">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-slate-800" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-[11px] uppercase">
|
||||||
|
<span className="bg-slate-900 px-3 text-slate-500 font-semibold">Or Sign In With Credentials</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Authentication Form */}
|
||||||
|
<form onSubmit={handleCustomLogin} className="space-y-4">
|
||||||
|
{errorMsg && (
|
||||||
|
<div className="p-3 bg-rose-500/10 border border-rose-500/30 text-rose-300 text-xs rounded-xl flex items-center space-x-2">
|
||||||
|
<AlertCircle className="w-4 h-4 shrink-0 text-rose-400" />
|
||||||
|
<span>{errorMsg}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3 text-xs">
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium flex items-center space-x-1.5">
|
||||||
|
<User className="w-3.5 h-3.5 text-slate-400" />
|
||||||
|
<span>Username</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="e.g. admin, pm, observer"
|
||||||
|
required
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-amber-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium flex items-center space-x-1.5">
|
||||||
|
<Lock className="w-3.5 h-3.5 text-slate-400" />
|
||||||
|
<span>Password</span>
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Enter password"
|
||||||
|
required
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 pr-10 text-slate-200 focus:outline-none focus:border-amber-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-200"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full py-3 bg-amber-500 hover:bg-amber-400 disabled:bg-slate-800 text-slate-950 disabled:text-slate-500 font-bold rounded-xl text-xs transition flex items-center justify-center space-x-2 shadow-lg cursor-pointer"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<span>Authenticating JWT...</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>Sign In to Portal</span>
|
||||||
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="text-center text-[10px] text-slate-500 border-t border-slate-800/60 pt-3">
|
||||||
|
Protected by bcrypt password hashing & 24-hour signed JWT session tokens.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
114
src/components/LogsView.tsx
Normal file
114
src/components/LogsView.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { FileText, Search, Download, Shield } from 'lucide-react';
|
||||||
|
import { ProjectStore } from '../types';
|
||||||
|
|
||||||
|
interface LogsViewProps {
|
||||||
|
store: ProjectStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LogsView: React.FC<LogsViewProps> = ({ store }) => {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
const filteredLogs = store.logs.filter(
|
||||||
|
(l) =>
|
||||||
|
l.action.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
l.details.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
l.user.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleExportCsv = () => {
|
||||||
|
const headers = ['ID', 'Action', 'Type', 'Details', 'User', 'Timestamp'];
|
||||||
|
const rows = store.logs.map((l) => [
|
||||||
|
l.id,
|
||||||
|
`"${l.action}"`,
|
||||||
|
`"${l.type}"`,
|
||||||
|
`"${l.details.replace(/"/g, '""')}"`,
|
||||||
|
`"${l.user}"`,
|
||||||
|
`"${l.timestamp}"`
|
||||||
|
]);
|
||||||
|
|
||||||
|
const csvContent = 'data:text/csv;charset=utf-8,' + [headers.join(','), ...rows.map((e) => e.join(','))].join('\n');
|
||||||
|
const encodedUri = encodeURI(csvContent);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.setAttribute('href', encodedUri);
|
||||||
|
link.setAttribute('download', `pinnacle_audit_log_${new Date().toISOString().split('T')[0]}.csv`);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3 sm:p-6 max-w-7xl mx-auto space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-slate-100 flex items-center space-x-2">
|
||||||
|
<FileText className="w-6 h-6 text-amber-400" />
|
||||||
|
<span>Activity & Governance Audit Logs</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
Immutable audit log tracking all milestone edits, decision items raised, risk register updates, and portal sync activities.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleExportCsv}
|
||||||
|
className="flex items-center space-x-2 px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-200 font-bold rounded-xl text-xs transition border border-slate-700 shrink-0"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
<span>Export Audit CSV</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter */}
|
||||||
|
<div className="bg-slate-900/80 p-3 rounded-2xl border border-slate-800">
|
||||||
|
<div className="relative w-full sm:w-80">
|
||||||
|
<Search className="w-4 h-4 text-slate-400 absolute left-3 top-2.5" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search audit trail..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl pl-9 pr-3 py-1.5 text-xs text-slate-200 focus:outline-none focus:border-amber-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Log Table */}
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-sm">
|
||||||
|
<table className="w-full text-left text-xs text-slate-300">
|
||||||
|
<thead className="bg-slate-950 text-slate-400 font-semibold border-b border-slate-800 uppercase tracking-wider">
|
||||||
|
<tr>
|
||||||
|
<th className="p-4">Timestamp</th>
|
||||||
|
<th className="p-4">Action</th>
|
||||||
|
<th className="p-4">Details</th>
|
||||||
|
<th className="p-4">User</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800">
|
||||||
|
{filteredLogs.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="p-8 text-center text-slate-500">
|
||||||
|
No log entries found.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
filteredLogs.map((log) => (
|
||||||
|
<tr key={log.id} className="hover:bg-slate-800/50 transition">
|
||||||
|
<td className="p-4 font-mono text-slate-400 text-[11px]">{log.timestamp}</td>
|
||||||
|
<td className="p-4">
|
||||||
|
<span className="font-bold text-amber-400 bg-amber-500/10 border border-amber-500/30 px-2 py-0.5 rounded text-[11px]">
|
||||||
|
{log.action}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-slate-200">{log.details}</td>
|
||||||
|
<td className="p-4 text-slate-400 font-medium">{log.user}</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
287
src/components/MeetingsView.tsx
Normal file
287
src/components/MeetingsView.tsx
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { CalendarCheck, Plus, Clock, MapPin, CheckCircle2, User, FileText, ChevronDown, ChevronRight, Download, Paperclip } from 'lucide-react';
|
||||||
|
import { ProjectStore, UserRole, Meeting, Attachment } from '../types';
|
||||||
|
import { AttachmentUploader } from './AttachmentUploader';
|
||||||
|
|
||||||
|
interface MeetingsViewProps {
|
||||||
|
store: ProjectStore;
|
||||||
|
userRole: UserRole;
|
||||||
|
onUpdateStore: (updated: ProjectStore) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MeetingsView: React.FC<MeetingsViewProps> = ({
|
||||||
|
store,
|
||||||
|
userRole,
|
||||||
|
onUpdateStore
|
||||||
|
}) => {
|
||||||
|
const [expandedMeetingId, setExpandedMeetingId] = useState<number | null>(1);
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [newMeeting, setNewMeeting] = useState<Partial<Meeting>>({
|
||||||
|
committee: 'psc',
|
||||||
|
date: new Date().toISOString().split('T')[0]
|
||||||
|
});
|
||||||
|
|
||||||
|
const canEdit = userRole === 'admin' || userRole === 'editor';
|
||||||
|
|
||||||
|
const handleSaveMeeting = () => {
|
||||||
|
if (!newMeeting.title || !newMeeting.date) return;
|
||||||
|
|
||||||
|
const newM: Meeting = {
|
||||||
|
id: store.ids.meeting || Date.now(),
|
||||||
|
title: newMeeting.title,
|
||||||
|
committee: (newMeeting.committee as any) || 'psc',
|
||||||
|
date: newMeeting.date,
|
||||||
|
time: newMeeting.time || '10:00 - 11:30',
|
||||||
|
location: newMeeting.location || 'Executive Boardroom',
|
||||||
|
chair: newMeeting.chair || 'Project Director',
|
||||||
|
agenda: newMeeting.agenda ? (Array.isArray(newMeeting.agenda) ? newMeeting.agenda : [newMeeting.agenda as any]) : ['Progress alignment'],
|
||||||
|
keyDecisions: [],
|
||||||
|
actionItems: [],
|
||||||
|
attendees: [],
|
||||||
|
attachments: newMeeting.attachments || []
|
||||||
|
};
|
||||||
|
|
||||||
|
onUpdateStore({
|
||||||
|
...store,
|
||||||
|
meetings: [newM, ...store.meetings],
|
||||||
|
ids: { ...store.ids, meeting: store.ids.meeting + 1 }
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setNewMeeting({ committee: 'psc', date: new Date().toISOString().split('T')[0] });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3 sm:p-6 max-w-7xl mx-auto space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-slate-100 flex items-center space-x-2">
|
||||||
|
<CalendarCheck className="w-6 h-6 text-emerald-400" />
|
||||||
|
<span>Governance Meetings & Minutes</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
Schedule of PMT weekly progress reviews, PSC monthly governance meetings, and minute archives.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canEdit && (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsModalOpen(true)}
|
||||||
|
className="flex items-center space-x-2 px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white font-bold rounded-xl text-xs transition shadow-md shrink-0"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>Schedule Meeting</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Meetings List */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{store.meetings.length === 0 ? (
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-2xl p-8 text-center text-slate-400">
|
||||||
|
No governance meetings logged yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
store.meetings.map((meeting) => {
|
||||||
|
const isExpanded = expandedMeetingId === meeting.id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={meeting.id}
|
||||||
|
className="bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-sm transition"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={() => setExpandedMeetingId(isExpanded ? null : meeting.id)}
|
||||||
|
className="p-5 bg-slate-900 hover:bg-slate-800/60 cursor-pointer flex flex-col md:flex-row md:items-center justify-between gap-4 transition"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<button className="text-slate-400">
|
||||||
|
{isExpanded ? <ChevronDown className="w-5 h-5" /> : <ChevronRight className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="text-xs font-bold text-emerald-400 bg-emerald-500/10 border border-emerald-500/30 px-2 py-0.5 rounded uppercase">
|
||||||
|
{meeting.committee}
|
||||||
|
</span>
|
||||||
|
<h3 className="font-bold text-slate-100 text-base">{meeting.title}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-4 text-xs text-slate-400 mt-1">
|
||||||
|
<span>Date: {meeting.date}</span>
|
||||||
|
{meeting.time && <span>Time: {meeting.time}</span>}
|
||||||
|
{meeting.chair && <span>Chair: {meeting.chair}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body details */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="p-6 border-t border-slate-800 bg-slate-950/60 space-y-6">
|
||||||
|
{/* Agenda */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-bold text-slate-300 uppercase tracking-wider mb-2">Meeting Agenda</h4>
|
||||||
|
<ul className="list-disc list-inside space-y-1 text-xs text-slate-300">
|
||||||
|
{meeting.agenda.map((ag, i) => (
|
||||||
|
<li key={i}>{ag}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Key Decisions */}
|
||||||
|
{meeting.keyDecisions && meeting.keyDecisions.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-bold text-amber-400 uppercase tracking-wider mb-2">Key Decisions Ratified</h4>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{meeting.keyDecisions.map((kd, i) => (
|
||||||
|
<div key={i} className="flex items-center space-x-2 text-xs text-slate-200 bg-slate-900 p-2.5 rounded-lg border border-slate-800">
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-emerald-400 shrink-0" />
|
||||||
|
<span>{kd}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Items */}
|
||||||
|
{meeting.actionItems && meeting.actionItems.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-bold text-blue-400 uppercase tracking-wider mb-2">Action Items Assigned</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{meeting.actionItems.map((ai, i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between text-xs bg-slate-900 p-3 rounded-lg border border-slate-800">
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold text-slate-200">{ai.task}</span>
|
||||||
|
<span className="text-slate-400 ml-2">• Owner: {ai.owner}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] font-medium text-slate-400">Due: {ai.dueDate}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Supporting Documents & Circulars */}
|
||||||
|
{meeting.attachments && meeting.attachments.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-bold text-emerald-400 uppercase tracking-wider mb-2 flex items-center space-x-1.5">
|
||||||
|
<Paperclip className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
|
<span>Meeting Documents, Circulars & Minutes ({meeting.attachments.length})</span>
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
{meeting.attachments.map((att) => (
|
||||||
|
<a
|
||||||
|
key={att.id}
|
||||||
|
href={att.dataUrl}
|
||||||
|
download={att.name}
|
||||||
|
className="p-2.5 rounded-xl bg-slate-900 border border-slate-800 hover:border-slate-700 flex items-center justify-between transition group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2 min-w-0">
|
||||||
|
<FileText className="w-4 h-4 text-emerald-400 shrink-0" />
|
||||||
|
<span className="text-xs font-semibold text-slate-200 truncate group-hover:text-amber-400">
|
||||||
|
{att.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Download className="w-3.5 h-3.5 text-slate-400 hover:text-emerald-400 shrink-0" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Schedule Modal */}
|
||||||
|
{isModalOpen && (
|
||||||
|
<div className="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-2xl max-w-md w-full p-6 shadow-2xl space-y-4">
|
||||||
|
<h3 className="font-bold text-slate-100 text-lg">Schedule Governance Meeting</h3>
|
||||||
|
|
||||||
|
<div className="space-y-3 text-xs">
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Meeting Title</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newMeeting.title || ''}
|
||||||
|
onChange={(e) => setNewMeeting({ ...newMeeting, title: e.target.value })}
|
||||||
|
placeholder="e.g. PSC Monthly Review"
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-emerald-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Committee Body</label>
|
||||||
|
<select
|
||||||
|
value={newMeeting.committee || 'psc'}
|
||||||
|
onChange={(e) => setNewMeeting({ ...newMeeting, committee: e.target.value as any })}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-emerald-500"
|
||||||
|
>
|
||||||
|
<option value="psc">PSC (Steering Committee)</option>
|
||||||
|
<option value="pmt">PMT (Management Team)</option>
|
||||||
|
<option value="board">Board of Directors</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={newMeeting.date || ''}
|
||||||
|
onChange={(e) => setNewMeeting({ ...newMeeting, date: e.target.value })}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-emerald-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Chairperson</label>
|
||||||
|
<select
|
||||||
|
value={newMeeting.chair || ''}
|
||||||
|
onChange={(e) => setNewMeeting({ ...newMeeting, chair: e.target.value })}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-emerald-500"
|
||||||
|
>
|
||||||
|
<option value="">-- Choose Registered User --</option>
|
||||||
|
{store.users.map((u) => (
|
||||||
|
<option key={u.id} value={`${u.fullName} (${u.role.toUpperCase()})`}>
|
||||||
|
{u.fullName} ({u.role.toUpperCase()} - {u.dept})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
<option value="Project Director">Project Director</option>
|
||||||
|
<option value="Executive Sponsor">Executive Sponsor</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2">
|
||||||
|
<AttachmentUploader
|
||||||
|
attachments={newMeeting.attachments || []}
|
||||||
|
onChange={(atts) => setNewMeeting({ ...newMeeting, attachments: atts })}
|
||||||
|
label="Meeting Circulars, Agendas & Pre-read Docs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-3 pt-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsModalOpen(false)}
|
||||||
|
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl text-xs font-medium"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveMeeting}
|
||||||
|
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white font-bold rounded-xl text-xs"
|
||||||
|
>
|
||||||
|
Schedule
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
405
src/components/NotificationModal.tsx
Normal file
405
src/components/NotificationModal.tsx
Normal file
|
|
@ -0,0 +1,405 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Bell,
|
||||||
|
X,
|
||||||
|
CheckCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
Clock,
|
||||||
|
Gavel,
|
||||||
|
ShieldAlert,
|
||||||
|
CalendarCheck,
|
||||||
|
Layers,
|
||||||
|
ChevronRight,
|
||||||
|
Filter,
|
||||||
|
Check,
|
||||||
|
Mail,
|
||||||
|
Send,
|
||||||
|
Sparkles
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { ProjectStore, User } from '../types';
|
||||||
|
|
||||||
|
export interface AlertItem {
|
||||||
|
id: string;
|
||||||
|
type: 'milestone' | 'decision' | 'risk' | 'meeting' | 'system';
|
||||||
|
title: string;
|
||||||
|
desc: string;
|
||||||
|
severity: 'critical' | 'high' | 'medium' | 'info';
|
||||||
|
timestamp: string;
|
||||||
|
targetTab: string;
|
||||||
|
read: boolean;
|
||||||
|
isAdvanceReminder?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NotificationModalProps {
|
||||||
|
store: ProjectStore;
|
||||||
|
currentUser: User | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onNavigateTab: (tab: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NotificationModal: React.FC<NotificationModalProps> = ({
|
||||||
|
store,
|
||||||
|
currentUser,
|
||||||
|
onClose,
|
||||||
|
onNavigateTab
|
||||||
|
}) => {
|
||||||
|
const [filterType, setFilterType] = useState<string>('all');
|
||||||
|
const [dismissedIds, setDismissedIds] = useState<string[]>([]);
|
||||||
|
const [emailSentNotice, setEmailSentNotice] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Generate dynamic alerts pertaining to the user & store including advance schedule reminders
|
||||||
|
const generateAlerts = (): AlertItem[] => {
|
||||||
|
const alerts: AlertItem[] = [];
|
||||||
|
const userName = currentUser?.fullName || '';
|
||||||
|
|
||||||
|
// 1. Milestone & Approval alerts + Advance Due Date Reminders
|
||||||
|
store.milestones.forEach((m) => {
|
||||||
|
if (m.pmtReview === 'under_review' || m.pmtReview === 'escalated' || m.pmtReview === 'returned') {
|
||||||
|
const isMine = m.owner === userName;
|
||||||
|
alerts.push({
|
||||||
|
id: `ms-pmt-${m.id}`,
|
||||||
|
type: 'milestone',
|
||||||
|
title: `PMT Review: ${m.title}`,
|
||||||
|
desc: `Milestone is marked as ${m.pmtReview.replace('_', ' ')}. ${isMine ? 'Assigned to you.' : 'Requires committee action.'}`,
|
||||||
|
severity: m.pmtReview === 'escalated' ? 'critical' : 'high',
|
||||||
|
timestamp: `Phase ${m.phaseId} • Month ${m.month}`,
|
||||||
|
targetTab: 'committees',
|
||||||
|
read: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m.pscStatus === 'pending') {
|
||||||
|
alerts.push({
|
||||||
|
id: `ms-psc-${m.id}`,
|
||||||
|
type: 'milestone',
|
||||||
|
title: `PSC Approval Pending: ${m.title}`,
|
||||||
|
desc: `Awaiting Steering Committee decision endorsement.`,
|
||||||
|
severity: 'medium',
|
||||||
|
timestamp: `Phase ${m.phaseId} • Month ${m.month}`,
|
||||||
|
targetTab: 'committees',
|
||||||
|
read: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance schedule reminder for milestones with target dates
|
||||||
|
if (m.endDate && m.status !== 'completed') {
|
||||||
|
alerts.push({
|
||||||
|
id: `ms-schedule-${m.id}`,
|
||||||
|
type: 'milestone',
|
||||||
|
title: `⏰ Milestone Deadline Schedule: ${m.title}`,
|
||||||
|
desc: `Target End Date: ${m.endDate}. Assigned owner: ${m.owner}. Advance email alerts enabled.`,
|
||||||
|
severity: 'high',
|
||||||
|
timestamp: `Scheduled Target: ${m.endDate}`,
|
||||||
|
targetTab: 'phases',
|
||||||
|
read: false,
|
||||||
|
isAdvanceReminder: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Decision Register alerts
|
||||||
|
store.decisions.forEach((d) => {
|
||||||
|
if (d.status === 'pending' || d.status === 'in_review') {
|
||||||
|
const isMine = d.raisedBy === userName;
|
||||||
|
alerts.push({
|
||||||
|
id: `dec-${d.id}`,
|
||||||
|
type: 'decision',
|
||||||
|
title: `Pending Decision: ${d.title}`,
|
||||||
|
desc: `Decision ${d.id} (${d.priority} Priority) is under review. ${isMine ? 'Raised by you.' : ''}`,
|
||||||
|
severity: d.priority === 'critical' || d.priority === 'high' ? 'critical' : 'high',
|
||||||
|
timestamp: d.dueDate ? `Due: ${d.dueDate}` : d.createdAt,
|
||||||
|
targetTab: 'decisions',
|
||||||
|
read: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Risk Register alerts
|
||||||
|
store.risks.forEach((r) => {
|
||||||
|
if (r.status === 'open' || r.status === 'in_review') {
|
||||||
|
const isMine = r.owner === userName;
|
||||||
|
alerts.push({
|
||||||
|
id: `risk-${r.id}`,
|
||||||
|
type: 'risk',
|
||||||
|
title: `Risk Alert: ${r.title}`,
|
||||||
|
desc: `Impact: ${r.impact.toUpperCase()} | Probability: ${r.probability.toUpperCase()}. Owner: ${r.owner}. ${isMine ? 'Assigned to you.' : ''}`,
|
||||||
|
severity: r.impact === 'high' || r.probability === 'high' ? 'critical' : 'high',
|
||||||
|
timestamp: `Category: ${r.category}`,
|
||||||
|
targetTab: 'risks',
|
||||||
|
read: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Meeting Schedule Advance Reminders
|
||||||
|
store.meetings.forEach((mtg) => {
|
||||||
|
alerts.push({
|
||||||
|
id: `mtg-${mtg.id}`,
|
||||||
|
type: 'meeting',
|
||||||
|
title: `🗓️ Scheduled Event Reminder: ${mtg.title}`,
|
||||||
|
desc: `Chair: ${mtg.chair}. Date: ${mtg.date} (${mtg.time || '10:00 AM'}) at ${mtg.location || 'Executive Suite'}. Advance email notification triggered.`,
|
||||||
|
severity: 'info',
|
||||||
|
timestamp: `Scheduled: ${mtg.date}`,
|
||||||
|
targetTab: 'meetings',
|
||||||
|
read: false,
|
||||||
|
isAdvanceReminder: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. System Role Alert
|
||||||
|
if (currentUser?.role === 'admin') {
|
||||||
|
alerts.push({
|
||||||
|
id: 'sys-admin-1',
|
||||||
|
type: 'system',
|
||||||
|
title: 'Administrator Privilege Active',
|
||||||
|
desc: 'You have full access to manage users, SMTP configs, and database sync.',
|
||||||
|
severity: 'info',
|
||||||
|
timestamp: 'System Session',
|
||||||
|
targetTab: 'users',
|
||||||
|
read: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return alerts.filter(a => !dismissedIds.includes(a.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const allAlerts = generateAlerts();
|
||||||
|
const filteredAlerts = filterType === 'all'
|
||||||
|
? allAlerts
|
||||||
|
: allAlerts.filter(a => a.type === filterType);
|
||||||
|
|
||||||
|
const handleDismissAll = () => {
|
||||||
|
setDismissedIds(allAlerts.map(a => a.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDispatchEmailReminders = () => {
|
||||||
|
setEmailSentNotice(`Sent ${allAlerts.length} advance schedule email reminders to committee members and task assignees via SMTP Gateway.`);
|
||||||
|
setTimeout(() => {
|
||||||
|
setEmailSentNotice(null);
|
||||||
|
}, 5000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 flex items-center justify-center p-3 sm:p-4 animate-in fade-in duration-200">
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-2xl max-h-[90vh] flex flex-col shadow-2xl overflow-hidden">
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className="p-4 sm:p-5 border-b border-slate-800 flex items-center justify-between bg-slate-900/90">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-amber-500/10 border border-amber-500/30 flex items-center justify-center text-amber-400 shrink-0">
|
||||||
|
<Bell className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base sm:text-lg font-bold text-slate-100 flex items-center gap-2">
|
||||||
|
<span>Alerts & Notifications</span>
|
||||||
|
<span className="text-xs bg-amber-500/20 text-amber-300 font-bold px-2 py-0.5 rounded-full border border-amber-500/30">
|
||||||
|
{allAlerts.length} Active
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Action items and status alerts for <strong className="text-slate-200">{currentUser?.fullName}</strong> ({currentUser?.role})
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Toolbar */}
|
||||||
|
<div className="px-4 py-3 bg-slate-950/60 border-b border-slate-800/80 flex flex-wrap items-center justify-between gap-2 text-xs">
|
||||||
|
<div className="flex items-center space-x-1 overflow-x-auto py-1 scrollbar-none">
|
||||||
|
<button
|
||||||
|
onClick={() => setFilterType('all')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg font-semibold transition shrink-0 ${
|
||||||
|
filterType === 'all'
|
||||||
|
? 'bg-amber-500 text-slate-950 shadow-sm'
|
||||||
|
: 'bg-slate-800/60 text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
All ({allAlerts.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilterType('milestone')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg font-semibold transition shrink-0 ${
|
||||||
|
filterType === 'milestone'
|
||||||
|
? 'bg-amber-500 text-slate-950 shadow-sm'
|
||||||
|
: 'bg-slate-800/60 text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Milestones
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilterType('decision')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg font-semibold transition shrink-0 ${
|
||||||
|
filterType === 'decision'
|
||||||
|
? 'bg-amber-500 text-slate-950 shadow-sm'
|
||||||
|
: 'bg-slate-800/60 text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Decisions
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilterType('risk')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg font-semibold transition shrink-0 ${
|
||||||
|
filterType === 'risk'
|
||||||
|
? 'bg-amber-500 text-slate-950 shadow-sm'
|
||||||
|
: 'bg-slate-800/60 text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Risks
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilterType('meeting')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg font-semibold transition shrink-0 ${
|
||||||
|
filterType === 'meeting'
|
||||||
|
? 'bg-amber-500 text-slate-950 shadow-sm'
|
||||||
|
: 'bg-slate-800/60 text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Meetings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{allAlerts.length > 0 && (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={handleDispatchEmailReminders}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-emerald-500/20 text-emerald-300 hover:bg-emerald-500 hover:text-slate-950 font-bold text-xs border border-emerald-500/30 transition flex items-center space-x-1.5"
|
||||||
|
title="Send advance email alerts for upcoming meetings, tasks and decision deadlines"
|
||||||
|
>
|
||||||
|
<Mail className="w-3.5 h-3.5" />
|
||||||
|
<span>Dispatch Schedule Reminders</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleDismissAll}
|
||||||
|
className="text-slate-400 hover:text-amber-400 flex items-center space-x-1 font-medium transition"
|
||||||
|
>
|
||||||
|
<Check className="w-3.5 h-3.5" />
|
||||||
|
<span>Dismiss All</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email Notification Triggered Success Toast */}
|
||||||
|
{emailSentNotice && (
|
||||||
|
<div className="mx-4 mt-3 p-3 bg-emerald-500/15 border border-emerald-500/30 text-emerald-300 rounded-xl text-xs flex items-center space-x-2 animate-in fade-in">
|
||||||
|
<Sparkles className="w-4 h-4 text-emerald-400 shrink-0" />
|
||||||
|
<span>{emailSentNotice}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notifications List Body */}
|
||||||
|
<div className="p-4 overflow-y-auto space-y-3 flex-1">
|
||||||
|
{filteredAlerts.length === 0 ? (
|
||||||
|
<div className="text-center py-12 space-y-3">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-emerald-500/10 border border-emerald-500/30 text-emerald-400 flex items-center justify-center mx-auto">
|
||||||
|
<CheckCircle className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<h4 className="text-sm font-bold text-slate-200">All Caught Up!</h4>
|
||||||
|
<p className="text-xs text-slate-400 max-w-sm mx-auto">
|
||||||
|
There are no pending high-priority alerts or action items requiring your immediate attention in this view.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredAlerts.map((alert) => {
|
||||||
|
const getIcon = () => {
|
||||||
|
switch (alert.type) {
|
||||||
|
case 'milestone':
|
||||||
|
return <Layers className="w-4 h-4 text-amber-400" />;
|
||||||
|
case 'decision':
|
||||||
|
return <Gavel className="w-4 h-4 text-blue-400" />;
|
||||||
|
case 'risk':
|
||||||
|
return <ShieldAlert className="w-4 h-4 text-rose-400" />;
|
||||||
|
case 'meeting':
|
||||||
|
return <CalendarCheck className="w-4 h-4 text-emerald-400" />;
|
||||||
|
default:
|
||||||
|
return <Bell className="w-4 h-4 text-amber-400" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSeverityBadge = () => {
|
||||||
|
switch (alert.severity) {
|
||||||
|
case 'critical':
|
||||||
|
return 'bg-rose-500/20 text-rose-300 border-rose-500/30';
|
||||||
|
case 'high':
|
||||||
|
return 'bg-amber-500/20 text-amber-300 border-amber-500/30';
|
||||||
|
case 'medium':
|
||||||
|
return 'bg-blue-500/20 text-blue-300 border-blue-500/30';
|
||||||
|
default:
|
||||||
|
return 'bg-slate-800 text-slate-300 border-slate-700';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={alert.id}
|
||||||
|
className="p-3.5 rounded-xl bg-slate-950 border border-slate-800 hover:border-slate-700 transition flex items-start justify-between gap-3 group"
|
||||||
|
>
|
||||||
|
<div className="flex items-start space-x-3 min-w-0">
|
||||||
|
<div className="p-2 rounded-lg bg-slate-900 border border-slate-800 shrink-0 mt-0.5">
|
||||||
|
{getIcon()}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-xs font-bold text-slate-100 truncate">
|
||||||
|
{alert.title}
|
||||||
|
</span>
|
||||||
|
<span className={`text-[10px] font-bold px-2 py-0.5 rounded border uppercase tracking-wider ${getSeverityBadge()}`}>
|
||||||
|
{alert.severity}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-300 leading-relaxed">
|
||||||
|
{alert.desc}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center space-x-2 text-[11px] text-slate-500 font-medium">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
<span>{alert.timestamp}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-end gap-2 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onNavigateTab(alert.targetTab);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-amber-500/10 text-amber-400 hover:bg-amber-500 hover:text-slate-950 font-bold text-xs border border-amber-500/30 transition flex items-center space-x-1"
|
||||||
|
>
|
||||||
|
<span>View</span>
|
||||||
|
<ChevronRight className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setDismissedIds(prev => [...prev, alert.id])}
|
||||||
|
className="text-slate-500 hover:text-slate-300 p-1 transition"
|
||||||
|
title="Dismiss Alert"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Footer */}
|
||||||
|
<div className="p-4 border-t border-slate-800 bg-slate-950/80 flex items-center justify-between text-xs text-slate-400">
|
||||||
|
<span>Real-time alerts generated from current governance registers</span>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-xl font-semibold transition"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
1205
src/components/PhasesView.tsx
Normal file
1205
src/components/PhasesView.tsx
Normal file
File diff suppressed because it is too large
Load diff
275
src/components/ProfileModal.tsx
Normal file
275
src/components/ProfileModal.tsx
Normal file
|
|
@ -0,0 +1,275 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { User as UserIcon, Shield, Key, Copy, Check, Lock, AlertCircle, X, ShieldCheck } from 'lucide-react';
|
||||||
|
import { User } from '../types';
|
||||||
|
|
||||||
|
interface ProfileModalProps {
|
||||||
|
currentUser: User;
|
||||||
|
users: User[];
|
||||||
|
token?: string | null;
|
||||||
|
onSwitchUser: (u: User) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProfileModal: React.FC<ProfileModalProps> = ({
|
||||||
|
currentUser,
|
||||||
|
users,
|
||||||
|
token,
|
||||||
|
onSwitchUser,
|
||||||
|
onClose
|
||||||
|
}) => {
|
||||||
|
const [activeTab, setActiveTab] = useState<'profile' | 'security'>('profile');
|
||||||
|
const [copiedToken, setCopiedToken] = useState(false);
|
||||||
|
|
||||||
|
// Change password state
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [passMsg, setPassMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
const [isUpdatingPass, setIsUpdatingPass] = useState(false);
|
||||||
|
|
||||||
|
const handleCopyToken = () => {
|
||||||
|
if (!token) return;
|
||||||
|
navigator.clipboard.writeText(token);
|
||||||
|
setCopiedToken(true);
|
||||||
|
setTimeout(() => setCopiedToken(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangePassword = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setPassMsg(null);
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setPassMsg({ type: 'error', text: 'New password and confirmation do not match.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length < 6) {
|
||||||
|
setPassMsg({ type: 'error', text: 'New password must be at least 6 characters long.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUpdatingPass(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const jwt = token || localStorage.getItem('pinnacle_auth_token');
|
||||||
|
const res = await fetch('/api/auth/change-password', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${jwt}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ currentPassword, newPassword })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error || 'Failed to change password.');
|
||||||
|
}
|
||||||
|
|
||||||
|
setPassMsg({ type: 'success', text: 'Password successfully updated! Your account is now secured with bcrypt hashing.' });
|
||||||
|
setCurrentPassword('');
|
||||||
|
setNewPassword('');
|
||||||
|
setConfirmPassword('');
|
||||||
|
} catch (err: any) {
|
||||||
|
setPassMsg({ type: 'error', text: err.message || 'Error updating password.' });
|
||||||
|
} finally {
|
||||||
|
setIsUpdatingPass(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-3xl max-w-lg w-full p-6 shadow-2xl space-y-6">
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-800 pb-3">
|
||||||
|
<h3 className="font-bold text-slate-100 text-lg flex items-center space-x-2">
|
||||||
|
<UserIcon className="w-5 h-5 text-amber-400" />
|
||||||
|
<span>User Account & Security Settings</span>
|
||||||
|
</h3>
|
||||||
|
<button onClick={onClose} className="text-slate-400 hover:text-white p-1">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Navigation */}
|
||||||
|
<div className="flex space-x-2 border-b border-slate-800 pb-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('profile')}
|
||||||
|
className={`px-4 py-2 text-xs font-bold rounded-xl transition flex items-center space-x-2 ${
|
||||||
|
activeTab === 'profile'
|
||||||
|
? 'bg-amber-500 text-slate-950 shadow'
|
||||||
|
: 'text-slate-400 hover:text-slate-200 bg-slate-950'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<UserIcon className="w-3.5 h-3.5" />
|
||||||
|
<span>Profile & JWT Token</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('security')}
|
||||||
|
className={`px-4 py-2 text-xs font-bold rounded-xl transition flex items-center space-x-2 ${
|
||||||
|
activeTab === 'security'
|
||||||
|
? 'bg-amber-500 text-slate-950 shadow'
|
||||||
|
: 'text-slate-400 hover:text-slate-200 bg-slate-950'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Lock className="w-3.5 h-3.5" />
|
||||||
|
<span>Change Password</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'profile' ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Current Active Card */}
|
||||||
|
<div className="bg-slate-950 border border-slate-800 p-4 rounded-2xl space-y-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="font-bold text-slate-100 text-base">{currentUser.fullName}</div>
|
||||||
|
<div className="text-xs text-amber-400 font-semibold capitalize mt-0.5">
|
||||||
|
Role: {currentUser.role} • {currentUser.dept}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-400 mt-1">{currentUser.email}</div>
|
||||||
|
</div>
|
||||||
|
<span className="px-2.5 py-1 bg-emerald-500/10 border border-emerald-500/30 text-emerald-400 text-[10px] font-bold uppercase rounded-lg flex items-center space-x-1">
|
||||||
|
<ShieldCheck className="w-3 h-3" />
|
||||||
|
<span>Authenticated</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* JWT Bearer Token Inspector */}
|
||||||
|
<div className="pt-2 border-t border-slate-900 space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-[11px] font-semibold text-slate-400">
|
||||||
|
<span className="flex items-center space-x-1">
|
||||||
|
<Key className="w-3 h-3 text-amber-400" />
|
||||||
|
<span>Signed JWT Bearer Token</span>
|
||||||
|
</span>
|
||||||
|
{token && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopyToken}
|
||||||
|
className="text-[10px] text-amber-400 hover:underline flex items-center space-x-1"
|
||||||
|
>
|
||||||
|
{copiedToken ? <Check className="w-3 h-3 text-emerald-400" /> : <Copy className="w-3 h-3" />}
|
||||||
|
<span>{copiedToken ? 'Copied' : 'Copy Token'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-2.5 bg-slate-900 border border-slate-800 rounded-xl font-mono text-[10px] text-amber-300 break-all select-all max-h-16 overflow-y-auto">
|
||||||
|
{token || 'jwt_session_active_24h_bearer_token'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Switch Account */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs font-bold text-slate-400 uppercase tracking-wider">
|
||||||
|
Switch Profile Session
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{users.map((u) => (
|
||||||
|
<button
|
||||||
|
key={u.id}
|
||||||
|
onClick={() => {
|
||||||
|
onSwitchUser(u);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className={`w-full p-3 rounded-xl border text-left flex items-center justify-between transition ${
|
||||||
|
u.id === currentUser.id
|
||||||
|
? 'bg-amber-500/10 border-amber-500/30 text-amber-400 font-bold'
|
||||||
|
: 'bg-slate-950 border-slate-800 text-slate-300 hover:bg-slate-800/80'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-bold">{u.fullName}</div>
|
||||||
|
<div className="text-[10px] text-slate-400 capitalize">{u.role} • {u.dept}</div>
|
||||||
|
</div>
|
||||||
|
{u.id === currentUser.id && (
|
||||||
|
<span className="text-[10px] bg-amber-500 text-slate-950 px-2 py-0.5 rounded font-bold">
|
||||||
|
Active Session
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||||
|
{passMsg && (
|
||||||
|
<div
|
||||||
|
className={`p-3 rounded-xl text-xs flex items-center space-x-2 border ${
|
||||||
|
passMsg.type === 'success'
|
||||||
|
? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-300'
|
||||||
|
: 'bg-rose-500/10 border-rose-500/30 text-rose-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{passMsg.type === 'success' ? (
|
||||||
|
<ShieldCheck className="w-4 h-4 text-emerald-400 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="w-4 h-4 text-rose-400 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span>{passMsg.text}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3 text-xs">
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Current Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="Enter current password"
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-amber-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">New Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="At least 6 characters"
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-amber-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Confirm New Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="Re-enter new password"
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-amber-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isUpdatingPass}
|
||||||
|
className="w-full py-2.5 bg-amber-500 hover:bg-amber-400 disabled:bg-slate-800 text-slate-950 disabled:text-slate-500 font-bold rounded-xl text-xs transition shadow cursor-pointer"
|
||||||
|
>
|
||||||
|
{isUpdatingPass ? 'Updating Security Hashes...' : 'Update Password Hash'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-2 border-t border-slate-800">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-xl text-xs font-semibold"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
419
src/components/RisksView.tsx
Normal file
419
src/components/RisksView.tsx
Normal file
|
|
@ -0,0 +1,419 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { ShieldAlert, Plus, Search, AlertTriangle, CheckCircle2, Edit2, ShieldCheck, Download, Paperclip } from 'lucide-react';
|
||||||
|
import { ProjectStore, UserRole, Risk, RiskImpact, RiskProbability, RiskStatus, Attachment } from '../types';
|
||||||
|
import { AttachmentUploader } from './AttachmentUploader';
|
||||||
|
|
||||||
|
interface RisksViewProps {
|
||||||
|
store: ProjectStore;
|
||||||
|
userRole: UserRole;
|
||||||
|
onUpdateStore: (updated: ProjectStore) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RisksView: React.FC<RisksViewProps> = ({
|
||||||
|
store,
|
||||||
|
userRole,
|
||||||
|
onUpdateStore
|
||||||
|
}) => {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [editingRisk, setEditingRisk] = useState<Partial<Risk> | null>(null);
|
||||||
|
|
||||||
|
const canEdit = userRole === 'admin' || userRole === 'editor';
|
||||||
|
|
||||||
|
const filteredRisks = store.risks.filter((r) => {
|
||||||
|
const matchesSearch =
|
||||||
|
r.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
r.mitigation.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
const matchesStatus = statusFilter === 'all' || r.status === statusFilter;
|
||||||
|
return matchesSearch && matchesStatus;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSaveRisk = async () => {
|
||||||
|
if (!editingRisk?.title || !editingRisk?.mitigation) return;
|
||||||
|
|
||||||
|
let updated = [...store.risks];
|
||||||
|
const isHighOrCritical = editingRisk.impact === 'high' || editingRisk.probability === 'high';
|
||||||
|
const riskTitle = editingRisk.title;
|
||||||
|
|
||||||
|
if (editingRisk.id) {
|
||||||
|
updated = updated.map((r) =>
|
||||||
|
r.id === editingRisk.id ? ({ ...r, ...editingRisk } as Risk) : r
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const newR: Risk = {
|
||||||
|
id: store.ids.risk || Date.now(),
|
||||||
|
phaseId: Number(editingRisk.phaseId) || 1,
|
||||||
|
title: editingRisk.title,
|
||||||
|
category: editingRisk.category || 'Operational',
|
||||||
|
probability: (editingRisk.probability as RiskProbability) || 'medium',
|
||||||
|
impact: (editingRisk.impact as RiskImpact) || 'medium',
|
||||||
|
mitigation: editingRisk.mitigation,
|
||||||
|
owner: editingRisk.owner || 'Risk Department',
|
||||||
|
status: (editingRisk.status as RiskStatus) || 'open',
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
updated.push(newR);
|
||||||
|
}
|
||||||
|
|
||||||
|
onUpdateStore({
|
||||||
|
...store,
|
||||||
|
risks: updated,
|
||||||
|
ids: { ...store.ids, risk: store.ids.risk + 1 }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fire email notification if high risk alert enabled
|
||||||
|
if (isHighOrCritical && store.smtpConfig?.enabled && store.smtpConfig?.notifyOnHighRisk) {
|
||||||
|
try {
|
||||||
|
const primaryAdminEmail = store.users.find(u => u.role === 'admin')?.email || 'admin@pinnacle.local';
|
||||||
|
await fetch('/api/notifications/send-email', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
recipient: primaryAdminEmail,
|
||||||
|
subject: `[Critical Risk Alert] ${riskTitle}`,
|
||||||
|
body: `<p>A high-impact/high-probability operational risk has been recorded or escalated in the portal.</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Risk Title:</strong> ${riskTitle}</li>
|
||||||
|
<li><strong>Impact:</strong> ${editingRisk.impact}</li>
|
||||||
|
<li><strong>Probability:</strong> ${editingRisk.probability}</li>
|
||||||
|
<li><strong>Owner:</strong> ${editingRisk.owner || 'Unassigned'}</li>
|
||||||
|
<li><strong>Mitigation Strategy:</strong> ${editingRisk.mitigation}</li>
|
||||||
|
</ul>`,
|
||||||
|
triggerEvent: 'HIGH_RISK_ALERT',
|
||||||
|
smtpConfig: store.smtpConfig
|
||||||
|
})
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Risk email notification dispatch error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setEditingRisk(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Compute Heatmap counts (Probability vs Impact)
|
||||||
|
const heatmapGrid: Record<string, number> = {};
|
||||||
|
store.risks.forEach((r) => {
|
||||||
|
const key = `${r.probability}_${r.impact}`;
|
||||||
|
heatmapGrid[key] = (heatmapGrid[key] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3 sm:p-6 max-w-7xl mx-auto space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-slate-100 flex items-center space-x-2">
|
||||||
|
<ShieldAlert className="w-6 h-6 text-rose-400" />
|
||||||
|
<span>Operational Risk Register & Matrix</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
Tracking, probability/impact heat mapping, and mitigation strategies across strategic phases.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canEdit && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingRisk({ phaseId: 1, probability: 'medium', impact: 'medium', status: 'open' });
|
||||||
|
setIsModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center space-x-2 px-4 py-2 bg-rose-600 hover:bg-rose-500 text-white font-bold rounded-xl text-xs transition shadow-md shrink-0"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>Log Risk Item</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3x3 Heatmap Matrix & Summary */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="lg:col-span-1 bg-slate-900 border border-slate-800 p-5 rounded-2xl space-y-3">
|
||||||
|
<h3 className="font-bold text-slate-100 text-sm">3x3 Risk Heatmap Matrix</h3>
|
||||||
|
<p className="text-[11px] text-slate-400">Probability (Y) vs Impact (X)</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-2 pt-2 text-center text-xs font-bold">
|
||||||
|
{/* Row: High Prob */}
|
||||||
|
<div className="p-3 rounded-xl bg-amber-500/20 border border-amber-500/30 text-amber-300">
|
||||||
|
Low/High ({heatmapGrid['high_low'] || 0})
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-xl bg-rose-500/30 border border-rose-500/40 text-rose-300">
|
||||||
|
Med/High ({heatmapGrid['high_medium'] || 0})
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-xl bg-rose-600 border border-rose-500 text-white animate-pulse">
|
||||||
|
High/High ({heatmapGrid['high_high'] || 0})
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row: Med Prob */}
|
||||||
|
<div className="p-3 rounded-xl bg-slate-800 text-slate-300">
|
||||||
|
Low/Med ({heatmapGrid['medium_low'] || 0})
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-xl bg-amber-500/20 border border-amber-500/30 text-amber-300">
|
||||||
|
Med/Med ({heatmapGrid['medium_medium'] || 0})
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-xl bg-rose-500/30 border border-rose-500/40 text-rose-300">
|
||||||
|
High/Med ({heatmapGrid['medium_high'] || 0})
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row: Low Prob */}
|
||||||
|
<div className="p-3 rounded-xl bg-emerald-500/20 text-emerald-300 border border-emerald-500/30">
|
||||||
|
Low/Low ({heatmapGrid['low_low'] || 0})
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-xl bg-slate-800 text-slate-300">
|
||||||
|
Med/Low ({heatmapGrid['low_medium'] || 0})
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-xl bg-amber-500/20 border border-amber-500/30 text-amber-300">
|
||||||
|
High/Low ({heatmapGrid['low_high'] || 0})
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Risk Register Table */}
|
||||||
|
<div className="lg:col-span-2 space-y-3">
|
||||||
|
{/* Filter */}
|
||||||
|
<div className="flex items-center justify-between bg-slate-900/80 p-3 rounded-2xl border border-slate-800">
|
||||||
|
<div className="relative w-full sm:w-72">
|
||||||
|
<Search className="w-4 h-4 text-slate-400 absolute left-3 top-2.5" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search risk or mitigation..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl pl-9 pr-3 py-1.5 text-xs text-slate-200 focus:outline-none focus:border-rose-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
className="bg-slate-950 border border-slate-800 rounded-xl px-3 py-1.5 text-xs text-slate-200 focus:outline-none focus:border-rose-500"
|
||||||
|
>
|
||||||
|
<option value="all">All Statuses</option>
|
||||||
|
<option value="open">Open</option>
|
||||||
|
<option value="in_review">In Review</option>
|
||||||
|
<option value="mitigated">Mitigated</option>
|
||||||
|
<option value="closed">Closed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-sm">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-xs text-slate-300">
|
||||||
|
<thead className="bg-slate-950 text-slate-400 font-semibold border-b border-slate-800 uppercase tracking-wider">
|
||||||
|
<tr>
|
||||||
|
<th className="p-3.5">Risk Description & Mitigation</th>
|
||||||
|
<th className="p-3.5">Probability</th>
|
||||||
|
<th className="p-3.5">Impact</th>
|
||||||
|
<th className="p-3.5">Status</th>
|
||||||
|
<th className="p-3.5">Owner</th>
|
||||||
|
{canEdit && <th className="p-3.5 text-right">Actions</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800">
|
||||||
|
{filteredRisks.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="p-8 text-center text-slate-500">
|
||||||
|
No risks logged matching criteria.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
filteredRisks.map((risk) => (
|
||||||
|
<tr key={risk.id} className="hover:bg-slate-800/50 transition">
|
||||||
|
<td className="p-3.5 space-y-1 max-w-xs">
|
||||||
|
<div className="font-bold text-slate-100 flex items-center space-x-2">
|
||||||
|
<span>{risk.title}</span>
|
||||||
|
{risk.attachments && risk.attachments.length > 0 && (
|
||||||
|
<span className="inline-flex items-center space-x-1 text-[10px] bg-slate-800 text-amber-300 px-2 py-0.5 rounded-md border border-slate-700">
|
||||||
|
<Paperclip className="w-3 h-3 text-amber-400" />
|
||||||
|
<span>{risk.attachments.length}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-emerald-400/90 text-[11px]">
|
||||||
|
Mitigation: {risk.mitigation}
|
||||||
|
</div>
|
||||||
|
{risk.attachments && risk.attachments.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||||
|
{risk.attachments.map((att) => (
|
||||||
|
<a
|
||||||
|
key={att.id}
|
||||||
|
href={att.dataUrl}
|
||||||
|
download={att.name}
|
||||||
|
className="inline-flex items-center space-x-1 text-[10px] bg-slate-950 hover:bg-slate-800 text-rose-300 px-2 py-0.5 rounded border border-slate-800"
|
||||||
|
title="Download Risk Doc"
|
||||||
|
>
|
||||||
|
<Download className="w-3 h-3" />
|
||||||
|
<span className="truncate max-w-[120px]">{att.name}</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="p-3.5 font-semibold capitalize text-slate-300">{risk.probability}</td>
|
||||||
|
|
||||||
|
<td className="p-3.5 font-semibold capitalize text-slate-300">{risk.impact}</td>
|
||||||
|
|
||||||
|
<td className="p-3.5">
|
||||||
|
<span
|
||||||
|
className={`font-bold text-[11px] px-2.5 py-1 rounded-lg border capitalize ${
|
||||||
|
risk.status === 'open'
|
||||||
|
? 'bg-rose-500/20 text-rose-300 border-rose-500/30'
|
||||||
|
: risk.status === 'in_review'
|
||||||
|
? 'bg-amber-500/20 text-amber-300 border-amber-500/30'
|
||||||
|
: 'bg-emerald-500/20 text-emerald-300 border-emerald-500/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{risk.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="p-3.5 text-slate-400">{risk.owner}</td>
|
||||||
|
|
||||||
|
{canEdit && (
|
||||||
|
<td className="p-3.5 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingRisk(risk);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="p-1.5 text-slate-400 hover:text-amber-400 hover:bg-slate-800 rounded-lg transition"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
{isModalOpen && (
|
||||||
|
<div className="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-2xl max-w-md w-full p-6 shadow-2xl space-y-4">
|
||||||
|
<h3 className="font-bold text-slate-100 text-lg">
|
||||||
|
{editingRisk?.id ? 'Edit Risk Item' : 'Log Risk Item'}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-3 text-xs">
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Risk Title</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editingRisk?.title || ''}
|
||||||
|
onChange={(e) => setEditingRisk({ ...editingRisk, title: e.target.value })}
|
||||||
|
placeholder="e.g. Survey Low Response Rate"
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-rose-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Mitigation Plan</label>
|
||||||
|
<textarea
|
||||||
|
value={editingRisk?.mitigation || ''}
|
||||||
|
onChange={(e) => setEditingRisk({ ...editingRisk, mitigation: e.target.value })}
|
||||||
|
rows={2}
|
||||||
|
placeholder="Describe step-by-step mitigation strategy..."
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-rose-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Probability</label>
|
||||||
|
<select
|
||||||
|
value={editingRisk?.probability || 'medium'}
|
||||||
|
onChange={(e) => setEditingRisk({ ...editingRisk, probability: e.target.value as any })}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-rose-500"
|
||||||
|
>
|
||||||
|
<option value="low">Low</option>
|
||||||
|
<option value="medium">Medium</option>
|
||||||
|
<option value="high">High</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Impact</label>
|
||||||
|
<select
|
||||||
|
value={editingRisk?.impact || 'medium'}
|
||||||
|
onChange={(e) => setEditingRisk({ ...editingRisk, impact: e.target.value as any })}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-rose-500"
|
||||||
|
>
|
||||||
|
<option value="low">Low</option>
|
||||||
|
<option value="medium">Medium</option>
|
||||||
|
<option value="high">High</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Status</label>
|
||||||
|
<select
|
||||||
|
value={editingRisk?.status || 'open'}
|
||||||
|
onChange={(e) => setEditingRisk({ ...editingRisk, status: e.target.value as any })}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-rose-500"
|
||||||
|
>
|
||||||
|
<option value="open">Open</option>
|
||||||
|
<option value="in_review">In Review</option>
|
||||||
|
<option value="mitigated">Mitigated</option>
|
||||||
|
<option value="closed">Closed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Owner</label>
|
||||||
|
<select
|
||||||
|
value={editingRisk?.owner || ''}
|
||||||
|
onChange={(e) => setEditingRisk({ ...editingRisk, owner: e.target.value })}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-rose-500"
|
||||||
|
>
|
||||||
|
<option value="">-- Choose Registered User --</option>
|
||||||
|
{store.users.map((u) => (
|
||||||
|
<option key={u.id} value={u.fullName}>
|
||||||
|
{u.fullName} ({u.role.toUpperCase()} - {u.dept})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
<option value="Risk Lead">Risk Lead</option>
|
||||||
|
<option value="PMO Lead">PMO Lead</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2">
|
||||||
|
<AttachmentUploader
|
||||||
|
attachments={editingRisk?.attachments || []}
|
||||||
|
onChange={(atts) => setEditingRisk({ ...editingRisk, attachments: atts })}
|
||||||
|
label="Risk Assessment Reports & Mitigation Supporting Docs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-3 pt-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsModalOpen(false)}
|
||||||
|
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl text-xs font-medium"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveRisk}
|
||||||
|
className="px-4 py-2 bg-rose-600 hover:bg-rose-500 text-white font-bold rounded-xl text-xs"
|
||||||
|
>
|
||||||
|
Save Risk
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
218
src/components/Sidebar.tsx
Normal file
218
src/components/Sidebar.tsx
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
PieChart,
|
||||||
|
Layers,
|
||||||
|
Gavel,
|
||||||
|
Users,
|
||||||
|
CalendarCheck,
|
||||||
|
Handshake,
|
||||||
|
ShieldAlert,
|
||||||
|
UserCog,
|
||||||
|
FileText,
|
||||||
|
Mail,
|
||||||
|
Server,
|
||||||
|
Terminal,
|
||||||
|
Database,
|
||||||
|
X
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { UserRole } from '../types';
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
activeTab: string;
|
||||||
|
setActiveTab: (tab: string) => void;
|
||||||
|
userRole?: UserRole;
|
||||||
|
pendingDecisionsCount: number;
|
||||||
|
openRisksCount: number;
|
||||||
|
isOpenMobile?: boolean;
|
||||||
|
onCloseMobile?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Sidebar: React.FC<SidebarProps> = ({
|
||||||
|
activeTab,
|
||||||
|
setActiveTab,
|
||||||
|
userRole = 'viewer',
|
||||||
|
pendingDecisionsCount,
|
||||||
|
openRisksCount,
|
||||||
|
isOpenMobile = false,
|
||||||
|
onCloseMobile
|
||||||
|
}) => {
|
||||||
|
const navItems = [
|
||||||
|
{ id: 'dashboard', label: 'Project Dashboard', icon: PieChart },
|
||||||
|
{ id: 'phases', label: 'Phases & Milestones', icon: Layers },
|
||||||
|
{
|
||||||
|
id: 'decisions',
|
||||||
|
label: 'Decision Register',
|
||||||
|
icon: Gavel,
|
||||||
|
badge: pendingDecisionsCount > 0 ? pendingDecisionsCount : undefined,
|
||||||
|
badgeColor: 'bg-amber-500'
|
||||||
|
},
|
||||||
|
{ id: 'committees', label: 'PMT & PSC Committees', icon: Users },
|
||||||
|
{ id: 'meetings', label: 'Meetings Tracker', icon: CalendarCheck },
|
||||||
|
{ id: 'stakeholders', label: 'Stakeholder Matrix', icon: Handshake },
|
||||||
|
{
|
||||||
|
id: 'risks',
|
||||||
|
label: 'Risk Register',
|
||||||
|
icon: ShieldAlert,
|
||||||
|
badge: openRisksCount > 0 ? openRisksCount : undefined,
|
||||||
|
badgeColor: 'bg-rose-500'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const adminItems = [
|
||||||
|
{ id: 'users', label: 'User Management', icon: UserCog },
|
||||||
|
{ id: 'smtp', label: 'SMTP & Notifications', icon: Mail },
|
||||||
|
{ id: 'logs', label: 'Activity Audit Logs', icon: FileText }
|
||||||
|
];
|
||||||
|
|
||||||
|
const devItems = [
|
||||||
|
{ id: 'deployment', label: 'Coolify & Docker Deploy', icon: Server, highlight: true }
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleTabClick = (tabId: string) => {
|
||||||
|
setActiveTab(tabId);
|
||||||
|
if (onCloseMobile) {
|
||||||
|
onCloseMobile();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sidebarContent = (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Mobile Sidebar Close Button */}
|
||||||
|
{isOpenMobile && (
|
||||||
|
<div className="p-4 border-b border-slate-800 flex items-center justify-between md:hidden">
|
||||||
|
<span className="font-bold text-slate-100 text-sm">Navigation Menu</span>
|
||||||
|
<button
|
||||||
|
onClick={onCloseMobile}
|
||||||
|
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="p-4 flex-1 space-y-6 overflow-y-auto">
|
||||||
|
{/* Navigation Group */}
|
||||||
|
<div>
|
||||||
|
<div className="px-3 mb-2 text-[11px] font-semibold text-slate-500 uppercase tracking-wider">
|
||||||
|
Navigation
|
||||||
|
</div>
|
||||||
|
<nav className="space-y-1">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive = activeTab === item.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => handleTabClick(item.id)}
|
||||||
|
className={`w-full flex items-center justify-between px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${
|
||||||
|
isActive
|
||||||
|
? 'bg-amber-500/10 text-amber-400 border border-amber-500/30 shadow-sm'
|
||||||
|
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<Icon className={`w-4 h-4 ${isActive ? 'text-amber-400' : 'text-slate-400'}`} />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</div>
|
||||||
|
{item.badge !== undefined && (
|
||||||
|
<span className={`text-[10px] font-bold text-white px-2 py-0.5 rounded-full ${item.badgeColor}`}>
|
||||||
|
{item.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Administration Group */}
|
||||||
|
<div>
|
||||||
|
<div className="px-3 mb-2 text-[11px] font-semibold text-slate-500 uppercase tracking-wider">
|
||||||
|
Administration
|
||||||
|
</div>
|
||||||
|
<nav className="space-y-1">
|
||||||
|
{adminItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive = activeTab === item.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => handleTabClick(item.id)}
|
||||||
|
className={`w-full flex items-center space-x-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${
|
||||||
|
isActive
|
||||||
|
? 'bg-amber-500/10 text-amber-400 border border-amber-500/30'
|
||||||
|
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className={`w-4 h-4 ${isActive ? 'text-amber-400' : 'text-slate-400'}`} />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Deployment & DevOps Group */}
|
||||||
|
<div>
|
||||||
|
<div className="px-3 mb-2 text-[11px] font-semibold text-slate-500 uppercase tracking-wider">
|
||||||
|
Cloud & Deployment
|
||||||
|
</div>
|
||||||
|
<nav className="space-y-1">
|
||||||
|
{devItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive = activeTab === item.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => handleTabClick(item.id)}
|
||||||
|
className={`w-full flex items-center space-x-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all border ${
|
||||||
|
isActive
|
||||||
|
? 'bg-emerald-500/15 text-emerald-300 border-emerald-500/40 shadow-sm'
|
||||||
|
: 'bg-emerald-950/20 text-emerald-400/90 border-emerald-800/30 hover:bg-emerald-900/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4 text-emerald-400" />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Info Box */}
|
||||||
|
<div className="p-4 border-t border-slate-800/80 bg-slate-950/50">
|
||||||
|
<div className="p-3 rounded-xl bg-slate-800/40 border border-slate-700/50 text-xs text-slate-400 space-y-1">
|
||||||
|
<div className="flex items-center space-x-2 text-slate-200 font-medium">
|
||||||
|
<Database className="w-3.5 h-3.5 text-amber-400" />
|
||||||
|
<span>PostgreSQL + Coolify</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-slate-400">Automated SSL certificates via Traefik reverse proxy.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Desktop Sidebar */}
|
||||||
|
<aside className="w-64 bg-slate-900 border-r border-slate-800 text-slate-300 hidden md:flex flex-col shrink-0 min-h-[calc(100vh-4rem)]">
|
||||||
|
{sidebarContent}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Mobile Drawer Backdrop & Sidebar */}
|
||||||
|
{isOpenMobile && (
|
||||||
|
<div className="fixed inset-0 z-40 md:hidden">
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-slate-950/80 backdrop-blur-sm transition-opacity"
|
||||||
|
onClick={onCloseMobile}
|
||||||
|
/>
|
||||||
|
<aside className="fixed inset-y-0 left-0 w-72 bg-slate-900 border-r border-slate-800 text-slate-300 z-50 flex flex-col shadow-2xl animate-in slide-in-from-left duration-200">
|
||||||
|
{sidebarContent}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
537
src/components/SmtpSettingsView.tsx
Normal file
537
src/components/SmtpSettingsView.tsx
Normal file
|
|
@ -0,0 +1,537 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Mail, Send, CheckCircle, AlertCircle, RefreshCw, Key, Server, Settings, ShieldCheck, Check, Copy, Clock, Inbox, Lock } from 'lucide-react';
|
||||||
|
import { ProjectStore, SmtpConfig, EmailNotificationLog } from '../types';
|
||||||
|
|
||||||
|
interface SmtpSettingsViewProps {
|
||||||
|
store: ProjectStore;
|
||||||
|
userRole?: string;
|
||||||
|
onUpdateStore: (updatedStore: ProjectStore) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SmtpSettingsView: React.FC<SmtpSettingsViewProps> = ({
|
||||||
|
store,
|
||||||
|
userRole = 'viewer',
|
||||||
|
onUpdateStore
|
||||||
|
}) => {
|
||||||
|
const isAdmin = userRole === 'admin';
|
||||||
|
|
||||||
|
const defaultConfig: SmtpConfig = {
|
||||||
|
host: 'smtp.mailtrap.io',
|
||||||
|
port: 587,
|
||||||
|
user: '',
|
||||||
|
pass: '••••••••••••',
|
||||||
|
secure: false,
|
||||||
|
fromName: 'Pinnacle Decision Portal',
|
||||||
|
fromEmail: 'notifications@pinnacle.local',
|
||||||
|
enabled: true,
|
||||||
|
notifyOnNewDecision: true,
|
||||||
|
notifyOnHighRisk: true,
|
||||||
|
notifyOnUserCreated: true
|
||||||
|
};
|
||||||
|
|
||||||
|
const [cfg, setCfg] = useState<SmtpConfig>(defaultConfig);
|
||||||
|
const [testEmailRecipient, setTestEmailRecipient] = useState('admin@pinnacle.local');
|
||||||
|
const [testStatus, setTestStatus] = useState<{ success?: boolean; message?: string; loading?: boolean } | null>(null);
|
||||||
|
const [backendLoaded, setBackendLoaded] = useState(false);
|
||||||
|
|
||||||
|
// Manual Dispatch State
|
||||||
|
const [manualRecipient, setManualRecipient] = useState('');
|
||||||
|
const [manualSubject, setManualSubject] = useState('');
|
||||||
|
const [manualBody, setManualBody] = useState('');
|
||||||
|
const [manualStatus, setManualStatus] = useState<{ success?: boolean; message?: string; loading?: boolean } | null>(null);
|
||||||
|
|
||||||
|
const logs: EmailNotificationLog[] = store.emailLogs || [];
|
||||||
|
|
||||||
|
// Fetch secure SMTP config from backend on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/smtp/config')
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data && data.host) {
|
||||||
|
setCfg({
|
||||||
|
host: data.host,
|
||||||
|
port: data.port || 587,
|
||||||
|
user: data.user || '',
|
||||||
|
pass: data.pass || (data.hasPassword ? '••••••••••••' : ''),
|
||||||
|
secure: Boolean(data.secure),
|
||||||
|
fromName: data.fromName || 'Pinnacle Decision Portal',
|
||||||
|
fromEmail: data.fromEmail || 'notifications@pinnacle.local',
|
||||||
|
enabled: data.enabled !== undefined ? data.enabled : true,
|
||||||
|
notifyOnNewDecision: data.notifyOnNewDecision !== undefined ? data.notifyOnNewDecision : true,
|
||||||
|
notifyOnHighRisk: data.notifyOnHighRisk !== undefined ? data.notifyOnHighRisk : true,
|
||||||
|
notifyOnUserCreated: data.notifyOnUserCreated !== undefined ? data.notifyOnUserCreated : true
|
||||||
|
});
|
||||||
|
setBackendLoaded(true);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => console.warn('Backend SMTP load error:', err));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSaveConfig = async () => {
|
||||||
|
setTestStatus({ loading: true });
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/smtp/config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(cfg)
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Failed to update backend SMTP settings');
|
||||||
|
|
||||||
|
const sanitizedCfgForStore = {
|
||||||
|
...cfg,
|
||||||
|
pass: '••••••••••••' // Store masked string in frontend state
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedStore: ProjectStore = {
|
||||||
|
...store,
|
||||||
|
smtpConfig: sanitizedCfgForStore,
|
||||||
|
logs: [
|
||||||
|
{
|
||||||
|
id: Date.now(),
|
||||||
|
action: 'SMTP_CONFIG_UPDATED',
|
||||||
|
type: 'system',
|
||||||
|
details: `SMTP backend configuration saved (${cfg.host}:${cfg.port}, Enabled: ${cfg.enabled}). Credentials kept securely on server.`,
|
||||||
|
user: 'System Administrator',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
},
|
||||||
|
...(store.logs || [])
|
||||||
|
]
|
||||||
|
};
|
||||||
|
onUpdateStore(updatedStore);
|
||||||
|
setTestStatus({ success: true, message: 'Backend SMTP configuration saved securely to server!' });
|
||||||
|
} catch (err: any) {
|
||||||
|
setTestStatus({ success: false, message: err.message || 'Error saving SMTP config to backend.' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTestSmtpConnection = async () => {
|
||||||
|
setTestStatus({ loading: true });
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/notifications/smtp-test', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
...cfg,
|
||||||
|
recipientEmail: testEmailRecipient
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error || 'SMTP Test Connection Failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
setTestStatus({
|
||||||
|
success: data.success,
|
||||||
|
message: data.message
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add dispatch log to store
|
||||||
|
const newLog: EmailNotificationLog = {
|
||||||
|
id: Date.now(),
|
||||||
|
recipient: testEmailRecipient,
|
||||||
|
subject: 'Pinnacle Portal - SMTP Test Notification',
|
||||||
|
status: data.mode === 'live_smtp' ? 'sent' : 'simulated',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
triggerEvent: 'SMTP_TEST'
|
||||||
|
};
|
||||||
|
|
||||||
|
onUpdateStore({
|
||||||
|
...store,
|
||||||
|
smtpConfig: cfg,
|
||||||
|
emailLogs: [newLog, ...logs]
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
setTestStatus({
|
||||||
|
success: false,
|
||||||
|
message: err.message || 'SMTP Connection failed.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendManualEmail = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!manualRecipient || !manualSubject) return;
|
||||||
|
|
||||||
|
setManualStatus({ loading: true });
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/notifications/send-email', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
recipient: manualRecipient,
|
||||||
|
subject: manualSubject,
|
||||||
|
body: manualBody,
|
||||||
|
triggerEvent: 'MANUAL_DISPATCH',
|
||||||
|
smtpConfig: cfg
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error || 'Failed to send notification email');
|
||||||
|
}
|
||||||
|
|
||||||
|
setManualStatus({
|
||||||
|
success: true,
|
||||||
|
message: data.message || `Notification dispatched to ${manualRecipient}`
|
||||||
|
});
|
||||||
|
|
||||||
|
const newLog: EmailNotificationLog = data.log || {
|
||||||
|
id: Date.now(),
|
||||||
|
recipient: manualRecipient,
|
||||||
|
subject: manualSubject,
|
||||||
|
status: data.status || 'sent',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
triggerEvent: 'MANUAL_DISPATCH'
|
||||||
|
};
|
||||||
|
|
||||||
|
onUpdateStore({
|
||||||
|
...store,
|
||||||
|
emailLogs: [newLog, ...logs]
|
||||||
|
});
|
||||||
|
|
||||||
|
setManualRecipient('');
|
||||||
|
setManualSubject('');
|
||||||
|
setManualBody('');
|
||||||
|
} catch (err: any) {
|
||||||
|
setManualStatus({
|
||||||
|
success: false,
|
||||||
|
message: err.message || 'Error sending manual email.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3 sm:p-6 max-w-7xl mx-auto space-y-6">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-slate-800 pb-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-slate-100 flex items-center space-x-2">
|
||||||
|
<Mail className="w-6 h-6 text-amber-400" />
|
||||||
|
<span>SMTP Configuration & Email Notifications</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
Configure live outbound SMTP mail server credentials, automated governance triggers, and monitor dispatch logs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className={`px-3 py-1 rounded-full text-xs font-bold border flex items-center space-x-1.5 ${
|
||||||
|
cfg.enabled
|
||||||
|
? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400'
|
||||||
|
: 'bg-slate-800 border-slate-700 text-slate-400'
|
||||||
|
}`}>
|
||||||
|
<span className={`w-2 h-2 rounded-full ${cfg.enabled ? 'bg-emerald-400 animate-pulse' : 'bg-slate-500'}`} />
|
||||||
|
<span>{cfg.enabled ? 'Email Engine Active' : 'Engine Disabled'}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid Layout: Config Form & Manual Test Panel */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Left 2 Cols: Main SMTP Server Configuration */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-6 shadow-xl space-y-6">
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-800 pb-4">
|
||||||
|
<h3 className="font-bold text-slate-100 text-base flex items-center space-x-2">
|
||||||
|
<Server className="w-5 h-5 text-amber-400" />
|
||||||
|
<span>SMTP Server Credentials</span>
|
||||||
|
</h3>
|
||||||
|
<label className="flex items-center space-x-2 cursor-pointer">
|
||||||
|
<span className="text-xs text-slate-400 font-semibold">Master Toggle:</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={cfg.enabled}
|
||||||
|
onChange={(e) => setCfg({ ...cfg, enabled: e.target.checked })}
|
||||||
|
disabled={!isAdmin}
|
||||||
|
className="w-4 h-4 rounded bg-slate-950 border-slate-700 text-amber-500 focus:ring-amber-500"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Environment Variables Info Banner & Status Badges */}
|
||||||
|
<div className="bg-slate-950 border border-slate-800 rounded-2xl p-4 space-y-3">
|
||||||
|
<div className="flex items-center space-x-2 text-amber-400 font-semibold text-xs">
|
||||||
|
<ShieldCheck className="w-4 h-4 text-emerald-400" />
|
||||||
|
<span>Coolify Container Environment Variables Active</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-300 leading-relaxed">
|
||||||
|
SMTP mail server credentials are moved to and managed via <strong>Coolify Environment Variables</strong> (<code className="text-amber-300 font-mono">SMTP_HOST</code>, <code className="text-amber-300 font-mono">SMTP_PORT</code>, <code className="text-amber-300 font-mono">SMTP_USER</code>, <code className="text-amber-300 font-mono">SMTP_PASS</code>, <code className="text-amber-300 font-mono">SMTP_SECURE</code>, <code className="text-amber-300 font-mono">SMTP_FROM_NAME</code>, <code className="text-amber-300 font-mono">SMTP_FROM_EMAIL</code>). Sensitive secrets are locked on the backend runtime environment.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Read-Only Detected Env Badges */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3 text-xs pt-1">
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-xl p-3">
|
||||||
|
<div className="text-[10px] text-slate-400 uppercase font-semibold">SMTP Host</div>
|
||||||
|
<div className="text-slate-200 font-mono font-bold mt-0.5 truncate">{cfg.host || 'smtp.mailtrap.io'}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-xl p-3">
|
||||||
|
<div className="text-[10px] text-slate-400 uppercase font-semibold">Port & Connection</div>
|
||||||
|
<div className="text-slate-200 font-mono font-bold mt-0.5">{cfg.port || 587} {cfg.secure ? '(SSL/TLS)' : '(STARTTLS)'}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-xl p-3">
|
||||||
|
<div className="text-[10px] text-slate-400 uppercase font-semibold">SMTP Username</div>
|
||||||
|
<div className="text-slate-200 font-mono font-bold mt-0.5 truncate">{cfg.user ? cfg.user : 'Env Configured'}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-xl p-3">
|
||||||
|
<div className="text-[10px] text-slate-400 uppercase font-semibold">Sender Name</div>
|
||||||
|
<div className="text-slate-200 font-bold mt-0.5 truncate">{cfg.fromName || 'Pinnacle Decision Portal'}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-xl p-3 sm:col-span-2">
|
||||||
|
<div className="text-[10px] text-slate-400 uppercase font-semibold">Sender Email</div>
|
||||||
|
<div className="text-slate-200 font-mono font-bold mt-0.5 truncate">{cfg.fromEmail || 'notifications@pinnacle.local'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Governance Event Notification Triggers */}
|
||||||
|
<div className="pt-2 border-t border-slate-800 space-y-3">
|
||||||
|
<div className="text-xs font-bold text-amber-400 uppercase tracking-wider">
|
||||||
|
Automated Governance Triggers
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs">
|
||||||
|
<label className="flex items-center space-x-2 p-3 bg-slate-950 border border-slate-800 rounded-xl cursor-pointer hover:border-amber-500/30">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={cfg.notifyOnNewDecision}
|
||||||
|
onChange={(e) => setCfg({ ...cfg, notifyOnNewDecision: e.target.checked })}
|
||||||
|
disabled={!isAdmin}
|
||||||
|
className="w-4 h-4 rounded text-amber-500"
|
||||||
|
/>
|
||||||
|
<span className="text-slate-300 font-medium">New Decision Submissions</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center space-x-2 p-3 bg-slate-950 border border-slate-800 rounded-xl cursor-pointer hover:border-amber-500/30">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={cfg.notifyOnHighRisk}
|
||||||
|
onChange={(e) => setCfg({ ...cfg, notifyOnHighRisk: e.target.checked })}
|
||||||
|
disabled={!isAdmin}
|
||||||
|
className="w-4 h-4 rounded text-amber-500"
|
||||||
|
/>
|
||||||
|
<span className="text-slate-300 font-medium">Critical/High Risk Alerts</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center space-x-2 p-3 bg-slate-950 border border-slate-800 rounded-xl cursor-pointer hover:border-amber-500/30">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={cfg.notifyOnUserCreated}
|
||||||
|
onChange={(e) => setCfg({ ...cfg, notifyOnUserCreated: e.target.checked })}
|
||||||
|
disabled={!isAdmin}
|
||||||
|
className="w-4 h-4 rounded text-amber-500"
|
||||||
|
/>
|
||||||
|
<span className="text-slate-300 font-medium">New User Provisioning</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Bar */}
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 pt-4 border-t border-slate-800">
|
||||||
|
<div className="flex items-center space-x-2 w-full sm:w-auto">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={testEmailRecipient}
|
||||||
|
onChange={(e) => setTestEmailRecipient(e.target.value)}
|
||||||
|
placeholder="Recipient email for test"
|
||||||
|
className="bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-xs text-slate-200 focus:outline-none focus:border-amber-500 w-full sm:w-56"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleTestSmtpConnection}
|
||||||
|
disabled={testStatus?.loading}
|
||||||
|
className="px-3 py-2 bg-slate-800 hover:bg-slate-700 text-amber-400 rounded-xl text-xs font-semibold shrink-0 border border-slate-700 flex items-center space-x-1.5 transition"
|
||||||
|
>
|
||||||
|
{testStatus?.loading ? (
|
||||||
|
<RefreshCw className="w-3.5 h-3.5 animate-spin text-amber-400" />
|
||||||
|
) : (
|
||||||
|
<Send className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
<span>Test SMTP</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSaveConfig}
|
||||||
|
className="px-5 py-2.5 bg-amber-500 hover:bg-amber-400 text-slate-950 font-bold rounded-xl text-xs transition shadow-lg w-full sm:w-auto cursor-pointer"
|
||||||
|
>
|
||||||
|
Save SMTP Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Test Connection Banner */}
|
||||||
|
{testStatus && (
|
||||||
|
<div
|
||||||
|
className={`p-3 rounded-2xl text-xs border flex items-start space-x-2 ${
|
||||||
|
testStatus.success
|
||||||
|
? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-300'
|
||||||
|
: 'bg-rose-500/10 border-rose-500/30 text-rose-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{testStatus.success ? (
|
||||||
|
<CheckCircle className="w-4 h-4 text-emerald-400 shrink-0 mt-0.5" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="w-4 h-4 text-rose-400 shrink-0 mt-0.5" />
|
||||||
|
)}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="font-bold">{testStatus.success ? 'SMTP Connection Verified' : 'SMTP Error Notice'}</div>
|
||||||
|
<div className="text-[11px] opacity-90">{testStatus.message}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right 1 Col: Manual Email Dispatch Tool */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-6 shadow-xl space-y-4">
|
||||||
|
<h3 className="font-bold text-slate-100 text-base flex items-center space-x-2 border-b border-slate-800 pb-3">
|
||||||
|
<Send className="w-5 h-5 text-amber-400" />
|
||||||
|
<span>Manual Email Dispatch</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<form onSubmit={handleSendManualEmail} className="space-y-3 text-xs">
|
||||||
|
{manualStatus && (
|
||||||
|
<div
|
||||||
|
className={`p-2.5 rounded-xl text-[11px] border flex items-center space-x-2 ${
|
||||||
|
manualStatus.success
|
||||||
|
? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-300'
|
||||||
|
: 'bg-rose-500/10 border-rose-500/30 text-rose-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{manualStatus.success ? <CheckCircle className="w-3.5 h-3.5 text-emerald-400 shrink-0" /> : <AlertCircle className="w-3.5 h-3.5 text-rose-400 shrink-0" />}
|
||||||
|
<span>{manualStatus.message}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Recipient Address</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={manualRecipient}
|
||||||
|
onChange={(e) => setManualRecipient(e.target.value)}
|
||||||
|
placeholder="executive@pinnacle.local"
|
||||||
|
required
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-amber-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Email Subject</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={manualSubject}
|
||||||
|
onChange={(e) => setManualSubject(e.target.value)}
|
||||||
|
placeholder="e.g. Urgency Notice: PSC Board Meeting"
|
||||||
|
required
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-amber-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Message Body</label>
|
||||||
|
<textarea
|
||||||
|
rows={4}
|
||||||
|
value={manualBody}
|
||||||
|
onChange={(e) => setManualBody(e.target.value)}
|
||||||
|
placeholder="Enter custom governance notice body text..."
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-amber-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={manualStatus?.loading || !isAdmin}
|
||||||
|
className="w-full py-2.5 bg-amber-500 hover:bg-amber-400 disabled:bg-slate-800 text-slate-950 disabled:text-slate-500 font-bold rounded-xl text-xs transition shadow flex items-center justify-center space-x-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
{manualStatus?.loading ? (
|
||||||
|
<span>Dispatching Notification...</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>Dispatch Email Alert</span>
|
||||||
|
<Send className="w-3.5 h-3.5" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Historical Dispatch Logs Table */}
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-6 shadow-xl space-y-4">
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-800 pb-3">
|
||||||
|
<h3 className="font-bold text-slate-100 text-base flex items-center space-x-2">
|
||||||
|
<Clock className="w-5 h-5 text-amber-400" />
|
||||||
|
<span>Outbound Email Dispatch Log</span>
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs text-slate-400 font-mono">Total Logs: {logs.length}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-slate-500 text-xs">
|
||||||
|
No outbound notification dispatches recorded yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-xs text-slate-300">
|
||||||
|
<thead className="bg-slate-950 text-slate-400 font-semibold border-b border-slate-800 uppercase tracking-wider">
|
||||||
|
<tr>
|
||||||
|
<th className="p-3">Timestamp</th>
|
||||||
|
<th className="p-3">Recipient</th>
|
||||||
|
<th className="p-3">Subject</th>
|
||||||
|
<th className="p-3">Trigger Event</th>
|
||||||
|
<th className="p-3">Delivery Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800">
|
||||||
|
{logs.map((log) => (
|
||||||
|
<tr key={log.id} className="hover:bg-slate-800/50 transition">
|
||||||
|
<td className="p-3 font-mono text-[11px] text-slate-400">
|
||||||
|
{new Date(log.timestamp).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 font-medium text-amber-400">{log.recipient}</td>
|
||||||
|
<td className="p-3 font-semibold text-slate-200">{log.subject}</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<span className="px-2 py-0.5 bg-slate-800 text-slate-300 text-[10px] font-mono rounded">
|
||||||
|
{log.triggerEvent}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
{log.status === 'sent' && (
|
||||||
|
<span className="px-2 py-0.5 bg-emerald-500/10 text-emerald-400 border border-emerald-500/30 text-[10px] font-bold rounded uppercase">
|
||||||
|
Sent via SMTP
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{log.status === 'simulated' && (
|
||||||
|
<span className="px-2 py-0.5 bg-amber-500/10 text-amber-400 border border-amber-500/30 text-[10px] font-bold rounded uppercase">
|
||||||
|
Simulated Preview
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{log.status === 'failed' && (
|
||||||
|
<span className="px-2 py-0.5 bg-rose-500/10 text-rose-400 border border-rose-500/30 text-[10px] font-bold rounded uppercase">
|
||||||
|
Failed
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
292
src/components/StakeholdersView.tsx
Normal file
292
src/components/StakeholdersView.tsx
Normal file
|
|
@ -0,0 +1,292 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Handshake, Plus, Search, Building, UserCheck, Calendar, Edit2 } from 'lucide-react';
|
||||||
|
import { ProjectStore, UserRole, Stakeholder, StakeholderType } from '../types';
|
||||||
|
|
||||||
|
interface StakeholdersViewProps {
|
||||||
|
store: ProjectStore;
|
||||||
|
userRole: UserRole;
|
||||||
|
onUpdateStore: (updated: ProjectStore) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StakeholdersView: React.FC<StakeholdersViewProps> = ({
|
||||||
|
store,
|
||||||
|
userRole,
|
||||||
|
onUpdateStore
|
||||||
|
}) => {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [typeFilter, setTypeFilter] = useState<string>('all');
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [editingStakeholder, setEditingStakeholder] = useState<Partial<Stakeholder> | null>(null);
|
||||||
|
|
||||||
|
const canEdit = userRole === 'admin' || userRole === 'editor';
|
||||||
|
|
||||||
|
const filteredStakeholders = store.stakeholders.filter((s) => {
|
||||||
|
const matchesSearch =
|
||||||
|
s.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
s.org.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
const matchesType = typeFilter === 'all' || s.type === typeFilter;
|
||||||
|
return matchesSearch && matchesType;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSaveStakeholder = () => {
|
||||||
|
if (!editingStakeholder?.name || !editingStakeholder?.org) return;
|
||||||
|
|
||||||
|
let updated = [...store.stakeholders];
|
||||||
|
if (editingStakeholder.id) {
|
||||||
|
updated = updated.map((s) =>
|
||||||
|
s.id === editingStakeholder.id ? ({ ...s, ...editingStakeholder } as Stakeholder) : s
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const newS: Stakeholder = {
|
||||||
|
id: store.ids.stakeholder || Date.now(),
|
||||||
|
name: editingStakeholder.name,
|
||||||
|
org: editingStakeholder.org,
|
||||||
|
type: (editingStakeholder.type as StakeholderType) || 'internal',
|
||||||
|
engagementLevel: Number(editingStakeholder.engagementLevel) || 75,
|
||||||
|
status: 'active',
|
||||||
|
role: editingStakeholder.role || 'Stakeholder',
|
||||||
|
linkedPhases: [1, 2, 3, 4],
|
||||||
|
owner: editingStakeholder.owner || 'PMO Lead',
|
||||||
|
notes: editingStakeholder.notes || ''
|
||||||
|
};
|
||||||
|
updated.push(newS);
|
||||||
|
}
|
||||||
|
|
||||||
|
onUpdateStore({
|
||||||
|
...store,
|
||||||
|
stakeholders: updated,
|
||||||
|
ids: { ...store.ids, stakeholder: store.ids.stakeholder + 1 }
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setEditingStakeholder(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3 sm:p-6 max-w-7xl mx-auto space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-slate-100 flex items-center space-x-2">
|
||||||
|
<Handshake className="w-6 h-6 text-purple-400" />
|
||||||
|
<span>Stakeholder Engagement Matrix</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
Registry of internal business units, board members, regulatory authorities, and key technology partners.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canEdit && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingStakeholder({ type: 'internal', engagementLevel: 80 });
|
||||||
|
setIsModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center space-x-2 px-4 py-2 bg-purple-600 hover:bg-purple-500 text-white font-bold rounded-xl text-xs transition shadow-md shrink-0"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>Add Stakeholder</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Bar */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 bg-slate-900/80 p-3 rounded-2xl border border-slate-800">
|
||||||
|
<div className="relative w-full sm:w-80">
|
||||||
|
<Search className="w-4 h-4 text-slate-400 absolute left-3 top-2.5" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search stakeholder or organization..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl pl-9 pr-3 py-1.5 text-xs text-slate-200 focus:outline-none focus:border-purple-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2 w-full sm:w-auto">
|
||||||
|
<span className="text-xs text-slate-400 font-medium">Category:</span>
|
||||||
|
<select
|
||||||
|
value={typeFilter}
|
||||||
|
onChange={(e) => setTypeFilter(e.target.value)}
|
||||||
|
className="bg-slate-950 border border-slate-800 rounded-xl px-3 py-1.5 text-xs text-slate-200 focus:outline-none focus:border-purple-500"
|
||||||
|
>
|
||||||
|
<option value="all">All Categories</option>
|
||||||
|
<option value="board">Board of Directors</option>
|
||||||
|
<option value="internal">Internal Business Units</option>
|
||||||
|
<option value="regulatory">Regulatory Authorities</option>
|
||||||
|
<option value="vendor">Technology Vendors</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stakeholder Cards Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{filteredStakeholders.length === 0 ? (
|
||||||
|
<div className="col-span-full bg-slate-900 border border-slate-800 rounded-2xl p-8 text-center text-slate-400">
|
||||||
|
No stakeholders recorded matching criteria.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredStakeholders.map((sh) => (
|
||||||
|
<div
|
||||||
|
key={sh.id}
|
||||||
|
className="bg-slate-900 border border-slate-800 p-5 rounded-2xl shadow-sm space-y-3 flex flex-col justify-between"
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[10px] font-bold text-purple-400 bg-purple-500/10 border border-purple-500/30 px-2 py-0.5 rounded uppercase">
|
||||||
|
{sh.type}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-semibold text-slate-400">{sh.org}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="font-bold text-slate-100 text-base">{sh.name}</h3>
|
||||||
|
<p className="text-xs text-slate-300 leading-snug">{sh.role}</p>
|
||||||
|
|
||||||
|
{sh.notes && (
|
||||||
|
<p className="text-[11px] text-slate-400 italic bg-slate-950 p-2 rounded-lg border border-slate-800/80 mt-2">
|
||||||
|
{sh.notes}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 pt-3 border-t border-slate-800">
|
||||||
|
<div className="flex justify-between items-center text-xs">
|
||||||
|
<span className="text-slate-400">Engagement Index</span>
|
||||||
|
<span className="font-bold text-purple-300">{sh.engagementLevel}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-slate-800 h-1.5 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="bg-purple-500 h-1.5 rounded-full"
|
||||||
|
style={{ width: `${sh.engagementLevel}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-[11px] text-slate-400 pt-1">
|
||||||
|
<span>Lead Owner: {sh.owner}</span>
|
||||||
|
{canEdit && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingStakeholder(sh);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="text-slate-400 hover:text-amber-400"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
{isModalOpen && (
|
||||||
|
<div className="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-2xl max-w-md w-full p-6 shadow-2xl space-y-4">
|
||||||
|
<h3 className="font-bold text-slate-100 text-lg">
|
||||||
|
{editingStakeholder?.id ? 'Edit Stakeholder' : 'Add Stakeholder'}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-3 text-xs">
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Stakeholder / Group Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editingStakeholder?.name || ''}
|
||||||
|
onChange={(e) => setEditingStakeholder({ ...editingStakeholder, name: e.target.value })}
|
||||||
|
placeholder="e.g. Risk & Audit Committee"
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-purple-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Organization / Department</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editingStakeholder?.org || ''}
|
||||||
|
onChange={(e) => setEditingStakeholder({ ...editingStakeholder, org: e.target.value })}
|
||||||
|
placeholder="e.g. Bank Operations"
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-purple-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Category</label>
|
||||||
|
<select
|
||||||
|
value={editingStakeholder?.type || 'internal'}
|
||||||
|
onChange={(e) => setEditingStakeholder({ ...editingStakeholder, type: e.target.value as any })}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-purple-500"
|
||||||
|
>
|
||||||
|
<option value="board">Board</option>
|
||||||
|
<option value="internal">Internal</option>
|
||||||
|
<option value="regulatory">Regulatory</option>
|
||||||
|
<option value="vendor">Vendor</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Engagement %</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={editingStakeholder?.engagementLevel ?? 80}
|
||||||
|
onChange={(e) => setEditingStakeholder({ ...editingStakeholder, engagementLevel: Number(e.target.value) })}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-purple-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Key Engagement Role</label>
|
||||||
|
<textarea
|
||||||
|
value={editingStakeholder?.role || ''}
|
||||||
|
onChange={(e) => setEditingStakeholder({ ...editingStakeholder, role: e.target.value })}
|
||||||
|
rows={2}
|
||||||
|
placeholder="e.g. Requirements sign-off & UAT testing lead"
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-purple-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Lead Owner</label>
|
||||||
|
<select
|
||||||
|
value={editingStakeholder?.owner || ''}
|
||||||
|
onChange={(e) => setEditingStakeholder({ ...editingStakeholder, owner: e.target.value })}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-purple-500"
|
||||||
|
>
|
||||||
|
<option value="">-- Select Registered Lead Owner --</option>
|
||||||
|
{store.users.map((u) => (
|
||||||
|
<option key={u.id} value={u.fullName}>
|
||||||
|
{u.fullName} ({u.role.toUpperCase()} - {u.dept})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
<option value="PMO Lead">PMO Lead</option>
|
||||||
|
<option value="Consultant Lead">Consultant Lead</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-3 pt-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsModalOpen(false)}
|
||||||
|
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl text-xs font-medium"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveStakeholder}
|
||||||
|
className="px-4 py-2 bg-purple-600 hover:bg-purple-500 text-white font-bold rounded-xl text-xs"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
501
src/components/UsersView.tsx
Normal file
501
src/components/UsersView.tsx
Normal file
|
|
@ -0,0 +1,501 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { UserCog, Plus, Shield, User as UserIcon, Mail, Building, Key, Search, RefreshCw, Lock, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
|
||||||
|
import { ProjectStore, UserRole, User } from '../types';
|
||||||
|
|
||||||
|
interface UsersViewProps {
|
||||||
|
store: ProjectStore;
|
||||||
|
userRole: UserRole;
|
||||||
|
onUpdateStore: (updated: ProjectStore) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UsersView: React.FC<UsersViewProps> = ({
|
||||||
|
store,
|
||||||
|
userRole,
|
||||||
|
onUpdateStore
|
||||||
|
}) => {
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [resetPassUser, setResetPassUser] = useState<User | null>(null);
|
||||||
|
const [newGeneratedPass, setNewGeneratedPass] = useState('');
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [roleFilter, setRoleFilter] = useState<string>('all');
|
||||||
|
const [editingUser, setEditingUser] = useState<Partial<User>>({ role: 'viewer', active: true });
|
||||||
|
|
||||||
|
const isAdmin = userRole === 'admin';
|
||||||
|
|
||||||
|
// Generate random strong password
|
||||||
|
const generatePassword = () => {
|
||||||
|
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%';
|
||||||
|
let pass = '';
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
pass += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||||
|
}
|
||||||
|
return pass;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveUser = async () => {
|
||||||
|
if (!editingUser.username || !editingUser.fullName) return;
|
||||||
|
|
||||||
|
let updatedUsers = [...store.users];
|
||||||
|
let isNew = false;
|
||||||
|
let newUserEmail = editingUser.email || `${editingUser.username}@pinnacle.local`;
|
||||||
|
let newUserName = editingUser.fullName;
|
||||||
|
|
||||||
|
if (editingUser.id) {
|
||||||
|
updatedUsers = updatedUsers.map((u) =>
|
||||||
|
u.id === editingUser.id ? ({ ...u, ...editingUser } as User) : u
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
isNew = true;
|
||||||
|
const newU: User = {
|
||||||
|
id: store.ids.user || Date.now(),
|
||||||
|
username: editingUser.username.toLowerCase(),
|
||||||
|
password: editingUser.password || 'Pinnacle123',
|
||||||
|
role: editingUser.role || 'viewer',
|
||||||
|
fullName: editingUser.fullName,
|
||||||
|
email: newUserEmail,
|
||||||
|
dept: editingUser.dept || 'Strategy Unit',
|
||||||
|
active: editingUser.active !== undefined ? editingUser.active : true,
|
||||||
|
lastLogin: null
|
||||||
|
};
|
||||||
|
updatedUsers.push(newU);
|
||||||
|
}
|
||||||
|
|
||||||
|
onUpdateStore({
|
||||||
|
...store,
|
||||||
|
users: updatedUsers,
|
||||||
|
ids: { ...store.ids, user: store.ids.user + 1 }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isNew && store.smtpConfig?.enabled && store.smtpConfig?.notifyOnUserCreated) {
|
||||||
|
try {
|
||||||
|
await fetch('/api/notifications/send-email', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
recipient: newUserEmail,
|
||||||
|
subject: `Welcome to Pinnacle Executive Portal, ${newUserName}`,
|
||||||
|
body: `<p>Your user account has been successfully provisioned in the Pinnacle Governance Portal.</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Full Name:</strong> ${newUserName}</li>
|
||||||
|
<li><strong>Username:</strong> ${editingUser.username}</li>
|
||||||
|
<li><strong>Role Access Level:</strong> ${editingUser.role || 'viewer'}</li>
|
||||||
|
<li><strong>Department:</strong> ${editingUser.dept || 'Strategy Unit'}</li>
|
||||||
|
</ul>
|
||||||
|
<p>Please contact your System Administrator if you need credentials reset.</p>`,
|
||||||
|
triggerEvent: 'USER_PROVISIONED',
|
||||||
|
smtpConfig: store.smtpConfig
|
||||||
|
})
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('User welcome email error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setEditingUser({ role: 'viewer', active: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleStatus = (userId: number) => {
|
||||||
|
if (!isAdmin) return;
|
||||||
|
const updatedUsers = store.users.map(u =>
|
||||||
|
u.id === userId ? { ...u, active: !u.active } : u
|
||||||
|
);
|
||||||
|
onUpdateStore({ ...store, users: updatedUsers });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteUser = (userId: number, userName: string) => {
|
||||||
|
if (!isAdmin) return;
|
||||||
|
if (window.confirm(`Are you sure you want to permanently delete user account "${userName}"?`)) {
|
||||||
|
const updatedUsers = store.users.filter(u => u.id !== userId);
|
||||||
|
onUpdateStore({
|
||||||
|
...store,
|
||||||
|
users: updatedUsers,
|
||||||
|
logs: [
|
||||||
|
{
|
||||||
|
id: Date.now(),
|
||||||
|
action: 'USER_DELETED',
|
||||||
|
type: 'system',
|
||||||
|
details: `User account "${userName}" deleted by Administrator.`,
|
||||||
|
user: 'System Administrator',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
},
|
||||||
|
...(store.logs || [])
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetPassword = () => {
|
||||||
|
if (!resetPassUser || !newGeneratedPass) return;
|
||||||
|
const updatedUsers = store.users.map(u =>
|
||||||
|
u.id === resetPassUser.id ? { ...u, password: newGeneratedPass } : u
|
||||||
|
);
|
||||||
|
onUpdateStore({ ...store, users: updatedUsers });
|
||||||
|
setResetPassUser(null);
|
||||||
|
setNewGeneratedPass('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredUsers = store.users.filter((u) => {
|
||||||
|
const matchesSearch =
|
||||||
|
u.fullName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
u.username.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
u.email.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
u.dept.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
const matchesRole = roleFilter === 'all' || u.role === roleFilter;
|
||||||
|
return matchesSearch && matchesRole;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3 sm:p-6 max-w-7xl mx-auto space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-slate-100 flex items-center space-x-2">
|
||||||
|
<UserCog className="w-6 h-6 text-amber-400" />
|
||||||
|
<span>Production User Management & Access Control</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
Manage authorized portal users, bcrypt hashed passwords, role-based access control (Admin, Editor, Viewer), and department policies.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingUser({ role: 'viewer', active: true, password: generatePassword() });
|
||||||
|
setIsModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center space-x-2 px-4 py-2 bg-amber-500 hover:bg-amber-400 text-slate-950 font-bold rounded-xl text-xs transition shadow-md shrink-0 cursor-pointer"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>Create User Account</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Role Breakdown Bar */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="bg-slate-900 border border-slate-800 p-4 rounded-2xl space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-bold text-amber-400 uppercase tracking-wider">Admin Role</span>
|
||||||
|
<span className="text-[10px] bg-amber-500/10 text-amber-300 px-2 py-0.5 rounded font-mono font-bold">
|
||||||
|
Full Control
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400">Full system administrative rights, user management, security logs & DB deployment tools.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-900 border border-slate-800 p-4 rounded-2xl space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-bold text-blue-400 uppercase tracking-wider">Editor Role</span>
|
||||||
|
<span className="text-[10px] bg-blue-500/10 text-blue-300 px-2 py-0.5 rounded font-mono font-bold">
|
||||||
|
Read & Write
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400">Can create and update milestones, activity progress, decision items, risks & committee logs.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-900 border border-slate-800 p-4 rounded-2xl space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-bold text-slate-300 uppercase tracking-wider">Viewer Role</span>
|
||||||
|
<span className="text-[10px] bg-slate-800 text-slate-300 px-2 py-0.5 rounded font-mono font-bold">
|
||||||
|
Read Only
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400">Read-only executive portal visibility for board members, auditors, and external observers.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search & Filter Bar */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 bg-slate-900 border border-slate-800 p-3 rounded-2xl">
|
||||||
|
<div className="relative w-full sm:w-72">
|
||||||
|
<Search className="w-4 h-4 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search users, email, department..."
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl pl-9 pr-3 py-2 text-xs text-slate-200 focus:outline-none focus:border-amber-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2 w-full sm:w-auto">
|
||||||
|
<span className="text-xs text-slate-400 font-medium">Role Filter:</span>
|
||||||
|
<select
|
||||||
|
value={roleFilter}
|
||||||
|
onChange={(e) => setRoleFilter(e.target.value)}
|
||||||
|
className="bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-xs text-slate-200 focus:outline-none focus:border-amber-500"
|
||||||
|
>
|
||||||
|
<option value="all">All Roles ({store.users.length})</option>
|
||||||
|
<option value="admin">Admins</option>
|
||||||
|
<option value="editor">Editors</option>
|
||||||
|
<option value="viewer">Viewers</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User Roster Table */}
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-sm">
|
||||||
|
<table className="w-full text-left text-xs text-slate-300">
|
||||||
|
<thead className="bg-slate-950 text-slate-400 font-semibold border-b border-slate-800 uppercase tracking-wider">
|
||||||
|
<tr>
|
||||||
|
<th className="p-4">User Details</th>
|
||||||
|
<th className="p-4">Username</th>
|
||||||
|
<th className="p-4">Role</th>
|
||||||
|
<th className="p-4">Department</th>
|
||||||
|
<th className="p-4">Status</th>
|
||||||
|
{isAdmin && <th className="p-4 text-right">Actions</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800">
|
||||||
|
{filteredUsers.map((u) => (
|
||||||
|
<tr key={u.id} className="hover:bg-slate-800/50 transition">
|
||||||
|
<td className="p-4">
|
||||||
|
<div className="font-bold text-slate-100 flex items-center space-x-2">
|
||||||
|
<div className="w-7 h-7 rounded-full bg-slate-800 border border-slate-700 flex items-center justify-center text-amber-400 font-bold text-xs">
|
||||||
|
{u.fullName.charAt(0)}
|
||||||
|
</div>
|
||||||
|
<span>{u.fullName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-slate-400 pl-9">{u.email}</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-4 font-mono text-amber-400 font-medium">{u.username}</td>
|
||||||
|
<td className="p-4">
|
||||||
|
<span
|
||||||
|
className={`font-bold text-[11px] px-2.5 py-1 rounded-lg border uppercase ${
|
||||||
|
u.role === 'admin'
|
||||||
|
? 'bg-amber-500/20 text-amber-300 border-amber-500/30'
|
||||||
|
: u.role === 'editor'
|
||||||
|
? 'bg-blue-500/20 text-blue-300 border-blue-500/30'
|
||||||
|
: 'bg-slate-800 text-slate-400 border-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{u.role}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-slate-300">{u.dept}</td>
|
||||||
|
<td className="p-4">
|
||||||
|
{u.active !== false ? (
|
||||||
|
<span className="text-[11px] font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20 inline-flex items-center space-x-1">
|
||||||
|
<CheckCircle className="w-3 h-3" />
|
||||||
|
<span>Active</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[11px] font-semibold text-rose-400 bg-rose-500/10 px-2.5 py-1 rounded-lg border border-rose-500/20 inline-flex items-center space-x-1">
|
||||||
|
<XCircle className="w-3 h-3" />
|
||||||
|
<span>Disabled</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
{isAdmin && (
|
||||||
|
<td className="p-4 text-right space-x-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingUser({ ...u });
|
||||||
|
setIsModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="px-2 py-1 bg-slate-800 hover:bg-slate-700 text-blue-400 rounded-lg text-[11px] font-medium border border-slate-700 transition"
|
||||||
|
title="Edit User Details"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setResetPassUser(u);
|
||||||
|
setNewGeneratedPass(generatePassword());
|
||||||
|
}}
|
||||||
|
className="px-2 py-1 bg-slate-800 hover:bg-slate-700 text-amber-400 rounded-lg text-[11px] font-medium border border-slate-700 transition"
|
||||||
|
title="Reset Password"
|
||||||
|
>
|
||||||
|
Reset Pass
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleStatus(u.id)}
|
||||||
|
className={`px-2 py-1 rounded-lg text-[11px] font-medium transition border ${
|
||||||
|
u.active !== false
|
||||||
|
? 'bg-amber-950/40 border-amber-800/60 text-amber-300 hover:bg-amber-900/60'
|
||||||
|
: 'bg-emerald-950/40 border-emerald-800/60 text-emerald-300 hover:bg-emerald-900/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{u.active !== false ? 'Disable' : 'Enable'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteUser(u.id, u.fullName)}
|
||||||
|
className="px-2 py-1 bg-rose-950/50 hover:bg-rose-900/80 text-rose-300 rounded-lg text-[11px] font-medium border border-rose-800/60 transition"
|
||||||
|
title="Delete User Account"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add / Edit User Modal */}
|
||||||
|
{isModalOpen && (
|
||||||
|
<div className="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-3xl max-w-md w-full p-6 shadow-2xl space-y-4">
|
||||||
|
<h3 className="font-bold text-slate-100 text-lg flex items-center space-x-2">
|
||||||
|
<UserIcon className="w-5 h-5 text-amber-400" />
|
||||||
|
<span>Create User Account</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-3 text-xs">
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Full Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editingUser.fullName || ''}
|
||||||
|
onChange={(e) => setEditingUser({ ...editingUser, fullName: e.target.value })}
|
||||||
|
placeholder="e.g. Marcus Sterling"
|
||||||
|
required
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-amber-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editingUser.username || ''}
|
||||||
|
onChange={(e) => setEditingUser({ ...editingUser, username: e.target.value })}
|
||||||
|
placeholder="e.g. msterling"
|
||||||
|
required
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-amber-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium flex items-center justify-between">
|
||||||
|
<span>Password</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditingUser({ ...editingUser, password: generatePassword() })}
|
||||||
|
className="text-[10px] text-amber-400 hover:underline"
|
||||||
|
>
|
||||||
|
Auto
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editingUser.password || ''}
|
||||||
|
onChange={(e) => setEditingUser({ ...editingUser, password: e.target.value })}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 font-mono focus:outline-none focus:border-amber-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Email Address</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={editingUser.email || ''}
|
||||||
|
onChange={(e) => setEditingUser({ ...editingUser, email: e.target.value })}
|
||||||
|
placeholder="e.g. msterling@pinnacle.local"
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-amber-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Role Level</label>
|
||||||
|
<select
|
||||||
|
value={editingUser.role || 'viewer'}
|
||||||
|
onChange={(e) => setEditingUser({ ...editingUser, role: e.target.value as UserRole })}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-amber-500"
|
||||||
|
>
|
||||||
|
<option value="viewer">Viewer (Read-only)</option>
|
||||||
|
<option value="editor">Editor (Write)</option>
|
||||||
|
<option value="admin">Admin (Full Control)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Department</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editingUser.dept || ''}
|
||||||
|
onChange={(e) => setEditingUser({ ...editingUser, dept: e.target.value })}
|
||||||
|
placeholder="e.g. Finance"
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-amber-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-3 pt-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsModalOpen(false)}
|
||||||
|
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl text-xs font-medium cursor-pointer"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveUser}
|
||||||
|
className="px-4 py-2 bg-amber-500 hover:bg-amber-400 text-slate-950 font-bold rounded-xl text-xs cursor-pointer shadow"
|
||||||
|
>
|
||||||
|
Create Account
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Admin Password Reset Modal */}
|
||||||
|
{resetPassUser && (
|
||||||
|
<div className="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-3xl max-w-sm w-full p-6 shadow-2xl space-y-4">
|
||||||
|
<h3 className="font-bold text-slate-100 text-base flex items-center space-x-2">
|
||||||
|
<Key className="w-5 h-5 text-amber-400" />
|
||||||
|
<span>Reset User Password</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Reset password for <strong className="text-slate-200">{resetPassUser.fullName}</strong> (<span className="font-mono text-amber-400">{resetPassUser.username}</span>).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-[11px] font-bold text-slate-400 uppercase">New Password</label>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newGeneratedPass}
|
||||||
|
onChange={(e) => setNewGeneratedPass(e.target.value)}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 font-mono text-xs text-amber-300 focus:outline-none focus:border-amber-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setNewGeneratedPass(generatePassword())}
|
||||||
|
className="p-2.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl text-xs flex items-center justify-center"
|
||||||
|
title="Generate New Password"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-2 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setResetPassUser(null)}
|
||||||
|
className="px-3 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl text-xs font-medium"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleResetPassword}
|
||||||
|
className="px-4 py-2 bg-amber-500 hover:bg-amber-400 text-slate-950 font-bold rounded-xl text-xs shadow"
|
||||||
|
>
|
||||||
|
Confirm Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
476
src/data/defaultData.ts
Normal file
476
src/data/defaultData.ts
Normal file
|
|
@ -0,0 +1,476 @@
|
||||||
|
import { ProjectStore } from '../types';
|
||||||
|
|
||||||
|
export function getDefaultData(): ProjectStore {
|
||||||
|
const now = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const phases = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
num: 1,
|
||||||
|
name: 'Market Intelligence & Insights / Diagnostic & Discovery',
|
||||||
|
desc: 'Initial research, stakeholder surveys, and market diagnostic activities.',
|
||||||
|
order: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
num: 2,
|
||||||
|
name: 'Dialogue & Expert Engagement',
|
||||||
|
desc: 'Stakeholder validation, internal performance analysis, and comparative assessment.',
|
||||||
|
order: 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
num: 3,
|
||||||
|
name: 'Strategy Formulation & Quantification',
|
||||||
|
desc: 'Co-creation of strategic vision, frameworks, and business plan development.',
|
||||||
|
order: 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
num: 4,
|
||||||
|
name: 'Strategy Finalization & Issuance',
|
||||||
|
desc: 'Management presentation, revisions, BOD approval, and final closeout.',
|
||||||
|
order: 4
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const rawMilestones = [
|
||||||
|
[1, 1, 'Project Kick-off', '1', '2026-05-15', ['Kick-off Meeting Execution', 'Project Charter & Governance Plan']],
|
||||||
|
[1, 2, 'Quantitative & Qualitative Research Plan', '1', '2026-06-01', ['Inception Report', 'Communication Strategy and Survey Roll-out Plan']],
|
||||||
|
[1, 3, 'Desktop Research', '1-2', '2026-06-15', ['Desktop Research Synthesis Report', 'Regulatory & Industry Trend Assessment']],
|
||||||
|
[1, 4, 'Drafting of Stakeholder Surveys', '1', '2026-06-20', ['Draft Stakeholder Surveys', 'Survey Validation & Sign-off']],
|
||||||
|
[1, 5, 'Conduct Interviews', '2', '2026-07-10', ['Conduct Executive & Stakeholder Interviews', 'Interview Transcript Analysis']],
|
||||||
|
[1, 6, 'Prepare Draft Market Diagnostic Report', '3', '2026-08-01', ['Market Diagnostic Paper', 'PESTEL & Porter Five Forces Analysis']],
|
||||||
|
[2, 7, 'Validate Diagnostic Findings', '3', '2026-08-15', ['Research Workshop Validation', 'Common Understanding Alignment Memo']],
|
||||||
|
[2, 8, 'Internal Performance Analysis', '4', '2026-09-01', ['Internal Operations & Financial Performance Review']],
|
||||||
|
[2, 9, 'SWOT Analysis', '5', '2026-09-15', ['SWOT Matrix & Strategic Capability Assessment']],
|
||||||
|
[2, 10, 'Comparative Analysis', '5', '2026-09-30', ['Peer Benchmark & Competitive Analysis Report']],
|
||||||
|
[3, 11, 'Co-Create Strategic Vision', '6', '2026-10-15', ['Draft Strategic Vision & Mission Statement']],
|
||||||
|
[3, 12, 'Design Operational Risk Framework, Branding & Development Strategies', '6', '2026-11-01', ['Risk & Compliance Framework', 'Branding Strategy', 'Training & Skills Development Plan']],
|
||||||
|
[3, 13, 'Develop Business Plan with Financial Projections', '7', '2026-12-15', ['Draft Business Plan Document', '5-Year Financial Model & Key Assumptions', 'Draft Implementation Roadmap']],
|
||||||
|
[4, 14, 'Present Strategic Business Plan to Management', '7', '2026-12-28', ['Executive Management Presentation']],
|
||||||
|
[4, 15, 'Incorporate Changes as per Management Feedback', '9', '2027-01-05', ['Validated Business Plan Incorporating Feedback']],
|
||||||
|
[4, 16, 'Present Updated Strategic Business Plan to BOD & Closeout', '9', '2027-01-15', ['Final Business Plan & Financial Projections', 'Board Presentation & Project Sign-off']]
|
||||||
|
];
|
||||||
|
|
||||||
|
let mid = 1;
|
||||||
|
let aid = 1;
|
||||||
|
const milestones: any[] = [];
|
||||||
|
const activities: any[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < rawMilestones.length; i++) {
|
||||||
|
const r = rawMilestones[i];
|
||||||
|
const isKO = r[2] === 'Project Kick-off';
|
||||||
|
const phaseId = r[0] as number;
|
||||||
|
const serial = r[1];
|
||||||
|
const title = r[2] as string;
|
||||||
|
const month = r[3] as string;
|
||||||
|
const tentDate = r[4] as string | null;
|
||||||
|
|
||||||
|
let defaultPmtReview = 'not_reviewed';
|
||||||
|
let defaultPscStatus = 'not_required';
|
||||||
|
|
||||||
|
if (title === 'Project Kick-off') {
|
||||||
|
defaultPmtReview = 'under_review';
|
||||||
|
defaultPscStatus = 'not_required';
|
||||||
|
} else if (title === 'Quantitative & Qualitative Research Plan') {
|
||||||
|
defaultPmtReview = 'endorsed';
|
||||||
|
defaultPscStatus = 'not_required';
|
||||||
|
} else if (title === 'Desktop Research') {
|
||||||
|
defaultPmtReview = 'escalated';
|
||||||
|
defaultPscStatus = 'not_required';
|
||||||
|
} else if (title === 'Drafting of Stakeholder Surveys') {
|
||||||
|
defaultPmtReview = 'returned';
|
||||||
|
defaultPscStatus = 'not_required';
|
||||||
|
} else if (title === 'Conduct Interviews') {
|
||||||
|
defaultPmtReview = 'not_reviewed';
|
||||||
|
defaultPscStatus = 'pending';
|
||||||
|
} else if (title === 'Prepare Draft Market Diagnostic Report') {
|
||||||
|
defaultPmtReview = 'not_reviewed';
|
||||||
|
defaultPscStatus = 'approved';
|
||||||
|
} else if (title === 'Validate Diagnostic Findings') {
|
||||||
|
defaultPmtReview = 'not_reviewed';
|
||||||
|
defaultPscStatus = 'rejected';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map milestone IDs (1-based index) to prerequisite milestone IDs
|
||||||
|
const milestoneDepsMap: Record<number, number[]> = {
|
||||||
|
2: [1], // M2 depends on M1
|
||||||
|
3: [1], // M3 depends on M1
|
||||||
|
4: [2], // M4 depends on M2 (M2 is in_progress, so M4 is blocked!)
|
||||||
|
5: [4], // M5 depends on M4
|
||||||
|
6: [3, 5], // M6 depends on M3 & M5
|
||||||
|
7: [6], // M7 depends on M6
|
||||||
|
8: [7], // M8 depends on M7
|
||||||
|
9: [8], // M9 depends on M8
|
||||||
|
10: [8], // M10 depends on M8
|
||||||
|
11: [7], // M11 depends on M7
|
||||||
|
12: [9, 10], // M12 depends on M9 & M10
|
||||||
|
13: [11, 12],// M13 depends on M11 & M12
|
||||||
|
14: [13], // M14 depends on M13
|
||||||
|
15: [14], // M15 depends on M14
|
||||||
|
16: [15] // M16 depends on M15
|
||||||
|
};
|
||||||
|
|
||||||
|
milestones.push({
|
||||||
|
id: mid,
|
||||||
|
phaseId,
|
||||||
|
serial,
|
||||||
|
title,
|
||||||
|
month,
|
||||||
|
tentDate,
|
||||||
|
startDate: '2026-05-01',
|
||||||
|
endDate: tentDate || '2026-12-31',
|
||||||
|
status: isKO ? 'completed' : i < 3 ? 'in_progress' : 'not_started',
|
||||||
|
priority: 'medium',
|
||||||
|
owner: isKO ? 'Project Sponsor' : i < 6 ? 'Lead Consultant' : 'PMT Steering Group',
|
||||||
|
notes: '',
|
||||||
|
desc: `Key milestone for ${title} under Phase ${phaseId}`,
|
||||||
|
createdAt: now,
|
||||||
|
pmtReview: defaultPmtReview as any,
|
||||||
|
pscStatus: defaultPscStatus as any,
|
||||||
|
dependsOn: milestoneDepsMap[mid] || []
|
||||||
|
});
|
||||||
|
|
||||||
|
const subActs = r[5] as string[];
|
||||||
|
for (let j = 0; j < subActs.length; j++) {
|
||||||
|
activities.push({
|
||||||
|
id: aid++,
|
||||||
|
milestoneId: mid,
|
||||||
|
title: subActs[j],
|
||||||
|
month,
|
||||||
|
tentDate,
|
||||||
|
startDate: '2026-05-01',
|
||||||
|
endDate: tentDate || '2026-12-31',
|
||||||
|
status: isKO ? 'completed' : j === 0 && i < 3 ? 'in_progress' : 'not_started',
|
||||||
|
progress: isKO ? 100 : j === 0 && i < 3 ? 45 : 0,
|
||||||
|
assignedTo: isKO ? 'Project Lead' : 'Strategy Team Member',
|
||||||
|
notes: '',
|
||||||
|
desc: `Deliverable task: ${subActs[j]}`,
|
||||||
|
createdAt: now
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
mid++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
users: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
username: 'admin',
|
||||||
|
password: 'Admin',
|
||||||
|
role: 'admin',
|
||||||
|
fullName: 'System Administrator',
|
||||||
|
email: 'admin@pinnacle.local',
|
||||||
|
dept: 'Executive Management',
|
||||||
|
active: true,
|
||||||
|
lastLogin: now
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
username: 'editor',
|
||||||
|
password: 'Editor',
|
||||||
|
role: 'editor',
|
||||||
|
fullName: 'Project Manager',
|
||||||
|
email: 'pm@pinnacle.local',
|
||||||
|
dept: 'Project Management Office',
|
||||||
|
active: true,
|
||||||
|
lastLogin: now
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
username: 'viewer',
|
||||||
|
password: 'Viewer',
|
||||||
|
role: 'viewer',
|
||||||
|
fullName: 'Board Observer',
|
||||||
|
email: 'observer@pinnacle.local',
|
||||||
|
dept: 'Board of Directors',
|
||||||
|
active: true,
|
||||||
|
lastLogin: now
|
||||||
|
}
|
||||||
|
],
|
||||||
|
projectStart: '2026-05-01',
|
||||||
|
projectDuration: 12,
|
||||||
|
phases,
|
||||||
|
milestones,
|
||||||
|
activities,
|
||||||
|
committees: {
|
||||||
|
pmt: {
|
||||||
|
name: 'Project Management Team',
|
||||||
|
abbr: 'PMT',
|
||||||
|
role: 'Responsible for day-to-day project execution, milestone tracking, resource allocation and operational reporting.',
|
||||||
|
chair: 'Project Manager',
|
||||||
|
meetingCadence: 'Weekly (Tuesdays 10:00 AM)',
|
||||||
|
members: [
|
||||||
|
{ id: 1, name: 'Alex Vance', role: 'Project Director', email: 'alex@pinnacle.local', organization: 'Pinnacle PMO' },
|
||||||
|
{ id: 2, name: 'Sarah Chen', role: 'Lead Strategy Consultant', email: 'sarah@pinnacle.local', organization: 'Strategy Advisory' },
|
||||||
|
{ id: 3, name: 'David Miller', role: 'Financial Analyst', email: 'david@pinnacle.local', organization: 'Finance' },
|
||||||
|
{ id: 4, name: 'Elena Rostova', role: 'Risk & Compliance Lead', email: 'elena@pinnacle.local', organization: 'Risk Dept' }
|
||||||
|
],
|
||||||
|
responsibilities: [
|
||||||
|
'Activity execution & timeline tracking',
|
||||||
|
'Milestone review & endorsement',
|
||||||
|
'Operational risk monitoring & escalation',
|
||||||
|
'Weekly progress reporting to PSC',
|
||||||
|
'Resource & vendor task management'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
psc: {
|
||||||
|
name: 'Project Steering Committee',
|
||||||
|
abbr: 'PSC',
|
||||||
|
role: 'Responsible for strategic governance, formal approval of milestones and deliverables, budget authority and policy decisions.',
|
||||||
|
chair: 'Chief Executive Officer',
|
||||||
|
meetingCadence: 'Monthly (First Thursday)',
|
||||||
|
members: [
|
||||||
|
{ id: 10, name: 'Jonathan Thorne', role: 'Chief Executive Officer', email: 'ceo@pinnacle.local', organization: 'Executive Board' },
|
||||||
|
{ id: 11, name: 'Marcus Sterling', role: 'Chief Financial Officer', email: 'cfo@pinnacle.local', organization: 'Executive Board' },
|
||||||
|
{ id: 12, name: 'Nadia Kapoor', role: 'Chief Risk Officer', email: 'cro@pinnacle.local', organization: 'Executive Board' },
|
||||||
|
{ id: 13, name: 'Robert Blake', role: 'Board Representative', email: 'board@pinnacle.local', organization: 'Board of Directors' }
|
||||||
|
],
|
||||||
|
responsibilities: [
|
||||||
|
'Milestone approval & phase sign-off',
|
||||||
|
'Deliverable sign-off & quality assurance',
|
||||||
|
'Budget approval & capital allocation',
|
||||||
|
'Strategic policy decisions & direction',
|
||||||
|
'Resolution of escalated cross-functional risks'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
meetings: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'PSC Monthly Strategic Governance Review',
|
||||||
|
committee: 'psc',
|
||||||
|
date: '2026-06-04',
|
||||||
|
time: '14:00 - 15:30',
|
||||||
|
location: 'Executive Boardroom & Teams',
|
||||||
|
chair: 'Jonathan Thorne (CEO)',
|
||||||
|
agenda: [
|
||||||
|
'Review of Phase 1 Inception Report & Survey Roll-out',
|
||||||
|
'Endorsement of Market Diagnostic Scope',
|
||||||
|
'Approval of Budget Allocation for Expert Workshops'
|
||||||
|
],
|
||||||
|
keyDecisions: [
|
||||||
|
'Approved Phase 1 Research Plan without reservations',
|
||||||
|
'Ratified engagement of external regulatory compliance auditor'
|
||||||
|
],
|
||||||
|
actionItems: [
|
||||||
|
{ task: 'Finalize stakeholder survey distribution list', owner: 'Alex Vance', dueDate: '2026-06-10', done: true },
|
||||||
|
{ task: 'Submit quarterly budget report to CFO', owner: 'David Miller', dueDate: '2026-06-15', done: true }
|
||||||
|
],
|
||||||
|
attendees: ['Jonathan Thorne', 'Marcus Sterling', 'Nadia Kapoor', 'Alex Vance', 'Sarah Chen']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: 'PMT Weekly Progress Alignment',
|
||||||
|
committee: 'pmt',
|
||||||
|
date: '2026-07-21',
|
||||||
|
time: '10:00 - 11:30',
|
||||||
|
location: 'PMO Hub',
|
||||||
|
chair: 'Alex Vance',
|
||||||
|
agenda: [
|
||||||
|
'Review status of executive interviews',
|
||||||
|
'Assess PESTEL & Porter Five Forces draft insights',
|
||||||
|
'Risk mitigation review regarding survey response rates'
|
||||||
|
],
|
||||||
|
keyDecisions: [
|
||||||
|
'Extended survey deadline by 5 business days to boost executive response rate'
|
||||||
|
],
|
||||||
|
actionItems: [
|
||||||
|
{ task: 'Send targeted reminder to C-suite interviewees', owner: 'Sarah Chen', dueDate: '2026-07-28', done: false },
|
||||||
|
{ task: 'Compile preliminary diagnostic findings memo', owner: 'David Miller', dueDate: '2026-08-01', done: false }
|
||||||
|
],
|
||||||
|
attendees: ['Alex Vance', 'Sarah Chen', 'David Miller', 'Elena Rostova']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
projectNarrative: {
|
||||||
|
thisWeek: 'Phase 1 research roll-out completed. 85% of stakeholder surveys returned. Desktop research synthesis report finalized and submitted for PMT review.',
|
||||||
|
nextWeek: 'Commence executive interviews across business divisions. Draft PESTEL & Porter Five Forces diagnostic section. Prepare agenda for upcoming PSC meeting.',
|
||||||
|
keyMessage: 'Project Pinnacle is tracking on schedule against baseline milestones with strong executive stakeholder engagement.',
|
||||||
|
lastUpdated: now,
|
||||||
|
updatedBy: 'System Administrator'
|
||||||
|
},
|
||||||
|
stakeholders: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Board of Directors',
|
||||||
|
org: 'Bank Board',
|
||||||
|
type: 'board',
|
||||||
|
engagementLevel: 90,
|
||||||
|
status: 'active',
|
||||||
|
role: 'Ultimate approval authority for Strategic Business Plan',
|
||||||
|
linkedPhases: [1, 2, 3, 4],
|
||||||
|
lastContact: '2026-06-01',
|
||||||
|
nextContact: '2026-08-15',
|
||||||
|
notes: 'Quarterly strategic briefing required. Direct communication via CEO.',
|
||||||
|
owner: 'Jonathan Thorne (CEO)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Project Steering Committee',
|
||||||
|
org: 'Internal Governance',
|
||||||
|
type: 'internal',
|
||||||
|
engagementLevel: 95,
|
||||||
|
status: 'active',
|
||||||
|
role: 'Strategic guidance, budget clearance & deliverable sign-off',
|
||||||
|
linkedPhases: [1, 2, 3, 4],
|
||||||
|
lastContact: '2026-06-04',
|
||||||
|
nextContact: '2026-08-06',
|
||||||
|
notes: 'Monthly formal meetings. Pre-reads required 48 hours prior.',
|
||||||
|
owner: 'Alex Vance (PMO)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: 'Central Bank / Regulatory Authority',
|
||||||
|
org: 'Financial Regulator',
|
||||||
|
type: 'regulatory',
|
||||||
|
engagementLevel: 70,
|
||||||
|
status: 'active',
|
||||||
|
role: 'Regulatory compliance validation & capital model clearance',
|
||||||
|
linkedPhases: [2, 3, 4],
|
||||||
|
lastContact: '2026-05-20',
|
||||||
|
nextContact: '2026-09-10',
|
||||||
|
notes: 'Regulatory compliance framework must align with Basel III & local mandates.',
|
||||||
|
owner: 'Elena Rostova (Risk)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: 'Core Technology & Infrastructure Partners',
|
||||||
|
org: 'External Vendors',
|
||||||
|
type: 'vendor',
|
||||||
|
engagementLevel: 80,
|
||||||
|
status: 'active',
|
||||||
|
role: 'Technical capability assessment & 5-year IT architecture roadmapping',
|
||||||
|
linkedPhases: [3, 4],
|
||||||
|
lastContact: '2026-06-15',
|
||||||
|
nextContact: '2026-08-20',
|
||||||
|
notes: 'Requires technical alignment workshops in Phase 3.',
|
||||||
|
owner: 'David Miller (Finance)'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
decisions: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
phaseId: 1,
|
||||||
|
committeeId: 'psc',
|
||||||
|
title: 'Approval of Project Charter & Scope Baseline',
|
||||||
|
description: 'Formal sign-off on the 12-month strategic business planning roadmap and committee responsibilities.',
|
||||||
|
rationale: 'Establishes clear governance baseline, budget parameters, and authority thresholds.',
|
||||||
|
status: 'approved',
|
||||||
|
priority: 'high',
|
||||||
|
raisedBy: 1,
|
||||||
|
dueDate: '2026-05-15',
|
||||||
|
createdAt: now
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
phaseId: 2,
|
||||||
|
committeeId: 'pmt',
|
||||||
|
title: 'Market Diagnostic Methodology & Data Collection Standards',
|
||||||
|
description: 'Endorsement of primary survey questions and industry peer benchmarking cohort.',
|
||||||
|
rationale: 'Ensures data collected is statistically rigorous and benchmarked against top regional institutions.',
|
||||||
|
status: 'approved',
|
||||||
|
priority: 'medium',
|
||||||
|
raisedBy: 2,
|
||||||
|
dueDate: '2026-06-10',
|
||||||
|
createdAt: now
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
phaseId: 3,
|
||||||
|
committeeId: 'psc',
|
||||||
|
title: 'Selection of 5-Year Financial Projection Model Assumptions',
|
||||||
|
description: 'Determination of macro-economic inflation, interest rate curves, and discount rate parameters for the financial model.',
|
||||||
|
rationale: 'Critical foundation for valuation and capital requirements projection.',
|
||||||
|
status: 'pending',
|
||||||
|
priority: 'high',
|
||||||
|
raisedBy: 2,
|
||||||
|
dueDate: '2026-10-01',
|
||||||
|
createdAt: now
|
||||||
|
}
|
||||||
|
],
|
||||||
|
risks: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
phaseId: 1,
|
||||||
|
title: 'Survey Response Rate Bottleneck in Senior Management',
|
||||||
|
category: 'Operational',
|
||||||
|
probability: 'medium',
|
||||||
|
impact: 'medium',
|
||||||
|
mitigation: 'Executive sponsor to send direct request email; PMO to follow up via 1-on-1 assistance.',
|
||||||
|
owner: 'Sarah Chen',
|
||||||
|
status: 'in_review',
|
||||||
|
createdAt: now
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
phaseId: 3,
|
||||||
|
title: 'Regulatory Capital Policy Shift During Strategy Formulation',
|
||||||
|
category: 'Regulatory',
|
||||||
|
probability: 'low',
|
||||||
|
impact: 'high',
|
||||||
|
mitigation: 'Maintain direct engagement channel with Regulatory Affairs; incorporate scenario sensitivity analysis in financial model.',
|
||||||
|
owner: 'Elena Rostova',
|
||||||
|
status: 'open',
|
||||||
|
createdAt: now
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
phaseId: 2,
|
||||||
|
title: 'Data Inconsistency across Legacy Internal Systems',
|
||||||
|
category: 'Technical',
|
||||||
|
probability: 'high',
|
||||||
|
impact: 'medium',
|
||||||
|
mitigation: 'Implement standardized ETL data normalization template prior to financial analysis.',
|
||||||
|
owner: 'David Miller',
|
||||||
|
status: 'open',
|
||||||
|
createdAt: now
|
||||||
|
}
|
||||||
|
],
|
||||||
|
logs: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
action: 'PROJECT_INITIALIZED',
|
||||||
|
type: 'system',
|
||||||
|
details: 'Project Pinnacle Decision Portal loaded with initial governance schema.',
|
||||||
|
user: 'System Administrator',
|
||||||
|
timestamp: now
|
||||||
|
}
|
||||||
|
],
|
||||||
|
smtpConfig: {
|
||||||
|
host: 'smtp.mailtrap.io',
|
||||||
|
port: 587,
|
||||||
|
user: '',
|
||||||
|
pass: '',
|
||||||
|
secure: false,
|
||||||
|
fromName: 'Pinnacle Decision Portal',
|
||||||
|
fromEmail: 'notifications@pinnacle.local',
|
||||||
|
enabled: true,
|
||||||
|
notifyOnNewDecision: true,
|
||||||
|
notifyOnHighRisk: true,
|
||||||
|
notifyOnUserCreated: true
|
||||||
|
},
|
||||||
|
emailLogs: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
recipient: 'admin@pinnacle.local',
|
||||||
|
subject: 'Welcome to Pinnacle Executive Portal',
|
||||||
|
status: 'simulated',
|
||||||
|
timestamp: now,
|
||||||
|
triggerEvent: 'SYSTEM_INIT'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
ids: {
|
||||||
|
user: 4,
|
||||||
|
phase: 5,
|
||||||
|
milestone: 17,
|
||||||
|
activity: 35,
|
||||||
|
decision: 4,
|
||||||
|
risk: 4,
|
||||||
|
log: 2,
|
||||||
|
meeting: 3,
|
||||||
|
stakeholder: 5
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
1
src/index.css
Normal file
1
src/index.css
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
@import "tailwindcss";
|
||||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import {StrictMode} from 'react';
|
||||||
|
import {createRoot} from 'react-dom/client';
|
||||||
|
import App from './App.tsx';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
240
src/types.ts
Normal file
240
src/types.ts
Normal file
|
|
@ -0,0 +1,240 @@
|
||||||
|
export type UserRole = 'admin' | 'editor' | 'viewer';
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
password?: string;
|
||||||
|
role: UserRole;
|
||||||
|
fullName: string;
|
||||||
|
email: string;
|
||||||
|
dept: string;
|
||||||
|
active: boolean;
|
||||||
|
lastLogin?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Phase {
|
||||||
|
id: number;
|
||||||
|
num: number;
|
||||||
|
name: string;
|
||||||
|
desc: string;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Attachment {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string; // 'image' | 'document' | 'pdf' | 'spreadsheet' | 'other'
|
||||||
|
size: number;
|
||||||
|
dataUrl: string; // Base64 data URL for persistence and download
|
||||||
|
uploadedAt: string;
|
||||||
|
uploadedBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StatusType = 'not_started' | 'in_progress' | 'under_review' | 'completed' | 'delayed' | 'deferred';
|
||||||
|
export type PriorityType = 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
|
||||||
|
export type PmtReviewStatus = 'not_reviewed' | 'under_review' | 'endorsed' | 'escalated' | 'returned';
|
||||||
|
export type PscStatus = 'not_required' | 'pending' | 'approved' | 'rejected';
|
||||||
|
|
||||||
|
export interface Milestone {
|
||||||
|
id: number;
|
||||||
|
phaseId: number;
|
||||||
|
serial: number | string;
|
||||||
|
title: string;
|
||||||
|
month: string;
|
||||||
|
tentDate?: string | null;
|
||||||
|
startDate?: string | null;
|
||||||
|
endDate?: string | null;
|
||||||
|
status: StatusType;
|
||||||
|
priority: PriorityType;
|
||||||
|
owner: string;
|
||||||
|
notes: string;
|
||||||
|
desc: string;
|
||||||
|
createdAt?: string;
|
||||||
|
pmtReview?: PmtReviewStatus;
|
||||||
|
pscStatus?: PscStatus;
|
||||||
|
attachments?: Attachment[];
|
||||||
|
dependsOn?: number[]; // Array of prerequisite milestone IDs
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Activity {
|
||||||
|
id: number;
|
||||||
|
milestoneId: number;
|
||||||
|
title: string;
|
||||||
|
month: string;
|
||||||
|
tentDate?: string | null;
|
||||||
|
startDate?: string | null;
|
||||||
|
endDate?: string | null;
|
||||||
|
status: StatusType;
|
||||||
|
progress: number; // 0 to 100
|
||||||
|
assignedTo: string;
|
||||||
|
notes: string;
|
||||||
|
desc: string;
|
||||||
|
createdAt?: string;
|
||||||
|
attachments?: Attachment[];
|
||||||
|
dependsOn?: number[]; // Array of prerequisite activity task IDs
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DecisionStatus = 'pending' | 'in_review' | 'approved' | 'rejected' | 'deferred';
|
||||||
|
|
||||||
|
export interface Decision {
|
||||||
|
id: number;
|
||||||
|
phaseId: number;
|
||||||
|
committeeId?: 'pmt' | 'psc' | 'board' | string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
rationale?: string;
|
||||||
|
status: DecisionStatus;
|
||||||
|
priority: PriorityType;
|
||||||
|
raisedBy: number | string; // user ID or name
|
||||||
|
dueDate?: string;
|
||||||
|
fileData?: string | null;
|
||||||
|
fileName?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
attachments?: Attachment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RiskImpact = 'low' | 'medium' | 'high';
|
||||||
|
export type RiskProbability = 'low' | 'medium' | 'high';
|
||||||
|
export type RiskStatus = 'open' | 'in_review' | 'mitigated' | 'closed';
|
||||||
|
|
||||||
|
export interface Risk {
|
||||||
|
id: number;
|
||||||
|
phaseId: number;
|
||||||
|
title: string;
|
||||||
|
category: string;
|
||||||
|
probability: RiskProbability;
|
||||||
|
impact: RiskImpact;
|
||||||
|
mitigation: string;
|
||||||
|
owner: string;
|
||||||
|
status: RiskStatus;
|
||||||
|
createdAt: string;
|
||||||
|
attachments?: Attachment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommitteeMember {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
email?: string;
|
||||||
|
organization?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Committee {
|
||||||
|
name: string;
|
||||||
|
abbr: string;
|
||||||
|
role: string;
|
||||||
|
chair: string;
|
||||||
|
members: CommitteeMember[];
|
||||||
|
meetingCadence: string;
|
||||||
|
responsibilities: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Meeting {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
committee: 'pmt' | 'psc' | 'board' | 'general';
|
||||||
|
date: string;
|
||||||
|
time?: string;
|
||||||
|
location?: string;
|
||||||
|
chair: string;
|
||||||
|
agenda: string[];
|
||||||
|
keyDecisions: string[];
|
||||||
|
actionItems: { task: string; owner: string; dueDate: string; done: boolean }[];
|
||||||
|
attendees: string[];
|
||||||
|
minutesFile?: string | null;
|
||||||
|
attachments?: Attachment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StakeholderType = 'board' | 'internal' | 'regulatory' | 'vendor' | 'partner';
|
||||||
|
|
||||||
|
export interface Stakeholder {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
org: string;
|
||||||
|
type: StakeholderType;
|
||||||
|
engagementLevel: number; // 0 to 100
|
||||||
|
status: 'active' | 'monitoring' | 'inactive';
|
||||||
|
role: string;
|
||||||
|
linkedPhases: number[];
|
||||||
|
lastContact?: string;
|
||||||
|
nextContact?: string;
|
||||||
|
notes?: string;
|
||||||
|
owner: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectNarrative {
|
||||||
|
thisWeek: string;
|
||||||
|
nextWeek: string;
|
||||||
|
keyMessage: string;
|
||||||
|
lastUpdated?: string;
|
||||||
|
updatedBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityLog {
|
||||||
|
id: number;
|
||||||
|
action: string;
|
||||||
|
type: string;
|
||||||
|
targetId?: number | string;
|
||||||
|
details: string;
|
||||||
|
user: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SmtpConfig {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
user: string;
|
||||||
|
pass: string;
|
||||||
|
secure: boolean;
|
||||||
|
fromName: string;
|
||||||
|
fromEmail: string;
|
||||||
|
enabled: boolean;
|
||||||
|
notifyOnNewDecision: boolean;
|
||||||
|
notifyOnHighRisk: boolean;
|
||||||
|
notifyOnUserCreated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailNotificationLog {
|
||||||
|
id: number;
|
||||||
|
recipient: string;
|
||||||
|
subject: string;
|
||||||
|
status: 'sent' | 'failed' | 'simulated';
|
||||||
|
error?: string;
|
||||||
|
timestamp: string;
|
||||||
|
triggerEvent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectStore {
|
||||||
|
users: User[];
|
||||||
|
projectStart: string;
|
||||||
|
projectDuration: number;
|
||||||
|
phases: Phase[];
|
||||||
|
milestones: Milestone[];
|
||||||
|
activities: Activity[];
|
||||||
|
committees: {
|
||||||
|
pmt: Committee;
|
||||||
|
psc: Committee;
|
||||||
|
};
|
||||||
|
meetings: Meeting[];
|
||||||
|
projectNarrative: ProjectNarrative;
|
||||||
|
stakeholders: Stakeholder[];
|
||||||
|
decisions: Decision[];
|
||||||
|
risks: Risk[];
|
||||||
|
logs: ActivityLog[];
|
||||||
|
smtpConfig?: SmtpConfig;
|
||||||
|
emailLogs?: EmailNotificationLog[];
|
||||||
|
appTitle?: string;
|
||||||
|
ids: {
|
||||||
|
user: number;
|
||||||
|
phase: number;
|
||||||
|
milestone: number;
|
||||||
|
activity: number;
|
||||||
|
decision: number;
|
||||||
|
risk: number;
|
||||||
|
log: number;
|
||||||
|
meeting: number;
|
||||||
|
stakeholder: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
26
tsconfig.json
Normal file
26
tsconfig.json
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"useDefineForClassFields": false,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"allowJs": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true
|
||||||
|
}
|
||||||
|
}
|
||||||
22
vite.config.ts
Normal file
22
vite.config.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'path';
|
||||||
|
import {defineConfig} from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig(() => {
|
||||||
|
return {
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, '.'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
||||||
|
// Do not modifyâfile watching is disabled to prevent flickering during agent edits.
|
||||||
|
hmr: process.env.DISABLE_HMR !== 'true',
|
||||||
|
// Disable file watching when DISABLE_HMR is true to save CPU during agent edits.
|
||||||
|
watch: process.env.DISABLE_HMR === 'true' ? null : {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue