Fix organization setup flow: redirect to onboarding for incomplete setup
This commit is contained in:
53
.gitignore
vendored
Normal file
53
.gitignore
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.*.local
|
||||
*.log
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
/figma/
|
||||
/figma-code/
|
||||
/server-minimal.js
|
||||
/employeeQuestions.ts
|
||||
/README.md
|
||||
/metadata.json
|
||||
**/Demo.tsx
|
||||
/.firebaserc
|
||||
/CLOUD_FUNCTIONS_README.md
|
||||
/Employee-Questions.md
|
||||
/.env.example
|
||||
/firebase.json
|
||||
/EMPLOYEE_FORMS_README.md
|
||||
/firebase-tools.json
|
||||
/firestore.indexes.json
|
||||
/firestore.rules
|
||||
/.env.production.example
|
||||
/.github/copilot-instructions.md*
|
||||
/.vscode/settings.json
|
||||
/.vscode/extensions.json
|
||||
!/.vscode/tasks.json
|
||||
/database.rules.json
|
||||
/functions/auditly*.json
|
||||
16
.vscode/tasks.json
vendored
Normal file
16
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "start-dev",
|
||||
"type": "shell",
|
||||
"command": "bun run dev",
|
||||
"args": [],
|
||||
"isBackground": true,
|
||||
"problemMatcher": [
|
||||
"$tsc"
|
||||
],
|
||||
"group": "build"
|
||||
}
|
||||
]
|
||||
}
|
||||
238
App.tsx
Normal file
238
App.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import React from 'react';
|
||||
import { HashRouter, Routes, Route, Navigate, useParams } from 'react-router-dom';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import { UserOrganizationsProvider, useUserOrganizations } from './contexts/UserOrganizationsContext';
|
||||
import { OrgProvider, useOrg } from './contexts/OrgContext';
|
||||
import { Layout } from './components/UiKit';
|
||||
import CompanyWiki from './pages/CompanyWiki';
|
||||
import EmployeeData from './pages/EmployeeData';
|
||||
import Chat from './pages/Chat';
|
||||
import HelpAndSettings from './pages/HelpAndSettings';
|
||||
import Login from './pages/Login';
|
||||
import ModernLogin from './pages/ModernLogin';
|
||||
import OrgSelection from './pages/OrgSelection';
|
||||
import Onboarding from './pages/Onboarding';
|
||||
import EmployeeQuestionnaire from './pages/EmployeeQuestionnaire';
|
||||
import EmployeeQuestionnaireSteps from './pages/EmployeeQuestionnaireSteps';
|
||||
import QuestionTypesDemo from './pages/QuestionTypesDemo';
|
||||
import FormsDashboard from './pages/FormsDashboard';
|
||||
import DebugEmployee from './pages/DebugEmployee';
|
||||
import QuestionnaireComplete from './pages/QuestionnaireComplete';
|
||||
import SubscriptionSetup from './pages/SubscriptionSetup';
|
||||
import { isFirebaseConfigured } from './services/firebase';
|
||||
|
||||
const RequireAuth: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
if (loading) return <div className="p-8">Loading...</div>;
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
const RequireOrgSelection: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { selectedOrgId, loading } = useUserOrganizations();
|
||||
|
||||
if (loading) return <div className="p-8">Loading your organizations...</div>;
|
||||
if (!selectedOrgId) return <Navigate to="/org-selection" replace />;
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
const RequireOnboarding: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { org } = useOrg();
|
||||
const { user } = useAuth();
|
||||
const { organizations } = 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';
|
||||
|
||||
if (isOrgOwner) {
|
||||
return <Navigate to="/onboarding" replace />;
|
||||
} else {
|
||||
// Non-owners should see a waiting message
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
<h2 className="text-xl font-semibold mb-4">Organization Setup In Progress</h2>
|
||||
<p className="text-gray-600">
|
||||
Your organization is currently being set up by the administrator.
|
||||
Please check back later or contact your administrator for more information.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
// Wrapper component that provides selected org to OrgProvider
|
||||
const OrgProviderWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { selectedOrgId } = useUserOrganizations();
|
||||
|
||||
if (!selectedOrgId) {
|
||||
return <div className="p-8">No organization selected</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<OrgProvider selectedOrgId={selectedOrgId}>
|
||||
{children}
|
||||
</OrgProvider>
|
||||
);
|
||||
};
|
||||
|
||||
// Redirect invite URLs directly to employee questionnaire (no auth needed)
|
||||
const InviteRedirect: React.FC = () => {
|
||||
const { inviteCode } = useParams<{ inviteCode: string }>();
|
||||
return <Navigate to={`/employee-form/${inviteCode}`} replace />;
|
||||
};
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<UserOrganizationsProvider>
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<ModernLogin />} />
|
||||
<Route path="/login/:inviteCode" element={<ModernLogin />} />
|
||||
<Route path="/invite/:inviteCode" element={<InviteRedirect />} />
|
||||
<Route path="/legacy-login" element={<Login />} />
|
||||
|
||||
{/* Employee questionnaire - no auth needed, uses invite code */}
|
||||
<Route path="/employee-form/:inviteCode" element={<EmployeeQuestionnaire />} />
|
||||
|
||||
{/* Organization Selection - after auth, before entering app */}
|
||||
<Route
|
||||
path="/org-selection"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<OrgSelection />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Subscription Setup - after organization creation */}
|
||||
<Route
|
||||
path="/subscription-setup"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<SubscriptionSetup />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Routes that require both auth and org selection */}
|
||||
<Route
|
||||
path="/employee-questionnaire"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<RequireOrgSelection>
|
||||
<OrgProviderWrapper>
|
||||
<EmployeeQuestionnaire />
|
||||
</OrgProviderWrapper>
|
||||
</RequireOrgSelection>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/employee-questionnaire-steps"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<RequireOrgSelection>
|
||||
<OrgProviderWrapper>
|
||||
<EmployeeQuestionnaireSteps />
|
||||
</OrgProviderWrapper>
|
||||
</RequireOrgSelection>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/onboarding"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<RequireOrgSelection>
|
||||
<OrgProviderWrapper>
|
||||
<Onboarding />
|
||||
</OrgProviderWrapper>
|
||||
</RequireOrgSelection>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="/questionnaire-complete" element={<QuestionnaireComplete />} />
|
||||
|
||||
{/* Main app routes - require auth, org selection, and completed onboarding */}
|
||||
<Route
|
||||
element={
|
||||
<RequireAuth>
|
||||
<RequireOrgSelection>
|
||||
<OrgProviderWrapper>
|
||||
<RequireOnboarding>
|
||||
<Layout />
|
||||
</RequireOnboarding>
|
||||
</OrgProviderWrapper>
|
||||
</RequireOrgSelection>
|
||||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
<Route path="/" element={<Navigate to="/reports" replace />} />
|
||||
<Route path="/company-wiki" element={<CompanyWiki />} />
|
||||
<Route path="/submissions" element={<EmployeeData mode="submissions" />} />
|
||||
<Route path="/reports" element={<EmployeeData mode="reports" />} />
|
||||
<Route path="/chat" element={<Chat />} />
|
||||
<Route path="/help" element={<HelpAndSettings />} />
|
||||
<Route path="/settings" element={<HelpAndSettings />} />
|
||||
</Route>
|
||||
|
||||
{/* Debug routes */}
|
||||
<Route
|
||||
path="/question-types-demo"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<RequireOrgSelection>
|
||||
<OrgProviderWrapper>
|
||||
<QuestionTypesDemo />
|
||||
</OrgProviderWrapper>
|
||||
</RequireOrgSelection>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/forms-dashboard"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<RequireOrgSelection>
|
||||
<OrgProviderWrapper>
|
||||
<FormsDashboard />
|
||||
</OrgProviderWrapper>
|
||||
</RequireOrgSelection>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/debug-employee"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<RequireOrgSelection>
|
||||
<OrgProviderWrapper>
|
||||
<DebugEmployee />
|
||||
</OrgProviderWrapper>
|
||||
</RequireOrgSelection>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</UserOrganizationsProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
361
components/UiKit.tsx
Normal file
361
components/UiKit.tsx
Normal file
@@ -0,0 +1,361 @@
|
||||
import React, { useState, ReactNode } from 'react';
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { Theme, NavItem } from '../types';
|
||||
import { useOrg } from '../contexts/OrgContext';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
// ========== ICONS ==========
|
||||
|
||||
export const CompanyWikiIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M4 22h16a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1zM8 7h8v2H8V7zm0 4h8v2H8v-2zm0 4h5v2H8v-2z" fill="currentColor" /></svg>
|
||||
);
|
||||
export const SubmissionsIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zM9 13v-2h6v2H9zm6 4H9v-2h6v2zm-6-8V4h1l5 5h-1V9H9z" fill="currentColor" /></svg>
|
||||
);
|
||||
export const ReportsIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M16 6h3a1 1 0 0 1 1 1v11a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h3V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2zm-4-2v2h-2V4h2zm-2 9v-2h-2v2h2zm4 0v-2h-2v2h2zm-4 4v-2h-2v2h2zm4 0v-2h-2v2h2zm-6-4H8V9h2v2z" fill="currentColor" /></svg>
|
||||
);
|
||||
export const ChatIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M21 11.5a8.5 8.5 0 0 1-17 0 8.5 8.5 0 0 1 17 0z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /><path d="M21 11.5a8.5 8.5 0 0 0-8.5-8.5V3m0 17v-1.5a8.5 8.5 0 0 0 8.5-8.5H11" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
||||
);
|
||||
export const HelpIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm0-13a1 1 0 0 0-1 1v4a1 1 0 0 0 2 0v-4a1 1 0 0 0-1-1zm0 8a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z" fill="currentColor" /></svg>
|
||||
);
|
||||
export const SettingsIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M19.14 12.94a2.001 2.001 0 0 0-2.28 0l-.71.71a2 2 0 0 0 0 2.83l.71.71a2 2 0 0 0 2.83 0l.71-.71a2 2 0 0 0 0-2.83l-.71-.71zM4.86 12.94a2 2 0 0 0 0 2.83l.71.71a2 2 0 0 0 2.83 0l.71-.71a2 2 0 0 0 0-2.83l-.71-.71a2 2 0 0 0-2.83 0zM12 10a2 2 0 1 0 0 4 2 2 0 0 0 0-4zm7.78-1.94a2 2 0 0 0-2.83 0l-.71.71a2 2 0 0 0 0 2.83l.71.71a2 2 0 0 0 2.83 0l.71-.71a2 2 0 0 0 0-2.83l-.71-.71zM4.86 5.22a2 2 0 0 0 0 2.83l.71.71a2 2 0 0 0 2.83 0l.71-.71a2 2 0 0 0 0-2.83l-.71-.71a2 2 0 0 0-2.83 0z" fill="currentColor" /></svg>
|
||||
);
|
||||
export const CopyIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M16 2H8a2 2 0 0 0-2 2v12h2V4h8v4h4v8h2V8l-4-4h-2z" fill="currentColor" /><path d="M4 8h12v12H4z" fill="currentColor" opacity="0.5" /></svg>
|
||||
);
|
||||
export const PlusIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" fill="currentColor" /></svg>
|
||||
);
|
||||
export const ChevronDownIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 15.5l-6-6 1.4-1.4 4.6 4.6 4.6-4.6L18 9.5l-6 6z" fill="currentColor" /></svg>
|
||||
);
|
||||
export const UploadIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M9 16h6v-6h4l-8-8-8 8h4v6zm-4 2h14v2H5v-2z" fill="currentColor" /></svg>
|
||||
);
|
||||
export const CheckIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" fill="currentColor" /></svg>
|
||||
);
|
||||
export const WarningIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2V7h2v7z" fill="currentColor" /></svg>
|
||||
);
|
||||
export const DownloadIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z" fill="currentColor" /></svg>
|
||||
);
|
||||
export const MinusIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M19 13H5v-2h14v2z" fill="currentColor" /></svg>
|
||||
);
|
||||
export const SunIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" /></svg>
|
||||
);
|
||||
export const MoonIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" /></svg>
|
||||
);
|
||||
export const SystemIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
|
||||
);
|
||||
export const SendIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" /></svg>
|
||||
);
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ href: '/company-wiki', label: 'Company Wiki', icon: CompanyWikiIcon },
|
||||
{ href: '/submissions', label: 'Submissions', icon: SubmissionsIcon },
|
||||
{ href: '/reports', label: 'Reports', icon: ReportsIcon },
|
||||
{ href: '/chat', label: 'Chat', icon: ChatIcon },
|
||||
{ href: '/help', label: 'Help', icon: HelpIcon },
|
||||
];
|
||||
|
||||
const BOTTOM_NAV_ITEMS: NavItem[] = [
|
||||
{ href: '/settings', label: 'Settings', icon: SettingsIcon }
|
||||
];
|
||||
|
||||
|
||||
// ========== LAYOUT COMPONENTS ==========
|
||||
|
||||
const Sidebar = () => {
|
||||
const { org, issueInviteViaApi } = useOrg();
|
||||
const { signOutUser } = useAuth();
|
||||
const [showInviteModal, setShowInviteModal] = useState(false);
|
||||
const [inviteForm, setInviteForm] = useState({ name: '', email: '', role: '', department: '' });
|
||||
const [inviteLink, setInviteLink] = useState('');
|
||||
const [emailLink, setEmailLink] = useState('');
|
||||
|
||||
const commonLinkClasses = "flex items-center w-full px-3 py-2.5 rounded-lg text-sm font-medium transition-colors duration-200";
|
||||
const activeClass = "bg-[--sidebar-active-bg] text-[--sidebar-active-text]";
|
||||
const inactiveClass = "text-[--sidebar-text] hover:bg-[--sidebar-active-bg] hover:text-[--sidebar-active-text]";
|
||||
|
||||
const handleInvite = async () => {
|
||||
try {
|
||||
const result = await issueInviteViaApi({
|
||||
name: inviteForm.name,
|
||||
email: inviteForm.email,
|
||||
role: inviteForm.role,
|
||||
department: inviteForm.department
|
||||
});
|
||||
setInviteLink(result.inviteLink);
|
||||
setEmailLink(result.emailLink);
|
||||
setInviteForm({ name: '', email: '', role: '', department: '' });
|
||||
} catch (error) {
|
||||
console.error('Failed to invite employee:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
};
|
||||
|
||||
const renderNavItems = (items: NavItem[]) => items.map(item => (
|
||||
<li key={item.href}>
|
||||
<NavLink
|
||||
to={item.href}
|
||||
className={({ isActive }) => `${commonLinkClasses} ${isActive ? activeClass : inactiveClass}`}
|
||||
>
|
||||
<item.icon className="w-5 h-5 mr-3" />
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
</li>
|
||||
));
|
||||
|
||||
return (
|
||||
<aside className="w-64 flex-shrink-0 bg-[--sidebar-bg] border-r border-[--border-color] flex flex-col p-4">
|
||||
<div className="flex items-center mb-8">
|
||||
<div className="w-8 h-8 bg-[--accent] rounded-full flex items-center justify-center font-bold text-white text-lg mr-2">A</div>
|
||||
<h1 className="text-xl font-bold text-[--text-primary]">{org?.name || 'Auditly'}</h1>
|
||||
</div>
|
||||
<nav className="flex-1">
|
||||
<ul className="space-y-2">
|
||||
{renderNavItems(NAV_ITEMS)}
|
||||
</ul>
|
||||
</nav>
|
||||
<div className="mt-auto space-y-4">
|
||||
<ul className="space-y-2 border-t border-[--border-color] pt-4">
|
||||
{renderNavItems(BOTTOM_NAV_ITEMS)}
|
||||
</ul>
|
||||
<div className="p-4 rounded-lg bg-[--background-tertiary] text-center">
|
||||
<h3 className="font-bold text-sm text-[--text-primary]">Build [Company]'s Report</h3>
|
||||
<p className="text-xs text-[--text-secondary] mt-1 mb-3">Share this form with your team members to capture valuable info about your company to train Auditly.</p>
|
||||
<div className="flex space-x-2">
|
||||
<Button size="sm" className="w-full" onClick={() => setShowInviteModal(true)}>
|
||||
<PlusIcon className="w-4 h-4 mr-1" /> Invite
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" className="w-full" onClick={() => (emailLink || inviteLink) && copyToClipboard(emailLink || inviteLink)}>
|
||||
<CopyIcon className="w-4 h-4 mr-1" /> Copy
|
||||
</Button>
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" className="w-full mt-2" onClick={signOutUser}>Sign out</Button>
|
||||
</div>
|
||||
|
||||
{/* Invite Modal */}
|
||||
{showInviteModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-[--background-secondary] p-6 rounded-lg max-w-md w-full mx-4">
|
||||
<h3 className="text-lg font-semibold text-[--text-primary] mb-4">Invite Employee</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[--text-primary] mb-2">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={inviteForm.name}
|
||||
onChange={(e) => setInviteForm(prev => ({ ...prev, name: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-primary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Employee name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[--text-primary] mb-2">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={inviteForm.email}
|
||||
onChange={(e) => setInviteForm(prev => ({ ...prev, email: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-primary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="employee@company.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[--text-primary] mb-2">Role</label>
|
||||
<input
|
||||
type="text"
|
||||
value={inviteForm.role}
|
||||
onChange={(e) => setInviteForm(prev => ({ ...prev, role: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-primary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="e.g. Software Engineer"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[--text-primary] mb-2">Department</label>
|
||||
<input
|
||||
type="text"
|
||||
value={inviteForm.department}
|
||||
onChange={(e) => setInviteForm(prev => ({ ...prev, department: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-primary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="e.g. Engineering"
|
||||
/>
|
||||
</div>
|
||||
{(inviteLink || emailLink) && (
|
||||
<div className="space-y-3">
|
||||
{emailLink && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[--text-primary] mb-2">
|
||||
Email Link (for GET requests)
|
||||
</label>
|
||||
<div className="flex space-x-2">
|
||||
<input
|
||||
type="text"
|
||||
value={emailLink}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-primary] text-[--text-primary] focus:outline-none text-sm"
|
||||
/>
|
||||
<Button size="sm" onClick={() => copyToClipboard(emailLink)}>
|
||||
<CopyIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{inviteLink && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[--text-primary] mb-2">
|
||||
App Link (direct)
|
||||
</label>
|
||||
<div className="flex space-x-2">
|
||||
<input
|
||||
type="text"
|
||||
value={inviteLink}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-primary] text-[--text-primary] focus:outline-none text-sm"
|
||||
/>
|
||||
<Button size="sm" onClick={() => copyToClipboard(inviteLink)}>
|
||||
<CopyIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex space-x-2 mt-6">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setShowInviteModal(false);
|
||||
setInviteLink('');
|
||||
setInviteForm({ name: '', email: '', role: '', department: '' });
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={handleInvite}
|
||||
disabled={!inviteForm.name || !inviteForm.email}
|
||||
>
|
||||
Generate Invite
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export const Layout = () => (
|
||||
<div className="flex h-screen bg-[--background-primary]">
|
||||
<Sidebar />
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
// ========== UI PRIMITIVES ==========
|
||||
|
||||
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
padding?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export const Card: React.FC<CardProps> = ({ children, className, padding = 'md', ...props }) => {
|
||||
const paddingClasses = {
|
||||
sm: 'p-4',
|
||||
md: 'p-6',
|
||||
lg: 'p-8',
|
||||
};
|
||||
return (
|
||||
<div className={`bg-[--background-secondary] border border-[--border-color] rounded-xl shadow-sm ${paddingClasses[padding]} ${className || ''}`} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children: ReactNode;
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export const Button: React.FC<ButtonProps> = ({ children, variant = 'primary', size = 'md', className, ...props }) => {
|
||||
const baseClasses = 'inline-flex items-center justify-center font-semibold rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
|
||||
const variantClasses = {
|
||||
primary: 'bg-[--accent] text-[--accent-text] hover:bg-[--accent-hover] focus:ring-[--accent]',
|
||||
secondary: 'bg-[--button-secondary-bg] text-[--text-primary] hover:bg-[--button-secondary-hover] focus:ring-[--accent] border border-[--border-color]',
|
||||
danger: 'bg-[--status-red] text-white hover:bg-red-700 focus:ring-red-500',
|
||||
ghost: 'bg-transparent text-[--text-primary] hover:bg-[--background-tertiary]'
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-3 py-1.5 text-xs',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-3 text-base',
|
||||
};
|
||||
|
||||
return (
|
||||
<button className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className || ''}`} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
interface AccordionProps {
|
||||
items: { question: string; answer: string }[];
|
||||
}
|
||||
|
||||
export const Accordion = ({ items }: AccordionProps) => {
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(0);
|
||||
|
||||
const toggleItem = (index: number) => {
|
||||
setOpenIndex(openIndex === index ? null : index);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{items.map((item, index) => (
|
||||
<div key={index} className="border-b border-[--border-color]">
|
||||
<button
|
||||
onClick={() => toggleItem(index)}
|
||||
className="w-full flex justify-between items-center py-4 text-left text-[--text-primary] font-medium"
|
||||
>
|
||||
<span>{item.question}</span>
|
||||
{openIndex === index ? <MinusIcon className="w-5 h-5" /> : <PlusIcon className="w-5 h-5" />}
|
||||
</button>
|
||||
{openIndex === index && (
|
||||
<div className="pb-4 text-[--text-secondary]">
|
||||
{item.answer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
38
components/charts/RadarPerformanceChart.tsx
Normal file
38
components/charts/RadarPerformanceChart.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { ResponsiveContainer, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar, Tooltip, Legend } from 'recharts';
|
||||
|
||||
export interface RadarMetric {
|
||||
label: string;
|
||||
value: number; // 0-100
|
||||
max?: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
data: RadarMetric[];
|
||||
height?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
const RadarPerformanceChart: React.FC<Props> = ({ title, data, height = 320, color = '#3b82f6' }) => {
|
||||
const chartData = data.map(d => ({ subject: d.label, A: d.value, fullMark: d.max ?? 100 }));
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
{title && <h4 className="text-sm font-medium text-[--text-secondary] mb-2">{title}</h4>}
|
||||
<div style={{ width: '100%', height }}>
|
||||
<ResponsiveContainer>
|
||||
<RadarChart data={chartData} margin={{ top: 10, right: 30, bottom: 10, left: 10 }}>
|
||||
<PolarGrid stroke="var(--border-color)" />
|
||||
<PolarAngleAxis dataKey="subject" tick={{ fill: 'var(--text-secondary)', fontSize: 11 }} />
|
||||
<PolarRadiusAxis angle={30} domain={[0, 100]} tick={{ fill: 'var(--text-secondary)', fontSize: 10 }} />
|
||||
<Radar name={title || 'Score'} dataKey="A" stroke={color} fill={color} fillOpacity={0.35} />
|
||||
<Tooltip wrapperStyle={{ fontSize: 12 }} contentStyle={{ background: 'var(--background-secondary)', border: '1px solid var(--border-color)' }} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RadarPerformanceChart;
|
||||
30
components/charts/ScoreBarList.tsx
Normal file
30
components/charts/ScoreBarList.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ScoreItem { label: string; value: number; max?: number; }
|
||||
interface Props { title?: string; items: ScoreItem[]; color?: string; }
|
||||
|
||||
const ScoreBarList: React.FC<Props> = ({ title, items, color = '#6366f1' }) => {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{title && <h4 className="text-sm font-medium text-[--text-secondary]">{title}</h4>}
|
||||
<ul className="space-y-2">
|
||||
{items.map(it => {
|
||||
const pct = Math.min(100, Math.round((it.value / (it.max ?? 100)) * 100));
|
||||
return (
|
||||
<li key={it.label} className="space-y-1">
|
||||
<div className="flex justify-between text-xs text-[--text-secondary]">
|
||||
<span>{it.label}</span>
|
||||
<span>{it.value}{it.max ? `/${it.max}` : ''}</span>
|
||||
</div>
|
||||
<div className="h-2 bg-[--background-secondary] rounded overflow-hidden">
|
||||
<div className="h-full transition-all" style={{ width: pct + '%', background: color }} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScoreBarList;
|
||||
108
components/figma/EnhancedFigmaQuestion.tsx
Normal file
108
components/figma/EnhancedFigmaQuestion.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React from 'react';
|
||||
import { EmployeeQuestion } from '../../employeeQuestions';
|
||||
import { QuestionInput } from '../ui/QuestionInput';
|
||||
|
||||
interface EnhancedFigmaQuestionProps {
|
||||
questionNumber?: string | number;
|
||||
question: EmployeeQuestion;
|
||||
answer?: string;
|
||||
onAnswerChange?: (value: string) => void;
|
||||
onBack?: () => void;
|
||||
onNext?: () => void;
|
||||
showNavigation?: boolean;
|
||||
nextLabel?: string;
|
||||
backLabel?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const EnhancedFigmaQuestion: React.FC<EnhancedFigmaQuestionProps> = ({
|
||||
questionNumber = 'Q',
|
||||
question,
|
||||
answer = '',
|
||||
onAnswerChange,
|
||||
onBack,
|
||||
onNext,
|
||||
showNavigation = true,
|
||||
nextLabel = 'Next',
|
||||
backLabel = 'Back',
|
||||
className = ''
|
||||
}) => {
|
||||
return (
|
||||
<div className={`w-full max-w-[600px] px-5 pt-5 pb-6 bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 inline-flex flex-col justify-end items-end gap-4 ${className}`}>
|
||||
{/* Question Header */}
|
||||
<div className="self-stretch inline-flex justify-start items-start gap-3">
|
||||
<div className="justify-start text-gray-400 text-xl font-medium font-['Inter'] leading-loose">
|
||||
{questionNumber}
|
||||
</div>
|
||||
<div className="flex-1 inline-flex flex-col justify-start items-start gap-2">
|
||||
<div className="self-stretch justify-start text-[--text-primary] text-xl font-semibold font-['Inter'] leading-loose">
|
||||
{question.prompt}
|
||||
</div>
|
||||
<div className="self-stretch justify-start text-[--text-secondary] text-sm font-normal font-['Inter'] leading-tight">
|
||||
{question.required ? 'Required' : 'Optional'} • {question.category}
|
||||
{question.type && ` • ${question.type}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="self-stretch h-px bg-gray-200 dark:bg-gray-700"></div>
|
||||
|
||||
{/* Answer Section */}
|
||||
<div className="self-stretch inline-flex justify-start items-start gap-3">
|
||||
<div className="justify-start text-gray-400 text-xl font-medium font-['Inter'] leading-loose">A</div>
|
||||
<div className="flex-1">
|
||||
<QuestionInput
|
||||
question={question}
|
||||
value={answer}
|
||||
onChange={onAnswerChange || (() => { })}
|
||||
className="border-0 bg-transparent focus:ring-0 p-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
{showNavigation && (
|
||||
<div className="inline-flex justify-start items-center gap-3">
|
||||
{onBack && (
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="px-4 py-3.5 bg-gray-100 dark:bg-gray-700 rounded-full flex justify-center items-center gap-1 overflow-hidden hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
<div className="relative">
|
||||
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13 15L8 10L13 5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="px-1 flex justify-center items-center">
|
||||
<div className="justify-center text-[--text-primary] text-sm font-medium font-['Inter'] leading-tight">
|
||||
{backLabel}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onNext && (
|
||||
<button
|
||||
onClick={onNext}
|
||||
className="px-4 py-3.5 bg-blue-500 rounded-full border-2 border-blue-400 flex justify-center items-center gap-1 overflow-hidden hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
<div className="px-1 flex justify-center items-center">
|
||||
<div className="justify-center text-white text-sm font-medium font-['Inter'] leading-tight">
|
||||
{nextLabel}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 15L13 10L8 5" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnhancedFigmaQuestion;
|
||||
50
components/figma/FigmaAlert.tsx
Normal file
50
components/figma/FigmaAlert.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
|
||||
// Figma-based Alert component with proper CSS variables and styling
|
||||
interface FigmaAlertProps {
|
||||
title: string;
|
||||
variant?: 'success' | 'error' | 'warning' | 'info';
|
||||
children?: React.ReactNode;
|
||||
onClose?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const FigmaAlert: React.FC<FigmaAlertProps> = ({
|
||||
title,
|
||||
variant = 'info',
|
||||
children,
|
||||
onClose,
|
||||
className = ''
|
||||
}) => {
|
||||
const getBorderColor = () => {
|
||||
switch (variant) {
|
||||
case 'success': return 'bg-Other-Green';
|
||||
case 'error': return 'bg-red-500';
|
||||
case 'warning': return 'bg-yellow-500';
|
||||
default: return 'bg-blue-500';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`p-4 relative bg-Other-White rounded-lg shadow-[0px_2px_2px_-1px_rgba(10,13,18,0.04)] shadow-[0px_4px_6px_-2px_rgba(10,13,18,0.03)] shadow-[0px_12px_16px_-4px_rgba(10,13,18,0.08)] outline outline-1 outline-offset-[-1px] outline-Neutrals-NeutralSlate200 inline-flex justify-center items-center gap-2.5 overflow-hidden ${className}`}>
|
||||
<div className="w-96 max-w-96 justify-start text-Neutrals-NeutralSlate950 text-sm font-medium font-['Inter'] leading-tight">
|
||||
{title}
|
||||
{children && <div className="mt-1 text-xs text-Neutrals-NeutralSlate600">{children}</div>}
|
||||
</div>
|
||||
|
||||
{onClose && (
|
||||
<button onClick={onClose} className="flex-shrink-0">
|
||||
<div data-svg-wrapper className="relative">
|
||||
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.6663 5.83325L6.33301 14.1666M6.33301 5.83325L14.6663 14.1666" stroke="var(--Neutrals-NeutralSlate600, #535862)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className={`w-2 h-32 left-[-4px] top-[-41px] absolute ${getBorderColor()}`} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FigmaAlert;
|
||||
76
components/figma/FigmaInput.tsx
Normal file
76
components/figma/FigmaInput.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
|
||||
interface FigmaInputProps {
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
type?: string;
|
||||
value?: string;
|
||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
icon?: React.ReactNode;
|
||||
buttonText?: string;
|
||||
onButtonClick?: () => void;
|
||||
className?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export const FigmaInput: React.FC<FigmaInputProps> = ({
|
||||
label = 'Email',
|
||||
placeholder = 'Enter your email',
|
||||
type = 'text',
|
||||
value,
|
||||
onChange,
|
||||
icon,
|
||||
buttonText,
|
||||
onButtonClick,
|
||||
className = '',
|
||||
required = false
|
||||
}) => {
|
||||
return (
|
||||
<div className={`w-[464px] inline-flex flex-col justify-start items-start gap-2 ${className}`}>
|
||||
{label && (
|
||||
<div className="self-stretch inline-flex justify-start items-center gap-0.5">
|
||||
<div className="justify-start text-Neutrals-NeutralSlate800 text-sm font-medium font-['Inter'] leading-tight">
|
||||
{label} {required && <span className="text-red-500">*</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="self-stretch inline-flex justify-start items-start gap-2">
|
||||
<div className="flex-1 px-4 py-3.5 bg-Neutrals-NeutralSlate100 rounded-[999px] flex justify-start items-center gap-2 overflow-hidden">
|
||||
{icon && (
|
||||
<div data-svg-wrapper className="relative">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
className="flex-1 bg-transparent outline-none text-Neutrals-NeutralSlate950 text-sm font-normal font-['Inter'] leading-tight placeholder:text-Neutrals-NeutralSlate500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{buttonText && (
|
||||
<button
|
||||
onClick={onButtonClick}
|
||||
className="w-32 max-w-32 px-4 py-3.5 bg-Neutrals-NeutralSlate100 rounded-[999px] flex justify-center items-center gap-1 overflow-hidden hover:bg-Neutrals-NeutralSlate200 transition-colors"
|
||||
>
|
||||
<div className="justify-center text-Neutrals-NeutralSlate500 text-sm font-medium font-['Inter'] leading-tight">
|
||||
{buttonText}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Email icon component for convenience
|
||||
export const EmailIcon: React.FC = () => (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.66675 5.83325L8.47085 10.5961C9.02182 10.9818 9.29731 11.1746 9.59697 11.2493C9.86166 11.3153 10.1385 11.3153 10.4032 11.2493C10.7029 11.1746 10.9783 10.9818 11.5293 10.5961L18.3334 5.83325M5.66675 16.6666H14.3334C15.7335 16.6666 16.4336 16.6666 16.9684 16.3941C17.4388 16.1544 17.8212 15.772 18.0609 15.3016C18.3334 14.7668 18.3334 14.0667 18.3334 12.6666V7.33325C18.3334 5.93312 18.3334 5.23306 18.0609 4.69828C17.8212 4.22787 17.4388 3.84542 16.9684 3.60574C16.4336 3.33325 15.7335 3.33325 14.3334 3.33325H5.66675C4.26662 3.33325 3.56655 3.33325 3.03177 3.60574C2.56137 3.84542 2.17892 4.22787 1.93923 4.69828C1.66675 5.23306 1.66675 5.93312 1.66675 7.33325V12.6666C1.66675 14.0667 1.66675 14.7668 1.93923 15.3016C2.17892 15.772 2.56137 16.1544 3.03177 16.3941C3.56655 16.6666 4.26662 16.6666 5.66675 16.6666Z" stroke="var(--Neutrals-NeutralSlate500, #717680)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default FigmaInput;
|
||||
71
components/figma/FigmaProgress.tsx
Normal file
71
components/figma/FigmaProgress.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ProgressStepProps {
|
||||
number: number;
|
||||
title: string;
|
||||
isActive?: boolean;
|
||||
isCompleted?: boolean;
|
||||
}
|
||||
|
||||
interface FigmaProgressProps {
|
||||
steps: ProgressStepProps[];
|
||||
currentStep?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ProgressStep: React.FC<ProgressStepProps> = ({ number, title, isActive = false, isCompleted = false }) => {
|
||||
const stepClasses = isActive
|
||||
? "p-2 bg-Main-BG-Gray-50 rounded-[10px] shadow-[0px_1px_2px_0px_rgba(10,13,20,0.03)]"
|
||||
: "p-2 bg-Other-White rounded-[10px]";
|
||||
|
||||
const numberClasses = isActive || isCompleted
|
||||
? "h-5 p-0.5 bg-Brand-Orange rounded-[999px]"
|
||||
: "h-5 p-0.5 bg-bg-white-0 rounded-[999px] outline outline-1 outline-offset-[-1px] outline-Neutrals-NeutralSlate200";
|
||||
|
||||
const numberTextClasses = isActive || isCompleted
|
||||
? "w-4 text-center justify-start text-Neutrals-NeutralSlate0 text-xs font-medium font-['Inter'] leading-none"
|
||||
: "w-4 text-center justify-start text-Neutrals-NeutralSlate600 text-xs font-medium font-['Inter'] leading-none";
|
||||
|
||||
const titleClasses = isActive
|
||||
? "flex-1 justify-start text-Neutrals-NeutralSlate950 text-sm font-normal font-['Inter'] leading-tight"
|
||||
: "flex-1 justify-start text-Neutrals-NeutralSlate600 text-sm font-normal font-['Inter'] leading-tight";
|
||||
|
||||
return (
|
||||
<div className={`self-stretch inline-flex justify-start items-center gap-2.5 overflow-hidden ${stepClasses}`}>
|
||||
<div className={`inline-flex flex-col justify-center items-center gap-0.5 overflow-hidden ${numberClasses}`}>
|
||||
<div className={numberTextClasses}>{number}</div>
|
||||
</div>
|
||||
<div className={titleClasses}>{title}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const FigmaProgress: React.FC<FigmaProgressProps> = ({ steps, currentStep = 1, className = '' }) => {
|
||||
return (
|
||||
<div className={`self-stretch inline-flex flex-col justify-start items-start gap-2 ${className}`}>
|
||||
{steps.map((step, index) => (
|
||||
<ProgressStep
|
||||
key={index}
|
||||
number={step.number}
|
||||
title={step.title}
|
||||
isActive={step.number === currentStep}
|
||||
isCompleted={step.number < currentStep}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Default onboarding steps as shown in Figma
|
||||
export const defaultOnboardingSteps: ProgressStepProps[] = [
|
||||
{ number: 1, title: "Company Overview & Vision" },
|
||||
{ number: 2, title: "Leadership & Organizational Structure" },
|
||||
{ number: 3, title: "Operations & Execution" },
|
||||
{ number: 4, title: "Culture & Team Health" },
|
||||
{ number: 5, title: "Sales, Marketing & Growth" },
|
||||
{ number: 6, title: "Financial Health & Metrics" },
|
||||
{ number: 7, title: "Innovation & Product/Service Strategy" },
|
||||
{ number: 8, title: "Personal Leadership & Risk" }
|
||||
];
|
||||
|
||||
export default FigmaProgress;
|
||||
127
components/figma/FigmaQuestion.tsx
Normal file
127
components/figma/FigmaQuestion.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import React from 'react';
|
||||
|
||||
interface FigmaQuestionProps {
|
||||
questionNumber?: string | number;
|
||||
title: string;
|
||||
description?: string;
|
||||
answer?: string;
|
||||
onAnswerChange?: (value: string) => void;
|
||||
onBack?: () => void;
|
||||
onNext?: () => void;
|
||||
showNavigation?: boolean;
|
||||
nextLabel?: string;
|
||||
backLabel?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const FigmaQuestion: React.FC<FigmaQuestionProps> = ({
|
||||
questionNumber = 'Q',
|
||||
title,
|
||||
description,
|
||||
answer = '',
|
||||
onAnswerChange,
|
||||
onBack,
|
||||
onNext,
|
||||
showNavigation = true,
|
||||
nextLabel = 'Next',
|
||||
backLabel = 'Back',
|
||||
className = ''
|
||||
}) => {
|
||||
return (
|
||||
<div className={`w-[600px] px-5 pt-5 pb-6 bg-Other-White rounded-2xl outline outline-1 outline-offset-[-1px] outline-Neutrals-NeutralSlate200 inline-flex flex-col justify-end items-end gap-4 ${className}`}>
|
||||
{/* Question Header */}
|
||||
<div className="self-stretch inline-flex justify-start items-start gap-3">
|
||||
<div className="justify-start text-zinc-300 text-xl font-medium font-['Inter'] leading-loose">
|
||||
{questionNumber}
|
||||
</div>
|
||||
<div className="flex-1 inline-flex flex-col justify-start items-start gap-2">
|
||||
<div className="self-stretch justify-start text-Neutrals-NeutralSlate950 text-xl font-semibold font-['Inter'] leading-loose">
|
||||
{title}
|
||||
</div>
|
||||
{description && (
|
||||
<div className="self-stretch justify-start text-Neutrals-NeutralSlate500 text-sm font-normal font-['Inter'] leading-tight">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div data-svg-wrapper>
|
||||
<svg width="563" height="5" viewBox="0 0 563 5" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_d_37_3168)">
|
||||
<path d="M1.5 1L561.5 1" stroke="var(--Neutrals-NeutralSlate200, #E9EAEB)" />
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_d_37_3168" x="0" y="0.5" width="563" height="4" filterUnits="userSpaceOnUse" colorInterpolationFilters="sRGB">
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
|
||||
<feOffset dy="1.5" />
|
||||
<feGaussianBlur stdDeviation="0.75" />
|
||||
<feComposite in2="hardAlpha" operator="out" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0" />
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_37_3168" />
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_37_3168" result="shape" />
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Answer Section */}
|
||||
<div className="self-stretch inline-flex justify-start items-center gap-3">
|
||||
<div className="justify-start text-zinc-300 text-xl font-medium font-['Inter'] leading-loose">A</div>
|
||||
<div className="flex-1">
|
||||
<textarea
|
||||
value={answer}
|
||||
onChange={(e) => onAnswerChange?.(e.target.value)}
|
||||
placeholder="Type your answer...."
|
||||
className="w-full bg-transparent outline-none resize-none text-Neutrals-NeutralSlate950 text-base font-normal font-['Inter'] leading-normal placeholder:text-Neutrals-NeutralSlate500 min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
{showNavigation && (
|
||||
<div className="inline-flex justify-start items-center gap-3">
|
||||
{onBack && (
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="px-4 py-3.5 bg-Neutrals-NeutralSlate100 rounded-[999px] flex justify-center items-center gap-1 overflow-hidden hover:bg-Neutrals-NeutralSlate200 transition-colors"
|
||||
>
|
||||
<div data-svg-wrapper className="relative">
|
||||
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13 15L8 10L13 5" stroke="var(--Neutrals-NeutralSlate950, #0A0D12)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="px-1 flex justify-center items-center">
|
||||
<div className="justify-center text-Neutrals-NeutralSlate950 text-sm font-medium font-['Inter'] leading-tight">
|
||||
{backLabel}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onNext && (
|
||||
<button
|
||||
onClick={onNext}
|
||||
className="px-4 py-3.5 bg-Brand-Orange rounded-[999px] outline outline-2 outline-offset-[-2px] outline-blue-400 flex justify-center items-center gap-1 overflow-hidden hover:bg-orange-600 transition-colors"
|
||||
>
|
||||
<div className="px-1 flex justify-center items-center">
|
||||
<div className="justify-center text-Other-White text-sm font-medium font-['Inter'] leading-tight">
|
||||
{nextLabel}
|
||||
</div>
|
||||
</div>
|
||||
<div data-svg-wrapper className="relative">
|
||||
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 15L13 10L8 5" stroke="var(--Neutrals-NeutralSlate0, #FDFDFD)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FigmaQuestion;
|
||||
35
components/ui/Alert.tsx
Normal file
35
components/ui/Alert.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
|
||||
type AlertVariant = 'info' | 'success' | 'warning' | 'error';
|
||||
|
||||
const variantStyles: Record<AlertVariant, string> = {
|
||||
info: 'bg-blue-50 text-blue-800 border-blue-300 dark:bg-blue-900/30 dark:text-blue-200 dark:border-blue-700',
|
||||
success: 'bg-green-50 text-green-800 border-green-300 dark:bg-green-900/30 dark:text-green-200 dark:border-green-700',
|
||||
warning: 'bg-amber-50 text-amber-800 border-amber-300 dark:bg-amber-900/30 dark:text-amber-200 dark:border-amber-700',
|
||||
error: 'bg-red-50 text-red-800 border-red-300 dark:bg-red-900/30 dark:text-red-200 dark:border-red-700'
|
||||
};
|
||||
|
||||
export interface AlertProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
title?: string;
|
||||
variant?: AlertVariant;
|
||||
children?: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export const Alert: React.FC<AlertProps> = ({ title, variant = 'info', children, icon, compact, className, ...rest }) => {
|
||||
return (
|
||||
<div
|
||||
className={`border rounded-md ${variantStyles[variant]} ${compact ? 'px-3 py-2 text-sm' : 'px-4 py-3'} flex items-start space-x-3 ${className || ''}`}
|
||||
{...rest}
|
||||
>
|
||||
{icon && <div className="pt-0.5">{icon}</div>}
|
||||
<div className="flex-1">
|
||||
{title && <div className="font-medium mb-0.5">{title}</div>}
|
||||
{children && <div className="leading-snug">{children}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Alert;
|
||||
25
components/ui/Breadcrumb.tsx
Normal file
25
components/ui/Breadcrumb.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface BreadcrumbItem { label: string; href?: string; }
|
||||
|
||||
export const Breadcrumbs: React.FC<{ items: BreadcrumbItem[]; onNavigate?: (href: string) => void; }> = ({ items, onNavigate }) => (
|
||||
<nav className="text-sm text-[--text-secondary]" aria-label="Breadcrumb">
|
||||
<ol className="flex flex-wrap items-center gap-1">
|
||||
{items.map((item, i) => (
|
||||
<li key={i} className="flex items-center">
|
||||
{item.href ? (
|
||||
<button
|
||||
onClick={() => onNavigate?.(item.href!)}
|
||||
className="text-blue-600 hover:underline dark:text-blue-400"
|
||||
>{item.label}</button>
|
||||
) : (
|
||||
<span className="text-[--text-primary] font-medium">{item.label}</span>
|
||||
)}
|
||||
{i < items.length - 1 && <span className="mx-2 text-[--border-color]">/</span>}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
|
||||
export default Breadcrumbs;
|
||||
1
components/ui/Icons.tsx
Normal file
1
components/ui/Icons.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { CompanyWikiIcon, SubmissionsIcon, ReportsIcon, ChatIcon, HelpIcon, SettingsIcon, CopyIcon, PlusIcon, ChevronDownIcon, UploadIcon, CheckIcon, WarningIcon, DownloadIcon, MinusIcon, SunIcon, MoonIcon, SystemIcon, SendIcon } from '../UiKit';
|
||||
49
components/ui/Inputs.tsx
Normal file
49
components/ui/Inputs.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
|
||||
interface BaseFieldProps { label?: string; description?: string; error?: string; required?: boolean; children: React.ReactNode; className?: string; }
|
||||
export const Field: React.FC<BaseFieldProps> = ({ label, description, error, required, children, className }) => (
|
||||
<div className={`space-y-2 ${className || ''}`}>
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-[--text-primary] tracking-[-0.14px]">
|
||||
{label} {required && <span className="text-[--status-red]">*</span>}
|
||||
</label>
|
||||
)}
|
||||
{children}
|
||||
{description && !error && <p className="text-sm text-[--text-secondary] tracking-[-0.14px]">{description}</p>}
|
||||
{error && <p className="text-sm text-[--status-red]">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> { invalid?: boolean; }
|
||||
export const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, invalid, ...rest }, ref) => (
|
||||
<input
|
||||
ref={ref}
|
||||
className={`w-full px-4 py-3.5 rounded-full border text-sm bg-[--input-bg] text-[--text-primary] placeholder:text-[--input-placeholder] focus:outline-none focus:ring-2 focus:ring-[--accent] focus:border-[--accent] border-[--input-border] transition-all duration-200 ${invalid ? 'border-red-500 focus:ring-red-500' : ''} ${className || ''}`}
|
||||
{...rest}
|
||||
/>
|
||||
));
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> { invalid?: boolean; }
|
||||
export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ className, invalid, ...rest }, ref) => (
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={`w-full px-4 py-3.5 rounded-2xl border text-sm resize-vertical bg-[--input-bg] text-[--text-primary] placeholder:text-[--input-placeholder] focus:outline-none focus:ring-2 focus:ring-[--accent] focus:border-[--accent] border-[--input-border] transition-all duration-200 ${invalid ? 'border-red-500 focus:ring-red-500' : ''} ${className || ''}`}
|
||||
{...rest}
|
||||
/>
|
||||
));
|
||||
Textarea.displayName = 'Textarea';
|
||||
|
||||
export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> { invalid?: boolean; }
|
||||
export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(({ className, invalid, children, ...rest }, ref) => (
|
||||
<select
|
||||
ref={ref}
|
||||
className={`w-full px-4 py-3.5 rounded-full border text-sm bg-[--input-bg] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-[--accent] focus:border-[--accent] border-[--input-border] transition-all duration-200 ${invalid ? 'border-red-500 focus:ring-red-500' : ''} ${className || ''}`}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
));
|
||||
Select.displayName = 'Select';
|
||||
|
||||
export default { Field, Input, Textarea, Select };
|
||||
24
components/ui/Progress.tsx
Normal file
24
components/ui/Progress.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
|
||||
export const LinearProgress: React.FC<{ value: number; className?: string; }> = ({ value, className }) => (
|
||||
<div className={`w-full h-2 rounded-full bg-[--background-tertiary] overflow-hidden ${className || ''}`}>
|
||||
<div className="h-full bg-blue-500 transition-all" style={{ width: `${Math.min(100, Math.max(0, value))}%` }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export interface StepProgressProps { current: number; total: number; labels?: string[]; }
|
||||
export const StepProgress: React.FC<StepProgressProps> = ({ current, total, labels }) => (
|
||||
<div>
|
||||
<div className="flex justify-between mb-2">
|
||||
{Array.from({ length: total }).map((_, i) => (
|
||||
<div key={i} className="flex-1 flex flex-col items-center">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-semibold mb-1 ${i <= current ? 'bg-blue-500 text-white' : 'bg-[--background-tertiary] text-[--text-secondary]'}`}>{i + 1}</div>
|
||||
{labels && labels[i] && <span className="text-[10px] text-center px-1 text-[--text-secondary] truncate max-w-[72px]">{labels[i]}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<LinearProgress value={((current + 1) / total) * 100} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default { LinearProgress, StepProgress };
|
||||
8
components/ui/Question.tsx
Normal file
8
components/ui/Question.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Field } from './Inputs';
|
||||
|
||||
interface QuestionProps { label: string; required?: boolean; description?: string; error?: string; children: React.ReactNode; }
|
||||
export const Question: React.FC<QuestionProps> = ({ label, required, description, error, children }) => (
|
||||
<Field label={label} required={required} description={description} error={error}>{children}</Field>
|
||||
);
|
||||
export default Question;
|
||||
182
components/ui/QuestionInput.tsx
Normal file
182
components/ui/QuestionInput.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import React from 'react';
|
||||
import { EmployeeQuestion, EMPLOYEE_QUESTIONS } from '../../employeeQuestions';
|
||||
import { Input, Textarea } from './Inputs';
|
||||
|
||||
interface QuestionInputProps {
|
||||
question: EmployeeQuestion;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
// For yes/no questions with follow-ups, we need access to all answers and ability to set follow-up
|
||||
allAnswers?: Record<string, string>;
|
||||
onFollowupChange?: (questionId: string, value: string) => void;
|
||||
}
|
||||
|
||||
// Helper function to find follow-up question for a given question
|
||||
const findFollowupQuestion = (questionId: string): EmployeeQuestion | null => {
|
||||
return EMPLOYEE_QUESTIONS.find(q => q.followupTo === questionId) || null;
|
||||
};
|
||||
|
||||
export const QuestionInput: React.FC<QuestionInputProps> = ({
|
||||
question,
|
||||
value,
|
||||
onChange,
|
||||
className = '',
|
||||
allAnswers = {},
|
||||
onFollowupChange
|
||||
}) => {
|
||||
const baseInputClasses = "w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-primary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500";
|
||||
|
||||
switch (question.type) {
|
||||
case 'text':
|
||||
return (
|
||||
<Input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={question.placeholder}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'textarea':
|
||||
return (
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={question.placeholder}
|
||||
className={className}
|
||||
rows={4}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'yesno':
|
||||
const followupQuestion = findFollowupQuestion(question.id);
|
||||
const followupValue = followupQuestion ? allAnswers[followupQuestion.id] || '' : '';
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
{/* Yes/No Radio Buttons */}
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name={question.id}
|
||||
value="Yes"
|
||||
checked={value === 'Yes'}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-4 h-4 text-[--accent] border-[--border-color] focus:ring-[--accent]"
|
||||
/>
|
||||
<span className="text-[--text-primary]">Yes</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name={question.id}
|
||||
value="No"
|
||||
checked={value === 'No'}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-4 h-4 text-[--accent] border-[--border-color] focus:ring-[--accent]"
|
||||
/>
|
||||
<span className="text-[--text-primary]">No</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Conditional Follow-up Textarea */}
|
||||
{followupQuestion && value === 'Yes' && (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-[--text-primary] tracking-[-0.14px]">
|
||||
{followupQuestion.prompt}
|
||||
</label>
|
||||
<Textarea
|
||||
value={followupValue}
|
||||
onChange={(e) => onFollowupChange?.(followupQuestion.id, e.target.value)}
|
||||
placeholder={followupQuestion.placeholder}
|
||||
rows={3}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'scale':
|
||||
const scaleMin = question.scaleMin || 1;
|
||||
const scaleMax = question.scaleMax || 10;
|
||||
const currentValue = parseInt(value) || scaleMin;
|
||||
|
||||
return (
|
||||
<div className={`space-y-3 ${className}`}>
|
||||
<div className="flex w-full justify-between text-sm text-[--text-secondary]">
|
||||
<span>{question.scaleLabels?.min || `${scaleMin}`}</span>
|
||||
<span>{question.scaleLabels?.max || `${scaleMax}`}</span>
|
||||
</div>
|
||||
|
||||
{/* Grid container that aligns slider and numbers */}
|
||||
<div className="grid grid-cols-[auto_1fr_auto] gap-4 items-center">
|
||||
{/* Left label space - dynamically sized */}
|
||||
<div className="text-xs -me-12 text-transparent select-none">
|
||||
{question.scaleLabels?.min || `${scaleMin}`}
|
||||
</div>
|
||||
|
||||
{/* Slider container */}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="range"
|
||||
min={scaleMin}
|
||||
max={scaleMax}
|
||||
value={currentValue}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer slider"
|
||||
/>
|
||||
{/* Numbers positioned absolutely under the slider */}
|
||||
<div className="flex justify-between absolute -bottom-5 left-0 right-0 text-xs text-[--text-secondary]">
|
||||
{Array.from({ length: scaleMax - scaleMin + 1 }, (_, i) => (
|
||||
<span key={i} className="w-4 text-center">
|
||||
{scaleMin + i}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current value badge */}
|
||||
<div className="w-12 h-8 bg-[--accent] text-white rounded flex items-center justify-center text-sm font-medium">
|
||||
{currentValue}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add some bottom padding to account for the absolute positioned numbers */}
|
||||
<div className="h-4"></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={`${baseInputClasses} ${className}`}
|
||||
>
|
||||
<option value="">Select an option...</option>
|
||||
{question.options?.map((option, index) => (
|
||||
<option key={index} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={question.placeholder || "Type your answer here..."}
|
||||
className={`${baseInputClasses} min-h-[100px] resize-vertical ${className}`}
|
||||
rows={4}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default QuestionInput;
|
||||
11
components/ui/Table.tsx
Normal file
11
components/ui/Table.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
export const Table: React.FC<React.TableHTMLAttributes<HTMLTableElement>> = ({ className, children, ...rest }) => (
|
||||
<table className={`w-full text-sm border-separate border-spacing-0 ${className || ''}`} {...rest}>{children}</table>
|
||||
);
|
||||
export const THead: React.FC<React.HTMLAttributes<HTMLTableSectionElement>> = ({ className, children, ...rest }) => (<thead className={`${className || ''}`} {...rest}>{children}</thead>);
|
||||
export const TBody: React.FC<React.HTMLAttributes<HTMLTableSectionElement>> = ({ className, children, ...rest }) => (<tbody className={`${className || ''}`} {...rest}>{children}</tbody>);
|
||||
export const TR: React.FC<React.HTMLAttributes<HTMLTableRowElement>> = ({ className, children, ...rest }) => (<tr className={`hover:bg-[--background-tertiary] transition-colors ${className || ''}`} {...rest}>{children}</tr>);
|
||||
export const TH: React.FC<React.ThHTMLAttributes<HTMLTableHeaderCellElement>> = ({ className, children, ...rest }) => (<th className={`text-left font-medium px-4 py-2 text-[--text-secondary] border-b border-[--border-color] bg-[--background-secondary] first:rounded-tl-md last:rounded-tr-md ${className || ''}`} {...rest}>{children}</th>);
|
||||
export const TD: React.FC<React.TdHTMLAttributes<HTMLTableCellElement>> = ({ className, children, ...rest }) => (<td className={`px-4 py-2 border-b border-[--border-color] text-[--text-primary] ${className || ''}`} {...rest}>{children}</td>);
|
||||
export default { Table, THead, TBody, TR, TH, TD };
|
||||
8
components/ui/index.ts
Normal file
8
components/ui/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export * from './Alert';
|
||||
export * from './Breadcrumb';
|
||||
export * from './Icons';
|
||||
export * from './Inputs';
|
||||
export * from './Progress';
|
||||
export * from './Question';
|
||||
export * from './QuestionInput';
|
||||
export * from './Table';
|
||||
208
constants.ts
Normal file
208
constants.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { Employee, Report, Submission, FaqItem, CompanyReport } from './types';
|
||||
|
||||
// URL Configuration - reads from environment variables with fallbacks
|
||||
export const SITE_URL = import.meta.env.VITE_SITE_URL || 'http://localhost:5173';
|
||||
|
||||
// API Base URL - auto-detect development vs production
|
||||
const isLocalhost = typeof window !== 'undefined' &&
|
||||
(window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1');
|
||||
|
||||
// Use Firebase Functions emulator in development, production functions in production
|
||||
export const API_URL = isLocalhost
|
||||
? 'http://127.0.0.1:5002/auditly-c0027/us-central1' // Firebase Functions Emulator
|
||||
: 'https://your-project.cloudfunctions.net'; // Production Firebase Functions
|
||||
|
||||
// Log URL configuration in development
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('🌐 Frontend URL Configuration:');
|
||||
console.log(` SITE_URL: ${SITE_URL}`);
|
||||
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' },
|
||||
];
|
||||
|
||||
export const REPORT_DATA: Report = {
|
||||
employeeId: 'AG',
|
||||
department: 'Influencer Marketing',
|
||||
role: 'Influencer Coordinator & Business Development Outreach',
|
||||
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.',
|
||||
},
|
||||
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.',
|
||||
},
|
||||
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.',
|
||||
],
|
||||
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.' },
|
||||
],
|
||||
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.",
|
||||
},
|
||||
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.',
|
||||
],
|
||||
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.",
|
||||
],
|
||||
},
|
||||
grading: [],
|
||||
};
|
||||
|
||||
export const SUBMISSIONS_DATA: Submission = {
|
||||
employeeId: 'AG',
|
||||
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.',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const FAQ_DATA: FaqItem[] = [
|
||||
{
|
||||
question: "What is the process for submitting a support ticket?",
|
||||
answer: "Team members will undergo evaluations every three months, focusing on their performance, teamwork, and communication skills. Role advancements will be considered at these intervals."
|
||||
},
|
||||
{
|
||||
question: "How can I reset my password?",
|
||||
answer: "You can reset your password by clicking the 'Forgot Password' link on the login page. An email will be sent to you with instructions."
|
||||
},
|
||||
{
|
||||
question: "What are the criteria for performance reviews?",
|
||||
answer: "Performance reviews are based on a combination of self-assessment, peer feedback, and manager evaluation. Key criteria include goal achievement, collaboration, and contribution to company values."
|
||||
},
|
||||
{
|
||||
question: "How can I access the company's training resources?",
|
||||
answer: "All training resources are available in the 'Company Wiki' section of the platform. You can find documents, videos, and links to external courses."
|
||||
},
|
||||
{
|
||||
question: "What should I do if I encounter a technical issue?",
|
||||
answer: "For any technical issues, please submit a ticket through the 'Help & Support' page. Our IT team will get back to you within 24 hours."
|
||||
},
|
||||
{
|
||||
question: "How do I provide feedback on team projects?",
|
||||
answer: "Feedback can be provided directly within the project management tool or during scheduled team retrospectives. We encourage open and constructive communication."
|
||||
}
|
||||
];
|
||||
|
||||
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?"
|
||||
];
|
||||
|
||||
export const SAMPLE_COMPANY_REPORT: CompanyReport = {
|
||||
id: 'sample-company-report',
|
||||
createdAt: Date.now() - 86400000, // 1 day ago
|
||||
overview: {
|
||||
totalEmployees: 0, // Fixed: Start with 0 employees instead of hardcoded 6
|
||||
departmentBreakdown: [],
|
||||
submissionRate: 0,
|
||||
lastUpdated: Date.now() - 86400000
|
||||
},
|
||||
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'
|
||||
}
|
||||
],
|
||||
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'
|
||||
]
|
||||
},
|
||||
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.`
|
||||
};
|
||||
234
contexts/AuthContext.tsx
Normal file
234
contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { onAuthStateChanged, signInWithPopup, signOut, User, createUserWithEmailAndPassword, signInWithEmailAndPassword, updateProfile } from 'firebase/auth';
|
||||
import { auth, googleProvider, isFirebaseConfigured } from '../services/firebase';
|
||||
import { demoStorage } from '../services/demoStorage';
|
||||
import { API_URL } from '../constants';
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
signInWithGoogle: () => Promise<void>;
|
||||
signOutUser: () => Promise<void>;
|
||||
signInWithEmail: (email: string, password: string) => Promise<void>;
|
||||
signUpWithEmail: (email: string, password: string, displayName?: string) => Promise<void>;
|
||||
sendOTP: (email: string, inviteCode?: string) => Promise<any>;
|
||||
verifyOTP: (email: string, otp: string, inviteCode?: string) => Promise<void>;
|
||||
signInWithOTP: (token: string, userData: any) => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
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 (sessionUser) {
|
||||
const parsedUser = JSON.parse(sessionUser);
|
||||
console.log('Restoring demo session for:', parsedUser.email);
|
||||
setUser(parsedUser as User);
|
||||
}
|
||||
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 () => {
|
||||
if (!isFirebaseConfigured) {
|
||||
// No-op in demo mode
|
||||
return;
|
||||
}
|
||||
await signInWithPopup(auth, googleProvider);
|
||||
};
|
||||
|
||||
const signOutUser = async () => {
|
||||
if (!isFirebaseConfigured) {
|
||||
// Clear demo session
|
||||
sessionStorage.removeItem('auditly_demo_session');
|
||||
setUser(null);
|
||||
return;
|
||||
}
|
||||
await signOut(auth);
|
||||
};
|
||||
|
||||
const signInWithEmail = async (email: string, password: string) => {
|
||||
console.log('signInWithEmail called, isFirebaseConfigured:', isFirebaseConfigured);
|
||||
if (!isFirebaseConfigured) {
|
||||
console.log('Demo mode: authenticating user', email);
|
||||
const existingUser = demoStorage.getUserByEmail(email);
|
||||
|
||||
if (existingUser) {
|
||||
// Verify password
|
||||
if (demoStorage.verifyPassword(password, existingUser.passwordHash)) {
|
||||
const mockUser = {
|
||||
uid: existingUser.uid,
|
||||
email: existingUser.email,
|
||||
displayName: existingUser.displayName
|
||||
} as unknown as User;
|
||||
|
||||
setUser(mockUser);
|
||||
sessionStorage.setItem('auditly_demo_session', JSON.stringify(mockUser));
|
||||
console.log('Demo login successful for:', email);
|
||||
} else {
|
||||
throw new Error('Invalid password');
|
||||
}
|
||||
} else {
|
||||
throw new Error('User not found. Please sign up first.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
console.log('Attempting Firebase auth');
|
||||
await signInWithEmailAndPassword(auth, email, password);
|
||||
} catch (e: any) {
|
||||
const code = e?.code || '';
|
||||
console.error('Firebase Auth Error:', code, e?.message);
|
||||
if (code === 'auth/configuration-not-found' || code === 'auth/operation-not-allowed') {
|
||||
console.warn('Email/Password provider disabled in Firebase. Falling back to local mock user for development.');
|
||||
const mock = { uid: `demo-${btoa(email).slice(0, 8)}`, email, displayName: email.split('@')[0] } as unknown as User;
|
||||
setUser(mock);
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const signUpWithEmail = async (email: string, password: string, displayName?: string) => {
|
||||
if (!isFirebaseConfigured) {
|
||||
console.log('Demo mode: creating new user', email);
|
||||
// Check if user already exists
|
||||
const existingUser = demoStorage.getUserByEmail(email);
|
||||
if (existingUser) {
|
||||
throw new Error('User already exists with this email');
|
||||
}
|
||||
|
||||
// Create new user
|
||||
const uid = `demo-${btoa(email).slice(0, 8)}`;
|
||||
const newUser = {
|
||||
uid,
|
||||
email,
|
||||
displayName: displayName || email.split('@')[0],
|
||||
passwordHash: demoStorage.hashPassword(password)
|
||||
};
|
||||
|
||||
demoStorage.saveUser(newUser);
|
||||
|
||||
const mockUser = {
|
||||
uid: newUser.uid,
|
||||
email: newUser.email,
|
||||
displayName: newUser.displayName
|
||||
} as unknown as User;
|
||||
|
||||
setUser(mockUser);
|
||||
sessionStorage.setItem('auditly_demo_session', JSON.stringify(mockUser));
|
||||
console.log('Demo signup successful for:', email);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const cred = await createUserWithEmailAndPassword(auth, email, password);
|
||||
if (displayName) {
|
||||
try { await updateProfile(cred.user, { displayName }); } catch { }
|
||||
}
|
||||
} catch (e: any) {
|
||||
const code = e?.code || '';
|
||||
if (code === 'auth/configuration-not-found' || code === 'auth/operation-not-allowed') {
|
||||
console.warn('Email/Password provider disabled in Firebase. Falling back to local mock user for development.');
|
||||
const mock = { uid: `demo-${btoa(email).slice(0, 8)}`, email, displayName: displayName || email.split('@')[0] } as unknown as User;
|
||||
setUser(mock);
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const sendOTP = async (email: string, inviteCode?: string) => {
|
||||
const response = await fetch(`${API_URL}/sendOTP`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, inviteCode })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to send OTP');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
const verifyOTP = async (email: string, otp: string, inviteCode?: string) => {
|
||||
const response = await fetch(`${API_URL}/verifyOTP`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, otp, inviteCode })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to verify OTP');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Set user in auth context
|
||||
const mockUser = {
|
||||
uid: data.user.uid,
|
||||
email: data.user.email,
|
||||
displayName: data.user.displayName,
|
||||
emailVerified: true
|
||||
} as unknown as User;
|
||||
|
||||
setUser(mockUser);
|
||||
sessionStorage.setItem('auditly_demo_session', JSON.stringify(mockUser));
|
||||
sessionStorage.setItem('auditly_auth_token', data.token);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const signInWithOTP = async (token: string, userData: any) => {
|
||||
const mockUser = {
|
||||
uid: userData.uid,
|
||||
email: userData.email,
|
||||
displayName: userData.displayName,
|
||||
emailVerified: true
|
||||
} as unknown as User;
|
||||
|
||||
setUser(mockUser);
|
||||
sessionStorage.setItem('auditly_demo_session', JSON.stringify(mockUser));
|
||||
sessionStorage.setItem('auditly_auth_token', token);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{
|
||||
user,
|
||||
loading,
|
||||
signInWithGoogle,
|
||||
signOutUser,
|
||||
signInWithEmail,
|
||||
signUpWithEmail,
|
||||
sendOTP,
|
||||
verifyOTP,
|
||||
signInWithOTP
|
||||
}}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||
return ctx;
|
||||
};
|
||||
799
contexts/OrgContext.tsx
Normal file
799
contexts/OrgContext.tsx
Normal file
@@ -0,0 +1,799 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { collection, doc, getDoc, getDocs, onSnapshot, setDoc } from 'firebase/firestore';
|
||||
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 { demoStorage } from '../services/demoStorage';
|
||||
|
||||
interface OrgData {
|
||||
orgId: string;
|
||||
name: string;
|
||||
industry?: string;
|
||||
size?: string;
|
||||
description?: string;
|
||||
mission?: string;
|
||||
vision?: string;
|
||||
values?: string;
|
||||
foundingYear?: string;
|
||||
evolution?: string;
|
||||
majorMilestones?: string;
|
||||
advantages?: string;
|
||||
vulnerabilities?: string;
|
||||
competitors?: string;
|
||||
marketPosition?: string;
|
||||
currentChallenges?: string;
|
||||
shortTermGoals?: string;
|
||||
longTermGoals?: string;
|
||||
keyMetrics?: string;
|
||||
cultureDescription?: string;
|
||||
workEnvironment?: string;
|
||||
leadershipStyle?: string;
|
||||
communicationStyle?: string;
|
||||
additionalContext?: string;
|
||||
onboardingCompleted?: boolean;
|
||||
}
|
||||
|
||||
interface OrgContextType {
|
||||
org: OrgData | null;
|
||||
orgId: string;
|
||||
employees: Employee[];
|
||||
submissions: Record<string, Submission>;
|
||||
reports: Record<string, Report>;
|
||||
upsertOrg: (data: Partial<OrgData>) => Promise<void>;
|
||||
saveReport: (employeeId: string, report: Report) => Promise<void>;
|
||||
inviteEmployee: (args: { name: string; email: string }) => Promise<{ employeeId: string; inviteLink: string }>;
|
||||
issueInviteViaApi: (args: { name: string; email: string; role?: string; department?: string }) => Promise<{ code: string; inviteLink: string; emailLink: string; employee: any }>;
|
||||
getInviteStatus: (code: string) => Promise<{ used: boolean; employee: any } | null>;
|
||||
consumeInvite: (code: string) => Promise<{ employee: any } | null>;
|
||||
getReportVersions: (employeeId: string) => Promise<Array<{ id: string; createdAt: number; report: Report }>>;
|
||||
saveReportVersion: (employeeId: string, report: Report) => Promise<void>;
|
||||
acceptInvite: (code: string) => Promise<void>;
|
||||
saveCompanyReport: (summary: string) => Promise<void>;
|
||||
getCompanyReportHistory: () => Promise<Array<{ id: string; createdAt: number; summary: string }>>;
|
||||
saveFullCompanyReport: (report: CompanyReport) => Promise<void>;
|
||||
getFullCompanyReportHistory: () => Promise<CompanyReport[]>;
|
||||
generateCompanyReport: () => Promise<CompanyReport>;
|
||||
generateCompanyWiki: (orgOverride?: OrgData) => Promise<CompanyReport>;
|
||||
seedInitialData: () => Promise<void>;
|
||||
isOwner: (employeeId?: string) => boolean;
|
||||
submitEmployeeAnswers: (employeeId: string, answers: Record<string, string>) => Promise<boolean>;
|
||||
generateEmployeeReport: (employee: Employee) => Promise<Report | null>;
|
||||
getEmployeeReport: (employeeId: string) => Promise<{ success: boolean; report?: Report; error?: string }>;
|
||||
getEmployeeReports: () => Promise<{ success: boolean; reports?: Report[]; error?: string }>;
|
||||
}
|
||||
|
||||
const OrgContext = createContext<OrgContextType | undefined>(undefined);
|
||||
|
||||
export const OrgProvider: React.FC<{ children: React.ReactNode; selectedOrgId: string }> = ({ children, selectedOrgId }) => {
|
||||
const { user } = useAuth();
|
||||
const [org, setOrg] = useState<OrgData | null>(null);
|
||||
const [employees, setEmployees] = useState<Employee[]>([]);
|
||||
const [submissions, setSubmissions] = useState<Record<string, Submission>>({});
|
||||
const [reports, setReports] = useState<Record<string, Report>>({});
|
||||
const [reportVersions, setReportVersions] = useState<Record<string, Array<{ id: string; createdAt: number; report: Report }>>>({});
|
||||
const [companyReports, setCompanyReports] = useState<Array<{ id: string; createdAt: number; summary: string }>>([]);
|
||||
const [fullCompanyReports, setFullCompanyReports] = useState<CompanyReport[]>([]);
|
||||
|
||||
// Use the provided selectedOrgId instead of deriving from user
|
||||
const orgId = selectedOrgId;
|
||||
|
||||
useEffect(() => {
|
||||
console.log('OrgContext effect running, orgId:', orgId, 'isFirebaseConfigured:', isFirebaseConfigured);
|
||||
if (!orgId) return; // Wait for orgId to be available
|
||||
if (!isFirebaseConfigured) {
|
||||
// Demo mode data - use persistent localStorage with proper initialization
|
||||
console.log('Setting up demo org data with persistence');
|
||||
|
||||
// Get or create persistent demo org
|
||||
let demoOrg = demoStorage.getOrganization(orgId);
|
||||
if (!demoOrg) {
|
||||
demoOrg = {
|
||||
orgId: orgId,
|
||||
name: 'Demo Company',
|
||||
onboardingCompleted: false
|
||||
};
|
||||
demoStorage.saveOrganization(demoOrg);
|
||||
|
||||
// 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."
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Save all sample submissions
|
||||
sampleSubmissions.forEach(submission => {
|
||||
demoStorage.saveSubmission(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);
|
||||
}
|
||||
|
||||
// Load persistent demo data
|
||||
setOrg({ orgId, name: demoOrg.name, onboardingCompleted: demoOrg.onboardingCompleted });
|
||||
|
||||
// Convert employees to expected format
|
||||
const demoEmployees = demoStorage.getEmployeesByOrg(orgId);
|
||||
const convertedEmployees: Employee[] = demoEmployees.map(emp => ({
|
||||
id: emp.id,
|
||||
name: emp.name,
|
||||
email: emp.email,
|
||||
initials: emp.name ? emp.name.split(' ').map(n => n[0]).join('').toUpperCase() : emp.email.substring(0, 2).toUpperCase(),
|
||||
department: emp.department,
|
||||
role: emp.role,
|
||||
isOwner: emp.id === user?.uid
|
||||
}));
|
||||
setEmployees(convertedEmployees);
|
||||
|
||||
// Convert submissions to expected format
|
||||
const orgSubmissions = demoStorage.getSubmissionsByOrg(orgId);
|
||||
const convertedSubmissions: Record<string, Submission> = {};
|
||||
Object.entries(orgSubmissions).forEach(([employeeId, demoSub]) => {
|
||||
convertedSubmissions[employeeId] = {
|
||||
employeeId,
|
||||
answers: Object.entries(demoSub.answers).map(([question, answer]) => ({
|
||||
question,
|
||||
answer
|
||||
}))
|
||||
};
|
||||
});
|
||||
setSubmissions(convertedSubmissions);
|
||||
|
||||
// Convert reports to expected format
|
||||
const orgReports = demoStorage.getEmployeeReportsByOrg(orgId);
|
||||
setReports(orgReports);
|
||||
|
||||
// Get company reports
|
||||
const companyReports = demoStorage.getCompanyReportsByOrg(orgId);
|
||||
setFullCompanyReports(companyReports);
|
||||
return;
|
||||
}
|
||||
console.log('Setting up Firebase org data');
|
||||
const orgRef = doc(db, 'orgs', orgId);
|
||||
getDoc(orgRef).then(async (snap) => {
|
||||
if (snap.exists()) {
|
||||
setOrg({ orgId, ...(snap.data() as any) });
|
||||
} else {
|
||||
const seed = { name: 'Your Company', onboardingCompleted: false };
|
||||
await setDoc(orgRef, seed);
|
||||
setOrg({ orgId, ...(seed as any) });
|
||||
}
|
||||
});
|
||||
|
||||
const employeesCol = collection(db, 'orgs', orgId, 'employees');
|
||||
const unsubEmp = onSnapshot(employeesCol, (snap) => {
|
||||
const arr: Employee[] = [];
|
||||
snap.forEach((d) => arr.push({ id: d.id, ...(d.data() as any) }));
|
||||
setEmployees(arr);
|
||||
});
|
||||
|
||||
const submissionsCol = collection(db, 'orgs', orgId, 'submissions');
|
||||
const unsubSub = onSnapshot(submissionsCol, (snap) => {
|
||||
const map: Record<string, Submission> = {};
|
||||
snap.forEach((d) => (map[d.id] = { employeeId: d.id, ...(d.data() as any) }));
|
||||
setSubmissions(map);
|
||||
});
|
||||
|
||||
const reportsCol = collection(db, 'orgs', orgId, 'reports');
|
||||
const unsubRep = onSnapshot(reportsCol, (snap) => {
|
||||
const map: Record<string, Report> = {};
|
||||
snap.forEach((d) => (map[d.id] = { employeeId: d.id, ...(d.data() as any) } as Report));
|
||||
setReports(map);
|
||||
});
|
||||
|
||||
return () => { unsubEmp(); unsubSub(); unsubRep(); };
|
||||
}, [orgId]);
|
||||
|
||||
const upsertOrg = async (data: Partial<OrgData>) => {
|
||||
if (!isFirebaseConfigured) {
|
||||
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 (!response.ok) {
|
||||
console.warn('Failed to sync organization data with server');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to sync organization data:', error);
|
||||
}
|
||||
} else {
|
||||
// Firebase mode - save to Firestore
|
||||
const orgRef = doc(db, 'orgs', orgId);
|
||||
await setDoc(orgRef, data, { merge: true });
|
||||
|
||||
// Update local state
|
||||
const updatedOrg = { ...(org || { orgId, name: 'Your Company' }), ...data } as OrgData;
|
||||
setOrg(updatedOrg);
|
||||
}
|
||||
};
|
||||
|
||||
const updateOrg = async (data: Partial<OrgData>) => {
|
||||
if (!isFirebaseConfigured) {
|
||||
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 (!response.ok) {
|
||||
console.warn('Failed to sync organization data with server');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to sync organization data:', error);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
const orgRef = doc(db, 'orgs', orgId);
|
||||
await setDoc(orgRef, data, { merge: true });
|
||||
};
|
||||
|
||||
const saveReport = async (employeeId: string, report: Report) => {
|
||||
if (!isFirebaseConfigured) {
|
||||
setReports(prev => ({ ...prev, [employeeId]: report }));
|
||||
// Persist to localStorage
|
||||
demoStorage.saveEmployeeReport(orgId, employeeId, report);
|
||||
return;
|
||||
}
|
||||
const ref = doc(db, 'orgs', orgId, 'reports', employeeId);
|
||||
await setDoc(ref, report, { merge: true });
|
||||
};
|
||||
|
||||
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 })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create invite: ${response.status}`);
|
||||
}
|
||||
|
||||
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) => {
|
||||
if (!isFirebaseConfigured) {
|
||||
return reportVersions[employeeId] || [];
|
||||
}
|
||||
const col = collection(db, 'orgs', orgId, 'reports', employeeId, 'versions');
|
||||
const snap = await getDocs(col);
|
||||
const arr: Array<{ id: string; createdAt: number; report: Report }> = [];
|
||||
snap.forEach(d => {
|
||||
const data = d.data() as any;
|
||||
arr.push({ id: d.id, createdAt: data.createdAt ?? 0, report: data.report as Report });
|
||||
});
|
||||
return arr.sort((a, b) => b.createdAt - a.createdAt);
|
||||
};
|
||||
|
||||
const saveReportVersion = async (employeeId: string, report: Report) => {
|
||||
const version = { id: Date.now().toString(), createdAt: Date.now(), report };
|
||||
if (!isFirebaseConfigured) {
|
||||
setReportVersions(prev => ({ ...prev, [employeeId]: [version, ...(prev[employeeId] || [])] }));
|
||||
return;
|
||||
}
|
||||
const ref = doc(db, 'orgs', orgId, 'reports', employeeId, 'versions', version.id);
|
||||
await setDoc(ref, { createdAt: version.createdAt, report });
|
||||
};
|
||||
|
||||
const acceptInvite = async (code: string) => {
|
||||
if (!code) return;
|
||||
|
||||
if (!isFirebaseConfigured) {
|
||||
// Demo mode: mark invite as used
|
||||
demoStorage.markInviteUsed(code);
|
||||
return;
|
||||
}
|
||||
|
||||
const inviteRef = doc(db, 'orgs', orgId, 'invites', code);
|
||||
const snap = await getDoc(inviteRef);
|
||||
if (!snap.exists()) return;
|
||||
const data = snap.data() as any;
|
||||
// Minimal: mark accepted
|
||||
await setDoc(inviteRef, { ...data, acceptedAt: Date.now() }, { merge: true });
|
||||
};
|
||||
|
||||
const saveCompanyReport = async (summary: string) => {
|
||||
const id = Date.now().toString();
|
||||
const createdAt = Date.now();
|
||||
if (!isFirebaseConfigured) {
|
||||
const reportData = { id, createdAt, summary };
|
||||
setCompanyReports(prev => [reportData, ...prev]);
|
||||
// Persist to localStorage (note: this method stores simple reports)
|
||||
return;
|
||||
}
|
||||
const ref = doc(db, 'orgs', orgId, 'companyReports', id);
|
||||
await setDoc(ref, { createdAt, summary });
|
||||
};
|
||||
|
||||
const getCompanyReportHistory = async () => {
|
||||
if (!isFirebaseConfigured) {
|
||||
return companyReports;
|
||||
}
|
||||
const col = collection(db, 'orgs', orgId, 'companyReports');
|
||||
const snap = await getDocs(col);
|
||||
const arr: Array<{ id: string; createdAt: number; summary: string }> = [];
|
||||
snap.forEach(d => {
|
||||
const data = d.data() as any;
|
||||
arr.push({ id: d.id, createdAt: data.createdAt ?? 0, summary: data.summary ?? '' });
|
||||
});
|
||||
return arr.sort((a, b) => b.createdAt - a.createdAt);
|
||||
};
|
||||
|
||||
const seedInitialData = async () => {
|
||||
if (!isFirebaseConfigured) {
|
||||
// Start with empty employee list for clean demo experience
|
||||
setEmployees([]);
|
||||
setSubmissions({ [SUBMISSIONS_DATA.employeeId]: SUBMISSIONS_DATA });
|
||||
setReports({ [REPORT_DATA.employeeId]: REPORT_DATA });
|
||||
setFullCompanyReports([SAMPLE_COMPANY_REPORT]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Start with clean slate - let users invite their own employees
|
||||
// (Removed automatic seeding per user feedback)
|
||||
};
|
||||
|
||||
const saveFullCompanyReport = async (report: CompanyReport) => {
|
||||
if (!isFirebaseConfigured) {
|
||||
setFullCompanyReports(prev => [report, ...prev]);
|
||||
// Persist to localStorage
|
||||
demoStorage.saveCompanyReport(orgId, report);
|
||||
return;
|
||||
}
|
||||
const ref = doc(db, 'orgs', orgId, 'fullCompanyReports', report.id);
|
||||
await setDoc(ref, report);
|
||||
};
|
||||
|
||||
const getFullCompanyReportHistory = async (): Promise<CompanyReport[]> => {
|
||||
if (!isFirebaseConfigured) {
|
||||
return fullCompanyReports;
|
||||
}
|
||||
const col = collection(db, 'orgs', orgId, 'fullCompanyReports');
|
||||
const snap = await getDocs(col);
|
||||
const arr: CompanyReport[] = [];
|
||||
snap.forEach(d => {
|
||||
arr.push({ id: d.id, ...d.data() } as CompanyReport);
|
||||
});
|
||||
return arr.sort((a, b) => b.createdAt - a.createdAt);
|
||||
};
|
||||
|
||||
const generateCompanyReport = async (): Promise<CompanyReport> => {
|
||||
// Generate comprehensive company report based on current data
|
||||
const totalEmployees = employees.length;
|
||||
const submittedEmployees = Object.keys(submissions).length;
|
||||
const submissionRate = totalEmployees > 0 ? (submittedEmployees / totalEmployees) * 100 : 0;
|
||||
|
||||
// Department breakdown
|
||||
const deptMap = new Map<string, number>();
|
||||
employees.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[] = [];
|
||||
|
||||
reportValues.forEach(report => {
|
||||
if (report.strengths) {
|
||||
organizationalStrengths.push(...report.strengths);
|
||||
}
|
||||
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 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(', ')}.`
|
||||
};
|
||||
|
||||
await saveFullCompanyReport(report);
|
||||
return report;
|
||||
};
|
||||
|
||||
const generateCompanyWiki = async (orgOverride?: OrgData): Promise<CompanyReport> => {
|
||||
const orgData = orgOverride || org;
|
||||
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');
|
||||
const payload = await res.json();
|
||||
const data: CompanyReport = payload.report || payload; // backward compatibility
|
||||
await saveFullCompanyReport(data);
|
||||
return data;
|
||||
} catch (e) {
|
||||
console.error('generateCompanyWiki error, falling back to local synthetic:', e);
|
||||
return generateCompanyReport();
|
||||
}
|
||||
};
|
||||
|
||||
const isOwner = (employeeId?: string): boolean => {
|
||||
const currentEmployee = employeeId ? employees.find(e => e.id === employeeId) :
|
||||
employees.find(e => e.email === user?.email);
|
||||
return currentEmployee?.isOwner === true;
|
||||
};
|
||||
|
||||
const getEmployeeReport = async (employeeId: string) => {
|
||||
try {
|
||||
if (isFirebaseConfigured && user) {
|
||||
// Firebase implementation
|
||||
const reportDoc = await getDoc(doc(db, 'organizations', orgId, 'employeeReports', employeeId));
|
||||
if (reportDoc.exists()) {
|
||||
return { success: true, report: reportDoc.data() };
|
||||
}
|
||||
return { success: false, error: 'Report not found' };
|
||||
} else {
|
||||
// Demo mode - call API
|
||||
const response = await fetch(`${API_URL}/api/employee-report/${employeeId}`);
|
||||
const result = await response.json();
|
||||
return result;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching employee report:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
};
|
||||
|
||||
const getEmployeeReports = async () => {
|
||||
try {
|
||||
if (isFirebaseConfigured && user) {
|
||||
// Firebase implementation
|
||||
const reportsSnapshot = await getDocs(collection(db, 'organizations', orgId, 'employeeReports'));
|
||||
const reports = reportsSnapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
|
||||
return { success: true, reports };
|
||||
} else {
|
||||
// Demo mode - call API
|
||||
const response = await fetch(`${API_URL}/api/employee-reports`);
|
||||
const result = await response.json();
|
||||
return result;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching employee reports:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
};
|
||||
|
||||
const value = {
|
||||
org,
|
||||
orgId,
|
||||
employees,
|
||||
submissions,
|
||||
reports,
|
||||
upsertOrg,
|
||||
saveReport,
|
||||
inviteEmployee,
|
||||
getReportVersions,
|
||||
saveReportVersion,
|
||||
acceptInvite,
|
||||
saveCompanyReport,
|
||||
getCompanyReportHistory,
|
||||
saveFullCompanyReport,
|
||||
getFullCompanyReportHistory,
|
||||
generateCompanyReport,
|
||||
generateCompanyWiki,
|
||||
seedInitialData,
|
||||
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 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 }]);
|
||||
return json;
|
||||
} catch (e) {
|
||||
console.error('issueInviteViaApi error', e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
getInviteStatus: async (code: string) => {
|
||||
if (!isFirebaseConfigured) {
|
||||
// Demo mode: check localStorage first, then server
|
||||
const invite = demoStorage.getInvite(code);
|
||||
if (invite) {
|
||||
return { used: invite.used, employee: invite.employee };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/getInvitationStatus?code=${code}`);
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
console.error('getInviteStatus error', e);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
consumeInvite: async (code: string) => {
|
||||
if (!isFirebaseConfigured) {
|
||||
// Demo mode: mark invite as used in localStorage and update state
|
||||
const invite = demoStorage.getInvite(code);
|
||||
if (invite && !invite.used) {
|
||||
demoStorage.markInviteUsed(code);
|
||||
// Ensure employee is in the list with proper typing
|
||||
const convertedEmployee: Employee = {
|
||||
id: invite.employee.id,
|
||||
name: invite.employee.name,
|
||||
email: invite.employee.email,
|
||||
initials: invite.employee.name ? invite.employee.name.split(' ').map(n => n[0]).join('').toUpperCase() : invite.employee.email.substring(0, 2).toUpperCase(),
|
||||
department: invite.employee.department,
|
||||
role: invite.employee.role
|
||||
};
|
||||
setEmployees(prev => prev.find(e => e.id === invite.employee.id) ? prev : [...prev, convertedEmployee]);
|
||||
return { employee: convertedEmployee };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/consumeInvitation?code=${code}`, { method: 'POST' });
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json();
|
||||
// Mark employee as active (could set a flag later)
|
||||
setEmployees(prev => prev.find(e => e.id === json.employee.id) ? prev : [...prev, json.employee]);
|
||||
return json;
|
||||
} catch (e) {
|
||||
console.error('consumeInvite error', e);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
submitEmployeeAnswers: async (employeeId: string, answers: Record<string, string>) => {
|
||||
if (!isFirebaseConfigured) {
|
||||
// Demo mode: save to localStorage and call server endpoint
|
||||
try {
|
||||
const submission = {
|
||||
employeeId,
|
||||
orgId,
|
||||
answers,
|
||||
createdAt: Date.now()
|
||||
};
|
||||
|
||||
// Save to localStorage for persistence
|
||||
demoStorage.saveSubmission(submission);
|
||||
|
||||
// Also call Cloud Function for processing with 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');
|
||||
|
||||
// Update local state for UI with proper typing
|
||||
const convertedSubmission: Submission = {
|
||||
employeeId,
|
||||
answers: Object.entries(answers).map(([question, answer]) => ({
|
||||
question,
|
||||
answer
|
||||
}))
|
||||
};
|
||||
setSubmissions(prev => ({ ...prev, [employeeId]: convertedSubmission }));
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('submitEmployeeAnswers error', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Firebase mode: save to Firestore
|
||||
try {
|
||||
const ref = doc(db, 'orgs', orgId, 'submissions', employeeId);
|
||||
await setDoc(ref, { ...answers, createdAt: Date.now() }, { merge: true });
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('submitEmployeeAnswers error', e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
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');
|
||||
const json = await res.json();
|
||||
if (json.report) {
|
||||
setReports(prev => ({ ...prev, [employee.id]: json.report }));
|
||||
return json.report as Report;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('generateEmployeeReport error', e);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
getEmployeeReport: async (employeeId: string) => {
|
||||
try {
|
||||
if (isFirebaseConfigured && user) {
|
||||
// Firebase implementation
|
||||
const reportDoc = await getDoc(doc(db, 'organizations', orgId, 'employeeReports', employeeId));
|
||||
if (reportDoc.exists()) {
|
||||
return { success: true, report: reportDoc.data() };
|
||||
}
|
||||
return { success: false, error: 'Report not found' };
|
||||
} else {
|
||||
// Demo mode - call Cloud Function
|
||||
const response = await fetch(`${API_URL}/generateEmployeeReport?employeeId=${employeeId}`);
|
||||
const result = await response.json();
|
||||
return result;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching employee report:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
getEmployeeReports: async () => {
|
||||
try {
|
||||
if (isFirebaseConfigured && user) {
|
||||
// Firebase implementation
|
||||
const reportsSnapshot = await getDocs(collection(db, 'organizations', orgId, 'employeeReports'));
|
||||
const reports = reportsSnapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
|
||||
return { success: true, reports };
|
||||
} else {
|
||||
// Demo mode - call Cloud Function
|
||||
const response = await fetch(`${API_URL}/generateEmployeeReport`);
|
||||
const result = await response.json();
|
||||
return result;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching employee reports:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<OrgContext.Provider value={value}>
|
||||
{children}
|
||||
</OrgContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useOrg = () => {
|
||||
const ctx = useContext(OrgContext);
|
||||
if (!ctx) throw new Error('useOrg must be used within OrgProvider');
|
||||
return ctx;
|
||||
};
|
||||
53
contexts/ThemeContext.tsx
Normal file
53
contexts/ThemeContext.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
|
||||
import React, { createContext, useState, useEffect, useContext, ReactNode } from 'react';
|
||||
import { Theme } from '../types';
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
export const ThemeProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
try {
|
||||
const storedTheme = localStorage.getItem('theme');
|
||||
return (storedTheme as Theme) || Theme.System;
|
||||
} catch (error) {
|
||||
console.warn('Could not access localStorage. Defaulting to system theme.', error);
|
||||
return Theme.System;
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
const systemIsDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
if (theme === Theme.System) {
|
||||
root.classList.toggle('dark', systemIsDark);
|
||||
} else {
|
||||
root.classList.toggle('dark', theme === Theme.Dark);
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem('theme', theme);
|
||||
} catch (error) {
|
||||
console.warn(`Could not save theme to localStorage: ${error}`);
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useTheme = (): ThemeContextType => {
|
||||
const context = useContext(ThemeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
300
contexts/UserOrganizationsContext.tsx
Normal file
300
contexts/UserOrganizationsContext.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { useAuth } from './AuthContext';
|
||||
import { isFirebaseConfigured } from '../services/firebase';
|
||||
import { API_URL } from '../constants';
|
||||
import { demoStorage } from '../services/demoStorage';
|
||||
|
||||
interface UserOrganization {
|
||||
orgId: string;
|
||||
name: string;
|
||||
role: 'owner' | 'admin' | 'employee';
|
||||
onboardingCompleted: boolean;
|
||||
joinedAt: number;
|
||||
}
|
||||
|
||||
interface UserOrganizationsContextType {
|
||||
organizations: UserOrganization[];
|
||||
selectedOrgId: string | null;
|
||||
loading: boolean;
|
||||
selectOrganization: (orgId: string) => void;
|
||||
createOrganization: (name: string) => Promise<{ orgId: string; requiresSubscription?: boolean }>;
|
||||
joinOrganization: (inviteCode: string) => Promise<string>;
|
||||
refreshOrganizations: () => Promise<void>;
|
||||
createCheckoutSession: (orgId: string, userEmail: string) => Promise<{ sessionUrl: string; sessionId: string }>;
|
||||
getSubscriptionStatus: (orgId: string) => Promise<any>;
|
||||
}
|
||||
|
||||
const UserOrganizationsContext = createContext<UserOrganizationsContextType | undefined>(undefined);
|
||||
|
||||
export const UserOrganizationsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { user } = useAuth();
|
||||
const [organizations, setOrganizations] = useState<UserOrganization[]>([]);
|
||||
const [selectedOrgId, setSelectedOrgId] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Load user's organizations
|
||||
const loadOrganizations = async () => {
|
||||
if (!user) {
|
||||
setOrganizations([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!isFirebaseConfigured) {
|
||||
// Demo mode - fetch from server API
|
||||
const response = await fetch(`${API_URL}/api/user/${user.uid}/organizations`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setOrganizations(data.organizations || []);
|
||||
} else {
|
||||
console.error('Failed to load organizations:', response.status);
|
||||
setOrganizations([]);
|
||||
}
|
||||
} else {
|
||||
// Firebase mode - fetch from Cloud Functions
|
||||
const response = await fetch(`${API_URL}/getUserOrganizations?userId=${user.uid}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setOrganizations(data.organizations || []);
|
||||
} else {
|
||||
console.error('Failed to load organizations:', response.status);
|
||||
setOrganizations([]);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load organizations:', error);
|
||||
setOrganizations([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize selected org from session storage
|
||||
useEffect(() => {
|
||||
const savedOrgId = sessionStorage.getItem('auditly_selected_org');
|
||||
if (savedOrgId) {
|
||||
setSelectedOrgId(savedOrgId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load organizations when user changes
|
||||
useEffect(() => {
|
||||
loadOrganizations();
|
||||
}, [user]);
|
||||
|
||||
const selectOrganization = (orgId: string) => {
|
||||
setSelectedOrgId(orgId);
|
||||
sessionStorage.setItem('auditly_selected_org', orgId);
|
||||
};
|
||||
|
||||
const createOrganization = async (name: string): Promise<{ orgId: string; requiresSubscription?: boolean }> => {
|
||||
if (!user) throw new Error('User not authenticated');
|
||||
|
||||
try {
|
||||
let newOrg: UserOrganization;
|
||||
let requiresSubscription = false;
|
||||
|
||||
if (!isFirebaseConfigured) {
|
||||
// Demo mode - use server API
|
||||
const response = await fetch(`${API_URL}/api/organizations`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, userId: user.uid })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create organization: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
newOrg = {
|
||||
orgId: data.orgId,
|
||||
name: data.name,
|
||||
role: data.role,
|
||||
onboardingCompleted: data.onboardingCompleted,
|
||||
joinedAt: data.joinedAt
|
||||
};
|
||||
|
||||
setOrganizations(prev => [...prev, newOrg]);
|
||||
} else {
|
||||
// Firebase mode - use Cloud Function
|
||||
const response = await fetch(`${API_URL}/createOrganization`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, userId: user.uid })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create organization: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
newOrg = {
|
||||
orgId: data.orgId,
|
||||
name: data.name,
|
||||
role: data.role,
|
||||
onboardingCompleted: data.onboardingCompleted,
|
||||
joinedAt: data.joinedAt
|
||||
};
|
||||
|
||||
requiresSubscription = data.requiresSubscription || false;
|
||||
setOrganizations(prev => [...prev, newOrg]);
|
||||
}
|
||||
|
||||
return { orgId: newOrg.orgId, requiresSubscription };
|
||||
} catch (error) {
|
||||
console.error('Failed to create organization:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const joinOrganization = async (inviteCode: string): Promise<string> => {
|
||||
if (!user) throw new Error('User not authenticated');
|
||||
|
||||
try {
|
||||
if (!isFirebaseConfigured) {
|
||||
// Demo mode - use server API to get and consume invite
|
||||
const inviteStatusRes = await fetch(`/api/invitations/${inviteCode}`);
|
||||
if (!inviteStatusRes.ok) {
|
||||
throw new Error('Invalid or expired invite code');
|
||||
}
|
||||
|
||||
const inviteData = await inviteStatusRes.json();
|
||||
if (inviteData.used) {
|
||||
throw new Error('Invite code has already been used');
|
||||
}
|
||||
|
||||
// Consume the invite
|
||||
const consumeRes = await fetch(`/api/invitations/${inviteCode}/consume`, {
|
||||
method: 'POST'
|
||||
});
|
||||
if (!consumeRes.ok) {
|
||||
throw new Error('Failed to consume invite');
|
||||
}
|
||||
|
||||
const consumedData = await consumeRes.json();
|
||||
const orgId = consumedData.orgId;
|
||||
|
||||
// Get organization data (this might be from localStorage for demo mode)
|
||||
const orgData = demoStorage.getOrganization(orgId);
|
||||
if (!orgData) {
|
||||
throw new Error('Organization not found');
|
||||
}
|
||||
|
||||
const userOrg: UserOrganization = {
|
||||
orgId: orgId,
|
||||
name: orgData.name,
|
||||
role: 'employee',
|
||||
onboardingCompleted: orgData.onboardingCompleted || false,
|
||||
joinedAt: Date.now()
|
||||
};
|
||||
|
||||
setOrganizations(prev => [...prev, userOrg]);
|
||||
return orgId;
|
||||
} else {
|
||||
// Firebase mode - use Cloud Function
|
||||
const response = await fetch(`${API_URL}/joinOrganization`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userId: user.uid, inviteCode })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to join organization');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const userOrg: UserOrganization = {
|
||||
orgId: data.orgId,
|
||||
name: data.name,
|
||||
role: data.role,
|
||||
onboardingCompleted: data.onboardingCompleted,
|
||||
joinedAt: data.joinedAt
|
||||
};
|
||||
|
||||
setOrganizations(prev => [...prev, userOrg]);
|
||||
return data.orgId;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to join organization:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const refreshOrganizations = async () => {
|
||||
setLoading(true);
|
||||
await loadOrganizations();
|
||||
};
|
||||
|
||||
const createCheckoutSession = async (orgId: string, userEmail: string): Promise<{ sessionUrl: string; sessionId: string }> => {
|
||||
if (!user) throw new Error('User not authenticated');
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/createCheckoutSession`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
orgId,
|
||||
userId: user.uid,
|
||||
userEmail
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to create checkout session');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
sessionUrl: data.sessionUrl,
|
||||
sessionId: data.sessionId
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to create checkout session:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const getSubscriptionStatus = async (orgId: string) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/getSubscriptionStatus?orgId=${orgId}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get subscription status');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Failed to get subscription status:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<UserOrganizationsContext.Provider value={{
|
||||
organizations,
|
||||
selectedOrgId,
|
||||
loading,
|
||||
selectOrganization,
|
||||
createOrganization,
|
||||
joinOrganization,
|
||||
refreshOrganizations,
|
||||
createCheckoutSession,
|
||||
getSubscriptionStatus
|
||||
}}>
|
||||
{children}
|
||||
</UserOrganizationsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useUserOrganizations = () => {
|
||||
const context = useContext(UserOrganizationsContext);
|
||||
if (!context) {
|
||||
throw new Error('useUserOrganizations must be used within UserOrganizationsProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
1064
functions/bun.lock
Normal file
1064
functions/bun.lock
Normal file
File diff suppressed because it is too large
Load Diff
1454
functions/index.js
Normal file
1454
functions/index.js
Normal file
File diff suppressed because it is too large
Load Diff
25
functions/package.json
Normal file
25
functions/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "functions",
|
||||
"description": "Cloud Functions for Firebase",
|
||||
"scripts": {
|
||||
"serve": "firebase emulators:start --only functions",
|
||||
"shell": "firebase functions:shell",
|
||||
"start": "npm run shell",
|
||||
"deploy": "firebase deploy --only functions",
|
||||
"logs": "firebase functions:log"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18"
|
||||
},
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"firebase-admin": "^13.4.0",
|
||||
"firebase-functions": "^4.5.0",
|
||||
"openai": "^5.12.2",
|
||||
"stripe": "^18.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"firebase-functions-test": "^3.1.0"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
182
index.css
Normal file
182
index.css
Normal file
@@ -0,0 +1,182 @@
|
||||
@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 */
|
||||
--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 */
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Dark theme variables - using new Figma color palette */
|
||||
--background-primary : #0A0D12;
|
||||
/* Dark 7 */
|
||||
--background-secondary : #181D27;
|
||||
/* Dark 6 */
|
||||
--background-tertiary : #252B37;
|
||||
/* Dark 5 */
|
||||
--text-primary : #FDFDFD;
|
||||
/* Gray 6 */
|
||||
--text-secondary : #D5D7DA;
|
||||
/* Gray 2 */
|
||||
--text-tertiary : #A4A7AE;
|
||||
/* Gray 1 */
|
||||
--accent : #5E48FC;
|
||||
/* Brand Main */
|
||||
--accent-hover : #6B56FF;
|
||||
/* Slightly lighter brand */
|
||||
--accent-text : #FFFFFF;
|
||||
/* Base White */
|
||||
--border-color : #535862;
|
||||
/* Dark 3 */
|
||||
--border-light : #414651;
|
||||
/* Dark 4 */
|
||||
--sidebar-bg : #181D27;
|
||||
/* Dark 6 */
|
||||
--sidebar-text : #D5D7DA;
|
||||
/* Gray 2 */
|
||||
--sidebar-active-bg : #5E48FC;
|
||||
/* Brand Main */
|
||||
--sidebar-active-text : #FFFFFF;
|
||||
/* Base White */
|
||||
--input-bg : #252B37;
|
||||
/* Dark 5 */
|
||||
--input-border : #414651;
|
||||
/* Dark 4 */
|
||||
--input-placeholder : #717680;
|
||||
/* Dark 2 */
|
||||
--button-secondary-bg : #414651;
|
||||
/* Dark 4 */
|
||||
--button-secondary-hover: #535862;
|
||||
/* Dark 3 */
|
||||
--status-red : #F63D68;
|
||||
/* Other Red */
|
||||
--status-green : #3CCB7F;
|
||||
/* Other Green */
|
||||
--status-orange : #FF4405;
|
||||
/* Other Orange */
|
||||
--status-yellow : #FEEE95;
|
||||
/* Other Yellow */
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing : antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color : var(--background-primary);
|
||||
color : var(--text-primary);
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Custom slider styling */
|
||||
.slider {
|
||||
-webkit-appearance: none;
|
||||
appearance : none;
|
||||
background : var(--border-color);
|
||||
outline : none;
|
||||
border-radius : 8px;
|
||||
transition : all 0.2s ease;
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance : none;
|
||||
width : 20px;
|
||||
height : 20px;
|
||||
border-radius : 50%;
|
||||
background : var(--accent);
|
||||
cursor : pointer;
|
||||
border : 2px solid white;
|
||||
box-shadow : 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
transition : all 0.2s ease;
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb:hover {
|
||||
background: var(--accent-hover);
|
||||
transform : scale(1.1);
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb {
|
||||
width : 20px;
|
||||
height : 20px;
|
||||
border-radius: 50%;
|
||||
background : var(--accent);
|
||||
cursor : pointer;
|
||||
border : 2px solid white;
|
||||
box-shadow : 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
transition : all 0.2s ease;
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb:hover {
|
||||
background: var(--accent-hover);
|
||||
transform : scale(1.1);
|
||||
}
|
||||
|
||||
/* Radio button styling */
|
||||
input[type="radio"] {
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Focus states */
|
||||
.slider:focus {
|
||||
box-shadow: 0 0 0 3px rgba(94, 72, 252, 0.3);
|
||||
}
|
||||
|
||||
input[type="radio"]:focus {
|
||||
box-shadow: 0 0 0 3px rgba(94, 72, 252, 0.3);
|
||||
}
|
||||
93
index.html
Normal file
93
index.html
Normal file
@@ -0,0 +1,93 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Auditly</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
:root {
|
||||
--background-primary: #F9FAFB;
|
||||
/* gray-50 */
|
||||
--background-secondary: #FFFFFF;
|
||||
/* white */
|
||||
--background-tertiary: #F3F4F6;
|
||||
/* gray-100 */
|
||||
--text-primary: #1F2937;
|
||||
/* gray-800 */
|
||||
--text-secondary: #6B7280;
|
||||
/* gray-500 */
|
||||
--text-tertiary: #9CA3AF;
|
||||
/* gray-400 */
|
||||
--border-color: #E5E7EB;
|
||||
/* gray-200 */
|
||||
--accent: #2563EB;
|
||||
/* blue-600 */
|
||||
--accent-hover: #1D4ED8;
|
||||
/* blue-700 */
|
||||
--accent-text: #FFFFFF;
|
||||
/* white */
|
||||
--sidebar-bg: #FFFFFF;
|
||||
/* white */
|
||||
--sidebar-active-bg: #F3F4F6;
|
||||
/* gray-100 */
|
||||
--sidebar-text: #374151;
|
||||
/* gray-700 */
|
||||
--sidebar-active-text: #1F2937;
|
||||
/* gray-800 */
|
||||
--sidebar-icon: #9CA3AF;
|
||||
/* gray-400 */
|
||||
}
|
||||
|
||||
html.dark {
|
||||
--background-primary: #111827;
|
||||
/* gray-900 */
|
||||
--background-secondary: #1F2937;
|
||||
/* gray-800 */
|
||||
--background-tertiary: #374151;
|
||||
/* gray-700 */
|
||||
--text-primary: #F9FAFB;
|
||||
/* gray-50 */
|
||||
--text-secondary: #9CA3AF;
|
||||
/* gray-400 */
|
||||
--text-tertiary: #6B7280;
|
||||
/* gray-500 */
|
||||
--border-color: #374151;
|
||||
/* gray-700 */
|
||||
--accent: #3B82F6;
|
||||
/* blue-500 */
|
||||
--accent-hover: #2563EB;
|
||||
/* blue-600 */
|
||||
--accent-text: #FFFFFF;
|
||||
/* white */
|
||||
--sidebar-bg: #1F2937;
|
||||
/* gray-800 */
|
||||
--sidebar-active-bg: #374151;
|
||||
/* gray-700 */
|
||||
--sidebar-text: #D1D5DB;
|
||||
/* gray-300 */
|
||||
--sidebar-active-text: #FFFFFF;
|
||||
/* white */
|
||||
--sidebar-icon: #9CA3AF;
|
||||
/* gray-400 */
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--background-primary);
|
||||
color: var(--text-primary);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
</style>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/index.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
17
index.tsx
Normal file
17
index.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error("Could not find root element to mount to");
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
3028
package-lock.json
generated
Normal file
3028
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
50
package.json
Normal file
50
package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "auditly",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently -k \"bun run dev:firebase\" \"bun run dev:client\"",
|
||||
"dev:client": "vite",
|
||||
"dev:server": "bun --watch server/index.js",
|
||||
"dev:firebase": "firebase emulators:start --only functions,firestore,database",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"deploy:functions": "firebase deploy --only functions",
|
||||
"deploy:firestore": "firebase deploy --only firestore",
|
||||
"deploy:database": "firebase deploy --only database",
|
||||
"emulators": "firebase emulators:start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "^1.14.0",
|
||||
"@tailwindcss/postcss": "^4.1.12",
|
||||
"@tailwindcss/vite": "^4.1.12",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.1.0",
|
||||
"firebase": "^12.1.0",
|
||||
"firebase-admin": "^13.4.0",
|
||||
"firebase-functions": "^6.4.0",
|
||||
"lucide-react": "^0.539.0",
|
||||
"openai": "^4.104.0",
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/dotenv": "^8.2.3",
|
||||
"@types/express": "^4.17.23",
|
||||
"@types/node": "^22.17.1",
|
||||
"@types/react": "^19.1.10",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"concurrently": "^9.2.0",
|
||||
"dotenv": "^17.2.1",
|
||||
"tsx": "^4.20.4",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.1.2"
|
||||
}
|
||||
}
|
||||
137
pages/Chat.tsx
Normal file
137
pages/Chat.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Card, Button } from '../components/UiKit';
|
||||
import { useOrg } from '../contexts/OrgContext';
|
||||
import { CHAT_STARTERS } from '../constants';
|
||||
|
||||
const Chat: React.FC = () => {
|
||||
const { employees, reports, generateEmployeeReport } = useOrg();
|
||||
const [messages, setMessages] = useState<Array<{ id: string, role: 'user' | 'assistant', text: string }>>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedEmployeeId, setSelectedEmployeeId] = useState<string>('');
|
||||
const selectedReport = selectedEmployeeId ? reports[selectedEmployeeId] : undefined;
|
||||
|
||||
const dynamicStarters = useMemo(() => {
|
||||
if (!selectedReport) return CHAT_STARTERS.slice(0, 4);
|
||||
const strengths = selectedReport.insights.strengths?.slice(0, 2) || [];
|
||||
const weaknesses = selectedReport.insights.weaknesses?.slice(0, 1) || [];
|
||||
const risk = selectedReport.retentionRisk;
|
||||
const starters: string[] = [];
|
||||
if (strengths[0]) starters.push(`How can we further leverage ${strengths[0]} for cross-team impact?`);
|
||||
if (weaknesses[0]) starters.push(`What is an actionable plan to address ${weaknesses[0]} this quarter?`);
|
||||
if (risk) starters.push(`What factors contribute to ${selectedReport.employeeId}'s ${risk} retention risk?`);
|
||||
starters.push(`Is ${selectedReport.employeeId} a candidate for expanded scope or leadership?`);
|
||||
while (starters.length < 4) starters.push(CHAT_STARTERS[starters.length] || 'Provide an organizational insight.');
|
||||
return starters.slice(0, 4);
|
||||
}, [selectedReport]);
|
||||
|
||||
const handleSend = async (message?: string) => {
|
||||
const textToSend = message || input;
|
||||
if (!textToSend.trim()) return;
|
||||
|
||||
const userMessage = { id: Date.now().toString(), role: 'user' as const, text: textToSend };
|
||||
setMessages(prev => [...prev, userMessage]);
|
||||
setInput('');
|
||||
setIsLoading(true);
|
||||
|
||||
// Simulate AI response (placeholder for server /api/chat usage)
|
||||
setTimeout(() => {
|
||||
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}".`
|
||||
};
|
||||
setMessages(prev => [...prev, aiResponse]);
|
||||
setIsLoading(false);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto h-full flex flex-col">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-[--text-primary]">Chat with AI</h1>
|
||||
<p className="text-[--text-secondary] mt-1">Ask questions about your employees and organization</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex flex-col md:flex-row md:items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-[--text-secondary]">Focus Employee:</label>
|
||||
<select
|
||||
className="px-2 py-1 text-sm bg-[--background-secondary] border border-[--border-color] rounded"
|
||||
value={selectedEmployeeId}
|
||||
onChange={e => setSelectedEmployeeId(e.target.value)}
|
||||
>
|
||||
<option value="">(Organization)</option>
|
||||
{employees.map(emp => <option key={emp.id} value={emp.id}>{emp.name}</option>)}
|
||||
</select>
|
||||
{selectedEmployeeId && !selectedReport && (
|
||||
<Button size="sm" variant="secondary" onClick={() => generateEmployeeReport(employees.find(e => e.id === selectedEmployeeId)!)}>
|
||||
Generate Report
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{messages.length === 0 && (
|
||||
<Card className="mb-6">
|
||||
<h3 className="text-lg font-semibold text-[--text-primary] mb-4">Get started with these questions:</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{dynamicStarters.map((starter, idx) => (
|
||||
<Button
|
||||
key={idx}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="text-left justify-start"
|
||||
onClick={() => handleSend(starter)}
|
||||
>
|
||||
{starter}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto mb-4 space-y-4">
|
||||
{messages.map(message => (
|
||||
<div key={message.id} className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`max-w-[70%] p-4 rounded-lg ${message.role === 'user'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-[--background-secondary] text-[--text-primary]'
|
||||
}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isLoading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-[--background-secondary] text-[--text-primary] p-4 rounded-lg">
|
||||
<div className="flex space-x-1">
|
||||
<div className="w-2 h-2 bg-gray-500 rounded-full animate-bounce"></div>
|
||||
<div className="w-2 h-2 bg-gray-500 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
|
||||
<div className="w-2 h-2 bg-gray-500 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card padding="sm">
|
||||
<div className="flex space-x-2">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleSend()}
|
||||
placeholder="Ask about employees, reports, or company insights..."
|
||||
className="flex-1 px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<Button onClick={() => handleSend()} disabled={isLoading || !input.trim()}>
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Chat;
|
||||
252
pages/CompanyWiki.tsx
Normal file
252
pages/CompanyWiki.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useOrg } from '../contexts/OrgContext';
|
||||
import { Card, Button } from '../components/UiKit';
|
||||
import { FigmaAlert } from '../components/figma/FigmaAlert';
|
||||
import { CompanyReport } from '../types';
|
||||
import RadarPerformanceChart from '../components/charts/RadarPerformanceChart';
|
||||
|
||||
const CompanyWiki: React.FC = () => {
|
||||
const { org, employees, getFullCompanyReportHistory, generateCompanyWiki } = useOrg();
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [companyReport, setCompanyReport] = useState<CompanyReport | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const history = await getFullCompanyReportHistory();
|
||||
if (history.length) setCompanyReport(history[0]);
|
||||
} catch (e) {
|
||||
console.error('Failed loading company report history', e);
|
||||
}
|
||||
})();
|
||||
}, [getFullCompanyReportHistory]);
|
||||
|
||||
const generateReport = async () => {
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
try {
|
||||
const report = await generateCompanyWiki();
|
||||
setCompanyReport(report);
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
setError('Failed to generate company wiki');
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-6xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-[--text-primary]">Company Wiki</h1>
|
||||
<p className="text-[--text-secondary] mt-1">
|
||||
Organization overview and insights
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-[--text-primary] mb-3">Company Info</h3>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<span className="text-sm text-[--text-secondary]">Name:</span>
|
||||
<div className="font-medium text-[--text-primary]">{org?.name}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-[--text-secondary]">Industry:</span>
|
||||
<div className="font-medium text-[--text-primary]">{org?.industry}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-[--text-secondary]">Size:</span>
|
||||
<div className="font-medium text-[--text-primary]">{org?.size}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-[--text-primary] mb-3">Team Stats</h3>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<span className="text-sm text-[--text-secondary]">Total Employees:</span>
|
||||
<div className="font-medium text-[--text-primary]">{employees.length}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-[--text-secondary]">Departments:</span>
|
||||
<div className="font-medium text-[--text-primary]">
|
||||
{[...new Set(employees.map(e => e.department))].length}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-[--text-secondary]">Roles:</span>
|
||||
<div className="font-medium text-[--text-primary]">
|
||||
{[...new Set(employees.map(e => e.role))].length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-[--text-primary] mb-3">Quick Actions</h3>
|
||||
<div className="space-y-3">
|
||||
<Button onClick={generateReport} disabled={isGenerating} className="w-full">
|
||||
{isGenerating ? 'Generating...' : companyReport ? 'Regenerate Company Wiki' : 'Generate Company Wiki'}
|
||||
</Button>
|
||||
{error && <FigmaAlert type="error" message={error} />}
|
||||
{!companyReport && !isGenerating && (
|
||||
<FigmaAlert type="info" message="No company wiki generated yet. Use the button above to create one." />
|
||||
)}
|
||||
<Button variant="secondary" className="w-full" disabled={!companyReport}>
|
||||
Export Data
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
{companyReport && (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<h3 className="text-xl font-semibold text-[--text-primary] mb-4">Executive Summary</h3>
|
||||
<p className="text-[--text-secondary] whitespace-pre-line mb-4">{companyReport.executiveSummary}</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="text-center p-4 bg-[--background-secondary] rounded-lg">
|
||||
<div className="text-2xl font-bold text-blue-500">{companyReport.overview.totalEmployees}</div>
|
||||
<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-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-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-sm text-[--text-secondary]">Risks</div>
|
||||
</div>
|
||||
</div>
|
||||
{companyReport.gradingOverview && (
|
||||
<div className="mt-6 p-4 bg-[--background-tertiary] rounded-lg">
|
||||
<RadarPerformanceChart
|
||||
title="Organizational Grading"
|
||||
data={companyReport.gradingOverview.map((g: any) => ({
|
||||
label: g.category || g.department || g.subject || 'Metric',
|
||||
value: g.value ?? g.averageScore ?? 0
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<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>)}
|
||||
</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>)}
|
||||
</ul>
|
||||
</Card>
|
||||
<Card>
|
||||
<h4 className="text-lg font-semibold text-[--text-primary] mb-3">Forward Plan</h4>
|
||||
<div>
|
||||
<h5 className="font-medium text-[--text-primary] text-sm mb-1">Goals</h5>
|
||||
<ul className="mb-2 list-disc list-inside text-[--text-secondary] text-sm space-y-1">
|
||||
{(companyReport.operatingPlan?.nextQuarterGoals || companyReport.forwardOperatingPlan?.quarterlyGoals || []).map((g: string, i: number) => <li key={i}>{g}</li>)}
|
||||
</ul>
|
||||
<h5 className="font-medium text-[--text-primary] text-sm mb-1">Resource Needs</h5>
|
||||
<ul className="mb-2 list-disc list-inside text-[--text-secondary] text-sm space-y-1">
|
||||
{(companyReport.operatingPlan?.resourceNeeds || companyReport.forwardOperatingPlan?.resourceNeeds || []).map((g: string, i: number) => <li key={i}>{g}</li>)}
|
||||
</ul>
|
||||
<h5 className="font-medium text-[--text-primary] text-sm mb-1">Risk Mitigation</h5>
|
||||
<ul className="list-disc list-inside text-[--text-secondary] text-sm space-y-1">
|
||||
{(companyReport.operatingPlan?.riskMitigation || companyReport.forwardOperatingPlan?.riskMitigation || []).map((g: string, i: number) => <li key={i}>{g}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</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">
|
||||
{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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{org?.description && (
|
||||
<Card className="mt-6">
|
||||
<h3 className="text-lg font-semibold text-[--text-primary] mb-3">About</h3>
|
||||
<p className="text-[--text-secondary]">{org.description}</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompanyWiki;
|
||||
215
pages/DebugEmployee.tsx
Normal file
215
pages/DebugEmployee.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import React from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useOrg } from '../contexts/OrgContext';
|
||||
import { Card } from '../components/UiKit';
|
||||
|
||||
const DebugEmployee: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const { employees, org } = useOrg();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[--background-primary] py-8 px-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="text-3xl font-bold text-[--text-primary] mb-8 text-center">
|
||||
Employee Debug Information
|
||||
</h1>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Current User Info */}
|
||||
<Card className="p-6">
|
||||
<h2 className="text-xl font-semibold text-[--text-primary] mb-4">
|
||||
Current User
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<span className="font-medium">Email:</span>
|
||||
<span className="ml-2 text-[--text-secondary]">{user?.email || 'Not logged in'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Display Name:</span>
|
||||
<span className="ml-2 text-[--text-secondary]">{user?.displayName || 'N/A'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">UID:</span>
|
||||
<span className="ml-2 text-[--text-secondary] text-xs break-all">{user?.uid || 'N/A'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Organization Info */}
|
||||
<Card className="p-6">
|
||||
<h2 className="text-xl font-semibold text-[--text-primary] mb-4">
|
||||
Organization
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<span className="font-medium">Name:</span>
|
||||
<span className="ml-2 text-[--text-secondary]">{org?.name || 'Not set'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Org ID:</span>
|
||||
<span className="ml-2 text-[--text-secondary] text-xs break-all">{org?.orgId || 'N/A'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Onboarding Complete:</span>
|
||||
<span className="ml-2 text-[--text-secondary]">{org?.onboardingCompleted ? 'Yes' : 'No'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Employee Matching Analysis */}
|
||||
<Card className="p-6 lg:col-span-2">
|
||||
<h2 className="text-xl font-semibold text-[--text-primary] mb-4">
|
||||
Employee Matching Analysis
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<span className="font-medium">Total Employees:</span>
|
||||
<span className="ml-2 text-[--text-secondary]">{employees.length}</span>
|
||||
</div>
|
||||
|
||||
{user?.email && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-medium text-[--text-primary]">Matching Results:</h3>
|
||||
|
||||
{/* Exact match */}
|
||||
<div className="pl-4">
|
||||
<span className="text-sm font-medium">Exact Email Match:</span>
|
||||
<span className="ml-2 text-[--text-secondary]">
|
||||
{employees.find(emp => emp.email === user.email) ? '✅ Found' : '❌ Not found'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Case insensitive match */}
|
||||
<div className="pl-4">
|
||||
<span className="text-sm font-medium">Case-Insensitive Match:</span>
|
||||
<span className="ml-2 text-[--text-secondary]">
|
||||
{employees.find(emp => emp.email?.toLowerCase() === user.email?.toLowerCase()) ? '✅ Found' : '❌ Not found'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Domain match */}
|
||||
<div className="pl-4">
|
||||
<span className="text-sm font-medium">Same Domain Match:</span>
|
||||
<span className="ml-2 text-[--text-secondary]">
|
||||
{(() => {
|
||||
const userDomain = user.email?.split('@')[1];
|
||||
const domainMatch = employees.find(emp => emp.email?.split('@')[1] === userDomain);
|
||||
return domainMatch ? `✅ Found: ${domainMatch.name} (${domainMatch.email})` : '❌ Not found';
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Username partial match */}
|
||||
<div className="pl-4">
|
||||
<span className="text-sm font-medium">Username Partial Match:</span>
|
||||
<span className="ml-2 text-[--text-secondary]">
|
||||
{(() => {
|
||||
const username = user.email?.split('@')[0];
|
||||
const partialMatch = employees.find(emp =>
|
||||
emp.email?.toLowerCase().includes(username?.toLowerCase() || '')
|
||||
);
|
||||
return partialMatch ? `✅ Found: ${partialMatch.name} (${partialMatch.email})` : '❌ Not found';
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* All Employees List */}
|
||||
<Card className="p-6 lg:col-span-2">
|
||||
<h2 className="text-xl font-semibold text-[--text-primary] mb-4">
|
||||
All Employees ({employees.length})
|
||||
</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
{employees.length === 0 ? (
|
||||
<p className="text-[--text-secondary] italic">No employees found</p>
|
||||
) : (
|
||||
employees.map((employee, index) => (
|
||||
<div key={employee.id} className="border border-[--border-color] rounded-lg p-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
|
||||
<div>
|
||||
<span className="font-medium">Name:</span>
|
||||
<span className="ml-2">{employee.name}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Email:</span>
|
||||
<span className="ml-2 text-sm">{employee.email || 'Not set'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Role:</span>
|
||||
<span className="ml-2 text-sm">{employee.role || 'Not set'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<span className="font-medium">ID:</span>
|
||||
<span className="ml-2 text-xs text-[--text-secondary] break-all">{employee.id}</span>
|
||||
</div>
|
||||
{user?.email && employee.email && (
|
||||
<div className="mt-2 text-sm">
|
||||
<span className="font-medium">Match Analysis:</span>
|
||||
<span className="ml-2">
|
||||
{employee.email === user.email && (
|
||||
<span className="bg-green-100 text-green-800 px-2 py-1 rounded text-xs mr-1">Exact</span>
|
||||
)}
|
||||
{employee.email?.toLowerCase() === user.email?.toLowerCase() && employee.email !== user.email && (
|
||||
<span className="bg-yellow-100 text-yellow-800 px-2 py-1 rounded text-xs mr-1">Case Diff</span>
|
||||
)}
|
||||
{employee.email?.split('@')[1] === user.email?.split('@')[1] && (
|
||||
<span className="bg-blue-100 text-blue-800 px-2 py-1 rounded text-xs mr-1">Same Domain</span>
|
||||
)}
|
||||
{employee.email?.toLowerCase().includes(user.email?.split('@')[0]?.toLowerCase() || '') && (
|
||||
<span className="bg-purple-100 text-purple-800 px-2 py-1 rounded text-xs mr-1">Username Match</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card className="p-6 lg:col-span-2">
|
||||
<h2 className="text-xl font-semibold text-[--text-primary] mb-4">
|
||||
Quick Actions
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<a
|
||||
href="#/employee-questionnaire"
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors text-sm"
|
||||
>
|
||||
Try Traditional Questionnaire
|
||||
</a>
|
||||
<a
|
||||
href="#/employee-questionnaire-steps"
|
||||
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 transition-colors text-sm"
|
||||
>
|
||||
Try Stepped Questionnaire
|
||||
</a>
|
||||
<a
|
||||
href="#/reports"
|
||||
className="px-4 py-2 bg-gray-500 text-white rounded hover:bg-gray-600 transition-colors text-sm"
|
||||
>
|
||||
Go to Reports
|
||||
</a>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-orange-500 text-white rounded hover:bg-orange-600 transition-colors text-sm"
|
||||
>
|
||||
Refresh Page
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DebugEmployee;
|
||||
453
pages/EmployeeData.tsx
Normal file
453
pages/EmployeeData.tsx
Normal file
@@ -0,0 +1,453 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useOrg } from '../contexts/OrgContext';
|
||||
import { Card, Button } from '../components/UiKit';
|
||||
import { CompanyReport, Employee, Report } from '../types';
|
||||
import RadarPerformanceChart from '../components/charts/RadarPerformanceChart';
|
||||
import ScoreBarList from '../components/charts/ScoreBarList';
|
||||
import { SAMPLE_COMPANY_REPORT } from '../constants';
|
||||
|
||||
interface EmployeeDataProps {
|
||||
mode: 'submissions' | 'reports';
|
||||
}
|
||||
|
||||
const CompanyReportCard: React.FC<{ report: CompanyReport }> = ({ report }) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<Card className="mb-6 border-l-4 border-blue-500">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-r from-blue-500 to-blue-600 rounded-full flex items-center justify-center text-white font-bold text-sm">
|
||||
ZM
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-[--text-primary]">Company Report</h2>
|
||||
<p className="text-sm text-[--text-secondary]">
|
||||
Last updated: {new Date(report.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
{isExpanded ? 'Collapse' : 'View Details'}
|
||||
</Button>
|
||||
<Button size="sm">Download as PDF</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overview Section - Always Visible */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
|
||||
<div className="bg-[--background-tertiary] p-4 rounded-lg">
|
||||
<h3 className="text-sm font-medium text-[--text-secondary]">Total Employees</h3>
|
||||
<p className="text-2xl font-bold text-[--text-primary]">{report.overview.totalEmployees}</p>
|
||||
</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>
|
||||
</div>
|
||||
<div className="bg-[--background-tertiary] p-4 rounded-lg">
|
||||
<h3 className="text-sm font-medium text-[--text-secondary]">Avg Performance</h3>
|
||||
<p className="text-2xl font-bold text-[--text-primary]">{report.overview.averagePerformanceScore}/5</p>
|
||||
</div>
|
||||
<div className="bg-[--background-tertiary] p-4 rounded-lg">
|
||||
<h3 className="text-sm font-medium text-[--text-secondary]">Risk Level</h3>
|
||||
<p className="text-2xl font-bold text-[--text-primary]">{report.overview.riskLevel}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-6 space-y-6">
|
||||
{/* Key Personnel Changes */}
|
||||
{report.keyPersonnelChanges && report.keyPersonnelChanges.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[--text-primary] mb-3 flex items-center">
|
||||
<span className="w-2 h-2 bg-orange-500 rounded-full mr-2"></span>
|
||||
Key Personnel Changes
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{report.keyPersonnelChanges.map((change, idx) => (
|
||||
<div key={idx} className="p-3 bg-[--background-tertiary] rounded-lg">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<p className="font-medium text-[--text-primary]">{change.employeeName}</p>
|
||||
<p className="text-sm text-[--text-secondary]">{change.role} - {change.department}</p>
|
||||
</div>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${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-sm text-[--text-secondary] mt-2">{change.impact}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Immediate Hiring Needs */}
|
||||
{report.immediateHiringNeeds && report.immediateHiringNeeds.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[--text-primary] mb-3 flex items-center">
|
||||
<span className="w-2 h-2 bg-red-500 rounded-full mr-2"></span>
|
||||
Immediate Hiring Needs
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{report.immediateHiringNeeds.map((need, idx) => (
|
||||
<div key={idx} className="p-4 bg-[--background-tertiary] rounded-lg">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<h4 className="font-medium text-[--text-primary]">{need.role}</h4>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${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} priority
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Forward Operating Plan */}
|
||||
{report.forwardOperatingPlan && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[--text-primary] mb-3 flex items-center">
|
||||
<span className="w-2 h-2 bg-blue-500 rounded-full mr-2"></span>
|
||||
Forward Operating Plan
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-[--background-tertiary] rounded-lg">
|
||||
<h4 className="font-medium text-[--text-primary] mb-2">Next Quarter Goals</h4>
|
||||
<ul className="space-y-1">
|
||||
{report.forwardOperatingPlan.nextQuarterGoals.map((goal, 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>
|
||||
{goal}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="p-4 bg-[--background-tertiary] rounded-lg">
|
||||
<h4 className="font-medium text-[--text-primary] mb-2">Key Initiatives</h4>
|
||||
<ul className="space-y-1">
|
||||
{report.forwardOperatingPlan.keyInitiatives.map((initiative, idx) => (
|
||||
<li key={idx} className="text-sm text-[--text-secondary] flex items-start">
|
||||
<span className="w-1.5 h-1.5 bg-green-500 rounded-full mt-2 mr-2 flex-shrink-0"></span>
|
||||
{initiative}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Organizational Strengths */}
|
||||
{report.organizationalStrengths && report.organizationalStrengths.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[--text-primary] mb-3 flex items-center">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full mr-2"></span>
|
||||
Organizational Strengths
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{report.organizationalStrengths.map((strength, idx) => (
|
||||
<div key={idx} className="p-3 bg-[--background-tertiary] rounded-lg">
|
||||
<div className="flex items-start space-x-3">
|
||||
<span className="text-2xl">{strength.icon}</span>
|
||||
<div>
|
||||
<h4 className="font-medium text-[--text-primary]">{strength.area}</h4>
|
||||
<p className="text-sm text-[--text-secondary]">{strength.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Organizational Impact Summary */}
|
||||
{report.organizationalImpactSummary && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[--text-primary] mb-3 flex items-center">
|
||||
<span className="w-2 h-2 bg-purple-500 rounded-full mr-2"></span>
|
||||
Organizational Impact Summary
|
||||
</h3>
|
||||
<div className="p-4 bg-[--background-tertiary] rounded-lg">
|
||||
<p className="text-[--text-secondary] text-sm leading-relaxed">
|
||||
{report.organizationalImpactSummary}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grading Overview */}
|
||||
{report.gradingOverview && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[--text-primary] mb-3 flex items-center">
|
||||
<span className="w-2 h-2 bg-indigo-500 rounded-full mr-2"></span>
|
||||
Grading Overview
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{Object.entries(report.gradingOverview).map(([category, score], idx) => (
|
||||
<div key={idx} className="text-center p-4 bg-[--background-tertiary] rounded-lg">
|
||||
<div className="text-2xl font-bold text-[--text-primary] mb-1">{score}/5</div>
|
||||
<div className="text-sm text-[--text-secondary] capitalize">{category.replace(/([A-Z])/g, ' $1').trim()}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const EmployeeCard: React.FC<{
|
||||
employee: Employee;
|
||||
report?: Report;
|
||||
mode: 'submissions' | 'reports';
|
||||
isOwner: boolean;
|
||||
onGenerateReport?: (employee: Employee) => void;
|
||||
isGeneratingReport?: boolean;
|
||||
}> = ({ employee, report, mode, isOwner, onGenerateReport, isGeneratingReport }) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<Card className="hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-r from-blue-500 to-purple-500 rounded-full flex items-center justify-center text-white font-bold text-sm">
|
||||
{employee.initials}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-[--text-primary]">{employee.name}</h3>
|
||||
<p className="text-sm text-[--text-secondary]">
|
||||
{employee.role} {employee.department && `• ${employee.department}`}
|
||||
</p>
|
||||
</div>
|
||||
{employee.isOwner && (
|
||||
<span className="px-2 py-1 bg-yellow-100 text-yellow-800 text-xs rounded-full">
|
||||
Owner
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
{report && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
{isExpanded ? 'Hide' : 'View'} Report
|
||||
</Button>
|
||||
)}
|
||||
{isOwner && mode === 'reports' && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onGenerateReport?.(employee)}
|
||||
disabled={isGeneratingReport}
|
||||
>
|
||||
{isGeneratingReport ? 'Generating...' : report ? 'Regenerate Report' : 'Generate Report'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && report && (
|
||||
<div className="mt-4 pt-4 border-t border-[--border-color] space-y-4">
|
||||
<div>
|
||||
<h4 className="font-medium text-[--text-primary] mb-2">Role & Output</h4>
|
||||
<p className="text-sm text-[--text-secondary]">{report.roleAndOutput.responsibilities}</p>
|
||||
</div>
|
||||
|
||||
{report.grading?.[0]?.scores && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-[--background-tertiary] rounded-lg p-4">
|
||||
<RadarPerformanceChart
|
||||
title="Performance Profile"
|
||||
data={report.grading[0].scores.map(s => ({ label: s.subject, value: (s.value / s.fullMark) * 100 }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-[--background-tertiary] rounded-lg p-4">
|
||||
<ScoreBarList
|
||||
title="Score Breakdown"
|
||||
items={report.grading[0].scores.map(s => ({ label: s.subject, value: s.value, max: s.fullMark }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium text-[--text-primary] mb-2">Key Strengths</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{report.insights.strengths.map((strength, idx) => (
|
||||
<span key={idx} className="px-2 py-1 bg-green-100 text-green-800 text-xs rounded-full">
|
||||
{strength}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium text-[--text-primary] mb-2">Development Areas</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{report.insights.weaknesses.map((weakness, idx) => (
|
||||
<span key={idx} className="px-2 py-1 bg-orange-100 text-orange-800 text-xs rounded-full">
|
||||
{weakness}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium text-[--text-primary] mb-2">Recommendations</h4>
|
||||
<ul className="space-y-1">
|
||||
{report.recommendations.map((rec, 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>
|
||||
{rec}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const EmployeeData: React.FC<EmployeeDataProps> = ({ mode }) => {
|
||||
const { employees, reports, user, isOwner, getFullCompanyReportHistory, generateEmployeeReport, saveReport, orgId } = useOrg();
|
||||
const [companyReport, setCompanyReport] = useState<CompanyReport | null>(null);
|
||||
const [generatingReports, setGeneratingReports] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
// Load company report for owners
|
||||
const loadCompanyReport = async () => {
|
||||
if (isOwner(user?.uid || '') && mode === 'reports') {
|
||||
try {
|
||||
const history = await getFullCompanyReportHistory();
|
||||
if (history.length > 0) {
|
||||
setCompanyReport(history[0]);
|
||||
} else {
|
||||
// Fallback to sample report if no real report exists
|
||||
setCompanyReport(SAMPLE_COMPANY_REPORT);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load company report:', error);
|
||||
setCompanyReport(SAMPLE_COMPANY_REPORT);
|
||||
}
|
||||
}
|
||||
};
|
||||
loadCompanyReport();
|
||||
}, [isOwner, user?.uid, mode, getFullCompanyReportHistory]);
|
||||
|
||||
const handleGenerateReport = async (employee: Employee) => {
|
||||
setGeneratingReports(prev => new Set(prev).add(employee.id));
|
||||
|
||||
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');
|
||||
}
|
||||
} else {
|
||||
console.error('API call failed:', response.status, response.statusText);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error generating report:', error);
|
||||
} finally {
|
||||
setGeneratingReports(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(employee.id);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
}; const currentUserIsOwner = isOwner(user?.uid || '');
|
||||
|
||||
// Filter employees based on user access
|
||||
const visibleEmployees = currentUserIsOwner
|
||||
? employees
|
||||
: employees.filter(emp => emp.id === user?.uid);
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-6xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-[--text-primary]">
|
||||
{mode === 'submissions' ? 'Employee Submissions' : 'Employee Reports'}
|
||||
</h1>
|
||||
<p className="text-[--text-secondary] mt-1">
|
||||
{mode === 'submissions'
|
||||
? 'Manage employee data and submissions'
|
||||
: 'View AI-generated insights and reports'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Company Report - Only visible to owners in reports mode */}
|
||||
{currentUserIsOwner && mode === 'reports' && companyReport && (
|
||||
<CompanyReportCard report={companyReport} />
|
||||
)}
|
||||
|
||||
{/* Employee Cards */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold text-[--text-primary]">
|
||||
{currentUserIsOwner ? 'All Employees' : 'Your Information'}
|
||||
</h2>
|
||||
{visibleEmployees.length === 0 ? (
|
||||
<Card>
|
||||
<div className="text-center py-8">
|
||||
<p className="text-[--text-secondary]">No employees found.</p>
|
||||
{currentUserIsOwner && (
|
||||
<Button className="mt-4" size="sm">
|
||||
Invite First Employee
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
visibleEmployees.map(employee => (
|
||||
<EmployeeCard
|
||||
key={employee.id}
|
||||
employee={employee}
|
||||
report={reports[employee.id]}
|
||||
mode={mode}
|
||||
isOwner={currentUserIsOwner}
|
||||
onGenerateReport={handleGenerateReport}
|
||||
isGeneratingReport={generatingReports.has(employee.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmployeeData;
|
||||
390
pages/EmployeeQuestionnaire.tsx
Normal file
390
pages/EmployeeQuestionnaire.tsx
Normal file
@@ -0,0 +1,390 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, useLocation, useParams } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useOrg } from '../contexts/OrgContext';
|
||||
import { Card, Button } from '../components/UiKit';
|
||||
import { EMPLOYEE_QUESTIONS, EmployeeSubmissionAnswers } from '../employeeQuestions';
|
||||
import { Question } from '../components/ui/Question';
|
||||
import { QuestionInput } from '../components/ui/QuestionInput';
|
||||
import { LinearProgress } from '../components/ui/Progress';
|
||||
import { Alert } from '../components/ui/Alert';
|
||||
import { API_URL } from '../constants';
|
||||
|
||||
const EmployeeQuestionnaire: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const params = useParams();
|
||||
const { user } = useAuth();
|
||||
const { submitEmployeeAnswers, generateEmployeeReport, employees } = useOrg();
|
||||
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) {
|
||||
loadInviteDetails(inviteCode);
|
||||
}
|
||||
}, [inviteCode]);
|
||||
|
||||
const loadInviteDetails = async (code: string) => {
|
||||
setIsLoadingInvite(true);
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/invitations/${code}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setInviteEmployee(data.employee);
|
||||
setError('');
|
||||
} else {
|
||||
setError('Invalid or expired invitation link');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to load invitation details');
|
||||
} finally {
|
||||
setIsLoadingInvite(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Get employee info from multiple sources
|
||||
const invitedEmployee = location.state?.invitedEmployee;
|
||||
|
||||
// Determine current employee - for invite flow, use invite employee data
|
||||
let currentEmployee;
|
||||
if (isInviteFlow) {
|
||||
currentEmployee = inviteEmployee;
|
||||
} else {
|
||||
// Original auth-based logic
|
||||
currentEmployee = invitedEmployee || employees.find(emp => emp.email === user?.email);
|
||||
|
||||
// Additional matching strategies for edge cases
|
||||
if (!currentEmployee && user?.email) {
|
||||
// Try case-insensitive email matching
|
||||
currentEmployee = employees.find(emp =>
|
||||
emp.email?.toLowerCase() === user.email?.toLowerCase()
|
||||
);
|
||||
|
||||
// Try matching by name if email doesn't work (for invite flow)
|
||||
if (!currentEmployee && invitedEmployee) {
|
||||
currentEmployee = employees.find(emp =>
|
||||
emp.name === invitedEmployee.name || emp.id === invitedEmployee.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If no match by email, and we're in demo mode with only one recent employee, use that
|
||||
if (!currentEmployee && user?.email === 'demo@auditly.local' && employees.length > 0) {
|
||||
// In demo mode, if there's only one employee or the most recent one, use it
|
||||
currentEmployee = employees[employees.length - 1];
|
||||
}
|
||||
|
||||
// If still no match and there's only one employee, assume it's them
|
||||
if (!currentEmployee && employees.length === 1) {
|
||||
currentEmployee = employees[0];
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced debugging
|
||||
console.log('EmployeeQuestionnaire debug:', {
|
||||
userEmail: user?.email,
|
||||
employeesCount: employees.length,
|
||||
employeeEmails: employees.map(e => ({ id: e.id, email: e.email, name: e.name })),
|
||||
invitedEmployee,
|
||||
currentEmployee,
|
||||
locationState: location.state
|
||||
});
|
||||
|
||||
const handleAnswerChange = (questionId: string, value: string) => {
|
||||
setAnswers(prev => ({ ...prev, [questionId]: value }));
|
||||
};
|
||||
|
||||
// Filter out followup questions that shouldn't be shown yet
|
||||
const getVisibleQuestions = () => {
|
||||
return EMPLOYEE_QUESTIONS.filter(question => {
|
||||
// Hide follow-up questions since they're now integrated into the parent yes/no question
|
||||
if (question.followupTo) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const handleFollowupChange = (questionId: string, value: string) => {
|
||||
setAnswers(prev => ({
|
||||
...prev,
|
||||
[questionId]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const submitViaInvite = async (employee: any, 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'
|
||||
});
|
||||
|
||||
if (!consumeResponse.ok) {
|
||||
throw new Error('Failed to process invitation');
|
||||
}
|
||||
|
||||
// Submit the questionnaire answers
|
||||
const submitResponse = await fetch(`${API_URL}/api/employee-submissions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
employeeId: employee.id,
|
||||
employee: employee,
|
||||
answers: answers
|
||||
})
|
||||
});
|
||||
|
||||
if (!submitResponse.ok) {
|
||||
throw new Error('Failed to submit questionnaire');
|
||||
}
|
||||
|
||||
const result = await submitResponse.json();
|
||||
return { success: true, reportGenerated: !!result.report };
|
||||
} catch (error) {
|
||||
console.error('Invite submission error:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
};
|
||||
|
||||
const visibleQuestions = getVisibleQuestions();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsSubmitting(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
// Validate required questions
|
||||
const requiredQuestions = visibleQuestions.filter(q => q.required);
|
||||
const missingAnswers = requiredQuestions.filter(q => !answers[q.id]?.trim());
|
||||
|
||||
if (missingAnswers.length > 0) {
|
||||
setError(`Please answer all required questions: ${missingAnswers.map(q => q.prompt).join(', ')}`);
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentEmployee) {
|
||||
// Enhanced fallback logic
|
||||
if (employees.length > 0) {
|
||||
// Try to find employee by matching with the user's email more aggressively
|
||||
let fallbackEmployee = employees.find(emp =>
|
||||
emp.email?.toLowerCase().includes(user?.email?.toLowerCase().split('@')[0] || '')
|
||||
);
|
||||
|
||||
// If still no match, use the most recent employee or one with matching domain
|
||||
if (!fallbackEmployee) {
|
||||
const userDomain = user?.email?.split('@')[1];
|
||||
fallbackEmployee = employees.find(emp =>
|
||||
emp.email?.split('@')[1] === userDomain
|
||||
) || employees[employees.length - 1];
|
||||
}
|
||||
|
||||
console.log('Using enhanced fallback employee:', fallbackEmployee);
|
||||
|
||||
const success = await submitEmployeeAnswers(fallbackEmployee.id, answers);
|
||||
if (success) {
|
||||
// Generate LLM report for fallback employee
|
||||
console.log('Questionnaire submitted for fallback employee, generating report...');
|
||||
try {
|
||||
const report = await generateEmployeeReport(fallbackEmployee);
|
||||
if (report) {
|
||||
console.log('Report generated successfully for fallback employee:', report);
|
||||
}
|
||||
} catch (reportError) {
|
||||
console.error('Failed to generate report for fallback employee:', reportError);
|
||||
}
|
||||
|
||||
navigate('/questionnaire-complete', {
|
||||
replace: true,
|
||||
state: {
|
||||
employeeId: fallbackEmployee.id,
|
||||
employeeName: fallbackEmployee.name,
|
||||
message: 'Questionnaire submitted successfully! Your responses have been recorded.'
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setError(`We couldn't match your account (${user?.email}) with an employee record. Please contact your administrator to ensure your invite was set up correctly.`);
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Submit answers - different logic for invite vs auth flow
|
||||
let result;
|
||||
if (isInviteFlow) {
|
||||
// Direct API submission for invite flow (no auth needed)
|
||||
result = await submitViaInvite(currentEmployee, answers, inviteCode);
|
||||
} else {
|
||||
// Use org context for authenticated flow
|
||||
result = await submitEmployeeAnswers(currentEmployee.id, answers);
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
// Show success message with AI report info
|
||||
const message = result.reportGenerated
|
||||
? 'Questionnaire submitted successfully! Your AI-powered performance report has been generated.'
|
||||
: 'Questionnaire submitted successfully! Your report will be available shortly.';
|
||||
|
||||
setError(null);
|
||||
|
||||
// Navigate to completion page with success info
|
||||
navigate('/questionnaire-complete', {
|
||||
state: {
|
||||
employeeId: currentEmployee.id,
|
||||
employeeName: currentEmployee.name,
|
||||
reportGenerated: result.reportGenerated,
|
||||
message: message
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setError(result.message || 'Failed to submit questionnaire');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Submission error:', error);
|
||||
setError('Failed to submit questionnaire. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getProgressPercentage = () => {
|
||||
const answeredQuestions = Object.keys(answers).filter(key => answers[key]?.trim()).length;
|
||||
return Math.round((answeredQuestions / visibleQuestions.length) * 100);
|
||||
};
|
||||
|
||||
// Early return for invite flow loading state
|
||||
if (isInviteFlow && isLoadingInvite) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[--background-primary] py-8 px-4">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<div className="w-16 h-16 bg-[--accent] rounded-full flex items-center justify-center font-bold text-white text-2xl mx-auto mb-4">
|
||||
A
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-[--text-primary] mb-4">Loading Your Invitation...</h1>
|
||||
<p className="text-[--text-secondary]">Please wait while we verify your invitation.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Early return for invite flow error state
|
||||
if (isInviteFlow && error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[--background-primary] py-8 px-4">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<div className="w-16 h-16 bg-red-500 rounded-full flex items-center justify-center font-bold text-white text-2xl mx-auto mb-4">
|
||||
!
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-[--text-primary] mb-4">Invitation Error</h1>
|
||||
<p className="text-[--text-secondary] mb-6">{error}</p>
|
||||
<Button onClick={() => window.location.href = '/'}>
|
||||
Return to Homepage
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[--background-primary] py-8 px-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-[--accent] rounded-full flex items-center justify-center font-bold text-white text-2xl mx-auto mb-4">
|
||||
A
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-[--text-primary] mb-2">
|
||||
Welcome to Auditly!
|
||||
</h1>
|
||||
<p className="text-[--text-secondary] mb-4">
|
||||
Please complete this questionnaire to help us understand your role and create personalized insights.
|
||||
</p>
|
||||
{currentEmployee ? (
|
||||
<div className="inline-flex items-center px-4 py-2 bg-blue-100 dark:bg-blue-900 rounded-lg">
|
||||
<span className="text-sm text-blue-800 dark:text-blue-200">
|
||||
👋 Hello {currentEmployee.name}! {currentEmployee.role && `(${currentEmployee.role})`}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="inline-flex items-center px-4 py-2 bg-yellow-100 dark:bg-yellow-900 rounded-lg">
|
||||
<span className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
⚠️ Employee info not found. User: {user?.email}, Employees: {employees.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-[--text-secondary] max-w-md mx-auto">
|
||||
<p>Don't worry - your account was created successfully! This is likely a temporary sync issue.</p>
|
||||
<p className="mt-1">You can still complete the questionnaire, and we'll match it to your profile automatically.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="mb-8">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm text-[--text-secondary]">Progress</span>
|
||||
<span className="text-sm text-[--text-secondary]">{getProgressPercentage()}%</span>
|
||||
</div>
|
||||
<LinearProgress value={getProgressPercentage()} />
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-6">
|
||||
{visibleQuestions.map((question, index) => (
|
||||
<Question
|
||||
key={question.id}
|
||||
label={`${index + 1}. ${question.prompt}`}
|
||||
required={question.required}
|
||||
description={`Category: ${question.category}`}
|
||||
>
|
||||
<QuestionInput
|
||||
question={question}
|
||||
value={answers[question.id] || ''}
|
||||
onChange={(value) => handleAnswerChange(question.id, value)}
|
||||
allAnswers={answers}
|
||||
onFollowupChange={handleFollowupChange}
|
||||
/>
|
||||
</Question>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-6">
|
||||
<Alert variant="error" title="Error">
|
||||
{error}
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 flex justify-center">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || getProgressPercentage() < 70}
|
||||
className="px-8 py-3"
|
||||
>
|
||||
{isSubmitting ? 'Submitting & Generating Report...' : 'Submit & Generate AI Report'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{getProgressPercentage() < 70 && (
|
||||
<p className="text-center text-sm text-[--text-secondary] mt-4">
|
||||
Please answer at least 70% of the questions to submit.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmployeeQuestionnaire;
|
||||
381
pages/EmployeeQuestionnaireSteps.tsx
Normal file
381
pages/EmployeeQuestionnaireSteps.tsx
Normal file
@@ -0,0 +1,381 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useOrg } from '../contexts/OrgContext';
|
||||
import { Card, Button } from '../components/UiKit';
|
||||
import { EMPLOYEE_QUESTIONS, EmployeeSubmissionAnswers } from '../employeeQuestions';
|
||||
import { Question } from '../components/ui/Question';
|
||||
import { QuestionInput } from '../components/ui/QuestionInput';
|
||||
import { FigmaQuestion } from '../components/figma/FigmaQuestion';
|
||||
import { EnhancedFigmaQuestion } from '../components/figma/EnhancedFigmaQuestion';
|
||||
import { LinearProgress } from '../components/ui/Progress';
|
||||
import { Alert } from '../components/ui/Alert';
|
||||
|
||||
const EmployeeQuestionnaireSteps: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user } = useAuth();
|
||||
const { submitEmployeeAnswers, generateEmployeeReport, employees } = useOrg();
|
||||
const [answers, setAnswers] = useState<EmployeeSubmissionAnswers>({});
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Get employee info from multiple sources
|
||||
const invitedEmployee = location.state?.invitedEmployee;
|
||||
|
||||
// Find current employee info - try multiple strategies
|
||||
let currentEmployee = invitedEmployee || employees.find(emp => emp.email === user?.email);
|
||||
|
||||
// Additional matching strategies for edge cases
|
||||
if (!currentEmployee && user?.email) {
|
||||
// Try case-insensitive email matching
|
||||
currentEmployee = employees.find(emp =>
|
||||
emp.email?.toLowerCase() === user.email?.toLowerCase()
|
||||
);
|
||||
|
||||
// Try matching by name if email doesn't work (for invite flow)
|
||||
if (!currentEmployee && invitedEmployee) {
|
||||
currentEmployee = employees.find(emp =>
|
||||
emp.name === invitedEmployee.name || emp.id === invitedEmployee.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Demo mode fallbacks
|
||||
if (!currentEmployee && user?.email === 'demo@auditly.local' && employees.length > 0) {
|
||||
currentEmployee = employees[employees.length - 1];
|
||||
}
|
||||
if (!currentEmployee && employees.length === 1) {
|
||||
currentEmployee = employees[0];
|
||||
}
|
||||
|
||||
// Filter out followup questions that shouldn't be shown yet
|
||||
const getVisibleQuestions = () => {
|
||||
return EMPLOYEE_QUESTIONS.filter(question => {
|
||||
if (!question.followupTo) return true;
|
||||
|
||||
const parentAnswer = answers[question.followupTo];
|
||||
if (question.followupTo === 'has_kpis') {
|
||||
return parentAnswer === 'Yes';
|
||||
}
|
||||
if (question.followupTo === 'unclear_responsibilities') {
|
||||
return parentAnswer === 'Yes';
|
||||
}
|
||||
if (question.followupTo === 'role_shift_interest') {
|
||||
return parentAnswer === 'Yes';
|
||||
}
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
const visibleQuestions = getVisibleQuestions();
|
||||
const currentQuestion = visibleQuestions[currentStep];
|
||||
|
||||
const handleAnswerChange = (value: string) => {
|
||||
if (currentQuestion) {
|
||||
setAnswers(prev => ({ ...prev, [currentQuestion.id]: value }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentStep < visibleQuestions.length - 1) {
|
||||
setCurrentStep(prev => prev + 1);
|
||||
} else {
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (currentStep > 0) {
|
||||
setCurrentStep(prev => prev - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsSubmitting(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
// Validate required questions
|
||||
const requiredQuestions = visibleQuestions.filter(q => q.required);
|
||||
const missingAnswers = requiredQuestions.filter(q => !answers[q.id]?.trim());
|
||||
|
||||
if (missingAnswers.length > 0) {
|
||||
setError(`Please answer all required questions: ${missingAnswers.map(q => q.prompt).join(', ')}`);
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentEmployee) {
|
||||
// Enhanced fallback logic
|
||||
if (employees.length > 0) {
|
||||
// Try to find employee by matching with the user's email more aggressively
|
||||
let fallbackEmployee = employees.find(emp =>
|
||||
emp.email?.toLowerCase().includes(user?.email?.toLowerCase().split('@')[0] || '')
|
||||
);
|
||||
|
||||
// If still no match, use the most recent employee or one with matching domain
|
||||
if (!fallbackEmployee) {
|
||||
const userDomain = user?.email?.split('@')[1];
|
||||
fallbackEmployee = employees.find(emp =>
|
||||
emp.email?.split('@')[1] === userDomain
|
||||
) || employees[employees.length - 1];
|
||||
}
|
||||
|
||||
console.log('Using enhanced fallback employee:', fallbackEmployee);
|
||||
|
||||
const success = await submitEmployeeAnswers(fallbackEmployee.id, answers);
|
||||
if (success) {
|
||||
try {
|
||||
const report = await generateEmployeeReport(fallbackEmployee);
|
||||
if (report) {
|
||||
console.log('Report generated successfully for fallback employee:', report);
|
||||
}
|
||||
} catch (reportError) {
|
||||
console.error('Failed to generate report for fallback employee:', reportError);
|
||||
}
|
||||
navigate('/questionnaire-complete', {
|
||||
replace: true,
|
||||
state: {
|
||||
employeeId: fallbackEmployee.id,
|
||||
employeeName: fallbackEmployee.name,
|
||||
message: 'Questionnaire submitted successfully! Your responses have been recorded.'
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
setError(`We couldn't match your account (${user?.email}) with an employee record. Please contact your administrator to ensure your invite was set up correctly.`);
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await submitEmployeeAnswers(currentEmployee.id, answers);
|
||||
|
||||
if (result.success) {
|
||||
const message = result.reportGenerated
|
||||
? 'Questionnaire submitted successfully! Your AI-powered performance report has been generated.'
|
||||
: 'Questionnaire submitted successfully! Your report will be available shortly.';
|
||||
|
||||
navigate('/questionnaire-complete', {
|
||||
state: {
|
||||
employeeId: currentEmployee.id,
|
||||
employeeName: currentEmployee.name,
|
||||
reportGenerated: result.reportGenerated,
|
||||
message: message
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setError(result.message || 'Failed to submit questionnaire');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Submission error:', error);
|
||||
setError('Failed to submit questionnaire. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getProgressPercentage = () => {
|
||||
return Math.round(((currentStep + 1) / visibleQuestions.length) * 100);
|
||||
};
|
||||
|
||||
const isCurrentQuestionAnswered = () => {
|
||||
if (!currentQuestion) return false;
|
||||
const answer = answers[currentQuestion.id];
|
||||
return answer && answer.trim().length > 0;
|
||||
};
|
||||
|
||||
if (!currentQuestion) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[--background-primary] py-8 px-4">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<h1 className="text-2xl font-bold text-[--text-primary] mb-4">
|
||||
No questions available
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[--background-primary] py-8 px-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-blue-500 rounded-full flex items-center justify-center font-bold text-white text-2xl mx-auto mb-4">
|
||||
A
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-[--text-primary] mb-2">
|
||||
Welcome to Auditly!
|
||||
</h1>
|
||||
<p className="text-[--text-secondary] mb-4">
|
||||
Please complete this questionnaire to help us understand your role and create personalized insights.
|
||||
</p>
|
||||
{currentEmployee ? (
|
||||
<div className="inline-flex items-center px-4 py-2 bg-blue-100 dark:bg-blue-900 rounded-lg">
|
||||
<span className="text-sm text-blue-800 dark:text-blue-200">
|
||||
👋 Hello {currentEmployee.name}! {currentEmployee.role && `(${currentEmployee.role})`}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="inline-flex items-center px-4 py-2 bg-yellow-100 dark:bg-yellow-900 rounded-lg">
|
||||
<span className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
⚠️ Employee info not found. User: {user?.email}, Employees: {employees.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-[--text-secondary] max-w-md mx-auto">
|
||||
<p>Don't worry - your account was created successfully! This is likely a temporary sync issue.</p>
|
||||
<p className="mt-1">You can still complete the questionnaire, and we'll match it to your profile automatically.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="mb-8">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm text-[--text-secondary]">
|
||||
Question {currentStep + 1} of {visibleQuestions.length}
|
||||
</span>
|
||||
<span className="text-sm text-[--text-secondary]">{getProgressPercentage()}%</span>
|
||||
</div>
|
||||
<LinearProgress value={getProgressPercentage()} />
|
||||
</div>
|
||||
|
||||
{/* Enhanced Question Card */}
|
||||
<div className="flex justify-center mb-8">
|
||||
<div className="w-full max-w-2xl">
|
||||
<EnhancedFigmaQuestion
|
||||
questionNumber={`Q${currentStep + 1}`}
|
||||
question={currentQuestion}
|
||||
answer={answers[currentQuestion.id] || ''}
|
||||
onAnswerChange={handleAnswerChange}
|
||||
onBack={currentStep > 0 ? handleBack : undefined}
|
||||
onNext={
|
||||
currentStep < visibleQuestions.length - 1
|
||||
? handleNext
|
||||
: (isCurrentQuestionAnswered() || !currentQuestion.required)
|
||||
? handleSubmit
|
||||
: undefined
|
||||
}
|
||||
nextLabel={
|
||||
currentStep < visibleQuestions.length - 1
|
||||
? 'Next'
|
||||
: isSubmitting
|
||||
? 'Submitting...'
|
||||
: 'Submit & Generate Report'
|
||||
}
|
||||
showNavigation={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Original Figma Question Card for Comparison */}
|
||||
<div className="flex justify-center mb-8">
|
||||
<div className="w-full max-w-2xl">
|
||||
<FigmaQuestion
|
||||
questionNumber={`Q${currentStep + 1}`}
|
||||
title={currentQuestion.prompt}
|
||||
description={currentQuestion.required ? 'Required' : 'Optional'}
|
||||
answer={answers[currentQuestion.id] || ''}
|
||||
onAnswerChange={handleAnswerChange}
|
||||
onBack={currentStep > 0 ? handleBack : undefined}
|
||||
onNext={
|
||||
currentStep < visibleQuestions.length - 1
|
||||
? handleNext
|
||||
: (isCurrentQuestionAnswered() || !currentQuestion.required)
|
||||
? handleSubmit
|
||||
: undefined
|
||||
}
|
||||
nextLabel={
|
||||
currentStep < visibleQuestions.length - 1
|
||||
? 'Next'
|
||||
: isSubmitting
|
||||
? 'Submitting...'
|
||||
: 'Submit & Generate Report'
|
||||
}
|
||||
showNavigation={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alternative Input for Different Question Types */}
|
||||
<div className="flex justify-center mb-8">
|
||||
<div className="w-full max-w-2xl">
|
||||
<Card className="p-6">
|
||||
<Question
|
||||
label={`${currentStep + 1}. ${currentQuestion.prompt}`}
|
||||
required={currentQuestion.required}
|
||||
description={`Category: ${currentQuestion.category}`}
|
||||
>
|
||||
<QuestionInput
|
||||
question={currentQuestion}
|
||||
value={answers[currentQuestion.id] || ''}
|
||||
onChange={handleAnswerChange}
|
||||
/>
|
||||
</Question>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="mb-6 flex justify-center">
|
||||
<div className="w-full max-w-2xl">
|
||||
<Alert variant="error" title="Error">
|
||||
{error}
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
<div className="flex justify-center gap-4">
|
||||
{currentStep > 0 && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleBack}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{currentStep < visibleQuestions.length - 1 ? (
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={currentQuestion.required && !isCurrentQuestionAnswered()}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
(currentQuestion.required && !isCurrentQuestionAnswered())
|
||||
}
|
||||
>
|
||||
{isSubmitting ? 'Submitting & Generating Report...' : 'Submit & Generate AI Report'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Help Text */}
|
||||
<div className="text-center mt-6">
|
||||
<p className="text-sm text-[--text-secondary]">
|
||||
{currentQuestion.required && !isCurrentQuestionAnswered()
|
||||
? 'Please answer this required question to continue.'
|
||||
: 'You can skip optional questions or come back to them later.'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmployeeQuestionnaireSteps;
|
||||
132
pages/FormsDashboard.tsx
Normal file
132
pages/FormsDashboard.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import React from 'react';
|
||||
import { Card } from '../components/UiKit';
|
||||
|
||||
const FormsDashboard: React.FC = () => {
|
||||
const forms = [
|
||||
{
|
||||
title: 'Question Types Demo',
|
||||
description: 'Interactive showcase of all question types and input styles',
|
||||
url: '#/question-types-demo',
|
||||
color: 'bg-purple-500',
|
||||
features: ['All Question Types', 'Side-by-side Comparison', 'Live Preview']
|
||||
},
|
||||
{
|
||||
title: 'Traditional Questionnaire',
|
||||
description: 'Complete questionnaire on a single page with all questions visible',
|
||||
url: '#/employee-questionnaire',
|
||||
color: 'bg-blue-500',
|
||||
features: ['All Questions Visible', 'Scroll Navigation', 'Conditional Logic']
|
||||
},
|
||||
{
|
||||
title: 'Stepped Questionnaire',
|
||||
description: 'One question at a time with progress tracking and navigation',
|
||||
url: '#/employee-questionnaire-steps',
|
||||
color: 'bg-green-500',
|
||||
features: ['One Question Per Step', 'Progress Tracking', 'Back/Next Navigation']
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[--background-primary] py-8 px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-gradient-to-r from-blue-500 to-purple-600 rounded-full flex items-center justify-center font-bold text-white text-2xl mx-auto mb-4">
|
||||
📝
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-[--text-primary] mb-2">
|
||||
Employee Forms Dashboard
|
||||
</h1>
|
||||
<p className="text-[--text-secondary] max-w-2xl mx-auto">
|
||||
Explore the different employee questionnaire implementations.
|
||||
Each form demonstrates different UX approaches and question types based on the Figma designs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||
{forms.map((form, index) => (
|
||||
<Card key={index} className="p-6 hover:shadow-lg transition-shadow">
|
||||
<div className={`w-12 h-12 ${form.color} rounded-lg flex items-center justify-center text-white font-bold text-xl mb-4`}>
|
||||
{index + 1}
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-[--text-primary] mb-2">
|
||||
{form.title}
|
||||
</h3>
|
||||
<p className="text-[--text-secondary] mb-4 text-sm">
|
||||
{form.description}
|
||||
</p>
|
||||
<div className="space-y-2 mb-4">
|
||||
{form.features.map((feature, featureIndex) => (
|
||||
<div key={featureIndex} className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span className="text-sm text-[--text-secondary]">{feature}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<a
|
||||
href={form.url}
|
||||
className={`w-full inline-block text-center px-4 py-2 ${form.color} text-white rounded-lg hover:opacity-90 transition-opacity`}
|
||||
>
|
||||
Try This Form
|
||||
</a>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Technical Overview */}
|
||||
<Card className="p-6">
|
||||
<h2 className="text-2xl font-semibold text-[--text-primary] mb-4">
|
||||
Implementation Overview
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[--text-primary] mb-3">
|
||||
Question Types Implemented
|
||||
</h3>
|
||||
<ul className="space-y-2 text-[--text-secondary]">
|
||||
<li>• <strong>Text Input:</strong> Single-line text for names, titles</li>
|
||||
<li>• <strong>Textarea:</strong> Multi-line for detailed responses</li>
|
||||
<li>• <strong>Scale Rating:</strong> 1-10 sliders with custom labels</li>
|
||||
<li>• <strong>Yes/No Radio:</strong> Binary choice questions</li>
|
||||
<li>• <strong>Select Dropdown:</strong> Predefined option selection</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[--text-primary] mb-3">
|
||||
Key Features
|
||||
</h3>
|
||||
<ul className="space-y-2 text-[--text-secondary]">
|
||||
<li>• <strong>Conditional Logic:</strong> Follow-up questions based on answers</li>
|
||||
<li>• <strong>Progress Tracking:</strong> Visual completion indicators</li>
|
||||
<li>• <strong>Responsive Design:</strong> Mobile-friendly layouts</li>
|
||||
<li>• <strong>Figma Integration:</strong> Design system compliance</li>
|
||||
<li>• <strong>Theme Support:</strong> Light/dark mode compatibility</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
|
||||
<Card className="p-4 text-center">
|
||||
<div className="text-2xl font-bold text-blue-500">32</div>
|
||||
<div className="text-sm text-[--text-secondary]">Total Questions</div>
|
||||
</Card>
|
||||
<Card className="p-4 text-center">
|
||||
<div className="text-2xl font-bold text-green-500">5</div>
|
||||
<div className="text-sm text-[--text-secondary]">Question Types</div>
|
||||
</Card>
|
||||
<Card className="p-4 text-center">
|
||||
<div className="text-2xl font-bold text-purple-500">8</div>
|
||||
<div className="text-sm text-[--text-secondary]">Categories</div>
|
||||
</Card>
|
||||
<Card className="p-4 text-center">
|
||||
<div className="text-2xl font-bold text-orange-500">3</div>
|
||||
<div className="text-sm text-[--text-secondary]">Conditional Flows</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormsDashboard;
|
||||
335
pages/HelpAndSettings.tsx
Normal file
335
pages/HelpAndSettings.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useOrg } from '../contexts/OrgContext';
|
||||
import { Card, Button } from '../components/UiKit';
|
||||
import { Theme } from '../types';
|
||||
|
||||
const HelpAndSettings: React.FC = () => {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { user, signOutUser } = useAuth();
|
||||
const { org, upsertOrg, issueInviteViaApi } = useOrg();
|
||||
const [activeTab, setActiveTab] = useState<'settings' | 'help'>('settings');
|
||||
const [inviteForm, setInviteForm] = useState({ name: '', email: '', role: '', department: '' });
|
||||
const [isInviting, setIsInviting] = useState(false);
|
||||
const [inviteResult, setInviteResult] = useState<string | null>(null);
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await signOutUser();
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestartOnboarding = async () => {
|
||||
try {
|
||||
await upsertOrg({ onboardingCompleted: false });
|
||||
// The RequireOnboarding component will redirect automatically
|
||||
} catch (error) {
|
||||
console.error('Failed to restart onboarding:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInviteEmployee = async () => {
|
||||
if (!inviteForm.name.trim() || !inviteForm.email.trim() || isInviting) return;
|
||||
|
||||
setIsInviting(true);
|
||||
setInviteResult(null);
|
||||
|
||||
try {
|
||||
const result = await issueInviteViaApi({
|
||||
name: inviteForm.name.trim(),
|
||||
email: inviteForm.email.trim(),
|
||||
role: inviteForm.role.trim() || undefined,
|
||||
department: inviteForm.department.trim() || undefined
|
||||
});
|
||||
|
||||
setInviteResult(`Invitation sent! Share this link: ${result.inviteLink}`);
|
||||
setInviteForm({ name: '', email: '', role: '', department: '' });
|
||||
} catch (error) {
|
||||
console.error('Failed to send invitation:', error);
|
||||
setInviteResult('Failed to send invitation. Please try again.');
|
||||
} finally {
|
||||
setIsInviting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderSettings = () => (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-[--text-primary] mb-4">Appearance</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[--text-primary] mb-2">
|
||||
Theme
|
||||
</label>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant={theme === Theme.Light ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => setTheme(Theme.Light)}
|
||||
>
|
||||
Light
|
||||
</Button>
|
||||
<Button
|
||||
variant={theme === Theme.Dark ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => setTheme(Theme.Dark)}
|
||||
>
|
||||
Dark
|
||||
</Button>
|
||||
<Button
|
||||
variant={theme === Theme.System ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => setTheme(Theme.System)}
|
||||
>
|
||||
System
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-[--text-primary] mb-4">Organization</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<span className="text-sm text-[--text-secondary]">Company:</span>
|
||||
<div className="font-medium text-[--text-primary]">{org?.name}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-[--text-secondary]">Onboarding:</span>
|
||||
<div className="font-medium text-[--text-primary]">
|
||||
{org?.onboardingCompleted ? 'Completed' : 'Incomplete'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-4">
|
||||
<Button variant="secondary" onClick={handleRestartOnboarding}>
|
||||
Restart Onboarding
|
||||
</Button>
|
||||
<p className="text-xs text-[--text-secondary] mt-2">
|
||||
This will reset your company profile and require you to complete the setup process again.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-[--text-primary] mb-4">Invite Employee</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[--text-primary] mb-1">
|
||||
Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={inviteForm.name}
|
||||
onChange={(e) => setInviteForm(prev => ({ ...prev, name: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="John Doe"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[--text-primary] mb-1">
|
||||
Email *
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={inviteForm.email}
|
||||
onChange={(e) => setInviteForm(prev => ({ ...prev, email: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="john.doe@company.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[--text-primary] mb-1">
|
||||
Role
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={inviteForm.role}
|
||||
onChange={(e) => setInviteForm(prev => ({ ...prev, role: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Senior Developer"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[--text-primary] mb-1">
|
||||
Department
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={inviteForm.department}
|
||||
onChange={(e) => setInviteForm(prev => ({ ...prev, department: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Engineering"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleInviteEmployee}
|
||||
disabled={!inviteForm.name.trim() || !inviteForm.email.trim() || isInviting}
|
||||
className="w-full"
|
||||
>
|
||||
{isInviting ? 'Sending Invitation...' : 'Send Invitation'}
|
||||
</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>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-[--text-secondary]">
|
||||
The invited employee will receive an email with instructions to join your organization.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-[--text-primary] mb-4">Account</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<span className="text-sm text-[--text-secondary]">Email:</span>
|
||||
<div className="font-medium text-[--text-primary]">{user?.email}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-[--text-secondary]">User ID:</span>
|
||||
<div className="font-medium text-[--text-primary] font-mono text-xs">{user?.uid}</div>
|
||||
</div>
|
||||
<div className="pt-4">
|
||||
<Button variant="secondary" onClick={handleLogout}>
|
||||
Sign Out
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-[--text-primary] mb-4">Data & Privacy</h3>
|
||||
<div className="space-y-3">
|
||||
<Button variant="secondary" className="w-full justify-start">
|
||||
Export My Data
|
||||
</Button>
|
||||
<Button variant="secondary" className="w-full justify-start">
|
||||
Privacy Settings
|
||||
</Button>
|
||||
<Button variant="secondary" className="w-full justify-start text-red-600">
|
||||
Delete Account
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderHelp = () => (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-[--text-primary] mb-4">Getting Started</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-medium text-[--text-primary] mb-2">1. Set up your organization</h4>
|
||||
<p className="text-[--text-secondary] text-sm">
|
||||
Complete the onboarding process to configure your company information and preferences.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-[--text-primary] mb-2">2. Add employees</h4>
|
||||
<p className="text-[--text-secondary] text-sm">
|
||||
Invite team members and add their basic information to start generating reports.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-[--text-primary] mb-2">3. Generate reports</h4>
|
||||
<p className="text-[--text-secondary] text-sm">
|
||||
Use AI-powered reports to gain insights into employee performance and organizational health.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-[--text-primary] mb-4">Frequently Asked Questions</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-medium text-[--text-primary] mb-2">How do I add new employees?</h4>
|
||||
<p className="text-[--text-secondary] text-sm">
|
||||
Go to the Reports page and use the "Add Employee" button to invite new team members.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-[--text-primary] mb-2">How are reports generated?</h4>
|
||||
<p className="text-[--text-secondary] text-sm">
|
||||
Reports use AI to analyze employee data and provide insights on performance, strengths, and development opportunities.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-[--text-primary] mb-2">Is my data secure?</h4>
|
||||
<p className="text-[--text-secondary] text-sm">
|
||||
Yes, all data is encrypted and stored securely. We follow industry best practices for data protection.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-[--text-primary] mb-4">Contact Support</h3>
|
||||
<div className="space-y-3">
|
||||
<Button variant="secondary" className="w-full justify-start">
|
||||
📧 Email Support
|
||||
</Button>
|
||||
<Button variant="secondary" className="w-full justify-start">
|
||||
💬 Live Chat
|
||||
</Button>
|
||||
<Button variant="secondary" className="w-full justify-start">
|
||||
📚 Documentation
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-[--text-primary]">Help & Settings</h1>
|
||||
<p className="text-[--text-secondary] mt-1">
|
||||
Manage your account and get help
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="flex space-x-4 border-b border-[--border-color]">
|
||||
<button
|
||||
onClick={() => setActiveTab('settings')}
|
||||
className={`px-4 py-2 font-medium border-b-2 transition-colors ${activeTab === 'settings'
|
||||
? 'border-blue-500 text-blue-500'
|
||||
: 'border-transparent text-[--text-secondary] hover:text-[--text-primary]'
|
||||
}`}
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('help')}
|
||||
className={`px-4 py-2 font-medium border-b-2 transition-colors ${activeTab === 'help'
|
||||
? 'border-blue-500 text-blue-500'
|
||||
: 'border-transparent text-[--text-secondary] hover:text-[--text-primary]'
|
||||
}`}
|
||||
>
|
||||
Help
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeTab === 'settings' ? renderSettings() : renderHelp()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HelpAndSettings;
|
||||
266
pages/Login.tsx
Normal file
266
pages/Login.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useOrg } from '../contexts/OrgContext';
|
||||
import { Card, Button } from '../components/UiKit';
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { inviteCode: routeInviteCode } = useParams<{ inviteCode: string }>();
|
||||
const [email, setEmail] = useState('demo@auditly.com');
|
||||
const [password, setPassword] = useState('demo123');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [inviteCode, setInviteCode] = useState<string | null>(null);
|
||||
const { signInWithGoogle, signInWithEmail, signUpWithEmail, user, loading } = useAuth();
|
||||
const { consumeInvite, org } = useOrg();
|
||||
|
||||
useEffect(() => {
|
||||
// Check for invite code in route params first, then fallback to query params
|
||||
if (routeInviteCode) {
|
||||
console.log('Invite code from route params:', routeInviteCode);
|
||||
setInviteCode(routeInviteCode);
|
||||
// Clear demo credentials for invite flow
|
||||
setEmail('');
|
||||
setPassword('');
|
||||
} else {
|
||||
// Extract query params from hash-based URL
|
||||
const hashSearch = location.hash.includes('?') ? location.hash.split('?')[1] : '';
|
||||
const searchParams = new URLSearchParams(hashSearch);
|
||||
const queryInvite = searchParams.get('invite');
|
||||
if (queryInvite) {
|
||||
console.log('Invite code from query params:', queryInvite);
|
||||
setInviteCode(queryInvite);
|
||||
// Clear demo credentials for invite flow
|
||||
setEmail('');
|
||||
setPassword('');
|
||||
}
|
||||
}
|
||||
}, [routeInviteCode, location]);
|
||||
|
||||
const handleSuccessfulLogin = async () => {
|
||||
if (inviteCode) {
|
||||
// Invite flow - redirect to org selection with invite code
|
||||
navigate(`/org-selection?invite=${inviteCode}`, { replace: true });
|
||||
} else {
|
||||
// Regular login - redirect to org selection to choose/create org
|
||||
navigate('/org-selection', { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (user && !loading) {
|
||||
handleSuccessfulLogin();
|
||||
}
|
||||
}, [user, loading]);
|
||||
|
||||
const handleEmailLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
if (inviteCode) {
|
||||
// For invites, try to create account first since they're new users
|
||||
console.log('Invite flow: attempting to create account for', email);
|
||||
await signUpWithEmail(email, password, email.split('@')[0]);
|
||||
} else {
|
||||
// Regular login
|
||||
await signInWithEmail(email, password);
|
||||
}
|
||||
// Success will be handled by the useEffect hook
|
||||
} catch (error) {
|
||||
console.error('Auth failed:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
if (inviteCode) {
|
||||
// For invite flow, if account creation failed, try login instead
|
||||
if (errorMessage.includes('User already exists') || errorMessage.includes('already-exists')) {
|
||||
try {
|
||||
console.log('Account exists, trying login instead...');
|
||||
await signInWithEmail(email, password);
|
||||
} catch (loginError) {
|
||||
console.error('Login also failed:', loginError);
|
||||
setError(`Account exists but password is incorrect. Please check your password or contact your administrator.`);
|
||||
setIsLoading(false);
|
||||
}
|
||||
} else {
|
||||
setError(`Failed to create account: ${errorMessage}. Please try a different email or contact your administrator.`);
|
||||
setIsLoading(false);
|
||||
}
|
||||
} else {
|
||||
// Regular login flow - try signup if user not found
|
||||
if (errorMessage.includes('User not found')) {
|
||||
try {
|
||||
console.log('User not found, attempting sign-up...');
|
||||
await signUpWithEmail(email, password, email.split('@')[0]);
|
||||
// Success will be handled by the useEffect hook
|
||||
} catch (signUpError) {
|
||||
console.error('Sign-up also failed:', signUpError);
|
||||
setError(`Failed to create account: ${signUpError instanceof Error ? signUpError.message : 'Unknown error'}`);
|
||||
setIsLoading(false);
|
||||
}
|
||||
} else {
|
||||
setError(`Login failed: ${errorMessage}`);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleLogin = async () => {
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await signInWithGoogle();
|
||||
// Success will be handled by the useEffect hook
|
||||
} catch (error) {
|
||||
console.error('Google login failed:', error);
|
||||
setError(`Google login failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[--background-primary] py-12 px-4 sm:px-6 lg:px-8">
|
||||
<Card className="max-w-md w-full space-y-8" padding="lg">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-blue-500 rounded-full flex items-center justify-center font-bold text-white text-2xl mx-auto mb-4">
|
||||
A
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-[--text-primary]">Welcome to Auditly</h2>
|
||||
<p className="text-[--text-secondary] mt-2">
|
||||
{inviteCode ? 'Complete your profile to join the team survey' : 'Sign in to your account'}
|
||||
</p>
|
||||
{inviteCode && (
|
||||
<div className="mt-3 p-3 bg-blue-100 dark:bg-blue-900 rounded-lg">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
<EFBFBD> <strong>Employee Survey Invitation</strong><br />
|
||||
No account needed! Just create a password to secure your responses and start the questionnaire.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="mt-3 p-3 bg-red-100 dark:bg-red-900 rounded-lg">
|
||||
<p className="text-sm text-red-800 dark:text-red-200">
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleEmailLogin} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Email {inviteCode && <span className="text-gray-500 dark:text-gray-400">(use your work email)</span>}
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-white text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-800 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Password {inviteCode && <span className="text-gray-500 dark:text-gray-400">(create a new password)</span>}
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-white text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-800 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"
|
||||
required
|
||||
/>
|
||||
{inviteCode && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Choose a secure password for your new account
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Processing...' : (inviteCode ? 'Create Account & Join Team' : 'Sign In')}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">Or continue with</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={handleGoogleLogin}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Sign in with Google
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Manual invite code entry - only show if no invite code in URL */}
|
||||
{!inviteCode && (
|
||||
<div className="border-t border-[--border-color] pt-6">
|
||||
<div className="text-center mb-4">
|
||||
<h3 className="text-sm font-medium text-[--text-primary] mb-2">Employee? Use Your Invite Code</h3>
|
||||
<p className="text-xs text-[--text-secondary]">
|
||||
Skip account creation - employees can go directly to their questionnaire
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter your invite code"
|
||||
className="flex-1 px-3 py-2 border border-[--input-border] rounded-lg bg-[--input-bg] text-[--text-primary] placeholder-[--input-placeholder] focus:outline-none focus:ring-2 focus:ring-[--accent] focus:border-[--accent]"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const code = (e.target as HTMLInputElement).value.trim();
|
||||
if (code) {
|
||||
window.location.href = `#/invite/${code}`;
|
||||
} else {
|
||||
alert('Please enter an invite code');
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
const input = document.querySelector('input[placeholder="Enter your invite code"]') as HTMLInputElement;
|
||||
const code = input?.value.trim();
|
||||
if (code) {
|
||||
window.location.href = `#/invite/${code}`;
|
||||
} else {
|
||||
alert('Please enter an invite code');
|
||||
}
|
||||
}}
|
||||
>
|
||||
Start Survey
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-[--text-secondary] mt-2 text-center">
|
||||
No account needed - just answer questions and submit
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-[--text-secondary]">
|
||||
{inviteCode ?
|
||||
'Demo mode: Enter any email and password to create your account.' :
|
||||
'Demo mode: No Firebase configuration detected.\nUse any email/password to continue.'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
403
pages/ModernLogin.tsx
Normal file
403
pages/ModernLogin.tsx
Normal file
@@ -0,0 +1,403 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { Button } from '../components/UiKit';
|
||||
|
||||
type AuthStep = 'email' | 'otp' | 'password-fallback';
|
||||
|
||||
const ModernLogin: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { inviteCode: routeInviteCode } = useParams<{ inviteCode: string }>();
|
||||
|
||||
// Auth state
|
||||
const { signInWithGoogle, signInWithEmail, signUpWithEmail, user, loading, sendOTP: authSendOTP, verifyOTP: authVerifyOTP } = useAuth();
|
||||
|
||||
// Form state
|
||||
const [step, setStep] = useState<AuthStep>('email');
|
||||
const [email, setEmail] = useState('');
|
||||
const [otp, setOtp] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [inviteCode, setInviteCode] = useState<string | null>(null);
|
||||
const [resendCooldown, setResendCooldown] = useState(0);
|
||||
const [demoOTP, setDemoOTP] = useState<string | null>(null);
|
||||
|
||||
// Extract invite code from URL
|
||||
useEffect(() => {
|
||||
if (routeInviteCode) {
|
||||
setInviteCode(routeInviteCode);
|
||||
} else {
|
||||
const hashSearch = location.hash.includes('?') ? location.hash.split('?')[1] : '';
|
||||
const searchParams = new URLSearchParams(hashSearch);
|
||||
const queryInvite = searchParams.get('invite');
|
||||
if (queryInvite) {
|
||||
setInviteCode(queryInvite);
|
||||
}
|
||||
}
|
||||
}, [routeInviteCode, location]);
|
||||
|
||||
// Handle successful authentication
|
||||
useEffect(() => {
|
||||
if (user && !loading) {
|
||||
if (inviteCode) {
|
||||
navigate(`/org-selection?invite=${inviteCode}`, { replace: true });
|
||||
} else {
|
||||
navigate('/org-selection', { replace: true });
|
||||
}
|
||||
}
|
||||
}, [user, loading, navigate, inviteCode]);
|
||||
|
||||
// Resend cooldown timer
|
||||
useEffect(() => {
|
||||
if (resendCooldown > 0) {
|
||||
const timer = setTimeout(() => setResendCooldown(resendCooldown - 1), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [resendCooldown]);
|
||||
|
||||
const sendOTP = async (emailAddress: string) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
setDemoOTP(null);
|
||||
|
||||
// Call auth context method
|
||||
const response = await authSendOTP(emailAddress, inviteCode || undefined);
|
||||
|
||||
// If OTP is returned in response (demo mode), display it
|
||||
if (response.otp) {
|
||||
setDemoOTP(response.otp);
|
||||
}
|
||||
|
||||
setStep('otp');
|
||||
setResendCooldown(60); // 60 second cooldown
|
||||
|
||||
} catch (err) {
|
||||
console.error('OTP send error:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to send verification code. Please try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}; const verifyOTP = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
// Call auth context method
|
||||
await authVerifyOTP(email, otp, inviteCode || undefined);
|
||||
|
||||
// Success - user will be set in auth context and useEffect will handle navigation
|
||||
|
||||
} catch (err) {
|
||||
console.error('OTP verification error:', err);
|
||||
setError(err instanceof Error ? err.message : 'Invalid verification code. Please try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmailSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!email.trim()) return;
|
||||
|
||||
await sendOTP(email);
|
||||
};
|
||||
|
||||
const handleOTPSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!otp.trim()) return;
|
||||
|
||||
await verifyOTP();
|
||||
};
|
||||
|
||||
const handlePasswordFallback = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!password.trim()) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
// Try login first, then signup if user doesn't exist
|
||||
try {
|
||||
await signInWithEmail(email, password);
|
||||
} catch (loginError) {
|
||||
// If login fails, try creating account
|
||||
await signUpWithEmail(email, password, email.split('@')[0]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Password auth error:', err);
|
||||
setError(err instanceof Error ? err.message : 'Authentication failed');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleAuth = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
await signInWithGoogle();
|
||||
} catch (err) {
|
||||
console.error('Google auth error:', err);
|
||||
setError('Google authentication failed. Please try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderEmailStep = () => (
|
||||
<div className="w-full max-w-md mx-auto bg-white rounded-xl shadow-lg p-8">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<span className="text-white text-2xl font-bold">A</span>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
{inviteCode ? 'Join Organization' : 'Welcome to Auditly'}
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
{inviteCode
|
||||
? 'Enter your email to join the organization'
|
||||
: 'Enter your email to get started'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleEmailSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email Address
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.66675 5.83325L8.47085 10.5961C9.02182 10.9818 9.29731 11.1746 9.59697 11.2493C9.86166 11.3153 10.1385 11.3153 10.4032 11.2493C10.7029 11.1746 10.9783 10.9818 11.5293 10.5961L18.3334 5.83325M5.66675 16.6666H14.3334C15.7335 16.6666 16.4336 16.6666 16.9684 16.3941C17.4388 16.1544 17.8212 15.772 18.0609 15.3016C18.3334 14.7668 18.3334 14.0667 18.3334 12.6666V7.33325C18.3334 5.93312 18.3334 5.23306 18.0609 4.69828C17.8212 4.22787 17.4388 3.84542 16.9684 3.60574C16.4336 3.33325 15.7335 3.33325 14.3334 3.33325H5.66675C4.26662 3.33325 3.56655 3.33325 3.03177 3.60574C2.56137 3.84542 2.17892 4.22787 1.93923 4.69828C1.66675 5.23306 1.66675 5.93312 1.66675 7.33325V12.6666C1.66675 14.0667 1.66675 14.7668 1.93923 15.3016C2.17892 15.772 2.56137 16.1544 3.03177 16.3941C3.56655 16.6666 4.26662 16.6666 5.66675 16.6666Z" stroke="#718096" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="Enter your email"
|
||||
className="w-full pl-12 pr-4 py-3.5 bg-gray-50 border text-gray-700 border-gray-200 rounded-full focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-red-600 text-sm bg-red-50 border border-red-200 p-3 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white py-3.5 rounded-full font-medium transition-all transform hover:scale-[1.02]"
|
||||
disabled={isLoading || !email.trim()}
|
||||
>
|
||||
{isLoading ? 'Sending...' : 'Continue with Email'}
|
||||
</Button>
|
||||
|
||||
<div className="relative my-6">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-200" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-3 bg-white text-gray-500">or</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="w-full border-gray-200 py-3.5 rounded-full transition-colors"
|
||||
onClick={handleGoogleAuth}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
|
||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
|
||||
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
|
||||
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
|
||||
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
|
||||
</svg>
|
||||
Continue with Google
|
||||
</Button>
|
||||
|
||||
<div className="text-center mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep('password-fallback')}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 transition-colors"
|
||||
>
|
||||
Use password instead
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderOTPStep = () => (
|
||||
<div className="w-full max-w-md mx-auto bg-white rounded-xl shadow-lg p-8">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-green-500 to-emerald-600 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Check your email</h1>
|
||||
<p className="text-gray-600">
|
||||
We sent a verification code to <br />
|
||||
<strong className="text-gray-900">{email}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleOTPSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2 text-center">
|
||||
Verification Code
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={otp}
|
||||
onChange={(e) => setOtp(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="000000"
|
||||
className="w-full px-4 py-4 bg-gray-50 border border-gray-200 text-gray-700 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent text-center text-3xl tracking-[0.5em] font-mono outline-none transition-all"
|
||||
maxLength={6}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{demoOTP && (
|
||||
<div className="bg-gradient-to-r from-yellow-50 to-orange-50 border border-yellow-200 rounded-xl p-4">
|
||||
<div className="text-yellow-800 text-sm text-center">
|
||||
<div className="flex items-center justify-center mb-2">
|
||||
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<strong>Demo Mode</strong>
|
||||
</div>
|
||||
Your verification code is <strong className="text-2xl font-mono bg-yellow-100 px-2 py-1 rounded">{demoOTP}</strong>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-red-600 text-sm bg-red-50 border border-red-200 p-3 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white py-4 rounded-xl font-medium transition-all transform hover:scale-[1.02]"
|
||||
disabled={isLoading || otp.length !== 6}
|
||||
>
|
||||
{isLoading ? 'Verifying...' : 'Verify Code'}
|
||||
</Button>
|
||||
|
||||
<div className="text-center space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sendOTP(email)}
|
||||
disabled={resendCooldown > 0 || isLoading}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 disabled:text-gray-400 transition-colors"
|
||||
>
|
||||
{resendCooldown > 0
|
||||
? `Resend code in ${resendCooldown}s`
|
||||
: 'Resend code'
|
||||
}
|
||||
</button>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setStep('email'); setError(''); setOtp(''); }}
|
||||
className="text-sm text-gray-600 hover:text-gray-800 transition-colors"
|
||||
>
|
||||
← Change email address
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderPasswordStep = () => (
|
||||
<div className="w-full max-w-md mx-auto bg-white rounded-xl shadow-lg p-8">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-purple-500 to-pink-600 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Sign in with password</h1>
|
||||
<p className="text-gray-600">
|
||||
Enter your password for <br />
|
||||
<strong className="text-gray-900">{email}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handlePasswordFallback} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
className="w-full px-4 py-3.5 bg-gray-50 border border-gray-200 rounded-full focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-red-600 text-sm bg-red-50 border border-red-200 p-3 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white py-3.5 rounded-full font-medium transition-all transform hover:scale-[1.02]"
|
||||
disabled={isLoading || !password.trim()}
|
||||
>
|
||||
{isLoading ? 'Signing in...' : 'Sign In'}
|
||||
</Button>
|
||||
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setStep('email'); setError(''); setPassword(''); }}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 transition-colors"
|
||||
>
|
||||
← Back to email verification
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-purple-50 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-purple-50 flex items-center justify-center p-4">
|
||||
{step === 'email' && renderEmailStep()}
|
||||
{step === 'otp' && renderOTPStep()}
|
||||
{step === 'password-fallback' && renderPasswordStep()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModernLogin;
|
||||
719
pages/Onboarding.tsx
Normal file
719
pages/Onboarding.tsx
Normal file
@@ -0,0 +1,719 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useOrg } from '../contexts/OrgContext';
|
||||
import { Card, Button } from '../components/UiKit';
|
||||
import { FigmaProgress } from '../components/figma/FigmaProgress';
|
||||
import { FigmaInput } from '../components/figma/FigmaInput';
|
||||
import { FigmaAlert } from '../components/figma/FigmaAlert';
|
||||
|
||||
interface OnboardingData {
|
||||
// Step 1: Company Basics
|
||||
companyName: string;
|
||||
industry: string;
|
||||
size: string;
|
||||
description: string;
|
||||
|
||||
// Step 2: Mission & Vision
|
||||
mission: string;
|
||||
vision: string;
|
||||
values: string[];
|
||||
|
||||
// Step 3: Company Evolution & History
|
||||
foundingYear: string;
|
||||
evolution: string;
|
||||
majorMilestones: string;
|
||||
|
||||
// Step 4: Competitive Landscape
|
||||
advantages: string;
|
||||
vulnerabilities: string;
|
||||
competitors: string;
|
||||
marketPosition: string;
|
||||
|
||||
// Step 5: Current Challenges & Goals
|
||||
currentChallenges: string[];
|
||||
shortTermGoals: string;
|
||||
longTermGoals: string;
|
||||
keyMetrics: string;
|
||||
|
||||
// Step 6: Team & Culture
|
||||
cultureDescription: string;
|
||||
workEnvironment: string;
|
||||
leadershipStyle: string;
|
||||
communicationStyle: string;
|
||||
|
||||
// Step 7: Final Review
|
||||
additionalContext: string;
|
||||
}
|
||||
|
||||
const Onboarding: React.FC = () => {
|
||||
const { org, upsertOrg, generateCompanyWiki } = useOrg();
|
||||
const navigate = useNavigate();
|
||||
const [step, setStep] = useState(0);
|
||||
const [isGeneratingReport, setIsGeneratingReport] = useState(false);
|
||||
const [formData, setFormData] = useState<OnboardingData>({
|
||||
companyName: org?.name || '',
|
||||
industry: '',
|
||||
size: '',
|
||||
description: '',
|
||||
mission: '',
|
||||
vision: '',
|
||||
values: [],
|
||||
foundingYear: '',
|
||||
evolution: '',
|
||||
majorMilestones: '',
|
||||
advantages: '',
|
||||
vulnerabilities: '',
|
||||
competitors: '',
|
||||
marketPosition: '',
|
||||
currentChallenges: [],
|
||||
shortTermGoals: '',
|
||||
longTermGoals: '',
|
||||
keyMetrics: '',
|
||||
cultureDescription: '',
|
||||
workEnvironment: '',
|
||||
leadershipStyle: '',
|
||||
communicationStyle: '',
|
||||
additionalContext: ''
|
||||
});
|
||||
|
||||
const steps = [
|
||||
{
|
||||
title: 'Company Basics',
|
||||
description: 'Tell us about your company fundamentals'
|
||||
},
|
||||
{
|
||||
title: 'Mission & Vision',
|
||||
description: 'Define your purpose and direction'
|
||||
},
|
||||
{
|
||||
title: 'Evolution & History',
|
||||
description: 'Share your company\'s journey'
|
||||
},
|
||||
{
|
||||
title: 'Competitive Position',
|
||||
description: 'Understand your market position'
|
||||
},
|
||||
{
|
||||
title: 'Goals & Challenges',
|
||||
description: 'Current objectives and obstacles'
|
||||
},
|
||||
{
|
||||
title: 'Team & Culture',
|
||||
description: 'Describe your work environment'
|
||||
},
|
||||
{
|
||||
title: 'Final Review',
|
||||
description: 'Complete your company profile'
|
||||
}
|
||||
];
|
||||
|
||||
const handleNext = async () => {
|
||||
// Prevent re-entry during generation
|
||||
if (isGeneratingReport) return;
|
||||
|
||||
if (step < steps.length - 1) {
|
||||
setStep(prev => prev + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Final step: persist org & generate report
|
||||
setIsGeneratingReport(true);
|
||||
console.log('Starting onboarding completion...', { step });
|
||||
try {
|
||||
const newOrgData = {
|
||||
name: formData.companyName,
|
||||
industry: formData.industry,
|
||||
size: formData.size,
|
||||
description: formData.description,
|
||||
mission: formData.mission,
|
||||
vision: formData.vision,
|
||||
values: formData.values.join(','),
|
||||
foundingYear: formData.foundingYear,
|
||||
evolution: formData.evolution,
|
||||
majorMilestones: formData.majorMilestones,
|
||||
advantages: formData.advantages,
|
||||
vulnerabilities: formData.vulnerabilities,
|
||||
competitors: formData.competitors,
|
||||
marketPosition: formData.marketPosition,
|
||||
currentChallenges: formData.currentChallenges.join(','),
|
||||
shortTermGoals: formData.shortTermGoals,
|
||||
longTermGoals: formData.longTermGoals,
|
||||
keyMetrics: formData.keyMetrics,
|
||||
cultureDescription: formData.cultureDescription,
|
||||
workEnvironment: formData.workEnvironment,
|
||||
leadershipStyle: formData.leadershipStyle,
|
||||
communicationStyle: formData.communicationStyle,
|
||||
additionalContext: formData.additionalContext,
|
||||
onboardingCompleted: true
|
||||
};
|
||||
|
||||
console.log('Saving org data...', newOrgData);
|
||||
await upsertOrg(newOrgData);
|
||||
console.log('Org data saved successfully');
|
||||
|
||||
console.log('Generating company wiki...');
|
||||
await generateCompanyWiki(newOrgData);
|
||||
console.log('Company wiki generated successfully');
|
||||
|
||||
// Small delay to ensure states are updated, then redirect
|
||||
console.log('Redirecting to reports...');
|
||||
setTimeout(() => {
|
||||
console.log('Navigation executing...');
|
||||
navigate('/reports', { replace: true });
|
||||
console.log('Navigation called successfully');
|
||||
}, 100);
|
||||
} catch (error) {
|
||||
console.error('Error completing onboarding:', error);
|
||||
// Show detailed error to user for debugging
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
|
||||
alert(`There was an error completing the setup: ${errorMessage}. Please check the console for more details and try again.`);
|
||||
} finally {
|
||||
setIsGeneratingReport(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (step > 0) setStep(step - 1);
|
||||
};
|
||||
|
||||
const addToArray = (field: 'values' | 'currentChallenges', value: string) => {
|
||||
if (value.trim()) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: [...prev[field], value.trim()]
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const removeFromArray = (field: 'values' | 'currentChallenges', index: number) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: prev[field].filter((_, i) => i !== index)
|
||||
}));
|
||||
};
|
||||
|
||||
const renderStep = () => {
|
||||
switch (step) {
|
||||
case 0:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[--text-primary] mb-2">
|
||||
Company Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.companyName}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, companyName: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Enter your company name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[--text-primary] mb-2">
|
||||
Industry *
|
||||
</label>
|
||||
<select
|
||||
value={formData.industry}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, industry: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Select industry</option>
|
||||
<option value="Technology">Technology</option>
|
||||
<option value="Healthcare">Healthcare</option>
|
||||
<option value="Finance">Finance</option>
|
||||
<option value="Manufacturing">Manufacturing</option>
|
||||
<option value="Retail">Retail</option>
|
||||
<option value="Professional Services">Professional Services</option>
|
||||
<option value="Education">Education</option>
|
||||
<option value="Media & Entertainment">Media & Entertainment</option>
|
||||
<option value="Other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[--text-primary] mb-2">
|
||||
Company Size *
|
||||
</label>
|
||||
<select
|
||||
value={formData.size}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, size: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Select size</option>
|
||||
<option value="1-10">1-10 employees</option>
|
||||
<option value="11-50">11-50 employees</option>
|
||||
<option value="51-200">51-200 employees</option>
|
||||
<option value="201-1000">201-1000 employees</option>
|
||||
<option value="1000+">1000+ employees</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[--text-primary] mb-2">
|
||||
Company Description *
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
rows={4}
|
||||
placeholder="Describe what your company does, its products/services, and target market"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 1:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[--text-primary] mb-2">
|
||||
Mission Statement *
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.mission}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, mission: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
rows={3}
|
||||
placeholder="What is your company's purpose? Why does it exist?"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[--text-primary] mb-2">
|
||||
Vision Statement *
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.vision}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, vision: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
rows={3}
|
||||
placeholder="Where do you see your company in the future? What impact do you want to make?"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[--text-primary] mb-2">
|
||||
Core Values
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{formData.values.map((value, index) => (
|
||||
<div key={index} className="flex items-center space-x-2">
|
||||
<span className="flex-1 px-3 py-2 bg-[--background-tertiary] rounded-lg text-[--text-primary]">
|
||||
{value}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onClick={() => removeFromArray('values', index)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex space-x-2">
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Add a core value"
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
addToArray('values', e.currentTarget.value);
|
||||
e.currentTarget.value = '';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
const input = (e.target as HTMLElement).parentElement?.querySelector('input');
|
||||
if (input) {
|
||||
addToArray('values', input.value);
|
||||
input.value = '';
|
||||
}
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 2:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[--text-primary] mb-2">
|
||||
Founding Year
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.foundingYear}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, foundingYear: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="When was your company founded?"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[--text-primary] mb-2">
|
||||
Company Evolution *
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.evolution}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, evolution: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
rows={4}
|
||||
placeholder="How has your company evolved since its founding? What major changes or pivots have occurred?"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[--text-primary] mb-2">
|
||||
Major Milestones
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.majorMilestones}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, majorMilestones: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
rows={4}
|
||||
placeholder="List key achievements, product launches, funding rounds, or other significant milestones"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 3:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[--text-primary] mb-2">
|
||||
Competitive Advantages *
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.advantages}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, advantages: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
rows={4}
|
||||
placeholder="What gives your company a competitive edge? What are your unique strengths?"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[--text-primary] mb-2">
|
||||
Vulnerabilities & Weaknesses *
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.vulnerabilities}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, vulnerabilities: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
rows={4}
|
||||
placeholder="What are your company's current weaknesses or areas of vulnerability?"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[--text-primary] mb-2">
|
||||
Key Competitors
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.competitors}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, competitors: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
rows={3}
|
||||
placeholder="Who are your main competitors? How do you differentiate from them?"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[--text-primary] mb-2">
|
||||
Market Position
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.marketPosition}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, marketPosition: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
rows={3}
|
||||
placeholder="How do you position yourself in the market? What's your market share or standing?"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 4:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[--text-primary] mb-2">
|
||||
Current Challenges
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{formData.currentChallenges.map((challenge, index) => (
|
||||
<div key={index} className="flex items-center space-x-2">
|
||||
<span className="flex-1 px-3 py-2 bg-[--background-tertiary] rounded-lg text-[--text-primary]">
|
||||
{challenge}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onClick={() => removeFromArray('currentChallenges', index)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex space-x-2">
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Add a current challenge"
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
addToArray('currentChallenges', e.currentTarget.value);
|
||||
e.currentTarget.value = '';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
const input = (e.target as HTMLElement).parentElement?.querySelector('input');
|
||||
if (input) {
|
||||
addToArray('currentChallenges', input.value);
|
||||
input.value = '';
|
||||
}
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[--text-primary] mb-2">
|
||||
Short-term Goals (6-12 months) *
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.shortTermGoals}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, shortTermGoals: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
rows={4}
|
||||
placeholder="What are your immediate priorities and goals for the next 6-12 months?"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[--text-primary] mb-2">
|
||||
Long-term Goals (1-3 years) *
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.longTermGoals}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, longTermGoals: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
rows={4}
|
||||
placeholder="What are your strategic objectives for the next 1-3 years?"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[--text-primary] mb-2">
|
||||
Key Metrics & Success Indicators
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.keyMetrics}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, keyMetrics: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
rows={3}
|
||||
placeholder="How do you measure success? What are your key performance indicators?"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 5:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[--text-primary] mb-2">
|
||||
Company Culture *
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.cultureDescription}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, cultureDescription: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
rows={4}
|
||||
placeholder="Describe your company culture. What's it like to work at your company?"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[--text-primary] mb-2">
|
||||
Work Environment *
|
||||
</label>
|
||||
<select
|
||||
value={formData.workEnvironment}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, workEnvironment: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Select work environment</option>
|
||||
<option value="Remote">Fully Remote</option>
|
||||
<option value="Hybrid">Hybrid (Remote + Office)</option>
|
||||
<option value="In-office">In-office</option>
|
||||
<option value="Flexible">Flexible/Varies by role</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[--text-primary] mb-2">
|
||||
Leadership Style *
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.leadershipStyle}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, leadershipStyle: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
rows={3}
|
||||
placeholder="Describe the leadership approach and management style in your organization"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[--text-primary] mb-2">
|
||||
Communication Style *
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.communicationStyle}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, communicationStyle: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
rows={3}
|
||||
placeholder="How does your team communicate? What tools and processes do you use?"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 6:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center mb-6">
|
||||
<div className="text-4xl mb-4">📋</div>
|
||||
<h3 className="text-xl font-semibold text-[--text-primary] mb-2">
|
||||
Review Your Information
|
||||
</h3>
|
||||
<p className="text-[--text-secondary]">
|
||||
Please review the information below and add any additional context
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-[--background-tertiary] p-4 rounded-lg space-y-3">
|
||||
<div><strong>Company:</strong> {formData.companyName}</div>
|
||||
<div><strong>Industry:</strong> {formData.industry}</div>
|
||||
<div><strong>Size:</strong> {formData.size}</div>
|
||||
<div><strong>Mission:</strong> {formData.mission.substring(0, 100)}{formData.mission.length > 100 ? '...' : ''}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[--text-primary] mb-2">
|
||||
Additional Context
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.additionalContext}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, additionalContext: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-[--border-color] rounded-lg bg-[--background-secondary] text-[--text-primary] focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
rows={4}
|
||||
placeholder="Is there anything else important about your company that Auditly should know to provide better insights?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-center pt-4">
|
||||
<div className="text-4xl mb-4">🎉</div>
|
||||
<h3 className="text-xl font-semibold text-[--text-primary]">
|
||||
Ready to Complete Setup!
|
||||
</h3>
|
||||
<p className="text-[--text-secondary]">
|
||||
{isGeneratingReport
|
||||
? 'Generating your personalized company insights...'
|
||||
: 'Once you complete this step, you\'ll have access to all Auditly features and your personalized company wiki will be generated.'
|
||||
}
|
||||
</p>
|
||||
{isGeneratingReport && (
|
||||
<div className="mt-4 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
||||
<span className="ml-3 text-[--text-secondary]">Creating your company profile...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const canProceed = () => {
|
||||
switch (step) {
|
||||
case 0:
|
||||
return formData.companyName.trim().length > 0 && formData.industry && formData.size && formData.description.trim().length > 0;
|
||||
case 1:
|
||||
return formData.mission.trim().length > 0 && formData.vision.trim().length > 0;
|
||||
case 2:
|
||||
return formData.evolution.trim().length > 0;
|
||||
case 3:
|
||||
return formData.advantages.trim().length > 0 && formData.vulnerabilities.trim().length > 0;
|
||||
case 4:
|
||||
return formData.shortTermGoals.trim().length > 0 && formData.longTermGoals.trim().length > 0;
|
||||
case 5:
|
||||
return formData.cultureDescription.trim().length > 0 && formData.workEnvironment && formData.leadershipStyle.trim().length > 0 && formData.communicationStyle.trim().length > 0;
|
||||
case 6:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[--background-primary] flex items-center justify-center p-4">
|
||||
<div className="max-w-4xl w-full">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-[--text-primary] mb-2">
|
||||
Welcome to Auditly
|
||||
</h1>
|
||||
<p className="text-[--text-secondary]">
|
||||
Let's build a comprehensive profile of your organization to provide the best insights
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Progress indicator */}
|
||||
<div className="mb-8">
|
||||
<FigmaProgress
|
||||
currentStep={step + 1}
|
||||
steps={steps.map((s, i) => ({ number: i + 1, title: s.title }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card className="max-w-none">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-[--text-primary] mb-2">
|
||||
{steps[step].title}
|
||||
</h2>
|
||||
<p className="text-[--text-secondary]">
|
||||
{steps[step].description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{renderStep()}
|
||||
|
||||
<div className="flex justify-between mt-8">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleBack}
|
||||
disabled={step === 0}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={!canProceed() || isGeneratingReport}
|
||||
>
|
||||
{isGeneratingReport
|
||||
? 'Generating Wiki...'
|
||||
: step === steps.length - 1 ? 'Complete Setup & Generate Wiki' : 'Next'
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Onboarding;
|
||||
218
pages/OrgSelection.tsx
Normal file
218
pages/OrgSelection.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useUserOrganizations } from '../contexts/UserOrganizationsContext';
|
||||
import { Card, Button } from '../components/UiKit';
|
||||
|
||||
const OrgSelection: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user } = useAuth();
|
||||
const {
|
||||
organizations,
|
||||
loading,
|
||||
selectOrganization,
|
||||
createOrganization,
|
||||
joinOrganization
|
||||
} = useUserOrganizations();
|
||||
|
||||
const [showCreateOrg, setShowCreateOrg] = useState(false);
|
||||
const [newOrgName, setNewOrgName] = useState('');
|
||||
const [inviteCode, setInviteCode] = useState('');
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isJoining, setIsJoining] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check for invite code in URL
|
||||
const hashSearch = location.hash.includes('?') ? location.hash.split('?')[1] : '';
|
||||
const searchParams = new URLSearchParams(hashSearch);
|
||||
const queryInvite = searchParams.get('invite');
|
||||
if (queryInvite) {
|
||||
setInviteCode(queryInvite);
|
||||
}
|
||||
}, [location]);
|
||||
|
||||
// Auto-join organization when invite code is present
|
||||
useEffect(() => {
|
||||
if (inviteCode && !isJoining && !loading) {
|
||||
handleJoinWithInvite();
|
||||
}
|
||||
}, [inviteCode, isJoining, loading]);
|
||||
|
||||
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') {
|
||||
navigate('/onboarding', { replace: true });
|
||||
} else {
|
||||
navigate('/reports', { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateOrg = async () => {
|
||||
if (!newOrgName.trim() || isCreating) return;
|
||||
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const result = await createOrganization(newOrgName);
|
||||
selectOrganization(result.orgId);
|
||||
|
||||
// Check if subscription setup is required
|
||||
if (result.requiresSubscription) {
|
||||
navigate(`/subscription-setup?orgId=${result.orgId}`, { replace: true });
|
||||
} else {
|
||||
navigate('/onboarding', { replace: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create organization:', error);
|
||||
alert('Failed to create organization. Please try again.');
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleJoinWithInvite = async () => {
|
||||
if (!inviteCode.trim() || isJoining) return;
|
||||
|
||||
setIsJoining(true);
|
||||
try {
|
||||
const orgId = await joinOrganization(inviteCode);
|
||||
selectOrganization(orgId);
|
||||
navigate('/employee-questionnaire', { replace: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to join organization:', error);
|
||||
alert('Failed to join organization. Please check your invite code.');
|
||||
} finally {
|
||||
setIsJoining(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8">Loading your organizations...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[--background-primary] py-12">
|
||||
<div className="max-w-md mx-auto">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-[--accent] rounded-full flex items-center justify-center font-bold text-white text-2xl mx-auto mb-4">
|
||||
A
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-[--text-primary] mb-2">Welcome to Auditly</h1>
|
||||
<p className="text-[--text-secondary]">Select an organization to continue</p>
|
||||
</div>
|
||||
|
||||
{/* Existing Organizations */}
|
||||
{organizations.length > 0 && (
|
||||
<Card className="mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4 text-[--text-primary]">Your Organizations</h2>
|
||||
<div className="space-y-3">
|
||||
{organizations.map((org) => (
|
||||
<div
|
||||
key={org.orgId}
|
||||
className="flex items-center justify-between p-3 border border-[--border-color] rounded-lg hover:bg-[--background-secondary] cursor-pointer"
|
||||
onClick={() => handleSelectOrg(org.orgId)}
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium text-[--text-primary]">{org.name}</div>
|
||||
<div className="text-sm text-[--text-secondary]">
|
||||
{org.role} • {org.onboardingCompleted ? 'Active' : 'Setup Required'}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm">
|
||||
Enter
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Join with Invite */}
|
||||
{inviteCode && (
|
||||
<Card className="mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4 text-[--text-primary]">Join Organization</h2>
|
||||
<p className="text-sm text-[--text-secondary] mb-4">
|
||||
You've been invited to join an organization with code: <code className="bg-[--background-tertiary] px-2 py-1 rounded text-[--text-primary]">{inviteCode}</code>
|
||||
</p>
|
||||
<Button onClick={handleJoinWithInvite} className="w-full" disabled={isJoining}>
|
||||
{isJoining ? 'Joining...' : 'Accept Invitation'}
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Create New Organization */}
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold mb-4 text-[--text-primary]">Create New Organization</h2>
|
||||
{!showCreateOrg ? (
|
||||
<Button
|
||||
onClick={() => setShowCreateOrg(true)}
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
>
|
||||
+ Create New Organization
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[--text-primary] mb-2">
|
||||
Organization Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newOrgName}
|
||||
onChange={(e) => setNewOrgName(e.target.value)}
|
||||
placeholder="Enter organization name"
|
||||
className="w-full px-3 py-2 border border-[--input-border] rounded-lg bg-[--input-bg] text-[--text-primary] placeholder-[--input-placeholder] focus:outline-none focus:ring-2 focus:ring-[--accent] focus:border-[--accent]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<Button
|
||||
onClick={handleCreateOrg}
|
||||
disabled={!newOrgName.trim() || isCreating}
|
||||
className="flex-1"
|
||||
>
|
||||
{isCreating ? 'Creating...' : 'Create Organization'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowCreateOrg(false)}
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Manual Invite Code Entry */}
|
||||
{!inviteCode && (
|
||||
<Card className="mt-6">
|
||||
<h2 className="text-lg font-semibold mb-4 text-[--text-primary]">Have an invite code?</h2>
|
||||
<div className="flex space-x-3">
|
||||
<input
|
||||
type="text"
|
||||
value={inviteCode}
|
||||
onChange={(e) => setInviteCode(e.target.value)}
|
||||
placeholder="Enter invite code"
|
||||
className="flex-1 px-3 py-2 border border-[--input-border] rounded-lg bg-[--input-bg] text-[--text-primary] placeholder-[--input-placeholder] focus:outline-none focus:ring-2 focus:ring-[--accent] focus:border-[--accent]"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleJoinWithInvite}
|
||||
disabled={!inviteCode.trim() || isJoining}
|
||||
variant="secondary"
|
||||
>
|
||||
{isJoining ? 'Joining...' : 'Join'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrgSelection;
|
||||
218
pages/QuestionTypesDemo.tsx
Normal file
218
pages/QuestionTypesDemo.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card } from '../components/UiKit';
|
||||
import { QuestionInput } from '../components/ui/QuestionInput';
|
||||
import { Question } from '../components/ui/Question';
|
||||
import { FigmaQuestion } from '../components/figma/FigmaQuestion';
|
||||
import { EmployeeQuestion } from '../employeeQuestions';
|
||||
|
||||
const QuestionTypesDemo: React.FC = () => {
|
||||
const [answers, setAnswers] = useState<Record<string, string>>({});
|
||||
|
||||
const demoQuestions: EmployeeQuestion[] = [
|
||||
{
|
||||
id: 'text_demo',
|
||||
prompt: 'What is your full name?',
|
||||
category: 'personal',
|
||||
type: 'text',
|
||||
placeholder: 'Enter your full name',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'scale_demo',
|
||||
prompt: 'How satisfied are you with your role?',
|
||||
category: 'role',
|
||||
type: 'scale',
|
||||
scaleMin: 1,
|
||||
scaleMax: 10,
|
||||
scaleLabels: { min: 'Very dissatisfied', max: 'Very satisfied' },
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'yesno_demo',
|
||||
prompt: 'Do you have regular one-on-ones with your manager?',
|
||||
category: 'collaboration',
|
||||
type: 'yesno',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: 'textarea_demo',
|
||||
prompt: 'Describe your main responsibilities',
|
||||
category: 'role',
|
||||
type: 'textarea',
|
||||
placeholder: 'List your key daily tasks and responsibilities...',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'select_demo',
|
||||
prompt: 'What is your department?',
|
||||
category: 'role',
|
||||
type: 'select',
|
||||
options: ['Engineering', 'Marketing', 'Sales', 'HR', 'Finance', 'Operations'],
|
||||
required: false,
|
||||
}
|
||||
];
|
||||
|
||||
const [currentDemoQuestion, setCurrentDemoQuestion] = useState(0);
|
||||
|
||||
const handleAnswerChange = (questionId: string, value: string) => {
|
||||
setAnswers(prev => ({ ...prev, [questionId]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[--background-primary] py-8 px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-[--text-primary] mb-2">
|
||||
Question Types Demo
|
||||
</h1>
|
||||
<p className="text-[--text-secondary]">
|
||||
Showcase of different question input types for the employee questionnaire
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Traditional Question Layout */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-[--text-primary] mb-4">
|
||||
Traditional Layout
|
||||
</h2>
|
||||
<div className="space-y-6">
|
||||
{demoQuestions.map((question, index) => (
|
||||
<Card key={question.id} className="p-6">
|
||||
<Question
|
||||
label={`${index + 1}. ${question.prompt}`}
|
||||
required={question.required}
|
||||
description={`Type: ${question.type} | Category: ${question.category}`}
|
||||
>
|
||||
<QuestionInput
|
||||
question={question}
|
||||
value={answers[question.id] || ''}
|
||||
onChange={(value) => handleAnswerChange(question.id, value)}
|
||||
/>
|
||||
</Question>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Figma Question Layout */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-[--text-primary] mb-4">
|
||||
Figma Design Layout
|
||||
</h2>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="flex gap-2 mb-4">
|
||||
{demoQuestions.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setCurrentDemoQuestion(index)}
|
||||
className={`px-3 py-1 rounded text-sm ${currentDemoQuestion === index
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
Q{index + 1}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<FigmaQuestion
|
||||
questionNumber={`Q${currentDemoQuestion + 1}`}
|
||||
title={demoQuestions[currentDemoQuestion].prompt}
|
||||
description={
|
||||
demoQuestions[currentDemoQuestion].required
|
||||
? 'Required'
|
||||
: 'Optional'
|
||||
}
|
||||
answer={answers[demoQuestions[currentDemoQuestion].id] || ''}
|
||||
onAnswerChange={(value) =>
|
||||
handleAnswerChange(demoQuestions[currentDemoQuestion].id, value)
|
||||
}
|
||||
onBack={
|
||||
currentDemoQuestion > 0
|
||||
? () => setCurrentDemoQuestion(prev => prev - 1)
|
||||
: undefined
|
||||
}
|
||||
onNext={
|
||||
currentDemoQuestion < demoQuestions.length - 1
|
||||
? () => setCurrentDemoQuestion(prev => prev + 1)
|
||||
: undefined
|
||||
}
|
||||
nextLabel={
|
||||
currentDemoQuestion < demoQuestions.length - 1
|
||||
? 'Next'
|
||||
: 'Finish'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Alternative Input Below Figma Question */}
|
||||
<div className="mt-6 max-w-2xl mx-auto">
|
||||
<Card className="p-6">
|
||||
<Question
|
||||
label={`Alternative Input for Q${currentDemoQuestion + 1}`}
|
||||
description={`Specialized ${demoQuestions[currentDemoQuestion].type} input`}
|
||||
>
|
||||
<QuestionInput
|
||||
question={demoQuestions[currentDemoQuestion]}
|
||||
value={answers[demoQuestions[currentDemoQuestion].id] || ''}
|
||||
onChange={(value) =>
|
||||
handleAnswerChange(demoQuestions[currentDemoQuestion].id, value)
|
||||
}
|
||||
/>
|
||||
</Question>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Answers Display */}
|
||||
<div className="mt-8">
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[--text-primary] mb-4">
|
||||
Current Answers
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{demoQuestions.map((question) => (
|
||||
<div key={question.id} className="grid grid-cols-1 md:grid-cols-3 gap-2 py-2 border-b border-[--border-color]">
|
||||
<div className="font-medium text-[--text-primary]">
|
||||
{question.prompt}
|
||||
</div>
|
||||
<div className="text-[--text-secondary] text-sm">
|
||||
{question.type}
|
||||
</div>
|
||||
<div className="text-[--text-primary]">
|
||||
{answers[question.id] || <span className="text-[--text-secondary] italic">No answer</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Navigation Links */}
|
||||
<div className="mt-8 text-center">
|
||||
<div className="space-x-4">
|
||||
<a
|
||||
href="#/employee-questionnaire"
|
||||
className="inline-block px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
Traditional Questionnaire
|
||||
</a>
|
||||
<a
|
||||
href="#/employee-questionnaire-steps"
|
||||
className="inline-block px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 transition-colors"
|
||||
>
|
||||
Stepped Questionnaire
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuestionTypesDemo;
|
||||
69
pages/QuestionnaireComplete.tsx
Normal file
69
pages/QuestionnaireComplete.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { CheckCircle, FileText, Sparkles } from 'lucide-react';
|
||||
|
||||
const QuestionnaireComplete: React.FC = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {
|
||||
employeeName = 'Employee',
|
||||
reportGenerated = false,
|
||||
message = 'Thank you for completing the questionnaire!'
|
||||
} = location.state || {};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[--background-primary] flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full bg-[--background-secondary] rounded-lg shadow-lg p-8 text-center">
|
||||
<div className="flex justify-center mb-6">
|
||||
{reportGenerated ? (
|
||||
<div className="relative">
|
||||
<CheckCircle className="w-16 h-16 text-green-500" />
|
||||
<Sparkles className="w-6 h-6 text-yellow-400 absolute -top-1 -right-1" />
|
||||
</div>
|
||||
) : (
|
||||
<CheckCircle className="w-16 h-16 text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold text-[--text-primary] mb-4">
|
||||
Questionnaire Complete!
|
||||
</h1>
|
||||
|
||||
<p className="text-[--text-secondary] mb-6">
|
||||
{message}
|
||||
</p>
|
||||
|
||||
{reportGenerated && (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-6">
|
||||
<div className="flex items-center justify-center mb-2">
|
||||
<FileText className="w-5 h-5 text-blue-600 mr-2" />
|
||||
<span className="text-sm font-medium text-blue-600">AI Report Generated</span>
|
||||
</div>
|
||||
<p className="text-sm text-blue-600 dark:text-blue-300">
|
||||
Your personalized performance report has been created using AI analysis of your responses.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => navigate('/reports')}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors"
|
||||
>
|
||||
{reportGenerated ? 'View Your Report' : 'Go to Dashboard'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="w-full bg-gray-600 hover:bg-gray-700 text-white font-medium py-2 px-4 rounded-lg transition-colors"
|
||||
>
|
||||
Return to Home
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuestionnaireComplete;
|
||||
210
pages/SubscriptionSetup.tsx
Normal file
210
pages/SubscriptionSetup.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useUserOrganizations } from '../contexts/UserOrganizationsContext';
|
||||
|
||||
const SubscriptionSetup: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { user } = useAuth();
|
||||
const { createCheckoutSession, getSubscriptionStatus } = useUserOrganizations();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [orgId, setOrgId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const orgIdParam = searchParams.get('orgId');
|
||||
const sessionId = searchParams.get('session_id');
|
||||
const canceled = searchParams.get('canceled');
|
||||
|
||||
if (orgIdParam) {
|
||||
setOrgId(orgIdParam);
|
||||
}
|
||||
|
||||
if (sessionId) {
|
||||
// Handle successful checkout
|
||||
handleSuccessfulCheckout(sessionId);
|
||||
} else if (canceled) {
|
||||
setError('Subscription setup was canceled. You can try again or use the 14-day trial.');
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const handleSuccessfulCheckout = async (sessionId: string) => {
|
||||
if (!orgId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
// Get updated subscription status
|
||||
await getSubscriptionStatus(orgId);
|
||||
|
||||
// Redirect to onboarding to complete organization setup
|
||||
setTimeout(() => {
|
||||
navigate('/onboarding', { replace: true });
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
console.error('Error handling successful checkout:', error);
|
||||
setError('There was an issue verifying your subscription. Please contact support.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartSubscription = async () => {
|
||||
if (!user || !orgId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const { sessionUrl } = await createCheckoutSession(orgId, user.email!);
|
||||
|
||||
// Redirect to Stripe Checkout
|
||||
window.location.href = sessionUrl;
|
||||
} catch (error) {
|
||||
console.error('Failed to create checkout session:', error);
|
||||
setError('Failed to start subscription setup. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkipForNow = () => {
|
||||
// Allow user to continue with trial - go to onboarding to complete setup
|
||||
navigate('/onboarding', { replace: true });
|
||||
};
|
||||
|
||||
if (searchParams.get('session_id')) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Subscription Active!</h2>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Your subscription has been successfully set up. Redirecting to complete your organization setup...
|
||||
</p>
|
||||
{loading && (
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="text-center">
|
||||
<h2 className="text-3xl font-extrabold text-gray-900">Complete Your Setup</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Set up your subscription to unlock all features
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
|
||||
<div className="space-y-6">
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-md p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-blue-800">14-Day Free Trial</h3>
|
||||
<p className="mt-2 text-sm text-blue-700">
|
||||
Start with a free trial. No payment required until the trial ends.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg p-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Auditly Standard Plan</h3>
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex items-center">
|
||||
<svg className="h-4 w-4 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-sm text-gray-600">Up to 50 employees</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<svg className="h-4 w-4 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-sm text-gray-600">AI-powered employee reports</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<svg className="h-4 w-4 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-sm text-gray-600">Company analytics & insights</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<svg className="h-4 w-4 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-sm text-gray-600">AI chat assistant</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-900 mb-1">$29<span className="text-sm font-normal text-gray-600">/month</span></div>
|
||||
<p className="text-xs text-gray-500">Billed monthly, cancel anytime</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={handleStartSubscription}
|
||||
disabled={loading}
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
) : (
|
||||
'Start Subscription'
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleSkipForNow}
|
||||
className="w-full flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Continue with Trial
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-center text-gray-500">
|
||||
Your trial will last 14 days. No payment required until the trial ends.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionSetup;
|
||||
3
postcss.config.mjs
Normal file
3
postcss.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
// Tailwind CSS v4: use the separate @tailwindcss/postcss plugin
|
||||
|
||||
export default { plugins: { "@tailwindcss/postcss": {} } }
|
||||
1162
server/index.js
Normal file
1162
server/index.js
Normal file
File diff suppressed because it is too large
Load Diff
219
services/demoStorage.ts
Normal file
219
services/demoStorage.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
// Demo mode data persistence using localStorage
|
||||
// This provides a more robust storage solution for demo mode without Firebase
|
||||
|
||||
export interface DemoUser {
|
||||
uid: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
passwordHash: string; // Simple hash for demo purposes
|
||||
}
|
||||
|
||||
export interface DemoOrganization {
|
||||
orgId: string;
|
||||
name: string;
|
||||
onboardingCompleted: boolean;
|
||||
[key: string]: any; // Additional org data from onboarding
|
||||
}
|
||||
|
||||
export interface DemoEmployee {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role?: string;
|
||||
department?: string;
|
||||
orgId: string;
|
||||
}
|
||||
|
||||
export interface DemoSubmission {
|
||||
employeeId: string;
|
||||
orgId: string;
|
||||
answers: Record<string, string>;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface DemoInvite {
|
||||
code: string;
|
||||
employee: DemoEmployee;
|
||||
used: boolean;
|
||||
createdAt: number;
|
||||
orgId: string;
|
||||
}
|
||||
|
||||
class DemoStorageService {
|
||||
private getKey(key: string): string {
|
||||
return `auditly_demo_${key}`;
|
||||
}
|
||||
|
||||
// User management
|
||||
saveUser(user: DemoUser): void {
|
||||
const users = this.getUsers();
|
||||
users[user.email] = user;
|
||||
localStorage.setItem(this.getKey('users'), JSON.stringify(users));
|
||||
}
|
||||
|
||||
getUsers(): Record<string, DemoUser> {
|
||||
const data = localStorage.getItem(this.getKey('users'));
|
||||
return data ? JSON.parse(data) : {};
|
||||
}
|
||||
|
||||
getUserByEmail(email: string): DemoUser | null {
|
||||
const users = this.getUsers();
|
||||
return users[email] || null;
|
||||
}
|
||||
|
||||
getUserByUid(uid: string): DemoUser | null {
|
||||
const users = this.getUsers();
|
||||
return Object.values(users).find(user => user.uid === uid) || null;
|
||||
}
|
||||
|
||||
// Simple password hashing for demo (not secure, just for demo purposes)
|
||||
hashPassword(password: string): string {
|
||||
return btoa(password).split('').reverse().join('');
|
||||
}
|
||||
|
||||
verifyPassword(password: string, hash: string): boolean {
|
||||
return this.hashPassword(password) === hash;
|
||||
}
|
||||
|
||||
// Organization management
|
||||
saveOrganization(org: DemoOrganization): void {
|
||||
const orgs = this.getOrganizations();
|
||||
orgs[org.orgId] = org;
|
||||
localStorage.setItem(this.getKey('organizations'), JSON.stringify(orgs));
|
||||
}
|
||||
|
||||
getOrganizations(): Record<string, DemoOrganization> {
|
||||
const data = localStorage.getItem(this.getKey('organizations'));
|
||||
return data ? JSON.parse(data) : {};
|
||||
}
|
||||
|
||||
getOrganization(orgId: string): DemoOrganization | null {
|
||||
const orgs = this.getOrganizations();
|
||||
return orgs[orgId] || null;
|
||||
}
|
||||
|
||||
// Employee management
|
||||
saveEmployee(employee: DemoEmployee): void {
|
||||
const employees = this.getEmployees();
|
||||
const key = `${employee.orgId}_${employee.id}`;
|
||||
employees[key] = employee;
|
||||
localStorage.setItem(this.getKey('employees'), JSON.stringify(employees));
|
||||
}
|
||||
|
||||
getEmployees(): Record<string, DemoEmployee> {
|
||||
const data = localStorage.getItem(this.getKey('employees'));
|
||||
return data ? JSON.parse(data) : {};
|
||||
}
|
||||
|
||||
getEmployeesByOrg(orgId: string): DemoEmployee[] {
|
||||
const employees = this.getEmployees();
|
||||
return Object.values(employees).filter(emp => emp.orgId === orgId);
|
||||
}
|
||||
|
||||
// Submission management
|
||||
saveSubmission(submission: DemoSubmission): void {
|
||||
const submissions = this.getSubmissions();
|
||||
const key = `${submission.orgId}_${submission.employeeId}`;
|
||||
submissions[key] = submission;
|
||||
localStorage.setItem(this.getKey('submissions'), JSON.stringify(submissions));
|
||||
}
|
||||
|
||||
getSubmissions(): Record<string, DemoSubmission> {
|
||||
const data = localStorage.getItem(this.getKey('submissions'));
|
||||
return data ? JSON.parse(data) : {};
|
||||
}
|
||||
|
||||
getSubmissionsByOrg(orgId: string): Record<string, DemoSubmission> {
|
||||
const submissions = this.getSubmissions();
|
||||
const result: Record<string, DemoSubmission> = {};
|
||||
Object.entries(submissions).forEach(([key, sub]) => {
|
||||
if (sub.orgId === orgId) {
|
||||
result[sub.employeeId] = sub;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// Invite management
|
||||
saveInvite(invite: DemoInvite): void {
|
||||
const invites = this.getInvites();
|
||||
invites[invite.code] = invite;
|
||||
localStorage.setItem(this.getKey('invites'), JSON.stringify(invites));
|
||||
}
|
||||
|
||||
getInvites(): Record<string, DemoInvite> {
|
||||
const data = localStorage.getItem(this.getKey('invites'));
|
||||
return data ? JSON.parse(data) : {};
|
||||
}
|
||||
|
||||
getInvite(code: string): DemoInvite | null {
|
||||
const invites = this.getInvites();
|
||||
return invites[code] || null;
|
||||
}
|
||||
|
||||
markInviteUsed(code: string): boolean {
|
||||
const invite = this.getInvite(code);
|
||||
if (invite && !invite.used) {
|
||||
invite.used = true;
|
||||
this.saveInvite(invite);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Company reports (simple storage)
|
||||
saveCompanyReport(orgId: string, report: any): void {
|
||||
const reports = this.getCompanyReports();
|
||||
if (!reports[orgId]) reports[orgId] = [];
|
||||
reports[orgId].push(report);
|
||||
localStorage.setItem(this.getKey('company_reports'), JSON.stringify(reports));
|
||||
}
|
||||
|
||||
getCompanyReports(): Record<string, any[]> {
|
||||
const data = localStorage.getItem(this.getKey('company_reports'));
|
||||
return data ? JSON.parse(data) : {};
|
||||
}
|
||||
|
||||
getCompanyReportsByOrg(orgId: string): any[] {
|
||||
const reports = this.getCompanyReports();
|
||||
return reports[orgId] || [];
|
||||
}
|
||||
|
||||
// Employee reports
|
||||
saveEmployeeReport(orgId: string, employeeId: string, report: any): void {
|
||||
const reports = this.getEmployeeReports();
|
||||
const key = `${orgId}_${employeeId}`;
|
||||
reports[key] = report;
|
||||
localStorage.setItem(this.getKey('employee_reports'), JSON.stringify(reports));
|
||||
}
|
||||
|
||||
getEmployeeReports(): Record<string, any> {
|
||||
const data = localStorage.getItem(this.getKey('employee_reports'));
|
||||
return data ? JSON.parse(data) : {};
|
||||
}
|
||||
|
||||
getEmployeeReportsByOrg(orgId: string): Record<string, any> {
|
||||
const reports = this.getEmployeeReports();
|
||||
const result: Record<string, any> = {};
|
||||
Object.entries(reports).forEach(([key, report]) => {
|
||||
if (key.startsWith(`${orgId}_`)) {
|
||||
const employeeId = key.substring(orgId.length + 1);
|
||||
result[employeeId] = report;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// Clear all demo data (for testing)
|
||||
clearAllData(): void {
|
||||
const keys = [
|
||||
'users', 'organizations', 'employees', 'submissions',
|
||||
'invites', 'company_reports', 'employee_reports'
|
||||
];
|
||||
keys.forEach(key => {
|
||||
localStorage.removeItem(this.getKey(key));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const demoStorage = new DemoStorageService();
|
||||
41
services/firebase.ts
Normal file
41
services/firebase.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { initializeApp, getApps } from 'firebase/app';
|
||||
import { getAuth, GoogleAuthProvider } from 'firebase/auth';
|
||||
import { getFirestore } from 'firebase/firestore';
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
|
||||
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
|
||||
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
|
||||
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
|
||||
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
|
||||
appId: import.meta.env.VITE_FIREBASE_APP_ID,
|
||||
};
|
||||
|
||||
export const isFirebaseConfigured = Boolean(
|
||||
firebaseConfig.apiKey &&
|
||||
firebaseConfig.authDomain &&
|
||||
firebaseConfig.projectId &&
|
||||
firebaseConfig.apiKey !== 'undefined' &&
|
||||
firebaseConfig.authDomain !== 'undefined' &&
|
||||
firebaseConfig.projectId !== 'undefined' &&
|
||||
// Force demo mode on localhost for testing
|
||||
!window.location.hostname.includes('localhost')
|
||||
);
|
||||
|
||||
let app;
|
||||
let auth;
|
||||
let db;
|
||||
let googleProvider;
|
||||
|
||||
if (isFirebaseConfigured) {
|
||||
app = getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0];
|
||||
auth = getAuth(app);
|
||||
db = getFirestore(app);
|
||||
googleProvider = new GoogleAuthProvider();
|
||||
} else {
|
||||
auth = null;
|
||||
db = null;
|
||||
googleProvider = null;
|
||||
}
|
||||
|
||||
export { auth, db, googleProvider };
|
||||
BIN
services/geminiService.ts
Normal file
BIN
services/geminiService.ts
Normal file
Binary file not shown.
BIN
services/llmService.ts
Normal file
BIN
services/llmService.ts
Normal file
Binary file not shown.
54
tailwind.config.js
Normal file
54
tailwind.config.js
Normal file
@@ -0,0 +1,54 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./**/*.{js,ts,jsx,tsx}",
|
||||
"./*.{js,ts,jsx,tsx,html,css}"
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Brand colors
|
||||
brand: {
|
||||
main: '#5E48FC',
|
||||
},
|
||||
// Neutral Light colors
|
||||
gray: {
|
||||
1: '#A4A7AE',
|
||||
2: '#D5D7DA',
|
||||
3: '#E9EAEB',
|
||||
4: '#F5F5F5',
|
||||
5: '#FAFAFA',
|
||||
6: '#FDFDFD',
|
||||
},
|
||||
// Neutral Dark colors
|
||||
dark: {
|
||||
1: '#A4A7AE',
|
||||
2: '#717680',
|
||||
3: '#535862',
|
||||
4: '#414651',
|
||||
5: '#252B37',
|
||||
6: '#181D27',
|
||||
7: '#0A0D12',
|
||||
},
|
||||
// Status colors
|
||||
status: {
|
||||
red: '#F63D68',
|
||||
green: '#3CCB7F',
|
||||
orange: '#FF4405',
|
||||
'orange-light': '#F38744',
|
||||
yellow: '#FEEE95',
|
||||
},
|
||||
// Base colors
|
||||
base: {
|
||||
white: '#FFFFFF',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
'inter': ['Inter Display', 'Inter', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
29
tsconfig.json
Normal file
29
tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"allowJs": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"types": [
|
||||
"vite/client"
|
||||
]
|
||||
}
|
||||
}
|
||||
156
types.ts
Normal file
156
types.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
|
||||
import React from 'react';
|
||||
|
||||
export enum Theme {
|
||||
Light = 'light',
|
||||
Dark = 'dark',
|
||||
System = 'system',
|
||||
}
|
||||
|
||||
export interface Employee {
|
||||
id: string;
|
||||
name: string;
|
||||
initials: string;
|
||||
email: string;
|
||||
department?: string;
|
||||
role?: string;
|
||||
isOwner?: boolean; // Company owner/HR access
|
||||
}
|
||||
|
||||
export interface Report {
|
||||
employeeId: string;
|
||||
department: string;
|
||||
role: string;
|
||||
roleAndOutput: {
|
||||
responsibilities: string;
|
||||
clarityOnRole: string;
|
||||
selfRatedOutput: string;
|
||||
recurringTasks: string;
|
||||
};
|
||||
insights: {
|
||||
personalityTraits: string;
|
||||
psychologicalIndicators: string[];
|
||||
selfAwareness: string;
|
||||
emotionalResponses: string;
|
||||
growthDesire: string;
|
||||
|
||||
strengths?: string[];
|
||||
weaknesses?: string[];
|
||||
value?: number;
|
||||
};
|
||||
actionableItems?: { id: string; title: string; impact: 'High' | 'Medium' | 'Low'; effort: 'High' | 'Medium' | 'Low'; description: string; }[];
|
||||
roleFitCandidates?: { employeeId: string; roles: string[]; rationale: string; score: number; }[];
|
||||
potentialExits?: { employeeId: string; risk: 'Low' | 'Medium' | 'High'; reason: string; }[];
|
||||
traitWeighting?: { trait: string; weight: number; rationale?: string; }[];
|
||||
strengths: string[];
|
||||
weaknesses: {
|
||||
isCritical: boolean;
|
||||
description: string;
|
||||
}[];
|
||||
opportunities: {
|
||||
roleAdjustment: string;
|
||||
accountabilitySupport: string;
|
||||
};
|
||||
risks: string[];
|
||||
recommendation: {
|
||||
action: 'Keep' | 'Restructure' | 'Terminate';
|
||||
details: string[];
|
||||
};
|
||||
grading: {
|
||||
department: string;
|
||||
lead: string;
|
||||
support: string;
|
||||
grade: string;
|
||||
comment: string;
|
||||
scores: { subject: string; value: number; fullMark: number; }[];
|
||||
}[];
|
||||
suitabilityScore?: number;
|
||||
retentionRisk?: 'Low' | 'Medium' | 'High';
|
||||
costEffectiveness?: 'Underperforming' | 'Aligned' | 'High Value';
|
||||
}
|
||||
|
||||
export interface Submission {
|
||||
employeeId: string;
|
||||
answers: {
|
||||
question: string;
|
||||
answer: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface FaqItem {
|
||||
question: string;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
export interface NavItem {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: (props: { className?: string }) => React.ReactNode;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
text: string;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export interface CompanyReport {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
overview: {
|
||||
totalEmployees: number;
|
||||
departmentBreakdown: { department: string; count: number; }[];
|
||||
submissionRate: number; // percentage of employees who submitted
|
||||
lastUpdated: number;
|
||||
// Optional aggregate metrics
|
||||
averagePerformanceScore?: number;
|
||||
riskLevel?: 'Low' | 'Medium' | 'High';
|
||||
};
|
||||
// Personnel lifecycle
|
||||
personnelChanges: {
|
||||
newHires: { name: string; department: string; role: string; impact?: string }[];
|
||||
promotions: { name: string; fromRole: string; toRole: string; impact?: string }[];
|
||||
departures: { name: string; department: string; reason: string; impact?: string }[];
|
||||
};
|
||||
// Backward-compatible flattened list (optional)
|
||||
keyPersonnelChanges?: Array<{ employeeName: string; role: string; department: string; changeType: 'newHire' | 'promotion' | 'departure'; impact?: string }>;
|
||||
// Hiring / talent gaps
|
||||
immediateHiringNeeds: {
|
||||
department: string;
|
||||
role: string;
|
||||
priority: 'High' | 'Medium' | 'Low';
|
||||
reasoning: string;
|
||||
urgency?: 'high' | 'medium' | 'low'; // UI alias
|
||||
}[];
|
||||
// Operating plan (dual naming for UI compatibility)
|
||||
operatingPlan: {
|
||||
nextQuarterGoals: string[];
|
||||
keyInitiatives: string[];
|
||||
resourceNeeds: string[];
|
||||
riskMitigation: string[];
|
||||
};
|
||||
forwardOperatingPlan?: { // deprecated; kept for existing calls until phased out
|
||||
quarterlyGoals: string[];
|
||||
resourceNeeds: string[];
|
||||
riskMitigation: string[];
|
||||
};
|
||||
// Strengths / risks
|
||||
organizationalStrengths: Array<{ icon?: string; area: string; description: string }>;
|
||||
organizationalRisks: string[];
|
||||
organizationalImpactSummary?: string;
|
||||
// Grading: array + map for UI
|
||||
gradingBreakdown: {
|
||||
category: string;
|
||||
value: number; // 0-100
|
||||
rationale?: string;
|
||||
}[];
|
||||
gradingOverview?: Record<string, number>; // legacy (0-5 scale expected by old UI)
|
||||
executiveSummary: string;
|
||||
}
|
||||
|
||||
export interface CompanyReportSummary {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
summary: string; // For backwards compatibility
|
||||
}
|
||||
52
utils/urls.ts
Normal file
52
utils/urls.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// URL utilities for consistent URL management across the application
|
||||
|
||||
// Get site URL from environment variable or default
|
||||
export const getSiteUrl = (): string => {
|
||||
// For client-side (Vite)
|
||||
if (typeof window !== 'undefined') {
|
||||
return import.meta.env.VITE_SITE_URL || window.location.origin;
|
||||
}
|
||||
|
||||
// For server-side (Node.js)
|
||||
return process.env.SITE_URL || 'http://localhost:5173';
|
||||
};
|
||||
|
||||
// Get API URL from environment variable or default
|
||||
export const getApiUrl = (): string => {
|
||||
// For client-side (Vite)
|
||||
if (typeof window !== 'undefined') {
|
||||
return import.meta.env.VITE_API_URL || 'http://localhost:5050';
|
||||
}
|
||||
|
||||
// For server-side (Node.js)
|
||||
return process.env.API_URL || 'http://localhost:5050';
|
||||
};
|
||||
|
||||
// Build full API endpoint URL
|
||||
export const buildApiUrl = (endpoint: string): string => {
|
||||
const baseUrl = getApiUrl();
|
||||
const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
|
||||
return `${baseUrl}${cleanEndpoint}`;
|
||||
};
|
||||
|
||||
// Build full site URL
|
||||
export const buildSiteUrl = (path: string): string => {
|
||||
const baseUrl = getSiteUrl();
|
||||
const cleanPath = path.startsWith('/') ? path : `/${path}`;
|
||||
return `${baseUrl}${cleanPath}`;
|
||||
};
|
||||
|
||||
// For hash-based routing (React Router with HashRouter)
|
||||
export const buildHashUrl = (path: string): string => {
|
||||
const baseUrl = getSiteUrl();
|
||||
const cleanPath = path.startsWith('/') ? path : `/${path}`;
|
||||
return `${baseUrl}/#${cleanPath}`;
|
||||
};
|
||||
|
||||
export default {
|
||||
getSiteUrl,
|
||||
getApiUrl,
|
||||
buildApiUrl,
|
||||
buildSiteUrl,
|
||||
buildHashUrl
|
||||
};
|
||||
23
vite.config.ts
Normal file
23
vite.config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import path from 'path';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import autoprefixer from 'autoprefixer';
|
||||
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '');
|
||||
const API_URL = env.VITE_API_URL || 'http://localhost:5050';
|
||||
|
||||
return {
|
||||
plugins: [tailwindcss(), react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: API_URL,
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user