Implement comprehensive report system with detailed viewing and AI enhancements

- Add detailed report viewing with full-screen ReportDetail component for both company and employee reports
- Fix company wiki to display onboarding Q&A in card format matching Figma designs
- Exclude company owners from employee submission counts (owners contribute to wiki, not employee data)
- Fix employee report generation to include company context (wiki + company report + employee answers)
- Fix company report generation to use filtered employee submissions only
- Add proper error handling for submission data format variations
- Update Firebase functions to use gpt-4o model instead of deprecated gpt-4.1
- Fix UI syntax errors and improve report display functionality
- Add comprehensive logging for debugging report generation flow

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ra
2025-08-18 19:08:29 -07:00
parent 557b113196
commit 1a9e92d7bd
20 changed files with 1793 additions and 635 deletions

7
.gitignore vendored
View File

@@ -50,4 +50,9 @@ dist-ssr
/.vscode/extensions.json
!/.vscode/tasks.json
/database.rules.json
/functions/auditly*.json
/functions/auditly*.json
/CLAUDE.md
/debug_firebase.js
/schema.json
/SECURITY_FIXES.md
/.claude

23
App.tsx
View File

@@ -41,16 +41,28 @@ const RequireOrgSelection: React.FC<{ children: React.ReactNode }> = ({ children
const RequireOnboarding: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { org } = useOrg();
const { user } = useAuth();
const { organizations } = useUserOrganizations();
const { organizations, selectedOrgId } = useUserOrganizations();
if (!org) return <div className="p-8">Loading organization...</div>;
if (!org.onboardingCompleted) {
// Only org owners should be redirected to onboarding
const userOrgRelation = organizations.find(o => o.orgId === org.orgId);
const isOrgOwner = userOrgRelation?.role === 'owner';
// Get the user's relationship to this organization
const userOrgRelation = organizations.find(o => o.orgId === selectedOrgId);
const isOrgOwner = userOrgRelation?.role === 'owner';
// SINGLE SOURCE OF TRUTH: Organization onboarding completion is the authoritative source
// User organization records are updated to reflect this, but org.onboardingCompleted is primary
const onboardingCompleted = org.onboardingCompleted === true;
console.log('RequireOnboarding check:', {
orgId: selectedOrgId,
orgOnboardingCompleted: org.onboardingCompleted,
userRole: userOrgRelation?.role,
finalDecision: onboardingCompleted
});
if (!onboardingCompleted) {
if (isOrgOwner) {
console.log('Redirecting owner to onboarding');
return <Navigate to="/onboarding" replace />;
} else {
// Non-owners should see a waiting message
@@ -104,6 +116,7 @@ function App() {
{/* Employee questionnaire - no auth needed, uses invite code */}
<Route path="/employee-form/:inviteCode" element={<EmployeeQuestionnaire />} />
<Route path="/questionnaire/:inviteCode" element={<EmployeeQuestionnaire />} />
{/* Organization Selection - after auth, before entering app */}
<Route

View File

@@ -13,13 +13,14 @@
"firebase-admin": "^13.4.0",
"firebase-functions": "^6.4.0",
"lucide-react": "^0.539.0",
"openai": "^4.104.0",
"openai": "^5.12.2",
"postcss": "^8.5.6",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router-dom": "^7.8.0",
"recharts": "^3.1.2",
"tailwindcss": "^4.1.12",
"zod": "^4.0.17",
},
"devDependencies": {
"@types/cors": "^2.8.19",
@@ -410,8 +411,6 @@
"@types/node": ["@types/node@22.17.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-y3tBaz+rjspDTylNjAX37jEC3TETEFGNJL6uQDxwF9/8GLLIjW1rvVHlynyuUKMnMr1Roq8jOv3vkopBjC4/VA=="],
"@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="],
"@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="],
"@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="],
@@ -438,8 +437,6 @@
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
@@ -598,11 +595,7 @@
"firebase-functions": ["firebase-functions@6.4.0", "", { "dependencies": { "@types/cors": "^2.8.5", "@types/express": "^4.17.21", "cors": "^2.8.5", "express": "^4.21.0", "protobufjs": "^7.2.2" }, "peerDependencies": { "firebase-admin": "^11.10.0 || ^12.0.0 || ^13.0.0" }, "bin": { "firebase-functions": "lib/bin/firebase-functions.js" } }, "sha512-Q/LGhJrmJEhT0dbV60J4hCkVSeOM6/r7xJS/ccmkXzTWMjo+UPAYX9zlQmGlEjotstZ0U9GtQSJSgbB2Z+TJDg=="],
"form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="],
"form-data-encoder": ["form-data-encoder@1.7.2", "", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="],
"formdata-node": ["formdata-node@4.4.1", "", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="],
"form-data": ["form-data@2.5.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.35", "safe-buffer": "^5.2.1" } }, "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A=="],
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
@@ -658,8 +651,6 @@
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"idb": ["idb@7.1.1", "", {}, "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ=="],
@@ -778,8 +769,6 @@
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
"node-forge": ["node-forge@1.3.1", "", {}, "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA=="],
@@ -796,7 +785,7 @@
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"openai": ["openai@4.104.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" }, "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA=="],
"openai": ["openai@5.12.2", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-xqzHHQch5Tws5PcKR2xsZGX9xtch+JQFz5zb14dGqlshmmDAFBFEWmeIpf7wVqWV+w7Emj7jRgkNJakyKE0tYQ=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
@@ -952,8 +941,6 @@
"vite": ["vite@7.1.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.6", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.14" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "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-J0SQBPlQiEXAF7tajiH+rUooJPo0l8KQgyg4/aMunNtrOa7bwuZJsJbDWzeljqQpgftxuq5yNJxQ91O9ts29UQ=="],
"web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="],
"web-vitals": ["web-vitals@4.2.4", "", {}, "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
@@ -980,6 +967,8 @@
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zod": ["zod@4.0.17", "", {}, "sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ=="],
"@google-cloud/storage/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.4.5", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.4", "tslib": "^2.4.0" }, "bundled": true }, "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q=="],
@@ -994,8 +983,6 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@types/request/form-data": ["form-data@2.5.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.35", "safe-buffer": "^5.2.1" } }, "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A=="],
"chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"firebase-functions/express": ["express@4.21.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "1.20.3", "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.13.0", "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-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA=="],
@@ -1018,16 +1005,12 @@
"lru-memoizer/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"openai/@types/node": ["@types/node@18.19.122", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-yzegtT82dwTNEe/9y+CM8cgb42WrUfMMCg2QqSddzO1J6uPmBD7qKCZ7dOHZP2Yrpm/kb0eqdNMn2MUyEiqBmA=="],
"react-router/cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
"teeny-request/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
"teeny-request/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
"@types/request/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"firebase-functions/express/accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
"firebase-functions/express/body-parser": ["body-parser@1.20.3", "", { "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.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" } }, "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g=="],
@@ -1060,12 +1043,8 @@
"lru-memoizer/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"openai/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"teeny-request/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
"@types/request/form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"firebase-functions/express/accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"firebase-functions/express/accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],

View File

@@ -19,91 +19,52 @@ if (import.meta.env.DEV) {
console.log(` API_URL: ${API_URL}`);
}
export const EMPLOYEES: Employee[] = [
{ id: 'AG', name: 'Alex Green', initials: 'AG', email: 'alex.green@zitlac.com', department: 'Influencer Marketing', role: 'Influencer Coordinator & Business Development Outreach' },
{ id: 'MB', name: 'Michael Brown', initials: 'MB', email: 'michael.brown@zitlac.com', department: 'Engineering', role: 'Senior Developer' },
{ id: 'KT', name: 'Kevin Taylor', initials: 'KT', email: 'kevin.taylor@zitlac.com', department: 'Marketing', role: 'Marketing Manager' },
{ id: 'LR', name: 'Laura Robinson', initials: 'LR', email: 'laura.robinson@zitlac.com', department: 'HR', role: 'HR Manager', isOwner: true },
{ id: 'DS', name: 'David Stone', initials: 'DS', email: 'david.stone@zitlac.com', department: 'Sales', role: 'Sales Representative' },
{ id: 'SR', name: 'Samantha Reed', initials: 'SR', email: 'samantha.reed@zitlac.com', department: 'Operations', role: 'Operations Specialist' },
];
// DEPRECATED: These are legacy sample data that should not be used in production
// Real data should be generated via AI backend calls or user input
export const EMPLOYEES: Employee[] = [];
// DEPRECATED: Sample report data - real reports should be generated via /generateEmployeeReport API
export const REPORT_DATA: Report = {
employeeId: 'AG',
department: 'Influencer Marketing',
role: 'Influencer Coordinator & Business Development Outreach',
employeeId: 'sample',
department: 'Sample Department',
role: 'Sample Role',
roleAndOutput: {
responsibilities: 'Recruiting influencers, onboarding, campaign support, business development.',
clarityOnRole: '10/10 - Feels very clear on responsibilities.',
selfRatedOutput: '7/10 - Indicates decent performance but room to grow.',
recurringTasks: 'Influencer outreach, onboarding, communications.',
responsibilities: 'This is sample data. Real reports are generated via AI.',
clarityOnRole: 'Sample data',
selfRatedOutput: 'Sample data',
recurringTasks: 'Sample data',
},
insights: {
personalityTraits: 'Loyal, well-liked by influencers, eager to grow, client-facing interest.',
psychologicalIndicators: [
'Scores high on optimism and external motivation.',
'Shows ambition but lacks self-discipline in execution.',
'Displays a desire for recognition and community; seeks more appreciation.',
],
selfAwareness: 'High - acknowledges weaknesses like lateness and disorganization.',
emotionalResponses: 'Frustrated by campaign disorganization; would prefer closer collaboration.',
growthDesire: 'Interested in becoming more client-facing and shifting toward biz dev.',
personalityTraits: 'Sample data - use AI-generated reports',
psychologicalIndicators: ['Sample data'],
selfAwareness: 'Sample data',
emotionalResponses: 'Sample data',
growthDesire: 'Sample data',
},
strengths: [
'Builds strong relationships with influencers.',
'Has sales and outreach potential.',
'Loyal, driven, and values-aligned with the company mission.',
'Open to feedback and self-improvement.',
],
strengths: ['Sample data - use AI-generated reports'],
weaknesses: [
{ isCritical: true, description: 'Disorganized and late with deliverables — confirmed by previous internal notes.' },
{ isCritical: false, description: 'Poor implementation and recruiting output — does not effectively close the loop on influencer onboarding.' },
{ isCritical: false, description: 'May unintentionally cause friction with campaigns team by stepping outside process boundaries.' },
{ isCritical: false, description: 'Sample data - use AI-generated reports' }
],
opportunities: {
roleAdjustment: 'Shift fully to Influencer Manager & Biz Dev Outreach as planned. Remove all execution and recruitment responsibilities.',
accountabilitySupport: "Pair with a high-output implementer (new hire) to balance Gentry's strategic skills.",
roleAdjustment: 'Sample data',
accountabilitySupport: 'Sample data',
},
risks: [
"Without strict structure, Gentry's performance will stay flat or become a bottleneck.",
'If kept in a dual-role (recruiting + outreach), productivity will suffer.',
'He needs system constraints and direct oversight to stay focused.',
],
risks: ['Sample data - use AI-generated reports'],
recommendation: {
action: 'Keep',
details: [
'But immediately restructure his role:',
'• Remove recruiting and logistical tasks.',
'• Focus only on influencer relationship-building, pitching, and business development.',
"Pair him with a new hire who is ultra-organized and can execute on Gentry's deals.",
],
action: 'Sample',
details: ['Sample data - use AI-generated reports'],
},
grading: [],
};
// DEPRECATED: Sample submission data - real submissions come from employee questionnaires
export const SUBMISSIONS_DATA: Submission = {
employeeId: 'AG',
employeeId: 'sample',
answers: [
{
question: 'What is the mission of your company?',
answer: 'To empower small businesses with AI-driven automation tools that increase efficiency and reduce operational overhead.',
},
{
question: 'How has your mission evolved in the last 1-3 years?',
answer: 'We shifted from general SaaS tools to vertical-specific solutions, with deeper integrations and onboarding support.',
},
{
question: 'What is your 5-year vision for the company?',
answer: 'To become the leading AI operations platform for SMBs in North America, serving over 100,000 customers.',
},
{
question: "What are your company's top 3 strategic advantages?",
answer: '1. Fast product iteration enabled by in-house AI capabilities\n2. Deep customer understanding from vertical specialization\n3. High customer retention due to integrated onboarding',
},
{
question: 'What are your biggest vulnerabilities or threats?',
answer: 'Dependence on a single marketing channel, weak middle management, and rising customer acquisition costs.',
},
question: 'Sample question',
answer: 'Sample answer - real data comes from employee questionnaires',
}
],
};
@@ -135,74 +96,38 @@ export const FAQ_DATA: FaqItem[] = [
];
export const CHAT_STARTERS = [
"Summarize Alex Green's latest report.",
"What are Alex's biggest strengths?",
"Identify any risks associated with Alex.",
"Should Alex be considered for a promotion?"
"Summarize the latest employee reports.",
"What are the company's organizational strengths?",
"Identify any risks in our current workforce.",
"Which employees should be considered for promotion?",
"What are our immediate hiring needs?",
"How can we improve team performance?"
];
// DEPRECATED: This should not be used in production - real company reports are AI-generated
export const SAMPLE_COMPANY_REPORT: CompanyReport = {
id: 'sample-company-report',
createdAt: Date.now() - 86400000, // 1 day ago
id: 'placeholder-report',
createdAt: Date.now(),
overview: {
totalEmployees: 0, // Fixed: Start with 0 employees instead of hardcoded 6
totalEmployees: 0,
departmentBreakdown: [],
submissionRate: 0,
lastUpdated: Date.now() - 86400000
lastUpdated: Date.now(),
averagePerformanceScore: 0,
riskLevel: 'Unknown'
},
gradingBreakdown: [],
operatingPlan: { nextQuarterGoals: [], keyInitiatives: [], resourceNeeds: [], riskMitigation: [] },
personnelChanges: { newHires: [], promotions: [], departures: [] },
keyPersonnelChanges: [
{ employeeName: "Alex Green", department: "Influencer Marketing", role: "Influencer Coordinator", changeType: "newHire" },
{ employeeName: "Jordan Smith", department: "Engineering", role: "Software Engineer", changeType: "promotion" }
],
immediateHiringNeeds: [
{
department: 'Engineering',
role: 'Frontend Developer',
priority: 'High',
reasoning: 'Growing product development workload requires additional frontend expertise'
},
{
department: 'Marketing',
role: 'Content Creator',
priority: 'Medium',
reasoning: 'Increasing content demands for influencer campaigns'
}
],
keyPersonnelChanges: [],
immediateHiringNeeds: [],
forwardOperatingPlan: {
quarterlyGoals: [
'Expand influencer network by 40%',
'Launch automated campaign tracking system',
'Implement comprehensive onboarding process',
'Increase team collaboration efficiency by 25%'
],
resourceNeeds: [
'Additional engineering talent',
'Enhanced project management tools',
'Training budget for skill development',
'Upgraded communication infrastructure'
],
riskMitigation: [
'Cross-train team members to reduce single points of failure',
'Implement backup processes for critical operations',
'Regular performance reviews and feedback cycles',
'Diversify client base to reduce dependency risks'
]
quarterlyGoals: [],
resourceNeeds: [],
riskMitigation: []
},
organizationalStrengths: [
],
organizationalRisks: [
'Key personnel dependency in critical roles',
'Limited project management oversight',
'Potential burnout from rapid growth',
'Communication gaps between departments'
],
gradingOverview: {
"overallGrade": 4,
"strengths": 3,
"weaknesses": 1
},
executiveSummary: `Your organization is ready to get started with employee assessments. Begin by inviting team members to complete their questionnaires and build comprehensive insights about your workforce.`
organizationalStrengths: [],
organizationalRisks: [],
gradingOverview: {},
executiveSummary: `Welcome to Auditly! Generate your first AI-powered company report by inviting employees and completing the onboarding process.`
};

