391 lines
17 KiB
TypeScript
391 lines
17 KiB
TypeScript
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;
|