Files
auditly/pages/EmployeeQuestionnaire.tsx

391 lines
17 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;