Fix organization setup flow: redirect to onboarding for incomplete setup

This commit is contained in:
Ra
2025-08-18 10:33:45 -07:00
commit 557b113196
60 changed files with 16246 additions and 0 deletions

View 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;