View File

@@ -24,25 +24,55 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
useEffect(() => {
console.log('AuthContext initializing, isFirebaseConfigured:', isFirebaseConfigured);
if (!isFirebaseConfigured) {
// Demo mode: check for persisted session
console.log('Demo mode: checking for persisted session');
const sessionUser = sessionStorage.getItem('auditly_demo_session');
if (isFirebaseConfigured) {
// Firebase mode: Set up proper Firebase auth state listener
const unsubscribe = onAuthStateChanged(auth, (firebaseUser) => {
console.log('Firebase auth state changed:', firebaseUser?.email);
if (firebaseUser) {
setUser(firebaseUser);
} else {
// Check for OTP session as fallback
const sessionUser = localStorage.getItem('auditly_demo_session');
if (sessionUser) {
try {
const parsedUser = JSON.parse(sessionUser);
console.log('Restoring OTP session for:', parsedUser.email);
setUser(parsedUser as User);
} catch (error) {
console.error('Failed to parse session user:', error);
localStorage.removeItem('auditly_demo_session');
setUser(null);
}
} else {
setUser(null);
}
}
setLoading(false);
});
return unsubscribe;
} else {
// Demo/OTP mode: Check localStorage for persisted session
console.log('Checking for persisted OTP session');
const sessionUser = localStorage.getItem('auditly_demo_session');
if (sessionUser) {
const parsedUser = JSON.parse(sessionUser);
console.log('Restoring demo session for:', parsedUser.email);
setUser(parsedUser as User);
try {
const parsedUser = JSON.parse(sessionUser);
console.log('Restoring session for:', parsedUser.email);
setUser(parsedUser as User);
} catch (error) {
console.error('Failed to parse session user:', error);
localStorage.removeItem('auditly_demo_session');
setUser(null);
}
} else {
setUser(null);
}
setLoading(false);
return () => { };
}
console.log('Setting up Firebase auth listener');
const unsub = onAuthStateChanged(auth, (u) => {
console.log('Auth state changed:', u);
setUser(u);
setLoading(false);
});
return () => unsub();
}, []);
const signInWithGoogle = async () => {
@@ -54,13 +84,24 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
};
const signOutUser = async () => {
if (!isFirebaseConfigured) {
// Clear demo session
sessionStorage.removeItem('auditly_demo_session');
setUser(null);
return;
try {
// Sign out from Firebase if configured and user is signed in via Firebase
if (isFirebaseConfigured && auth.currentUser) {
await signOut(auth);
console.log('Firebase signout completed');
}
} catch (error) {
console.error('Firebase signout error:', error);
}
await signOut(auth);
// Always clear all local session data
localStorage.removeItem('auditly_demo_session');
localStorage.removeItem('auditly_auth_token');
localStorage.removeItem('auditly_selected_org');
sessionStorage.clear();
setUser(null);
console.log('User signed out and all sessions cleared');
};
const signInWithEmail = async (email: string, password: string) => {
@@ -79,7 +120,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
} as unknown as User;
setUser(mockUser);
sessionStorage.setItem('auditly_demo_session', JSON.stringify(mockUser));
localStorage.setItem('auditly_demo_session', JSON.stringify(mockUser));
console.log('Demo login successful for:', email);
} else {
throw new Error('Invalid password');
@@ -132,7 +173,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
} as unknown as User;
setUser(mockUser);
sessionStorage.setItem('auditly_demo_session', JSON.stringify(mockUser));
localStorage.setItem('auditly_demo_session', JSON.stringify(mockUser));
console.log('Demo signup successful for:', email);
return;
}
@@ -191,8 +232,8 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
} as unknown as User;
setUser(mockUser);
sessionStorage.setItem('auditly_demo_session', JSON.stringify(mockUser));
sessionStorage.setItem('auditly_auth_token', data.token);
localStorage.setItem('auditly_demo_session', JSON.stringify(mockUser));
localStorage.setItem('auditly_auth_token', data.token);
return data;
};
@@ -206,8 +247,8 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
} as unknown as User;
setUser(mockUser);
sessionStorage.setItem('auditly_demo_session', JSON.stringify(mockUser));
sessionStorage.setItem('auditly_auth_token', token);
localStorage.setItem('auditly_demo_session', JSON.stringify(mockUser));
localStorage.setItem('auditly_auth_token', token);
};
return (

View File

@@ -3,8 +3,9 @@ import { collection, doc, getDoc, getDocs, onSnapshot, setDoc } from 'firebase/f
import { db, isFirebaseConfigured } from '../services/firebase';
import { useAuth } from './AuthContext';
import { Employee, Report, Submission, CompanyReport } from '../types';
import { REPORT_DATA, SUBMISSIONS_DATA, SAMPLE_COMPANY_REPORT, API_URL } from '../constants';
import { SAMPLE_COMPANY_REPORT, API_URL } from '../constants';
import { demoStorage } from '../services/demoStorage';
import { apiPost, apiPut } from '../services/api';
interface OrgData {
orgId: string;
@@ -98,65 +99,12 @@ export const OrgProvider: React.FC<{ children: React.ReactNode; selectedOrgId: s
// Initialize with empty employee list for clean start
// (Removed automatic seeding of 6 default employees per user feedback)
// Create sample submissions for multiple employees
const sampleSubmissions = [
{
employeeId: 'AG',
orgId,
createdAt: Date.now(),
answers: {
role_clarity: "I understand my role very clearly as Influencer Coordinator & Business Development Outreach.",
key_outputs: "Recruited 15 new influencers, managed 8 campaigns, initiated 3 business development partnerships.",
bottlenecks: "Campaign organization could be better, sometimes unclear on priorities between recruiting and outreach.",
hidden_talent: "Strong relationship building skills that could be leveraged for client-facing work.",
retention_risk: "Happy with the company but would like more structure and clearer processes.",
energy_distribution: "50% influencer recruiting, 30% campaign support, 20% business development outreach.",
performance_indicators: "Good influencer relationships, but delivery timeline improvements needed.",
workflow: "Morning outreach, afternoon campaign work, weekly business development calls."
}
},
{
employeeId: 'MB',
orgId,
createdAt: Date.now(),
answers: {
role_clarity: "I understand my role as a Senior Developer very clearly. I'm responsible for architecting solutions, code reviews, and mentoring junior developers.",
key_outputs: "Delivered 3 major features this quarter, reduced technical debt by 20%, and led code review process improvements.",
bottlenecks: "Sometimes waiting for design specs from the product team, and occasional deployment pipeline issues.",
hidden_talent: "I have strong business analysis skills and could help bridge the gap between technical and business requirements.",
retention_risk: "I'm satisfied with my current role and compensation. The only concern would be limited growth opportunities.",
energy_distribution: "80% development work, 15% mentoring, 5% planning and architecture.",
performance_indicators: "Code quality metrics improved, zero production bugs in my recent releases, positive peer feedback.",
workflow: "Morning standup, focused coding blocks, afternoon reviews and collaboration, weekly planning sessions."
}
},
{
employeeId: 'KT',
orgId,
createdAt: Date.now(),
answers: {
role_clarity: "My role as Marketing Manager is clear - I oversee campaigns, analyze performance metrics, and coordinate with sales.",
key_outputs: "Launched 5 successful campaigns this quarter, increased lead quality by 30%, improved attribution tracking.",
bottlenecks: "Limited budget for premium tools, sometimes slow approval process for creative assets.",
hidden_talent: "I have experience with data science and could help build predictive models for customer behavior.",
retention_risk: "Overall happy, but would like more strategic input in product positioning and pricing decisions.",
energy_distribution: "40% campaign execution, 30% analysis and reporting, 20% strategy, 10% team coordination.",
performance_indicators: "Campaign ROI improved by 25%, lead conversion rates increased, better cross-team collaboration.",
workflow: "Weekly campaign planning, daily performance monitoring, bi-weekly strategy reviews, monthly board reporting."
}
}
];
// Don't automatically create sample submissions - let users create real data
// through the proper questionnaire flow
// Save all sample submissions
sampleSubmissions.forEach(submission => {
demoStorage.saveSubmission(submission);
});
// Note: Sample employee reports removed - real reports generated via AI after questionnaire submission
// Save sample employee report (only for AG initially)
demoStorage.saveEmployeeReport(orgId, REPORT_DATA.employeeId, REPORT_DATA);
// Save sample company report
demoStorage.saveCompanyReport(orgId, SAMPLE_COMPANY_REPORT);
// Don't save sample company report - let users generate real AI-powered reports
}
// Load persistent demo data
@@ -175,7 +123,7 @@ export const OrgProvider: React.FC<{ children: React.ReactNode; selectedOrgId: s
}));
setEmployees(convertedEmployees);
// Convert submissions to expected format
// Load any existing submissions from localStorage
const orgSubmissions = demoStorage.getSubmissionsByOrg(orgId);
const convertedSubmissions: Record<string, Submission> = {};
Object.entries(orgSubmissions).forEach(([employeeId, demoSub]) => {
@@ -189,11 +137,11 @@ export const OrgProvider: React.FC<{ children: React.ReactNode; selectedOrgId: s
});
setSubmissions(convertedSubmissions);
// Convert reports to expected format
// Load any existing AI-generated reports from localStorage
const orgReports = demoStorage.getEmployeeReportsByOrg(orgId);
setReports(orgReports);
// Get company reports
// Load any existing company reports from localStorage
const companyReports = demoStorage.getCompanyReportsByOrg(orgId);
setFullCompanyReports(companyReports);
return;
@@ -239,20 +187,29 @@ export const OrgProvider: React.FC<{ children: React.ReactNode; selectedOrgId: s
const updatedOrg = { ...(org || { orgId, name: 'Demo Company' }), ...data } as OrgData;
setOrg(updatedOrg);
// Also sync with server for multi-tenant persistence
try {
const response = await fetch(`${API_URL}/api/organizations/${orgId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
// If onboarding was completed, update localStorage for persistence and notify other contexts
if (data.onboardingCompleted) {
const demoOrgData = {
orgId: updatedOrg.orgId,
name: updatedOrg.name,
onboardingCompleted: updatedOrg.onboardingCompleted || false,
...updatedOrg // Include all additional fields
};
demoStorage.saveOrganization(demoOrgData);
console.log('OrgContext: Onboarding completed, dispatching update event', {
orgId: updatedOrg.orgId,
onboardingCompleted: true
});
if (!response.ok) {
console.warn('Failed to sync organization data with server');
}
} catch (error) {
console.warn('Failed to sync organization data:', error);
// Signal to UserOrganizationsContext and other components about completion
window.dispatchEvent(new CustomEvent('organizationUpdated', {
detail: { orgId: updatedOrg.orgId, onboardingCompleted: true }
}));
}
// Organization already exists, no need to sync with server during onboarding
// We'll update Firestore directly in the Firebase mode below
} else {
// Firebase mode - save to Firestore
const orgRef = doc(db, 'orgs', orgId);
@@ -261,6 +218,18 @@ export const OrgProvider: React.FC<{ children: React.ReactNode; selectedOrgId: s
// Update local state
const updatedOrg = { ...(org || { orgId, name: 'Your Company' }), ...data } as OrgData;
setOrg(updatedOrg);
// If onboarding was completed, notify other contexts
if (data.onboardingCompleted) {
console.log('OrgContext (Firebase): Onboarding completed, dispatching update event', {
orgId: updatedOrg.orgId,
onboardingCompleted: true
});
window.dispatchEvent(new CustomEvent('organizationUpdated', {
detail: { orgId: updatedOrg.orgId, onboardingCompleted: true }
}));
}
}
};
@@ -302,39 +271,57 @@ export const OrgProvider: React.FC<{ children: React.ReactNode; selectedOrgId: s
};
const inviteEmployee = async ({ name, email }: { name: string; email: string }) => {
// Always use Cloud Functions for invites to ensure multi-tenant compliance
const response = await fetch(`${API_URL}/createInvitation`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, orgId })
});
console.log('inviteEmployee called:', { name, email, orgId });
if (!response.ok) {
throw new Error(`Failed to create invite: ${response.status}`);
try {
// Always use Cloud Functions for invites to ensure multi-tenant compliance
const res = await apiPost('/createInvitation', {
name,
email
}, orgId);
if (!res.ok) {
const errorData = await res.json();
console.error('Invite creation failed:', errorData);
throw new Error(errorData.error || `Failed to create invite: ${res.status}`);
}
const data = await res.json();
const { code, employee, inviteLink } = data;
console.log('Invite created successfully:', { code, employee: employee.name, inviteLink });
// Store employee locally for immediate UI update with proper typing
const newEmployee: Employee = {
id: employee.id,
name: employee.name,
email: employee.email,
initials: employee.name ? employee.name.split(' ').map(n => n[0]).join('').toUpperCase() : employee.email.substring(0, 2).toUpperCase(),
department: employee.department,
role: employee.role,
isOwner: false
};
if (!isFirebaseConfigured) {
const employeeWithOrg = { ...newEmployee, orgId };
setEmployees(prev => {
if (prev.find(e => e.id === employee.id)) return prev;
return [...prev, newEmployee];
});
demoStorage.saveEmployee(employeeWithOrg);
} else {
// For Firebase, add to local state for immediate UI update
setEmployees(prev => {
if (prev.find(e => e.id === employee.id)) return prev;
return [...prev, newEmployee];
});
}
return { employeeId: employee.id, inviteLink };
} catch (error) {
console.error('inviteEmployee error:', error);
throw error;
}
const data = await response.json();
const { code, employee, inviteLink } = data;
// Store employee locally for immediate UI update
if (!isFirebaseConfigured) {
const newEmployee = { ...employee, orgId };
setEmployees(prev => {
if (prev.find(e => e.id === employee.id)) return prev;
return [...prev, newEmployee];
});
demoStorage.saveEmployee(newEmployee);
} else {
// For Firebase, the employee will be created when they accept the invite
// But we can add them to local state for immediate UI update
const newEmployee = { ...employee, orgId };
setEmployees(prev => {
if (prev.find(e => e.id === employee.id)) return prev;
return [...prev, newEmployee];
});
}
return { employeeId: employee.id, inviteLink };
};
const getReportVersions = async (employeeId: string) => {
@@ -407,27 +394,36 @@ export const OrgProvider: React.FC<{ children: React.ReactNode; selectedOrgId: s
const seedInitialData = async () => {
if (!isFirebaseConfigured) {
// Start with empty employee list for clean demo experience
// Start with completely clean slate - no sample data
setEmployees([]);
setSubmissions({ [SUBMISSIONS_DATA.employeeId]: SUBMISSIONS_DATA });
setReports({ [REPORT_DATA.employeeId]: REPORT_DATA });
setFullCompanyReports([SAMPLE_COMPANY_REPORT]);
setSubmissions({});
setReports({});
setFullCompanyReports([]);
return;
}
// Start with clean slate - let users invite their own employees
// (Removed automatic seeding per user feedback)
// Start with clean slate - let users invite their own employees and generate real data
};
const saveFullCompanyReport = async (report: CompanyReport) => {
if (!isFirebaseConfigured) {
if (!orgId) {
console.error('Cannot save company report: orgId is undefined');
throw new Error('Organization ID is required to save company report');
}
if (!isFirebaseConfigured || !db) {
// Fallback to local storage in demo mode
setFullCompanyReports(prev => [report, ...prev]);
// Persist to localStorage
demoStorage.saveCompanyReport(orgId, report);
return;
}
// Use direct Firestore operations - much more efficient
const ref = doc(db, 'orgs', orgId, 'fullCompanyReports', report.id);
await setDoc(ref, report);
// Update local state after successful save
setFullCompanyReports(prev => [report, ...prev]);
};
const getFullCompanyReportHistory = async (): Promise<CompanyReport[]> => {
@@ -444,91 +440,132 @@ export const OrgProvider: React.FC<{ children: React.ReactNode; selectedOrgId: s
};
const generateCompanyReport = async (): Promise<CompanyReport> => {
// Generate comprehensive company report based on current data
const totalEmployees = employees.length;
const submittedEmployees = Object.keys(submissions).length;
console.log('generateCompanyReport called for org:', orgId);
// Calculate concrete metrics from actual data (no AI needed)
// Exclude owners from employee counts - they are company wiki contributors, not employees
const actualEmployees = employees.filter(emp => !emp.isOwner);
const totalEmployees = actualEmployees.length;
// Only count submissions from non-owner employees
const employeeSubmissions = Object.fromEntries(
Object.entries(submissions).filter(([employeeId]) => {
const employee = employees.find(emp => emp.id === employeeId);
return employee && !employee.isOwner;
})
);
const submittedEmployees = Object.keys(employeeSubmissions).length;
const submissionRate = totalEmployees > 0 ? (submittedEmployees / totalEmployees) * 100 : 0;
// Department breakdown
// Department breakdown (concrete data) - exclude owners
const deptMap = new Map<string, number>();
employees.forEach(emp => {
actualEmployees.forEach(emp => {
const dept = emp.department || 'Unassigned';
deptMap.set(dept, (deptMap.get(dept) || 0) + 1);
});
const departmentBreakdown = Array.from(deptMap.entries()).map(([department, count]) => ({ department, count }));
// Analyze employee reports for insights
const reportValues = Object.values(reports) as Report[];
const organizationalStrengths: string[] = [];
const organizationalRisks: string[] = [];
try {
// Use AI only for analysis and insights that require reasoning
const res = await apiPost('/generateCompanyWiki', {
org: org,
submissions: employeeSubmissions, // Only employee submissions, not owner data
metrics: {
totalEmployees,
submissionRate,
departmentBreakdown
}
}, orgId);
reportValues.forEach(report => {
if (report.strengths) {
organizationalStrengths.push(...report.strengths);
if (!res.ok) {
const errorData = await res.json();
console.error('Company report generation failed:', errorData);
throw new Error(errorData.error || 'Failed to generate company report');
}
if (report.risks) {
organizationalRisks.push(...report.risks);
}
});
// Remove duplicates and take top items
const uniqueStrengths = [...new Set(organizationalStrengths)].slice(0, 5);
const uniqueRisks = [...new Set(organizationalRisks)].slice(0, 5);
const data = await res.json();
console.log('Company insights generated via AI successfully');
console.log('AI response data:', data);
const gradingBreakdown = [
{ category: 'Execution', value: 70 + Math.random() * 15 },
{ category: 'People', value: 70 + Math.random() * 15 },
{ category: 'Strategy', value: 65 + Math.random() * 15 },
{ category: 'Risk', value: 60 + Math.random() * 15 }
];
const legacy = gradingBreakdown.reduce<Record<string, number>>((acc, g) => { acc[g.category.toLowerCase()] = Math.round((g.value / 100) * 5 * 10) / 10; return acc; }, {});
const report: CompanyReport = {
id: Date.now().toString(),
createdAt: Date.now(),
overview: {
totalEmployees,
departmentBreakdown,
submissionRate,
lastUpdated: Date.now(),
averagePerformanceScore: gradingBreakdown.reduce((a, g) => a + g.value, 0) / gradingBreakdown.length / 20,
riskLevel: uniqueRisks.length > 4 ? 'High' : uniqueRisks.length > 2 ? 'Medium' : 'Low'
},
personnelChanges: { newHires: [], promotions: [], departures: [] },
immediateHiringNeeds: [],
operatingPlan: {
nextQuarterGoals: ['Increase productivity', 'Implement review system'],
keyInitiatives: ['Mentorship program'],
resourceNeeds: ['Senior engineer'],
riskMitigation: ['Cross-training']
},
forwardOperatingPlan: { // legacy fields
quarterlyGoals: ['Increase productivity'],
resourceNeeds: ['Senior engineer'],
riskMitigation: ['Cross-training']
},
organizationalStrengths: uniqueStrengths.map(s => ({ area: s, description: s })),
organizationalRisks: uniqueRisks,
organizationalImpactSummary: 'Impact summary placeholder',
gradingBreakdown,
gradingOverview: legacy,
executiveSummary: `Company overview for ${org?.name || 'Organization'} as of ${new Date().toLocaleDateString()}. Total workforce: ${totalEmployees}. Submission rate: ${submissionRate.toFixed(1)}%. Key strengths: ${uniqueStrengths.slice(0, 2).join(', ')}. Risks: ${uniqueRisks.slice(0, 2).join(', ')}.`
};
// Combine concrete metrics with AI insights
const report: CompanyReport = {
id: Date.now().toString(),
createdAt: Date.now(),
// Use AI-generated insights for subjective analysis
...data.report,
// Override with our concrete metrics
overview: {
totalEmployees,
departmentBreakdown,
submissionRate,
lastUpdated: Date.now(),
averagePerformanceScore: data.report?.overview?.averagePerformanceScore || 0,
riskLevel: data.report?.overview?.riskLevel || 'Unknown'
}
};
await saveFullCompanyReport(report);
return report;
console.log('Final company report object:', report);
await saveFullCompanyReport(report);
return report;
} catch (error) {
console.error('generateCompanyReport error:', error);
throw error;
}
};
const generateCompanyWiki = async (orgOverride?: OrgData): Promise<CompanyReport> => {
const orgData = orgOverride || org;
console.log('generateCompanyWiki called with:', { orgData, orgId, submissionsCount: Object.keys(submissions || {}).length, isFirebaseConfigured });
if (!orgId) {
throw new Error('Organization ID is required to generate company wiki');
}
// ALWAYS use API call for wiki generation, with local fallback
try {
const res = await fetch(`${API_URL}/generateCompanyWiki`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ org: orgData, submissions })
});
if (!res.ok) throw new Error('Failed to generate company wiki');
console.log('Making API call to generateCompanyWiki...');
const res = await apiPost('/generateCompanyWiki', {
org: orgData,
submissions: submissions || []
}, orgId);
console.log('API response status:', res.status);
if (!res.ok) {
const errorData = await res.json();
console.error('API error response:', errorData);
throw new Error(errorData.error || 'Failed to generate company wiki');
}
const payload = await res.json();
const data: CompanyReport = payload.report || payload; // backward compatibility
console.log('API success response:', payload);
// Ensure the report has all required fields to prevent undefined errors
const data: CompanyReport = {
id: Date.now().toString(),
createdAt: Date.now(),
overview: {
totalEmployees: employees.length,
departmentBreakdown: [],
submissionRate: 0,
lastUpdated: Date.now(),
averagePerformanceScore: 0,
riskLevel: 'Unknown'
},
gradingBreakdown: [],
operatingPlan: { nextQuarterGoals: [], keyInitiatives: [], resourceNeeds: [], riskMitigation: [] },
personnelChanges: { newHires: [], promotions: [], departures: [] },
keyPersonnelChanges: [],
immediateHiringNeeds: [],
forwardOperatingPlan: { quarterlyGoals: [], resourceNeeds: [], riskMitigation: [] },
organizationalStrengths: [],
organizationalRisks: [],
gradingOverview: {},
executiveSummary: 'Company report generated successfully.',
// Override with API data if available
...(payload.report || payload)
};
await saveFullCompanyReport(data);
return data;
} catch (e) {
@@ -605,12 +642,18 @@ export const OrgProvider: React.FC<{ children: React.ReactNode; selectedOrgId: s
isOwner,
issueInviteViaApi: async ({ name, email, role, department }) => {
try {
const res = await fetch(`${API_URL}/createInvitation`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, role, department, orgId })
});
if (!res.ok) throw new Error('invite creation failed');
const res = await apiPost('/createInvitation', {
name,
email,
role,
department
}, orgId);
if (!res.ok) {
const errorData = await res.json();
throw new Error(errorData.error || 'Invite creation failed');
}
const json = await res.json();
// Optimistically add employee shell (not yet active until consume)
setEmployees(prev => prev.find(e => e.id === json.employee.id) ? prev : [...prev, { ...json.employee }]);
@@ -685,19 +728,18 @@ export const OrgProvider: React.FC<{ children: React.ReactNode; selectedOrgId: s
// Save to localStorage for persistence
demoStorage.saveSubmission(submission);
// Also call Cloud Function for processing with orgId
// Also call Cloud Function for processing with authentication and orgId
const employee = employees.find(e => e.id === employeeId);
const res = await fetch(`${API_URL}/submitEmployeeAnswers`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
employeeId,
answers,
orgId,
employee
})
});
if (!res.ok) throw new Error('Failed to submit to server');
const res = await apiPost('/submitEmployeeAnswers', {
employeeId,
answers,
employee
}, orgId);
if (!res.ok) {
const errorData = await res.json();
throw new Error(errorData.error || 'Failed to submit to server');
}
// Update local state for UI with proper typing
const convertedSubmission: Submission = {
@@ -727,22 +769,76 @@ export const OrgProvider: React.FC<{ children: React.ReactNode; selectedOrgId: s
},
generateEmployeeReport: async (employee: Employee) => {
try {
const submission = submissions[employee.id]?.answers || submissions[employee.id] || {};
const res = await fetch(`${API_URL}/generateEmployeeReport`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ employee, submission })
});
if (!res.ok) throw new Error('failed to generate');
console.log('generateEmployeeReport called for:', employee.name, 'in org:', orgId);
// Get submission data for this employee
const submission = submissions[employee.id];
if (!submission) {
throw new Error(`No questionnaire submission found for ${employee.name}. Please ensure they have completed the employee questionnaire first.`);
}
// Convert submission format for API
let submissionAnswers: Record<string, string> = {};
if (submission.answers) {
if (Array.isArray(submission.answers)) {
// If answers is an array of {question, answer} objects
submissionAnswers = submission.answers.reduce((acc, item) => {
acc[item.question] = item.answer;
return acc;
}, {} as Record<string, string>);
} else {
// If answers is already a key-value object
submissionAnswers = submission.answers as Record<string, string>;
}
}
console.log('Submission data found:', Object.keys(submissionAnswers).length, 'answers');
// Get company report and wiki data for context
let companyWiki = null;
try {
const companyReports = await getFullCompanyReportHistory();
if (companyReports.length > 0) {
companyWiki = {
org: org,
companyReport: companyReports[0]
};
console.log('Including company context in employee report generation');
}
} catch (error) {
console.warn('Could not fetch company report for context:', error);
}
const res = await apiPost('/generateEmployeeReport', {
employee,
submission: submissionAnswers,
companyWiki
}, orgId);
if (!res.ok) {
const errorData = await res.json();
console.error('API error response:', errorData);
throw new Error(errorData.error || 'Failed to generate employee report');
}
const json = await res.json();
if (json.report) {
console.log('Employee report generated successfully');
setReports(prev => ({ ...prev, [employee.id]: json.report }));
// Also save to persistent storage in demo mode
if (!isFirebaseConfigured) {
demoStorage.saveEmployeeReport(orgId, employee.id, json.report);
}
return json.report as Report;
} else {
throw new Error('No report data received from API');
}
} catch (e) {
console.error('generateEmployeeReport error', e);
throw e; // Re-throw to allow caller to handle
}
return null;
},
getEmployeeReport: async (employeeId: string) => {
try {

View File

@@ -70,9 +70,9 @@ export const UserOrganizationsProvider: React.FC<{ children: React.ReactNode }>
}
};
// Initialize selected org from session storage
// Initialize selected org from localStorage (persistent across sessions)
useEffect(() => {
const savedOrgId = sessionStorage.getItem('auditly_selected_org');
const savedOrgId = localStorage.getItem('auditly_selected_org');
if (savedOrgId) {
setSelectedOrgId(savedOrgId);
}
@@ -83,9 +83,48 @@ export const UserOrganizationsProvider: React.FC<{ children: React.ReactNode }>
loadOrganizations();
}, [user]);
// Listen for organization updates (e.g., onboarding completion)
useEffect(() => {
const handleOrgUpdate = (event: CustomEvent) => {
const { orgId, onboardingCompleted } = event.detail;
console.log('UserOrganizationsContext received org update:', { orgId, onboardingCompleted });
if (onboardingCompleted && orgId) {
// Update the specific organization in the list to reflect onboarding completion
setOrganizations(prev => {
const updated = prev.map(org =>
org.orgId === orgId
? { ...org, onboardingCompleted: true }
: org
);
console.log('Updated organizations after onboarding completion:', updated);
return updated;
});
}
};
window.addEventListener('organizationUpdated', handleOrgUpdate as EventListener);
return () => {
window.removeEventListener('organizationUpdated', handleOrgUpdate as EventListener);
};
}, []);
const selectOrganization = (orgId: string) => {
console.log('Switching to organization:', orgId);
// Clear any cached data when switching organizations for security
sessionStorage.removeItem('auditly_cached_employees');
sessionStorage.removeItem('auditly_cached_submissions');
sessionStorage.removeItem('auditly_cached_reports');
setSelectedOrgId(orgId);
sessionStorage.setItem('auditly_selected_org', orgId);
localStorage.setItem('auditly_selected_org', orgId);
// Dispatch event to notify other contexts about the org switch
window.dispatchEvent(new CustomEvent('organizationChanged', {
detail: { newOrgId: orgId }
}));
};
const createOrganization = async (name: string): Promise<{ orgId: string; requiresSubscription?: boolean }> => {

View File

@@ -20,6 +20,158 @@ const stripe = process.env.STRIPE_SECRET_KEY ? new Stripe(process.env.STRIPE_SEC
apiVersion: '2024-11-20.acacia',
}) : null;
const RESPONSE_FORMAT = {
type: "json_schema",
json_schema: {
name: "company_artifacts",
strict: true,
schema: {
type: "object",
additionalProperties: false,
properties: {
report: {
type: "object",
additionalProperties: false,
properties: {
companyPerformance: {
type: "object",
additionalProperties: false,
properties: {
summary: { type: "string" },
metrics: {
type: "array",
items: {
type: "object",
additionalProperties: false,
properties: {
name: { type: "string" },
value: { anyOf: [{ type: "string" }, { type: "number" }] },
trend: { enum: ["up", "down", "flat"] }
},
required: ["name", "value", "trend"]
}
}
},
required: ["summary", "metrics"]
},
keyPersonnelChanges: {
type: "array",
items: {
type: "object",
additionalProperties: false,
properties: {
person: { type: "string" },
change: { type: "string" }, // e.g. "Promoted to VP Eng"
impact: { type: "string" },
effectiveDate: { type: "string" }
},
required: ["person", "change", "impact", "effectiveDate"]
}
},
immediateHiringNeeds: {
type: "array",
items: {
type: "object",
additionalProperties: false,
properties: {
role: { type: "string" },
urgency: { enum: ["low", "medium", "high"] },
reason: { type: "string" }
},
required: ["role", "urgency", "reason"]
}
},
forwardOperatingPlan: {
type: "object",
additionalProperties: false,
properties: {
nextQuarterObjectives: { type: "array", items: { type: "string" } },
initiatives: {
type: "array",
items: {
type: "object",
additionalProperties: false,
properties: {
name: { type: "string" },
owner: { type: "string" },
kpis: { type: "array", items: { type: "string" } }
},
required: ["name", "owner", "kpis"]
}
},
risks: {
type: "array",
items: {
type: "object",
additionalProperties: false,
properties: {
risk: { type: "string" },
mitigation: { type: "string" }
},
required: ["risk", "mitigation"]
}
}
},
required: ["nextQuarterObjectives", "initiatives", "risks"]
},
organizationalInsights: {
type: "object",
additionalProperties: false,
properties: {
culture: { type: "string" },
teamDynamics: { type: "string" },
blockers: { type: "array", items: { type: "string" } }
},
required: ["culture", "teamDynamics", "blockers"]
},
strengths: { type: "array", items: { type: "string" } },
gradingOverview: {
type: "array",
items: {
type: "object",
additionalProperties: false,
properties: {
department: { type: "string" },
grade: { enum: ["A", "B", "C", "D", "F"] },
notes: { type: "string" }
},
required: ["department", "grade", "notes"]
}
}
},
required: ["companyPerformance", "keyPersonnelChanges", "immediateHiringNeeds", "forwardOperatingPlan", "organizationalInsights", "strengths", "gradingOverview"]
},
wiki: {
type: "object",
additionalProperties: false,
properties: {
companyName: { type: "string" },
industry: { type: "string" },
description: { type: "string" },
mission: { type: "string" },
values: { type: "array", items: { type: "string" } },
culture: { type: "string" },
orgInfo: {
type: "object",
additionalProperties: false,
properties: {
hq: { type: "string" },
foundedYear: { type: "number" },
headcount: { type: "number" },
products: { type: "array", items: { type: "string" } }
},
required: ["hq", "foundedYear", "headcount", "products"]
}
},
required: ["companyName", "industry", "description", "mission", "values", "culture", "orgInfo"]
}
},
required: ["report", "wiki"]
}
}
};
// Helper function to generate OTP
const generateOTP = () => {
return Math.floor(100000 + Math.random() * 900000).toString();
@@ -29,7 +181,7 @@ const generateOTP = () => {
const cors = (req, res, next) => {
res.set('Access-Control-Allow-Origin', '*');
res.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.set('Access-Control-Allow-Headers', 'Content-Type');
res.set('Access-Control-Allow-Headers', 'Content-Type,Authorization');
res.set('Access-Control-Max-Age', '3600');
if (req.method === 'OPTIONS') {
@@ -44,7 +196,7 @@ const cors = (req, res, next) => {
const setCorsHeaders = (res) => {
res.set('Access-Control-Allow-Origin', '*');
res.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.set('Access-Control-Allow-Headers', 'Content-Type');
res.set('Access-Control-Allow-Headers', 'Content-Type,Authorization');
res.set('Access-Control-Max-Age', '3600');
};
@@ -235,17 +387,31 @@ exports.createInvitation = functions.https.onRequest(async (req, res) => {
return res.status(405).json({ error: "Method not allowed" });
}
const { orgId, email, role = "employee" } = req.body;
const { orgId, name, email, role = "employee", department } = req.body;
if (!orgId || !email) {
return res.status(400).json({ error: "Organization ID and email are required" });
if (!orgId || !email || !name) {
return res.status(400).json({ error: "Organization ID, name, and email are required" });
}
try {
// Generate invite code
const code = Math.random().toString(36).substring(2, 15);
// Generate employee ID
const employeeId = `emp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
// Store invitation
// Create employee object for the invite
const employee = {
id: employeeId,
name: name.trim(),
email: email.trim(),
role: role?.trim() || "employee",
department: department?.trim() || "General",
status: "invited",
inviteCode: code
};
// Store invitation with employee data
const inviteRef = await db
.collection("orgs")
.doc(orgId)
@@ -254,20 +420,29 @@ exports.createInvitation = functions.https.onRequest(async (req, res) => {
await inviteRef.set({
code,
employee,
email,
role,
orgId,
status: "pending",
createdAt: Date.now(),
expiresAt: Date.now() + (7 * 24 * 60 * 60 * 1000), // 7 days
});
// Generate invite links
const baseUrl = process.env.CLIENT_URL || 'http://localhost:5174';
const inviteLink = `${baseUrl}/#/employee-form/${code}`;
const emailLink = `mailto:${email}?subject=You're invited to join our organization&body=Hi ${name},%0A%0AYou've been invited to complete a questionnaire for our organization. Please click the link below to get started:%0A%0A${inviteLink}%0A%0AThis link will expire in 7 days.%0A%0AThank you!`;
// In production, send actual invitation email
console.log(`📧 Invitation sent to ${email} with code: ${code}`);
console.log(`📧 Invitation sent to ${email} (${name}) with code: ${code}`);
console.log(`📧 Invite link: ${inviteLink}`);
res.json({
success: true,
inviteCode: code,
code,
employee,
inviteLink,
emailLink,
message: "Invitation sent successfully",
});
} catch (error) {
@@ -315,6 +490,8 @@ exports.getInvitationStatus = functions.https.onRequest(async (req, res) => {
res.json({
success: true,
used: invite.status !== 'pending',
employee: invite.employee,
invite,
});
} catch (error) {
@@ -338,8 +515,8 @@ exports.consumeInvitation = functions.https.onRequest(async (req, res) => {
const { code, userId } = req.body;
if (!code || !userId) {
return res.status(400).json({ error: "Invitation code and user ID are required" });
if (!code) {
return res.status(400).json({ error: "Invitation code is required" });
}
try {
@@ -362,25 +539,34 @@ exports.consumeInvitation = functions.https.onRequest(async (req, res) => {
return res.status(400).json({ error: "Invitation has expired" });
}
// Get employee data from the invite
const employee = invite.employee;
if (!employee) {
return res.status(400).json({ error: "Invalid invitation data - missing employee information" });
}
// Mark invitation as consumed
await inviteDoc.ref.update({
status: "consumed",
consumedBy: userId,
consumedBy: employee.id,
consumedAt: Date.now(),
});
// Add user to organization employees
// Add employee to organization using data from invite
await db
.collection("orgs")
.doc(invite.orgId)
.collection("employees")
.doc(userId)
.doc(employee.id)
.set({
id: userId,
email: invite.email,
role: invite.role,
id: employee.id,
name: employee.name || employee.email.split("@")[0],
email: employee.email,
role: employee.role || "employee",
department: employee.department || "General",
joinedAt: Date.now(),
status: "active",
inviteCode: code,
});
res.json({
@@ -407,25 +593,59 @@ exports.submitEmployeeAnswers = functions.https.onRequest(async (req, res) => {
return res.status(405).json({ error: "Method not allowed" });
}
const { orgId, employeeId, answers } = req.body;
const { orgId, employeeId, answers, inviteCode } = req.body;
if (!orgId || !employeeId || !answers) {
return res.status(400).json({ error: "Organization ID, employee ID, and answers are required" });
// For invite-based submissions, we need inviteCode and answers
// For regular submissions, we need orgId, employeeId, and answers
if (inviteCode) {
if (!inviteCode || !answers) {
return res.status(400).json({ error: "Invite code and answers are required for invite submissions" });
}
} else {
if (!orgId || !employeeId || !answers) {
return res.status(400).json({ error: "Organization ID, employee ID, and answers are required" });
}
}
try {
let finalOrgId, finalEmployeeId;
if (inviteCode) {
// For invite-based submissions, look up the invite to get employee and org data
const inviteSnapshot = await db
.collectionGroup("invites")
.where("code", "==", inviteCode)
.where("status", "==", "consumed")
.limit(1)
.get();
if (inviteSnapshot.empty) {
return res.status(404).json({ error: "Invitation not found or not consumed yet" });
}
const invite = inviteSnapshot.docs[0].data();
finalOrgId = invite.orgId;
finalEmployeeId = invite.employee.id;
} else {
// Regular submission
finalOrgId = orgId;
finalEmployeeId = employeeId;
}
// Store submission
const submissionRef = await db
.collection("orgs")
.doc(orgId)
.doc(finalOrgId)
.collection("submissions")
.doc(employeeId);
.doc(finalEmployeeId);
await submissionRef.set({
employeeId,
employeeId: finalEmployeeId,
answers,
submittedAt: Date.now(),
status: "completed",
submissionType: inviteCode ? "invite" : "regular",
...(inviteCode && { inviteCode })
});
res.json({
@@ -597,62 +817,40 @@ exports.generateCompanyWiki = functions.https.onRequest(async (req, res) => {
if (openai) {
// Use OpenAI to generate the company report and wiki
const prompt = `
You are an expert business analyst. Generate a comprehensive company report and wiki based on the following data:
Organization Information:
${JSON.stringify(org, null, 2)}
Employee Submissions:
${JSON.stringify(submissions, null, 2)}
Generate a detailed analysis with two main components:
1. COMPANY REPORT with:
- companyPerformance: Overall performance metrics and trends
- keyPersonnelChanges: Recent personnel moves and their impact
- immediateHiringNeeds: Urgent staffing requirements
- forwardOperatingPlan: Strategic planning for next quarter
- organizationalInsights: Team dynamics and cultural health
- strengths: Company strengths
- gradingOverview: Performance breakdown by department
2. COMPANY WIKI with:
- companyName, industry, description
- mission, values, culture
- Key organizational information
Return ONLY valid JSON with 'report' and 'wiki' objects. Be thorough and professional.
`.trim();
const system = "You are a cut-and-dry expert business analyst. Return ONLY JSON that conforms to the provided schema.";
const user = [
"Generate a COMPANY REPORT and COMPANY WIKI that fully leverage the input data.",
"Be thorough and professional.",
"",
"Organization Information:",
JSON.stringify(org, null, 2),
"",
"Employee Submissions:",
JSON.stringify(submissions, null, 2)
].join("\n");
const completion = await openai.chat.completions.create({
model: "gpt-4o",
temperature: 0, // consistency
response_format: RESPONSE_FORMAT,
messages: [
{
role: "system",
content: "You are an expert business analyst. Generate comprehensive company reports and wikis in JSON format."
},
{
role: "user",
content: prompt
}
],
response_format: { type: "json_object" },
temperature: 0.7,
{ role: "system", content: system },
{ role: "user", content: user }
]
});
const aiResponse = completion.choices[0].message.content;
const parsedResponse = JSON.parse(aiResponse);
// content is guaranteed to be schema-conformant JSON
const parsed = JSON.parse(completion.choices[0].message.content);
report = {
const report = {
generatedAt: Date.now(),
...parsedResponse.report
...parsed.report
};
wiki = {
companyName: org.name,
const wiki = {
companyName: org?.name ?? parsed.wiki.companyName,
generatedAt: Date.now(),
...parsedResponse.wiki
...parsed.wiki,
};
} else {
// Fallback to mock data when OpenAI is not available
@@ -1451,4 +1649,49 @@ async function handlePaymentFailed(invoice) {
// } else {
// response.status(400).json({ error: 'Invalid verification code' });
// }
// });
// });
// Save Company Report Function
exports.saveCompanyReport = functions.https.onRequest(async (req, res) => {
setCorsHeaders(res);
if (req.method === 'OPTIONS') {
res.status(204).send('');
return;
}
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
const { orgId, report } = req.body;
if (!orgId || !report) {
return res.status(400).json({ error: "Organization ID and report are required" });
}
try {
// Add ID and timestamp if not present
if (!report.id) {
report.id = `report_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
if (!report.createdAt) {
report.createdAt = Date.now();
}
// Save to Firestore
const reportRef = db.collection("orgs").doc(orgId).collection("fullCompanyReports").doc(report.id);
await reportRef.set(report);
console.log(`Company report saved successfully for org ${orgId}`);
res.json({
success: true,
reportId: report.id,
message: "Company report saved successfully"
});
} catch (error) {
console.error("Save company report error:", error);
res.status(500).json({ error: "Failed to save company report" });
}
});

121
index.css
View File

@@ -1,55 +1,63 @@
@import "tailwindcss";
:root {
/* Light theme variables - using new Figma color palette */
--background-primary : #FFFFFF;
/* Base White */
--background-secondary : #FDFDFD;
/* Gray 6 */
--background-tertiary : #FAFAFA;
/* Gray 5 */
--text-primary : #0A0D12;
/* Dark 7 */
--text-secondary : #717680;
/* Dark 2 */
--text-tertiary : #A4A7AE;
/* Gray 1 */
--accent : #5E48FC;
/* Brand Main */
--accent-hover : #4C3CF0;
/* Slightly darker brand */
--accent-text : #FFFFFF;
/* Base White */
--border-color : #E9EAEB;
/* Gray 3 */
--border-light : #F5F5F5;
/* Gray 4 */
--sidebar-bg : #FDFDFD;
/* Gray 6 */
--sidebar-text : #717680;
/* Dark 2 */
--sidebar-active-bg : #5E48FC;
/* Brand Main */
--sidebar-active-text : #FFFFFF;
/* Base White */
--input-bg : #F5F5F5;
/* Gray 4 */
--input-border : #E9EAEB;
/* Gray 3 */
--input-placeholder : #717680;
/* Dark 2 */
--background-primary : #FFFFFF;
--background-secondary: #FDFDFD;
--background-tertiary : #FAFAFA;
--text-primary : #0A0D12;
--text-secondary: #717680;
--text-tertiary : #A4A7AE;
--accent : ##3399FF;
--accent-hover: #4C3CF0;
--accent-text : #FFFFFF;
--border-color: #E9EAEB;
--border-light: #F5F5F5;
--sidebar-bg : #FDFDFD;
--sidebar-text : #717680;
--sidebar-active-bg : #3399FF;
--sidebar-active-text: #FFFFFF;
--input-bg : #F5F5F5;
--input-border : #E9EAEB;
--input-placeholder: #717680;
--button-secondary-bg : #F5F5F5;
/* Gray 4 */
--button-secondary-hover: #E9EAEB;
/* Gray 3 */
--status-red : #F63D68;
/* Other Red */
--status-green : #3CCB7F;
/* Other Green */
--status-orange : #FF4405;
/* Other Orange */
--status-yellow : #FEEE95;
/* Other Yellow */
--color-red : #F63D68;
--color-green : #3CCB7F;
--color-orange : #FF4405;
--color-light-orange: #F38744;
--color-yellow : #FEEE95;
--gray-0 : #FFFFFF;
--gray-50 : #F7F7F8;
--gray-100: #F1F2F4;
--gray-200: #E2E5E9;
--gray-300: #CBD0D7;
--gray-400: #99A1AE;
--gray-500: #6C7889;
--gray-600: #515A67;
--gray-700: #2D3239;
--gray-800: #24282E;
--gray-900: #121417;
--neutral-100: #A4A7AE;
--neutral-200: #D5D7DA;
--neutral-300: #E9EAEB;
--neutral-400: #f5f5f5;
--neutral-500: #FAFAFA;
--neutral-600: #FDFDFD;
--neutral-700: #FEFEFE;
--button-bg-primary : #39F;
--button-border-primary: #66B2FF;
}
.dark {
@@ -102,6 +110,25 @@
/* Other Orange */
--status-yellow : #FEEE95;
/* Other Yellow */
--neutral-200 : #717670;
--neutral-300 : #535862;
--neutral-400 : #414651;
--neutral-500 : #252B37;
--neutral-600 : #181D27;
--neutral-700 : #0A0D12;
--gray-900: #FFFFFF;
--gray-800: #F7F7F8;
--gray-700: #F1F2F4;
--gray-600: #E2E5E9;
--gray-500: #CBD0D7;
--gray-400: #99A1AE;
--gray-300: #6C7889;
--gray-200: #515A67;
--gray-100: #2D3239;
--gray-50 : #24282E;
--gray-0 : #121417;
}
* {

View File

@@ -25,13 +25,14 @@
"firebase-admin": "^13.4.0",
"firebase-functions": "^6.4.0",
"lucide-react": "^0.539.0",
"openai": "^4.104.0",
"openai": "^5.12.2",
"postcss": "^8.5.6",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router-dom": "^7.8.0",
"recharts": "^3.1.2",
"tailwindcss": "^4.1.12"
"tailwindcss": "^4.1.12",
"zod": "^4.0.17"
},
"devDependencies": {
"@types/cors": "^2.8.19",

View File

@@ -2,9 +2,10 @@ import React, { useState, useMemo } from 'react';
import { Card, Button } from '../components/UiKit';
import { useOrg } from '../contexts/OrgContext';
import { CHAT_STARTERS } from '../constants';
import { apiPost } from '../services/api';
const Chat: React.FC = () => {
const { employees, reports, generateEmployeeReport } = useOrg();
const { employees, reports, generateEmployeeReport, orgId } = useOrg();
const [messages, setMessages] = useState<Array<{ id: string, role: 'user' | 'assistant', text: string }>>([]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
@@ -34,16 +35,44 @@ const Chat: React.FC = () => {
setInput('');
setIsLoading(true);
// Simulate AI response (placeholder for server /api/chat usage)
setTimeout(() => {
try {
// Build context for the AI
const context = {
selectedEmployee: selectedEmployeeId ? employees.find(e => e.id === selectedEmployeeId) : null,
selectedReport: selectedReport,
totalEmployees: employees.length,
organizationScope: !selectedEmployeeId
};
const res = await apiPost('/chat', {
message: textToSend,
employeeId: selectedEmployeeId,
context
}, orgId);
if (!res.ok) {
const errorData = await res.json();
throw new Error(errorData.error || 'Failed to get AI response');
}
const data = await res.json();
const aiResponse = {
id: (Date.now() + 1).toString(),
role: 'assistant' as const,
text: `Based on ${selectedEmployeeId ? 'the selected employee\'s' : 'organizational'} data, here's an insight related to: "${textToSend}".`
text: data.response || 'I apologize, but I couldn\'t generate a response at this time.'
};
setMessages(prev => [...prev, aiResponse]);
} catch (error) {
console.error('Chat error:', error);
const errorResponse = {
id: (Date.now() + 1).toString(),
role: 'assistant' as const,
text: `I apologize, but I encountered an error: ${error.message}. Please try again.`
};
setMessages(prev => [...prev, errorResponse]);
} finally {
setIsLoading(false);
}, 1500);
}
};
return (

View File

@@ -113,19 +113,19 @@ const CompanyWiki: React.FC = () => {
<div className="text-sm text-[--text-secondary]">Employees</div>
</div>
<div className="text-center p-4 bg-[--background-secondary] rounded-lg">
<div className="text-2xl font-bold text-green-500">{companyReport.overview.departmentBreakdown.length}</div>
<div className="text-2xl font-bold text-green-500">{companyReport.overview?.departmentBreakdown?.length || 0}</div>
<div className="text-sm text-[--text-secondary]">Departments</div>
</div>
<div className="text-center p-4 bg-[--background-secondary] rounded-lg">
<div className="text-2xl font-bold text-purple-500">{companyReport.organizationalStrengths.length}</div>
<div className="text-2xl font-bold text-purple-500">{companyReport.organizationalStrengths?.length || 0}</div>
<div className="text-sm text-[--text-secondary]">Strength Areas</div>
</div>
<div className="text-center p-4 bg-[--background-secondary] rounded-lg">
<div className="text-2xl font-bold text-orange-500">{companyReport.organizationalRisks.length}</div>
<div className="text-2xl font-bold text-orange-500">{companyReport.organizationalRisks?.length || 0}</div>
<div className="text-sm text-[--text-secondary]">Risks</div>
</div>
</div>
{companyReport.gradingOverview && (
{(Array.isArray(companyReport.gradingOverview) && companyReport.gradingOverview.length > 0) && (
<div className="mt-6 p-4 bg-[--background-tertiary] rounded-lg">
<RadarPerformanceChart
title="Organizational Grading"
@@ -142,13 +142,13 @@ const CompanyWiki: React.FC = () => {
<Card>
<h4 className="text-lg font-semibold text-[--text-primary] mb-3">Strengths</h4>
<ul className="space-y-2">
{companyReport.organizationalStrengths.map((s: any, i) => <li key={i} className="text-[--text-secondary] text-sm"> {s.area}</li>)}
{(companyReport.organizationalStrengths || []).map((s: any, i) => <li key={i} className="text-[--text-secondary] text-sm"> {s.area || s}</li>)}
</ul>
</Card>
<Card>
<h4 className="text-lg font-semibold text-[--text-primary] mb-3">Risks</h4>
<ul className="space-y-2">
{companyReport.organizationalRisks.map((r, i) => <li key={i} className="text-[--text-secondary] text-sm"> {r}</li>)}
{(companyReport.organizationalRisks || []).map((r, i) => <li key={i} className="text-[--text-secondary] text-sm"> {r}</li>)}
</ul>
</Card>
<Card>
@@ -172,72 +172,152 @@ const CompanyWiki: React.FC = () => {
</div>
)}
{/* Company Profile - Onboarding Data */}
<Card className="mt-6">
<h3 className="text-lg font-semibold text-[--text-primary] mb-4">Company Profile</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Company Profile - Q&A Format from Onboarding */}
<div className="mt-6">
<h3 className="text-2xl font-semibold text-[--text-primary] mb-6">Company Profile</h3>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{org?.mission && (
<div>
<h4 className="font-medium text-[--text-primary] mb-2">Mission</h4>
<p className="text-[--text-secondary] text-sm">{org.mission}</p>
</div>
<Card className="p-4">
<div className="space-y-3">
<div>
<h4 className="text-sm font-medium text-[--text-secondary] mb-1">Question:</h4>
<p className="text-[--text-primary] font-medium">What is your company's mission?</p>
</div>
<div>
<h4 className="text-sm font-medium text-[--text-secondary] mb-1">Answer:</h4>
<p className="text-[--text-primary] text-sm leading-relaxed">{org.mission}</p>
</div>
</div>
</Card>
)}
{org?.vision && (
<div>
<h4 className="font-medium text-[--text-primary] mb-2">Vision</h4>
<p className="text-[--text-secondary] text-sm">{org.vision}</p>
</div>
<Card className="p-4">
<div className="space-y-3">
<div>
<h4 className="text-sm font-medium text-[--text-secondary] mb-1">Question:</h4>
<p className="text-[--text-primary] font-medium">What is your company's vision?</p>
</div>
<div>
<h4 className="text-sm font-medium text-[--text-secondary] mb-1">Answer:</h4>
<p className="text-[--text-primary] text-sm leading-relaxed">{org.vision}</p>
</div>
</div>
</Card>
)}
{org?.evolution && (
<div>
<h4 className="font-medium text-[--text-primary] mb-2">Company Evolution</h4>
<p className="text-[--text-secondary] text-sm">{org.evolution}</p>
</div>
<Card className="p-4">
<div className="space-y-3">
<div>
<h4 className="text-sm font-medium text-[--text-secondary] mb-1">Question:</h4>
<p className="text-[--text-primary] font-medium">How has your company evolved over time?</p>
</div>
<div>
<h4 className="text-sm font-medium text-[--text-secondary] mb-1">Answer:</h4>
<p className="text-[--text-primary] text-sm leading-relaxed">{org.evolution}</p>
</div>
</div>
</Card>
)}
{org?.advantages && (
<div>
<h4 className="font-medium text-[--text-primary] mb-2">Competitive Advantages</h4>
<p className="text-[--text-secondary] text-sm">{org.advantages}</p>
</div>
<Card className="p-4">
<div className="space-y-3">
<div>
<h4 className="text-sm font-medium text-[--text-secondary] mb-1">Question:</h4>
<p className="text-[--text-primary] font-medium">What are your competitive advantages?</p>
</div>
<div>
<h4 className="text-sm font-medium text-[--text-secondary] mb-1">Answer:</h4>
<p className="text-[--text-primary] text-sm leading-relaxed">{org.advantages}</p>
</div>
</div>
</Card>
)}
{org?.vulnerabilities && (
<div>
<h4 className="font-medium text-[--text-primary] mb-2">Vulnerabilities</h4>
<p className="text-[--text-secondary] text-sm">{org.vulnerabilities}</p>
</div>
<Card className="p-4">
<div className="space-y-3">
<div>
<h4 className="text-sm font-medium text-[--text-secondary] mb-1">Question:</h4>
<p className="text-[--text-primary] font-medium">What are your key vulnerabilities?</p>
</div>
<div>
<h4 className="text-sm font-medium text-[--text-secondary] mb-1">Answer:</h4>
<p className="text-[--text-primary] text-sm leading-relaxed">{org.vulnerabilities}</p>
</div>
</div>
</Card>
)}
{org?.shortTermGoals && (
<div>
<h4 className="font-medium text-[--text-primary] mb-2">Short Term Goals</h4>
<p className="text-[--text-secondary] text-sm">{org.shortTermGoals}</p>
</div>
<Card className="p-4">
<div className="space-y-3">
<div>
<h4 className="text-sm font-medium text-[--text-secondary] mb-1">Question:</h4>
<p className="text-[--text-primary] font-medium">What are your short-term goals?</p>
</div>
<div>
<h4 className="text-sm font-medium text-[--text-secondary] mb-1">Answer:</h4>
<p className="text-[--text-primary] text-sm leading-relaxed">{org.shortTermGoals}</p>
</div>
</div>
</Card>
)}
{org?.longTermGoals && (
<div>
<h4 className="font-medium text-[--text-primary] mb-2">Long Term Goals</h4>
<p className="text-[--text-secondary] text-sm">{org.longTermGoals}</p>
</div>
<Card className="p-4">
<div className="space-y-3">
<div>
<h4 className="text-sm font-medium text-[--text-secondary] mb-1">Question:</h4>
<p className="text-[--text-primary] font-medium">What are your long-term goals?</p>
</div>
<div>
<h4 className="text-sm font-medium text-[--text-secondary] mb-1">Answer:</h4>
<p className="text-[--text-primary] text-sm leading-relaxed">{org.longTermGoals}</p>
</div>
</div>
</Card>
)}
{org?.cultureDescription && (
<div>
<h4 className="font-medium text-[--text-primary] mb-2">Culture</h4>
<p className="text-[--text-secondary] text-sm">{org.cultureDescription}</p>
</div>
<Card className="p-4">
<div className="space-y-3">
<div>
<h4 className="text-sm font-medium text-[--text-secondary] mb-1">Question:</h4>
<p className="text-[--text-primary] font-medium">How would you describe your company culture?</p>
</div>
<div>
<h4 className="text-sm font-medium text-[--text-secondary] mb-1">Answer:</h4>
<p className="text-[--text-primary] text-sm leading-relaxed">{org.cultureDescription}</p>
</div>
</div>
</Card>
)}
{org?.workEnvironment && (
<div>
<h4 className="font-medium text-[--text-primary] mb-2">Work Environment</h4>
<p className="text-[--text-secondary] text-sm">{org.workEnvironment}</p>
</div>
<Card className="p-4">
<div className="space-y-3">
<div>
<h4 className="text-sm font-medium text-[--text-secondary] mb-1">Question:</h4>
<p className="text-[--text-primary] font-medium">What is your work environment like?</p>
</div>
<div>
<h4 className="text-sm font-medium text-[--text-secondary] mb-1">Answer:</h4>
<p className="text-[--text-primary] text-sm leading-relaxed">{org.workEnvironment}</p>
</div>
</div>
</Card>
)}
{org?.additionalContext && (
<div className="md:col-span-2">
<h4 className="font-medium text-[--text-primary] mb-2">Additional Context</h4>
<p className="text-[--text-secondary] text-sm">{org.additionalContext}</p>
</div>
<Card className="p-4 lg:col-span-2">
<div className="space-y-3">
<div>
<h4 className="text-sm font-medium text-[--text-secondary] mb-1">Question:</h4>
<p className="text-[--text-primary] font-medium">Any additional context about your company?</p>
</div>
<div>
<h4 className="text-sm font-medium text-[--text-secondary] mb-1">Answer:</h4>
<p className="text-[--text-primary] text-sm leading-relaxed">{org.additionalContext}</p>
</div>
</div>
</Card>
)}
</div>
</Card>
</div>
{org?.description && (
<Card className="mt-6">

View File

@@ -5,6 +5,7 @@ import { CompanyReport, Employee, Report } from '../types';
import RadarPerformanceChart from '../components/charts/RadarPerformanceChart';
import ScoreBarList from '../components/charts/ScoreBarList';
import { SAMPLE_COMPANY_REPORT } from '../constants';
import ReportDetail from './ReportDetail';
interface EmployeeDataProps {
mode: 'submissions' | 'reports';
@@ -47,7 +48,7 @@ const CompanyReportCard: React.FC<{ report: CompanyReport }> = ({ report }) => {
</div>
<div className="bg-[--background-tertiary] p-4 rounded-lg">
<h3 className="text-sm font-medium text-[--text-secondary]">Departments</h3>
<p className="text-2xl `font-bold text-[--text-primary]">{report.overview.departmentBreakdown.length}</p>
<p className="text-2xl font-bold text-[--text-primary]">{report.overview.departmentBreakdown.length}</p>
</div>
<div className="bg-[--background-tertiary] p-4 rounded-lg">
<h3 className="text-sm font-medium text-[--text-secondary]">Avg Performance</h3>
@@ -219,7 +220,8 @@ const EmployeeCard: React.FC<{
isOwner: boolean;
onGenerateReport?: (employee: Employee) => void;
isGeneratingReport?: boolean;
}> = ({ employee, report, mode, isOwner, onGenerateReport, isGeneratingReport }) => {
onViewReport?: (report: Report, employeeName: string) => void;
}> = ({ employee, report, mode, isOwner, onGenerateReport, isGeneratingReport, onViewReport }) => {
const [isExpanded, setIsExpanded] = useState(false);
return (
@@ -243,13 +245,22 @@ const EmployeeCard: React.FC<{
</div>
<div className="flex space-x-2">
{report && (
<Button
size="sm"
variant="secondary"
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? 'Hide' : 'View'} Report
</Button>
<>
<Button
size="sm"
variant="secondary"
onClick={() => onViewReport?.(report, employee.name)}
>
View Full Report
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? 'Hide' : 'View'} Summary
</Button>
</>
)}
{isOwner && mode === 'reports' && (
<Button
@@ -327,9 +338,11 @@ const EmployeeCard: React.FC<{
};
const EmployeeData: React.FC<EmployeeDataProps> = ({ mode }) => {
const { employees, reports, user, isOwner, getFullCompanyReportHistory, generateEmployeeReport, saveReport, orgId } = useOrg();
const { employees, reports, user, isOwner, getFullCompanyReportHistory, generateEmployeeReport, generateCompanyReport, saveReport, orgId } = useOrg();
const [companyReport, setCompanyReport] = useState<CompanyReport | null>(null);
const [generatingReports, setGeneratingReports] = useState<Set<string>>(new Set());
const [generatingCompanyReport, setGeneratingCompanyReport] = useState(false);
const [selectedReport, setSelectedReport] = useState<{ report: CompanyReport | Report; type: 'company' | 'employee'; employeeName?: string } | null>(null);
useEffect(() => {
// Load company report for owners
@@ -358,32 +371,19 @@ const EmployeeData: React.FC<EmployeeDataProps> = ({ mode }) => {
try {
console.log('Generating report for employee:', employee.name, 'in org:', orgId);
// Call the API endpoint with orgId
const response = await fetch(`/api/employee-report`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
employeeId: employee.id,
orgId: orgId
}),
});
if (response.ok) {
const result = await response.json();
if (result.success && result.report) {
// Save the report using the context method
await saveReport(employee.id, result.report);
console.log('Report generated and saved successfully');
} else {
console.error('Report generation failed:', result.error || 'Unknown error');
}
// Use the OrgContext method instead of direct API call
const report = await generateEmployeeReport(employee);
if (report) {
console.log('Report generated and saved successfully for:', employee.name);
} else {
console.error('API call failed:', response.status, response.statusText);
console.error('Report generation failed for:', employee.name);
// Show user-friendly error
alert(`Failed to generate report for ${employee.name}. Please try again.`);
}
} catch (error) {
console.error('Error generating report:', error);
alert(`Error generating report for ${employee.name}: ${error.message}`);
} finally {
setGeneratingReports(prev => {
const newSet = new Set(prev);
@@ -391,7 +391,26 @@ const EmployeeData: React.FC<EmployeeDataProps> = ({ mode }) => {
return newSet;
});
}
}; const currentUserIsOwner = isOwner(user?.uid || '');
};
const handleGenerateCompanyReport = async () => {
setGeneratingCompanyReport(true);
try {
console.log('Generating company report for org:', orgId);
const newReport = await generateCompanyReport();
console.log('Received new company report:', newReport);
setCompanyReport(newReport);
console.log('Company report generated and state updated successfully');
} catch (error) {
console.error('Error generating company report:', error);
alert(`Error generating company report: ${error.message}`);
} finally {
setGeneratingCompanyReport(false);
}
};
const currentUserIsOwner = isOwner(user?.uid || '');
// Filter employees based on user access
const visibleEmployees = currentUserIsOwner
@@ -412,8 +431,51 @@ const EmployeeData: React.FC<EmployeeDataProps> = ({ mode }) => {
</div>
{/* Company Report - Only visible to owners in reports mode */}
{currentUserIsOwner && mode === 'reports' && companyReport && (
<CompanyReportCard report={companyReport} />
{currentUserIsOwner && mode === 'reports' && (
<div className="mb-6">
{companyReport ? (
<div>
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold text-[--text-primary]">Company Report</h2>
<div className="flex space-x-2">
<Button
size="sm"
variant="secondary"
onClick={() => setSelectedReport({ report: companyReport, type: 'company' })}
>
View Full Report
</Button>
<Button
size="sm"
onClick={handleGenerateCompanyReport}
disabled={generatingCompanyReport}
>
{generatingCompanyReport ? 'Regenerating...' : 'Regenerate Report'}
</Button>
</div>
</div>
<CompanyReportCard report={companyReport} />
</div>
) : (
<Card>
<div className="text-center py-8">
<h3 className="text-lg font-semibold text-[--text-primary] mb-2">
Generate Company Report
</h3>
<p className="text-[--text-secondary] mb-4">
Create a comprehensive AI-powered report analyzing your organization's performance,
strengths, and recommendations based on employee data.
</p>
<Button
onClick={handleGenerateCompanyReport}
disabled={generatingCompanyReport}
>
{generatingCompanyReport ? 'Generating Report...' : 'Generate Company Report'}
</Button>
</div>
</Card>
)}
</div>
)}
{/* Employee Cards */}
@@ -442,10 +504,21 @@ const EmployeeData: React.FC<EmployeeDataProps> = ({ mode }) => {
isOwner={currentUserIsOwner}
onGenerateReport={handleGenerateReport}
isGeneratingReport={generatingReports.has(employee.id)}
onViewReport={(report, employeeName) => setSelectedReport({ report, type: 'employee', employeeName })}
/>
))
)}
</div>
{/* Report Detail Modal */}
{selectedReport && (
<ReportDetail
report={selectedReport.report}
type={selectedReport.type}
employeeName={selectedReport.employeeName}
onClose={() => setSelectedReport(null)}
/>
)}
</div>
);
};

View File

@@ -15,17 +15,28 @@ const EmployeeQuestionnaire: React.FC = () => {
const location = useLocation();
const params = useParams();
const { user } = useAuth();
const { submitEmployeeAnswers, generateEmployeeReport, employees } = useOrg();
// Check if this is an invite-based flow (no auth/org needed)
const inviteCode = params.inviteCode;
const isInviteFlow = !!inviteCode;
// Only use org context for authenticated flows
let submitEmployeeAnswers, generateEmployeeReport, employees;
if (!isInviteFlow) {
const orgContext = useOrg();
({ submitEmployeeAnswers, generateEmployeeReport, employees } = orgContext);
} else {
// For invite flows, we don't need these functions from org context
submitEmployeeAnswers = null;
generateEmployeeReport = null;
employees = [];
}
const [answers, setAnswers] = useState<EmployeeSubmissionAnswers>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState('');
const [inviteEmployee, setInviteEmployee] = useState<any>(null);
const [isLoadingInvite, setIsLoadingInvite] = useState(false);
// Check if this is an invite-based flow (no auth needed)
const inviteCode = params.inviteCode;
const isInviteFlow = !!inviteCode;
// Load invite details if this is an invite flow
useEffect(() => {
if (inviteCode) {
@@ -36,15 +47,24 @@ const EmployeeQuestionnaire: React.FC = () => {
const loadInviteDetails = async (code: string) => {
setIsLoadingInvite(true);
try {
const response = await fetch(`${API_URL}/api/invitations/${code}`);
// Use Cloud Function endpoint for invite status
const response = await fetch(`${API_URL}/getInvitationStatus?code=${code}`);
if (response.ok) {
const data = await response.json();
setInviteEmployee(data.employee);
setError('');
if (data.used) {
setError('This invitation has already been used');
} else if (data.employee) {
setInviteEmployee(data.employee);
setError('');
} else {
setError('Invalid invitation data');
}
} else {
setError('Invalid or expired invitation link');
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }));
setError(errorData.error || 'Invalid or expired invitation link');
}
} catch (err) {
console.error('Error loading invite details:', err);
setError('Failed to load invitation details');
} finally {
setIsLoadingInvite(false);
@@ -120,30 +140,39 @@ const EmployeeQuestionnaire: React.FC = () => {
}));
};
const submitViaInvite = async (employee: any, answers: EmployeeSubmissionAnswers, inviteCode: string) => {
const submitViaInvite = async (answers: EmployeeSubmissionAnswers, inviteCode: string) => {
try {
// First, consume the invite to mark it as used
const consumeResponse = await fetch(`${API_URL}/api/invitations/${inviteCode}/consume`, {
method: 'POST'
const consumeResponse = await fetch(`${API_URL}/consumeInvitation`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code: inviteCode
})
});
if (!consumeResponse.ok) {
throw new Error('Failed to process invitation');
}
// Submit the questionnaire answers
const submitResponse = await fetch(`${API_URL}/api/employee-submissions`, {
// Get orgId from the consume response
const consumeData = await consumeResponse.json();
const orgId = consumeData.orgId;
// Submit the questionnaire answers using Cloud Function
const submitResponse = await fetch(`${API_URL}/submitEmployeeAnswers`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
employeeId: employee.id,
employee: employee,
answers: answers
inviteCode: inviteCode,
answers: answers,
orgId: orgId
})
});
if (!submitResponse.ok) {
throw new Error('Failed to submit questionnaire');
const errorData = await submitResponse.json();
throw new Error(errorData.error || 'Failed to submit questionnaire');
}
const result = await submitResponse.json();
@@ -223,7 +252,7 @@ const EmployeeQuestionnaire: React.FC = () => {
let result;
if (isInviteFlow) {
// Direct API submission for invite flow (no auth needed)
result = await submitViaInvite(currentEmployee, answers, inviteCode);
result = await submitViaInvite(answers, inviteCode);
} else {
// Use org context for authenticated flow
result = await submitEmployeeAnswers(currentEmployee.id, answers);
@@ -338,7 +367,7 @@ const EmployeeQuestionnaire: React.FC = () => {
<LinearProgress value={getProgressPercentage()} />
</div>
<form onSubmit={handleSubmit}>
<form onSubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
<div className="space-y-6">
{visibleQuestions.map((question, index) => (
<Question
@@ -372,7 +401,7 @@ const EmployeeQuestionnaire: React.FC = () => {
disabled={isSubmitting || getProgressPercentage() < 70}
className="px-8 py-3"
>
{isSubmitting ? 'Submitting & Generating Report...' : 'Submit & Generate AI Report'}
{isSubmitting ? 'Submitting...' : 'Submit'}
</Button>
</div>

View File

@@ -45,7 +45,12 @@ const HelpAndSettings: React.FC = () => {
department: inviteForm.department.trim() || undefined
});
setInviteResult(`Invitation sent! Share this link: ${result.inviteLink}`);
setInviteResult(JSON.stringify({
success: true,
inviteLink: result.inviteLink,
emailLink: result.emailLink,
employeeName: result.employee.name
}));
setInviteForm({ name: '', email: '', role: '', department: '' });
} catch (error) {
console.error('Failed to send invitation:', error);
@@ -178,11 +183,61 @@ const HelpAndSettings: React.FC = () => {
</Button>
{inviteResult && (
<div className={`p-3 rounded-md text-sm ${inviteResult.includes('Failed')
? 'bg-red-50 text-red-800 border border-red-200'
: 'bg-green-50 text-green-800 border border-green-200'
}`}>
{inviteResult}
<div>
{inviteResult.includes('Failed') ? (
<div className="p-3 rounded-md text-sm bg-red-50 text-red-800 border border-red-200">
{inviteResult}
</div>
) : (
(() => {
try {
const result = JSON.parse(inviteResult);
return (
<div className="p-4 rounded-md bg-green-50 border border-green-200">
<h4 className="text-sm font-semibold text-green-800 mb-3">
Invitation sent to {result.employeeName}!
</h4>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-green-700 mb-1">
Direct Link (share this with the employee):
</label>
<div className="flex gap-2">
<input
type="text"
value={result.inviteLink}
readOnly
className="flex-1 px-2 py-1 text-xs bg-white border border-green-300 rounded font-mono"
/>
<Button
size="sm"
variant="secondary"
onClick={() => navigator.clipboard.writeText(result.inviteLink)}
>
Copy
</Button>
</div>
</div>
<div>
<a
href={result.emailLink}
className="inline-flex items-center px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700"
>
📧 Open Email Draft
</a>
</div>
</div>
</div>
);
} catch {
return (
<div className="p-3 rounded-md text-sm bg-green-50 text-green-800 border border-green-200">
{inviteResult}
</div>
);
}
})()
)}
</div>
)}

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useOrg } from '../contexts/OrgContext';
import { Card, Button } from '../components/UiKit';
@@ -48,6 +48,13 @@ interface OnboardingData {
const Onboarding: React.FC = () => {
const { org, upsertOrg, generateCompanyWiki } = useOrg();
const navigate = useNavigate();
useEffect(() => {
if (org?.onboardingCompleted) {
navigate('/reports', { replace: true });
}
}, [org, navigate]);
const [step, setStep] = useState(0);
const [isGeneratingReport, setIsGeneratingReport] = useState(false);
const [formData, setFormData] = useState<OnboardingData>({
@@ -152,7 +159,7 @@ const Onboarding: React.FC = () => {
console.log('Org data saved successfully');
console.log('Generating company wiki...');
await generateCompanyWiki(newOrgData);
await generateCompanyWiki({ ...newOrgData, orgId: org!.orgId });
console.log('Company wiki generated successfully');
// Small delay to ensure states are updated, then redirect
@@ -674,7 +681,7 @@ const Onboarding: React.FC = () => {
{/* Progress indicator */}
<div className="mb-8">
<FigmaProgress
<FigmaProgress
currentStep={step + 1}
steps={steps.map((s, i) => ({ number: i + 1, title: s.title }))}
/>

View File

@@ -41,7 +41,7 @@ const OrgSelection: React.FC = () => {
const handleSelectOrg = (orgId: string) => {
selectOrganization(orgId);
// Check if the organization needs onboarding completion
const selectedOrg = organizations.find(org => org.orgId === orgId);
if (selectedOrg && !selectedOrg.onboardingCompleted && selectedOrg.role === 'owner') {

413
pages/ReportDetail.tsx Normal file
View File

@@ -0,0 +1,413 @@
import React from 'react';
import { Card, Button } from '../components/UiKit';
import { CompanyReport, Report } from '../types';
import RadarPerformanceChart from '../components/charts/RadarPerformanceChart';
import ScoreBarList from '../components/charts/ScoreBarList';
interface ReportDetailProps {
report: CompanyReport | Report;
type: 'company' | 'employee';
employeeName?: string;
onClose: () => void;
}
const ReportDetail: React.FC<ReportDetailProps> = ({ report, type, employeeName, onClose }) => {
if (type === 'company') {
const companyReport = report as CompanyReport;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-[--background-primary] rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-[--background-primary] border-b border-[--border-color] p-6 flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-[--text-primary]">Company Report</h1>
<p className="text-[--text-secondary]">Last updated: {new Date(companyReport.createdAt).toLocaleDateString()}</p>
</div>
<div className="flex space-x-2">
<Button size="sm">Download as PDF</Button>
<Button size="sm" variant="secondary" onClick={onClose}>Close</Button>
</div>
</div>
<div className="p-6 space-y-6">
{/* Executive Summary */}
<Card>
<h2 className="text-xl font-semibold text-[--text-primary] mb-4">Executive Summary</h2>
<p className="text-[--text-secondary] whitespace-pre-line leading-relaxed">
{companyReport.executiveSummary}
</p>
</Card>
{/* Overview Stats */}
<Card>
<h2 className="text-xl font-semibold text-[--text-primary] mb-4">Company Overview</h2>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-[--background-tertiary] p-4 rounded-lg text-center">
<div className="text-3xl font-bold text-blue-500 mb-1">{companyReport.overview.totalEmployees}</div>
<div className="text-sm text-[--text-secondary]">Total Employees</div>
</div>
<div className="bg-[--background-tertiary] p-4 rounded-lg text-center">
<div className="text-3xl font-bold text-green-500 mb-1">{companyReport.overview.departmentBreakdown?.length || 0}</div>
<div className="text-sm text-[--text-secondary]">Departments</div>
</div>
<div className="bg-[--background-tertiary] p-4 rounded-lg text-center">
<div className="text-3xl font-bold text-purple-500 mb-1">{companyReport.overview.averagePerformanceScore || 'N/A'}</div>
<div className="text-sm text-[--text-secondary]">Avg Performance</div>
</div>
<div className="bg-[--background-tertiary] p-4 rounded-lg text-center">
<div className="text-3xl font-bold text-orange-500 mb-1">{companyReport.overview.riskLevel || 'Low'}</div>
<div className="text-sm text-[--text-secondary]">Risk Level</div>
</div>
</div>
</Card>
{/* Key Personnel Changes */}
{companyReport.keyPersonnelChanges && companyReport.keyPersonnelChanges.length > 0 && (
<Card>
<h2 className="text-xl font-semibold text-[--text-primary] mb-4 flex items-center">
<span className="w-3 h-3 bg-orange-500 rounded-full mr-3"></span>
Key Personnel Changes
</h2>
<div className="space-y-3">
{companyReport.keyPersonnelChanges.map((change, idx) => (
<div key={idx} className="p-4 bg-[--background-tertiary] rounded-lg">
<div className="flex justify-between items-start mb-2">
<div>
<h3 className="font-semibold text-[--text-primary]">{change.employeeName}</h3>
<p className="text-sm text-[--text-secondary]">{change.role} {change.department}</p>
</div>
<span className={`px-3 py-1 text-xs rounded-full font-medium ${
change.changeType === 'departure' ? 'bg-red-100 text-red-800' :
change.changeType === 'promotion' ? 'bg-green-100 text-green-800' :
'bg-blue-100 text-blue-800'
}`}>
{change.changeType}
</span>
</div>
<p className="text-[--text-secondary] text-sm">{change.impact}</p>
</div>
))}
</div>
</Card>
)}
{/* Immediate Hiring Needs */}
{companyReport.immediateHiringNeeds && companyReport.immediateHiringNeeds.length > 0 && (
<Card>
<h2 className="text-xl font-semibold text-[--text-primary] mb-4 flex items-center">
<span className="w-3 h-3 bg-red-500 rounded-full mr-3"></span>
Immediate Hiring Needs
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{companyReport.immediateHiringNeeds.map((need, idx) => (
<div key={idx} className="p-4 bg-[--background-tertiary] rounded-lg">
<div className="flex justify-between items-start mb-2">
<h3 className="font-semibold text-[--text-primary]">{need.role}</h3>
<span className={`px-2 py-1 text-xs rounded-full font-medium ${
need.urgency === 'high' ? 'bg-red-100 text-red-800' :
need.urgency === 'medium' ? 'bg-yellow-100 text-yellow-800' :
'bg-green-100 text-green-800'
}`}>
{need.urgency}
</span>
</div>
<p className="text-sm text-[--text-secondary] mb-2">{need.department}</p>
<p className="text-sm text-[--text-secondary]">{need.reason}</p>
</div>
))}
</div>
</Card>
)}
{/* Forward Operating Plan */}
{companyReport.forwardOperatingPlan && (
<Card>
<h2 className="text-xl font-semibold text-[--text-primary] mb-4 flex items-center">
<span className="w-3 h-3 bg-blue-500 rounded-full mr-3"></span>
Forward Operating Plan
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="p-4 bg-[--background-tertiary] rounded-lg">
<h3 className="font-semibold text-[--text-primary] mb-3">Next Quarter Goals</h3>
<ul className="space-y-2">
{companyReport.forwardOperatingPlan.nextQuarterGoals?.map((goal, idx) => (
<li key={idx} className="text-sm text-[--text-secondary] flex items-start">
<span className="w-2 h-2 bg-blue-500 rounded-full mt-2 mr-3 flex-shrink-0"></span>
{goal}
</li>
))}
</ul>
</div>
<div className="p-4 bg-[--background-tertiary] rounded-lg">
<h3 className="font-semibold text-[--text-primary] mb-3">Key Initiatives</h3>
<ul className="space-y-2">
{companyReport.forwardOperatingPlan.keyInitiatives?.map((initiative, idx) => (
<li key={idx} className="text-sm text-[--text-secondary] flex items-start">
<span className="w-2 h-2 bg-green-500 rounded-full mt-2 mr-3 flex-shrink-0"></span>
{initiative}
</li>
))}
</ul>
</div>
</div>
</Card>
)}
{/* Organizational Strengths */}
{companyReport.organizationalStrengths && companyReport.organizationalStrengths.length > 0 && (
<Card>
<h2 className="text-xl font-semibold text-[--text-primary] mb-4 flex items-center">
<span className="w-3 h-3 bg-green-500 rounded-full mr-3"></span>
Organizational Strengths
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{companyReport.organizationalStrengths.map((strength, idx) => (
<div key={idx} className="p-4 bg-[--background-tertiary] rounded-lg">
<div className="flex items-start space-x-3">
<span className="text-3xl">{strength.icon || '💪'}</span>
<div>
<h3 className="font-semibold text-[--text-primary] mb-1">{strength.area || strength}</h3>
<p className="text-sm text-[--text-secondary]">{strength.description}</p>
</div>
</div>
</div>
))}
</div>
</Card>
)}
{/* Grading Overview */}
{companyReport.gradingOverview && (
<Card>
<h2 className="text-xl font-semibold text-[--text-primary] mb-4 flex items-center">
<span className="w-3 h-3 bg-indigo-500 rounded-full mr-3"></span>
Grading Overview
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
{Object.entries(companyReport.gradingOverview).map(([category, score], idx) => (
<div key={idx} className="text-center p-4 bg-[--background-tertiary] rounded-lg">
<div className="text-3xl font-bold text-[--text-primary] mb-2">{score}/5</div>
<div className="text-sm text-[--text-secondary] capitalize">
{category.replace(/([A-Z])/g, ' $1').trim()}
</div>
</div>
))}
</div>
</Card>
)}
{/* Organizational Impact Summary */}
{companyReport.organizationalImpactSummary && (
<Card>
<h2 className="text-xl font-semibold text-[--text-primary] mb-4 flex items-center">
<span className="w-3 h-3 bg-purple-500 rounded-full mr-3"></span>
Organizational Impact Summary
</h2>
<div className="p-4 bg-[--background-tertiary] rounded-lg">
<p className="text-[--text-secondary] leading-relaxed">
{companyReport.organizationalImpactSummary}
</p>
</div>
</Card>
)}
</div>
</div>
</div>
);
} else {
const employeeReport = report as Report;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-[--background-primary] rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-[--background-primary] border-b border-[--border-color] p-6 flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-[--text-primary]">{employeeName}'s Performance Report</h1>
<p className="text-[--text-secondary]">{employeeReport.employee?.role} • {employeeReport.employee?.department}</p>
</div>
<div className="flex space-x-2">
<Button size="sm">Download as PDF</Button>
<Button size="sm" variant="secondary" onClick={onClose}>Close</Button>
</div>
</div>
<div className="p-6 space-y-6">
{/* Self-Reported Role & Output */}
{employeeReport.roleAndOutput && (
<Card>
<h2 className="text-xl font-semibold text-[--text-primary] mb-4">Self-Reported Role & Output</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h3 className="font-medium text-[--text-primary] mb-2">Responsibilities</h3>
<p className="text-[--text-secondary] text-sm leading-relaxed">{employeeReport.roleAndOutput.responsibilities}</p>
</div>
<div className="space-y-3">
<div>
<h3 className="font-medium text-[--text-primary] mb-1">Clarity on Role</h3>
<p className="text-[--text-secondary] text-sm">{employeeReport.roleAndOutput.clarityOnRole}</p>
</div>
<div>
<h3 className="font-medium text-[--text-primary] mb-1">Self-Rated Output</h3>
<p className="text-[--text-secondary] text-sm">{employeeReport.roleAndOutput.selfRatedOutput}</p>
</div>
</div>
</div>
</Card>
)}
{/* Performance Charts */}
{employeeReport.grading?.[0]?.scores && (
<Card>
<h2 className="text-xl font-semibold text-[--text-primary] mb-4">Performance Analysis</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-[--background-tertiary] rounded-lg p-6">
<RadarPerformanceChart
title="Performance Profile"
data={employeeReport.grading[0].scores.map(s => ({
label: s.subject,
value: (s.value / s.fullMark) * 100
}))}
/>
</div>
<div className="bg-[--background-tertiary] rounded-lg p-6">
<ScoreBarList
title="Score Breakdown"
items={employeeReport.grading[0].scores.map(s => ({
label: s.subject,
value: s.value,
max: s.fullMark
}))}
/>
</div>
</div>
</Card>
)}
{/* Behavioral & Psychological Insights */}
{employeeReport.insights && (
<Card>
<h2 className="text-xl font-semibold text-[--text-primary] mb-4">Behavioral & Psychological Insights</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h3 className="font-medium text-[--text-primary] mb-3">Personality Traits</h3>
<p className="text-[--text-secondary] text-sm leading-relaxed mb-4">
{employeeReport.insights.personalityTraits || 'No personality traits data available.'}
</p>
<h3 className="font-medium text-[--text-primary] mb-3">Self-awareness</h3>
<p className="text-[--text-secondary] text-sm leading-relaxed">
{employeeReport.insights.selfAwareness || 'No self-awareness data available.'}
</p>
</div>
<div>
<h3 className="font-medium text-[--text-primary] mb-3">Psychological Indicators</h3>
<ul className="space-y-2 mb-4">
{employeeReport.insights.psychologicalIndicators?.map((indicator, idx) => (
<li key={idx} className="text-sm text-[--text-secondary] flex items-start">
<span className="w-1.5 h-1.5 bg-blue-500 rounded-full mt-2 mr-2 flex-shrink-0"></span>
{indicator}
</li>
)) || <li className="text-sm text-[--text-secondary]">No psychological indicators available.</li>}
</ul>
<h3 className="font-medium text-[--text-primary] mb-3">Growth Desire</h3>
<p className="text-[--text-secondary] text-sm leading-relaxed">
{employeeReport.insights.growthDesire || 'No growth desire data available.'}
</p>
</div>
</div>
</Card>
)}
{/* Strengths & Weaknesses */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card>
<h2 className="text-xl font-semibold text-[--text-primary] mb-4 flex items-center">
<span className="w-3 h-3 bg-green-500 rounded-full mr-3"></span>
Strengths
</h2>
<div className="space-y-2">
{employeeReport.insights?.strengths?.map((strength, idx) => (
<div key={idx} className="flex items-center space-x-2">
<span className="text-green-500">✓</span>
<span className="text-sm text-[--text-secondary]">{strength}</span>
</div>
))}
</div>
</Card>
<Card>
<h2 className="text-xl font-semibold text-[--text-primary] mb-4 flex items-center">
<span className="w-3 h-3 bg-orange-500 rounded-full mr-3"></span>
Development Areas
</h2>
<div className="space-y-2">
{employeeReport.insights?.weaknesses?.map((weakness, idx) => (
<div key={idx} className="flex items-center space-x-2">
<span className="text-orange-500">!</span>
<span className="text-sm text-[--text-secondary]">{weakness}</span>
</div>
))}
</div>
</Card>
</div>
{/* Opportunities */}
{employeeReport.opportunities && employeeReport.opportunities.length > 0 && (
<Card>
<h2 className="text-xl font-semibold text-[--text-primary] mb-4 flex items-center">
<span className="w-3 h-3 bg-blue-500 rounded-full mr-3"></span>
Opportunities
</h2>
<div className="space-y-4">
{employeeReport.opportunities.map((opp, idx) => (
<div key={idx} className="p-4 bg-[--background-tertiary] rounded-lg">
<h3 className="font-medium text-[--text-primary] mb-2">{opp.roleAdjustment || 'Opportunity'}</h3>
<p className="text-sm text-[--text-secondary]">{opp.accountabilitySupport || opp.description}</p>
</div>
))}
</div>
</Card>
)}
{/* Risks */}
{employeeReport.risks && employeeReport.risks.length > 0 && (
<Card>
<h2 className="text-xl font-semibold text-[--text-primary] mb-4 flex items-center">
<span className="w-3 h-3 bg-yellow-500 rounded-full mr-3"></span>
Risks
</h2>
<div className="space-y-2">
{employeeReport.risks.map((risk, idx) => (
<div key={idx} className="flex items-start space-x-2 p-3 bg-yellow-50 rounded-lg">
<span className="text-yellow-500 mt-0.5"></span>
<span className="text-sm text-gray-700">{risk}</span>
</div>
))}
</div>
</Card>
)}
{/* Recommendations */}
{employeeReport.recommendations && employeeReport.recommendations.length > 0 && (
<Card>
<h2 className="text-xl font-semibold text-[--text-primary] mb-4 flex items-center">
<span className="w-3 h-3 bg-purple-500 rounded-full mr-3"></span>
Recommendations
</h2>
<div className="space-y-3">
{employeeReport.recommendations.map((rec, idx) => (
<div key={idx} className="flex items-start space-x-3 p-3 bg-[--background-tertiary] rounded-lg">
<span className="w-2 h-2 bg-purple-500 rounded-full mt-2 flex-shrink-0"></span>
<span className="text-sm text-[--text-secondary]">{rec}</span>
</div>
))}
</div>
</Card>
)}
</div>
</div>
</div>
);
}
};
export default ReportDetail;

88
services/api.ts Normal file
View File

@@ -0,0 +1,88 @@
import { API_URL } from '../constants';
// Get auth token from localStorage (persistent across sessions)
const getAuthToken = (): string | null => {
return localStorage.getItem('auditly_auth_token');
};
// Make authenticated API call
export const makeAuthenticatedRequest = async (
endpoint: string,
options: RequestInit = {},
orgId?: string
): Promise<Response> => {
const token = getAuthToken();
if (!token) {
throw new Error('No authentication token found. Please log in again.');
}
const headers = new Headers(options.headers);
headers.set('Authorization', `Bearer ${token}`);
headers.set('Content-Type', 'application/json');
// Add orgId to request body if provided and it's a POST/PUT request
let body = options.body;
if (orgId && (options.method === 'POST' || options.method === 'PUT')) {
if (typeof body === 'string') {
try {
const parsedBody = JSON.parse(body);
parsedBody.orgId = orgId;
body = JSON.stringify(parsedBody);
} catch (e) {
// If body isn't JSON, create a new object
body = JSON.stringify({ orgId, data: body });
}
} else if (body instanceof FormData) {
body.append('orgId', orgId);
} else if (body && typeof body === 'object') {
body = JSON.stringify({ orgId, ...body });
} else {
body = JSON.stringify({ orgId });
}
}
const url = endpoint.startsWith('http') ? endpoint : `${API_URL}${endpoint}`;
// Add timeout to prevent hanging forever
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout
try {
const response = await fetch(url, {
...options,
headers,
body,
signal: controller.signal,
});
clearTimeout(timeoutId);
return response;
} catch (error) {
clearTimeout(timeoutId);
if (error.name === 'AbortError') {
throw new Error('Request timeout: The server took too long to respond');
}
throw error;
}
};
// Convenience methods for different HTTP verbs
export const apiGet = (endpoint: string, orgId?: string) =>
makeAuthenticatedRequest(endpoint, { method: 'GET' }, orgId);
export const apiPost = (endpoint: string, data: any = {}, orgId?: string) => {
console.log('apiPost called with:', { endpoint, data, orgId, hasToken: !!getAuthToken() });
return makeAuthenticatedRequest(endpoint, {
method: 'POST',
body: JSON.stringify(data)
}, orgId);
};
export const apiPut = (endpoint: string, data: any = {}, orgId?: string) =>
makeAuthenticatedRequest(endpoint, {
method: 'PUT',
body: JSON.stringify(data)
}, orgId);
export const apiDelete = (endpoint: string, orgId?: string) =>
makeAuthenticatedRequest(endpoint, { method: 'DELETE' }, orgId);

View File

@@ -1,6 +1,6 @@
import { initializeApp, getApps } from 'firebase/app';
import { getAuth, GoogleAuthProvider } from 'firebase/auth';
import { getFirestore } from 'firebase/firestore';
import { getAuth, GoogleAuthProvider, setPersistence, browserLocalPersistence } from 'firebase/auth';
import { getFirestore, connectFirestoreEmulator } from 'firebase/firestore';
const firebaseConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
@@ -17,9 +17,7 @@ export const isFirebaseConfigured = Boolean(
firebaseConfig.projectId &&
firebaseConfig.apiKey !== 'undefined' &&
firebaseConfig.authDomain !== 'undefined' &&
firebaseConfig.projectId !== 'undefined' &&
// Force demo mode on localhost for testing
!window.location.hostname.includes('localhost')
firebaseConfig.projectId !== 'undefined'
);
let app;
@@ -30,12 +28,29 @@ let googleProvider;
if (isFirebaseConfigured) {
app = getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0];
auth = getAuth(app);
setPersistence(auth, browserLocalPersistence);
db = getFirestore(app);
googleProvider = new GoogleAuthProvider();
// Connect to emulator in development
const isLocalhost = typeof window !== 'undefined' &&
(window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1');
if (isLocalhost && !db._settings?.host?.includes('localhost')) {
try {
connectFirestoreEmulator(db, 'localhost', 5003);
console.log('🔥 Connected to Firestore emulator on localhost:5003');
} catch (error) {
console.log('⚠️ Firestore emulator already connected or connection failed');
}
}
console.log('🔥 Firebase initialized successfully');
} else {
auth = null;
db = null;
googleProvider = null;
console.log('⚠️ Firebase not configured - missing environment variables');
}
export { auth, db, googleProvider };