Spaces:
Sleeping
Sleeping
| import { useState, useEffect, forwardRef, useImperativeHandle } from "react"; | |
| import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; | |
| import { Button } from "@/components/ui/button"; | |
| import { Textarea } from "@/components/ui/textarea"; | |
| import { | |
| Card, | |
| CardContent, | |
| CardDescription, | |
| CardFooter, | |
| CardHeader, | |
| CardTitle, | |
| } from "@/components/ui/card"; | |
| import { Badge } from "@/components/ui/badge"; | |
| import { toast } from "sonner"; | |
| import { | |
| getRoleAssessmentQuestionsByRoleId, | |
| getAssessmentAnswersByAppraisalId, | |
| createAssessmentAnswer, | |
| updateAssessmentAnswer, | |
| getCompetencyRatingsByAppraisalId, | |
| createCompetencyRating, | |
| updateCompetencyRating, | |
| getCompetencyIndicatorsByCompetencyId, | |
| getTrainingNeedsByAppraisalId, | |
| createTrainingNeed, | |
| updateTrainingNeed, | |
| deleteTrainingNeed, | |
| getHrRemarksByAppraisalId, | |
| updateHrRemark, | |
| getAssessmentAreas, | |
| } from "@/services/appraisalApi"; | |
| import { | |
| Appraisal, | |
| Employee, | |
| AssessmentAnswer, | |
| RoleAssessmentQuestion, | |
| CompetencyRating, | |
| Competency, | |
| CompetencyIndicator, | |
| TrainingNeed, | |
| HrRemark, | |
| AssessmentArea, | |
| } from "@/types"; | |
| // Extended interface for questions with answers | |
| interface QuestionWithAnswer extends RoleAssessmentQuestion { | |
| answerId?: number; | |
| selfRemarks: string; | |
| appraiserRemarks: string; | |
| areaTitle: string; // Using areaTitle instead of areaName | |
| } | |
| interface IndicatorWithRating extends CompetencyIndicator { | |
| ratingId?: number; | |
| selfRating: number; | |
| appraiserRating: number; | |
| weightedScore: number; | |
| appraiserRemark: string; | |
| } | |
| interface CompetencyWithIndicators extends Competency { | |
| indicators: IndicatorWithRating[]; | |
| } | |
| // Helper functions for competency ratings | |
| const getRatingColorClass = (rating: number): string => { | |
| switch (rating) { | |
| case 1: | |
| return "red"; | |
| case 2: | |
| return "orange"; | |
| case 3: | |
| return "yellow"; | |
| case 4: | |
| return "green"; | |
| case 5: | |
| return "emerald"; | |
| default: | |
| return "gray"; | |
| } | |
| }; | |
| const getRatingButtonClass = (rating: number): string => { | |
| switch (rating) { | |
| case 1: | |
| return "bg-red-500 hover:bg-red-600 text-white"; | |
| case 2: | |
| return "bg-orange-500 hover:bg-orange-600 text-white"; | |
| case 3: | |
| return "bg-yellow-500 hover:bg-yellow-600 text-black"; | |
| case 4: | |
| return "bg-green-500 hover:bg-green-600 text-white"; | |
| case 5: | |
| return "bg-emerald-500 hover:bg-emerald-600 text-white"; | |
| default: | |
| return ""; | |
| } | |
| }; | |
| const getAverageRatingColorClass = (rating: number): string => { | |
| if (rating < 1.5) return "bg-red-500 text-white"; | |
| if (rating < 2.5) return "bg-orange-500 text-white"; | |
| if (rating < 3.5) return "bg-yellow-500 text-black"; | |
| if (rating < 4.5) return "bg-green-500 text-white"; | |
| return "bg-emerald-500 text-white"; | |
| }; | |
| const getRatingLabel = (rating: number): string => { | |
| switch (rating) { | |
| case 1: | |
| return "Needs significant improvement"; | |
| case 2: | |
| return "Below expectations"; | |
| case 3: | |
| return "Meets expectations"; | |
| case 4: | |
| return "Exceeds expectations"; | |
| case 5: | |
| return "Outstanding"; | |
| default: | |
| return "Not rated"; | |
| } | |
| }; | |
| const getAverageSelfRating = (competency: CompetencyWithIndicators): number => { | |
| const validRatings = competency.indicators.filter(i => i.selfRating > 0); | |
| if (validRatings.length === 0) return 0; | |
| const sum = validRatings.reduce((acc, indicator) => acc + indicator.selfRating, 0); | |
| return sum / validRatings.length; | |
| }; | |
| const getAverageManagerRating = (competency: CompetencyWithIndicators): number => { | |
| const validRatings = competency.indicators.filter(i => i.appraiserRating > 0); | |
| if (validRatings.length === 0) return 0; | |
| const sum = validRatings.reduce((acc, indicator) => acc + indicator.appraiserRating, 0); | |
| return sum / validRatings.length; | |
| }; | |
| const getTotalWeightedScore = (competency: CompetencyWithIndicators): number => { | |
| return competency.indicators.reduce((acc, indicator) => acc + indicator.weightedScore, 0); | |
| }; | |
| interface AppraisalFormProps { | |
| appraisal: Appraisal; | |
| employee: Employee; | |
| appraiser: Employee; | |
| competencies: Competency[]; | |
| isAppraiser: boolean; | |
| isHR: boolean; | |
| onSave: () => void; | |
| onSubmit: () => void; | |
| } | |
| export const AppraisalForm = forwardRef< | |
| { saveAnswers: () => Promise<void> }, | |
| AppraisalFormProps | |
| >(({ | |
| appraisal, | |
| employee, | |
| appraiser, | |
| competencies, | |
| isAppraiser, | |
| isHR, | |
| onSave, | |
| onSubmit, | |
| }, ref) => { | |
| const [activeTab, setActiveTab] = useState("areas"); | |
| const [isLoading, setIsLoading] = useState(false); | |
| const [isSaving, setIsSaving] = useState(false); | |
| const [questionsByArea, setQuestionsByArea] = useState<Record<string, QuestionWithAnswer[]>>({}); | |
| const [competenciesWithIndicators, setCompetenciesWithIndicators] = useState<CompetencyWithIndicators[]>([]); | |
| const [trainingNeeds, setTrainingNeeds] = useState<TrainingNeed[]>([]); | |
| const [newTrainingNeed, setNewTrainingNeed] = useState(""); | |
| const [hrRemarks, setHrRemarks] = useState<HrRemark | null>(null); | |
| const [assessmentAreas, setAssessmentAreas] = useState<Record<number, string>>({}); | |
| // Expose methods to parent component via ref | |
| useImperativeHandle(ref, () => ({ | |
| saveAnswers | |
| })); | |
| // Fetch all necessary data on component mount | |
| useEffect(() => { | |
| const fetchAppraisalData = async () => { | |
| setIsLoading(true); | |
| try { | |
| await Promise.all([ | |
| fetchQuestions(), | |
| fetchCompetencyData(), | |
| fetchTrainingNeeds(), | |
| isHR && fetchHrRemarks(), | |
| ]); | |
| } catch (error) { | |
| console.error("Error fetching appraisal data:", error); | |
| toast.error("Failed to load appraisal data"); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }; | |
| fetchAppraisalData(); | |
| }, [appraisal.appraisalId]); | |
| const fetchQuestions = async () => { | |
| try { | |
| // First fetch all assessment areas to get their titles | |
| const areasResponse = await getAssessmentAreas(); | |
| if (!areasResponse?.success || !areasResponse?.data) { | |
| throw new Error(areasResponse?.message || "Failed to fetch assessment areas"); | |
| } | |
| // Create a lookup map of areaId to area title | |
| const areaMap: Record<number, string> = {}; | |
| areasResponse.data.forEach((area: AssessmentArea) => { | |
| areaMap[area.areaId] = area.title; | |
| }); | |
| setAssessmentAreas(areaMap); | |
| // Fetch assessment questions for the employee's role | |
| const questionsResponse = await getRoleAssessmentQuestionsByRoleId(employee.roleId); | |
| if (!questionsResponse?.success || !questionsResponse?.data) { | |
| throw new Error(questionsResponse?.message || "Failed to fetch questions"); | |
| } | |
| // Fetch existing answers for this appraisal | |
| const answersResponse = await getAssessmentAnswersByAppraisalId(appraisal.appraisalId); | |
| // Handle possible undefined data from answersResponse | |
| const answers = answersResponse?.data || []; | |
| console.log(`Fetched ${answers.length} existing answers for appraisal ${appraisal.appraisalId}`); | |
| // Map answers to questions and attach area titles | |
| const questions = questionsResponse.data.map((question) => { | |
| // Find the answer for this question, if any | |
| const answer = answers.find(a => a.questionId === question.questionId); | |
| return { | |
| ...question, | |
| answerId: answer?.answerId || undefined, // Use undefined instead of null for unanswered questions | |
| selfRemarks: answer?.selfRemarks || "", | |
| appraiserRemarks: answer?.appraiserRemarks || "", | |
| areaTitle: areaMap[question.areaId] || "Other", | |
| }; | |
| }); | |
| // Group questions by area | |
| const grouped: Record<string, QuestionWithAnswer[]> = {}; | |
| questions.forEach((question) => { | |
| const areaTitle = question.areaTitle; | |
| if (!grouped[areaTitle]) { | |
| grouped[areaTitle] = []; | |
| } | |
| grouped[areaTitle].push(question); | |
| }); | |
| setQuestionsByArea(grouped); | |
| } catch (error) { | |
| console.error("Error fetching questions:", error); | |
| throw error; | |
| } | |
| }; | |
| const fetchCompetencyData = async () => { | |
| try { | |
| console.log("Starting competency data fetch for appraisal", appraisal.appraisalId); | |
| // Fetch existing ratings for this appraisal | |
| const ratingsResponse = await getCompetencyRatingsByAppraisalId(appraisal.appraisalId); | |
| // Handle possible undefined data | |
| const ratings = ratingsResponse?.data || []; | |
| console.log(`Fetched ${ratings.length} competency ratings for appraisal ${appraisal.appraisalId}`); | |
| if (!competencies || competencies.length === 0) { | |
| console.warn("No competencies available to fetch indicators for"); | |
| setCompetenciesWithIndicators([]); | |
| return; | |
| } | |
| // For each competency, fetch its indicators | |
| const competenciesWithIndicatorsPromises = competencies.map(async (competency) => { | |
| // Add extra debugging for problematic competencies | |
| const isProblemCompetency = competency.title.includes("Client Management") || | |
| competency.title.includes("Technical Knowledge"); | |
| if (isProblemCompetency) { | |
| console.log(`Special debug for ${competency.title} (ID: ${competency.competencyId})`); | |
| } | |
| console.log(`Fetching indicators for competency ${competency.competencyId}: ${competency.title}`); | |
| const indicatorsResponse = await getCompetencyIndicatorsByCompetencyId(competency.competencyId); | |
| // Handle possible undefined data | |
| const indicators = indicatorsResponse?.data || []; | |
| if (isProblemCompetency) { | |
| console.log(`Response for ${competency.title}:`, indicatorsResponse); | |
| console.log(`Raw indicators for ${competency.title}:`, indicators); | |
| } | |
| console.log(`Fetched ${indicators.length} indicators for competency ${competency.competencyId}`); | |
| if (indicators.length === 0) { | |
| console.warn(`No indicators found for competency ${competency.competencyId}: ${competency.title}`); | |
| } | |
| // Map ratings to indicators | |
| const indicatorsWithRatings = indicators.map((indicator) => { | |
| const rating = ratings.find( | |
| (r) => r.indicatorId === indicator.indicatorId | |
| ); | |
| if (rating) { | |
| console.log(`Found existing rating for indicator ${indicator.indicatorId} (${rating.ratingId})`); | |
| } | |
| return { | |
| ...indicator, | |
| ratingId: rating?.ratingId, | |
| selfRating: rating?.selfRating || 0, | |
| appraiserRating: rating?.appraiserRating || 0, | |
| weightedScore: rating?.weightedScore || 0, | |
| appraiserRemark: rating?.appraiserRemark || "", | |
| }; | |
| }); | |
| return { | |
| ...competency, | |
| indicators: indicatorsWithRatings, | |
| }; | |
| }); | |
| const result = await Promise.all(competenciesWithIndicatorsPromises); | |
| // Check for any competencies with empty indicators | |
| result.forEach(competency => { | |
| if (competency.indicators.length === 0) { | |
| console.warn(`Competency ${competency.competencyId}: ${competency.title} has no indicators after data fetching`); | |
| } | |
| }); | |
| console.log(`Loaded ${result.length} competencies with their indicators`); | |
| setCompetenciesWithIndicators(result); | |
| // Check and fix any issues with specific competencies | |
| result.forEach(competency => { | |
| if ((competency.title.includes("Client Management") || competency.title.includes("Technical Knowledge")) | |
| && competency.indicators.length === 0) { | |
| console.warn(`Found problematic competency with no indicators: ${competency.title} (ID: ${competency.competencyId})`); | |
| } | |
| }); | |
| } catch (error) { | |
| console.error("Error fetching competency data:", error); | |
| toast.error("Failed to load competency data", { | |
| description: "Please try refreshing the page", | |
| }); | |
| throw error; | |
| } | |
| }; | |
| const fetchTrainingNeeds = async () => { | |
| try { | |
| const response = await getTrainingNeedsByAppraisalId(appraisal.appraisalId); | |
| // Handle possible undefined data | |
| const trainingNeedsData = response?.data || []; | |
| setTrainingNeeds(trainingNeedsData); | |
| } catch (error) { | |
| console.error("Error fetching training needs:", error); | |
| throw error; | |
| } | |
| }; | |
| const fetchHrRemarks = async () => { | |
| try { | |
| const response = await getHrRemarksByAppraisalId(appraisal.appraisalId); | |
| // Handle possible undefined data | |
| const hrRemarksData = response?.data || []; | |
| if (hrRemarksData.length > 0) { | |
| setHrRemarks(hrRemarksData[0]); | |
| } | |
| } catch (error) { | |
| console.error("Error fetching HR remarks:", error); | |
| throw error; | |
| } | |
| }; | |
| const handleQuestionAnswerChange = (questionId: number, field: "selfRemarks" | "appraiserRemarks", value: string) => { | |
| setQuestionsByArea((prev) => { | |
| const updated = { ...prev }; | |
| // Find the question in any area and update it | |
| Object.keys(updated).forEach((areaTitle) => { | |
| updated[areaTitle] = updated[areaTitle].map((q) => | |
| q.questionId === questionId ? { ...q, [field]: value } : q | |
| ); | |
| }); | |
| return updated; | |
| }); | |
| }; | |
| const handleCompetencyRatingChange = ( | |
| indicatorId: number, | |
| competencyId: number, | |
| field: "selfRating" | "appraiserRating" | "appraiserRemark", | |
| value: number | string | |
| ) => { | |
| setCompetenciesWithIndicators((prev) => { | |
| return prev.map((competency) => { | |
| if (competency.competencyId !== competencyId) return competency; | |
| return { | |
| ...competency, | |
| indicators: competency.indicators.map((indicator) => { | |
| if (indicator.indicatorId !== indicatorId) return indicator; | |
| // Calculate weighted score if applicable | |
| let updatedIndicator = { ...indicator, [field]: value }; | |
| if (field === "appraiserRating" && typeof value === "number") { | |
| const weightedScore = (value * competency.weightage) / 100; | |
| updatedIndicator.weightedScore = weightedScore; | |
| } | |
| return updatedIndicator; | |
| }), | |
| }; | |
| }); | |
| }); | |
| // Auto-save the rating when changed - but only for rating buttons (not comments) | |
| if (field === "selfRating" || field === "appraiserRating") { | |
| const autoSaveRating = async () => { | |
| try { | |
| // First get the latest data to avoid stale state issues | |
| const competency = competenciesWithIndicators.find(c => c.competencyId === competencyId); | |
| if (!competency) return; | |
| const indicator = competency.indicators.find(i => i.indicatorId === indicatorId); | |
| if (!indicator) return; | |
| // Update the indicator with the new value | |
| const updatedIndicator = { ...indicator, [field]: value }; | |
| // Calculate weighted score if needed | |
| if (field === "appraiserRating" && typeof value === "number") { | |
| updatedIndicator.weightedScore = (value * competency.weightage) / 100; | |
| } | |
| // Get latest ratings to check if we need to create or update | |
| const latestRatingsResponse = await getCompetencyRatingsByAppraisalId(appraisal.appraisalId); | |
| const latestRatings = latestRatingsResponse?.data || []; | |
| const existingRating = latestRatings.find(r => r.indicatorId === indicatorId); | |
| let result; | |
| if (existingRating) { | |
| // Update existing rating | |
| result = await updateCompetencyRating({ | |
| ratingId: existingRating.ratingId, | |
| appraisalId: appraisal.appraisalId, | |
| indicatorId: updatedIndicator.indicatorId, | |
| selfRating: updatedIndicator.selfRating, | |
| appraiserRating: updatedIndicator.appraiserRating, | |
| weightedScore: updatedIndicator.weightedScore, | |
| appraiserRemark: updatedIndicator.appraiserRemark, | |
| }); | |
| } else { | |
| // Create new rating | |
| result = await createCompetencyRating({ | |
| appraisalId: appraisal.appraisalId, | |
| indicatorId: updatedIndicator.indicatorId, | |
| selfRating: updatedIndicator.selfRating, | |
| appraiserRating: updatedIndicator.appraiserRating, | |
| weightedScore: updatedIndicator.weightedScore, | |
| appraiserRemark: updatedIndicator.appraiserRemark, | |
| }); | |
| } | |
| // Check if result has success property explicitly set to false | |
| const isExplicitFailure = result && result.success === false; | |
| // Check if it's a successful response - either has success=true or contains ratingId | |
| const isSuccessfulResponse = (result && result.success === true) || | |
| (result && result.data && 'ratingId' in result.data) || | |
| (result && typeof result === 'object' && 'ratingId' in result); | |
| if (isSuccessfulResponse) { | |
| console.log(`Rating ${existingRating ? 'updated' : 'created'} successfully:`, result); | |
| // Only show toast for first rating or creation to avoid spam | |
| if (!existingRating || field === "selfRating") { | |
| toast.success(`Rating ${existingRating ? 'updated' : 'saved'} successfully`); | |
| } | |
| // Refresh data to update UI with the latest ratings | |
| await refreshData(); | |
| } else if (isExplicitFailure) { | |
| console.error("Failed to save rating:", result); | |
| toast.error(`Failed to save rating: ${result.message || 'Unknown error'}`); | |
| } else { | |
| // Handle the case where result might have unexpected structure | |
| console.warn("Received unexpected response when saving rating:", result); | |
| // Check if it looks like a valid rating object | |
| if (result && typeof result === 'object' && 'indicatorId' in result && | |
| (('selfRating' in result && result.selfRating !== undefined) || | |
| ('appraiserRating' in result && result.appraiserRating !== undefined))) { | |
| console.log("Response appears to be a valid rating object, treating as success"); | |
| // Treat it as success if it has required fields | |
| if (!existingRating) { | |
| toast.success("Rating saved successfully"); | |
| } | |
| await refreshData(); | |
| } else { | |
| // Only show error if it's clearly not a success | |
| toast.error("Unable to determine if rating was saved properly"); | |
| } | |
| } | |
| } catch (error) { | |
| console.error("Error auto-saving rating:", error); | |
| // Only show toast for actual errors | |
| if (error instanceof Error) { | |
| toast.error(`Error saving rating: ${error.message}`); | |
| } else { | |
| toast.error("Error saving rating"); | |
| } | |
| } | |
| }; | |
| autoSaveRating(); | |
| } | |
| }; | |
| const handleAddTrainingNeed = async () => { | |
| if (!newTrainingNeed.trim()) return; | |
| setIsSaving(true); | |
| try { | |
| const response = await createTrainingNeed({ | |
| appraisalId: appraisal.appraisalId, | |
| needDescription: newTrainingNeed, | |
| }); | |
| if (!response.success) { | |
| throw new Error(response.message); | |
| } | |
| setTrainingNeeds([...trainingNeeds, response.data]); | |
| setNewTrainingNeed(""); | |
| toast.success("Training need added"); | |
| } catch (error) { | |
| console.error("Error adding training need:", error); | |
| toast.error("Failed to add training need"); | |
| } finally { | |
| setIsSaving(false); | |
| } | |
| }; | |
| const handleDeleteTrainingNeed = async (trainingId: number) => { | |
| setIsSaving(true); | |
| try { | |
| const response = await deleteTrainingNeed(trainingId); | |
| if (!response.success) { | |
| throw new Error(response.message); | |
| } | |
| setTrainingNeeds(trainingNeeds.filter((need) => need.trainingId !== trainingId)); | |
| toast.success("Training need deleted"); | |
| } catch (error) { | |
| console.error("Error deleting training need:", error); | |
| toast.error("Failed to delete training need"); | |
| } finally { | |
| setIsSaving(false); | |
| } | |
| }; | |
| const handleHrRemarksChange = (remarks: string) => { | |
| setHrRemarks((prev) => prev ? { ...prev, remarks } : null); | |
| }; | |
| const saveAnswers = async () => { | |
| if (isSaving) { | |
| console.log("Save operation already in progress, skipping"); | |
| return; | |
| } | |
| setIsSaving(true); | |
| console.log("Starting save operation for appraisal", appraisal.appraisalId); | |
| try { | |
| // First, fetch the latest answers to ensure we're working with up-to-date data | |
| const latestAnswersResponse = await getAssessmentAnswersByAppraisalId(appraisal.appraisalId); | |
| const latestAnswers = latestAnswersResponse?.data || []; | |
| console.log(`Found ${latestAnswers.length} existing assessment answers`); | |
| // Save all answers | |
| const saveAnswersPromises: Promise<any>[] = []; | |
| // Tracking operations for debug logging | |
| const operations = { | |
| answerUpdates: 0, | |
| answerCreates: 0, | |
| ratingUpdates: 0, | |
| ratingCreates: 0 | |
| }; | |
| // Save assessment answers - with duplicate checking | |
| Object.values(questionsByArea).flat().forEach((question) => { | |
| // Check if this question already has an answer in the latest data | |
| const existingAnswer = latestAnswers.find(a => a.questionId === question.questionId); | |
| if (existingAnswer) { | |
| // Update existing answer | |
| operations.answerUpdates++; | |
| saveAnswersPromises.push( | |
| updateAssessmentAnswer({ | |
| answerId: existingAnswer.answerId, | |
| appraisalId: appraisal.appraisalId, | |
| questionId: question.questionId, | |
| selfRemarks: question.selfRemarks, | |
| appraiserRemarks: question.appraiserRemarks, | |
| }) | |
| ); | |
| } else if (!question.answerId) { | |
| // Only create if we're sure it doesn't exist | |
| operations.answerCreates++; | |
| saveAnswersPromises.push( | |
| createAssessmentAnswer({ | |
| appraisalId: appraisal.appraisalId, | |
| questionId: question.questionId, | |
| selfRemarks: question.selfRemarks, | |
| appraiserRemarks: question.appraiserRemarks, | |
| }) | |
| ); | |
| } | |
| }); | |
| // Similarly fetch the latest ratings | |
| const latestRatingsResponse = await getCompetencyRatingsByAppraisalId(appraisal.appraisalId); | |
| const latestRatings = latestRatingsResponse?.data || []; | |
| console.log(`Found ${latestRatings.length} existing competency ratings`); | |
| // Save competency ratings with duplicate checking | |
| competenciesWithIndicators.forEach((competency) => { | |
| competency.indicators.forEach((indicator) => { | |
| // Check if this indicator already has a rating in the latest data | |
| const existingRating = latestRatings.find(r => r.indicatorId === indicator.indicatorId); | |
| if (existingRating) { | |
| // Update existing rating | |
| operations.ratingUpdates++; | |
| saveAnswersPromises.push( | |
| updateCompetencyRating({ | |
| ratingId: existingRating.ratingId, | |
| appraisalId: appraisal.appraisalId, | |
| indicatorId: indicator.indicatorId, | |
| selfRating: indicator.selfRating, | |
| appraiserRating: indicator.appraiserRating, | |
| weightedScore: indicator.weightedScore, | |
| appraiserRemark: indicator.appraiserRemark, | |
| }) | |
| ); | |
| } else if (!indicator.ratingId) { | |
| // Only create if we're sure it doesn't exist | |
| operations.ratingCreates++; | |
| saveAnswersPromises.push( | |
| createCompetencyRating({ | |
| appraisalId: appraisal.appraisalId, | |
| indicatorId: indicator.indicatorId, | |
| selfRating: indicator.selfRating, | |
| appraiserRating: indicator.appraiserRating, | |
| weightedScore: indicator.weightedScore, | |
| appraiserRemark: indicator.appraiserRemark, | |
| }) | |
| ); | |
| } | |
| }); | |
| }); | |
| // Save HR remarks if applicable | |
| if (isHR && hrRemarks) { | |
| saveAnswersPromises.push( | |
| updateHrRemark({ | |
| hrRemarkId: hrRemarks.hrRemarkId, | |
| appraisalId: appraisal.appraisalId, | |
| remarks: hrRemarks.remarks, | |
| }) | |
| ); | |
| } | |
| console.log("Save operations:", operations); | |
| console.log(`Executing ${saveAnswersPromises.length} total API operations`); | |
| const results = await Promise.all(saveAnswersPromises); | |
| // Check if any of the operations failed | |
| const hasFailure = results.some(result => result.success === false); | |
| if (hasFailure) { | |
| console.error("One or more save operations failed", results.filter(r => !r.success)); | |
| throw new Error("One or more save operations failed"); | |
| } | |
| console.log("All operations completed successfully, refreshing data"); | |
| // Refresh data after saving to update the component state with server data | |
| await refreshData(); | |
| toast.success("Appraisal saved successfully"); | |
| console.log("Save process completed"); | |
| // Call onSave only if it's safe (won't cause infinite loops) | |
| if (typeof onSave === 'function') { | |
| onSave(); | |
| } | |
| } catch (error) { | |
| console.error("Error saving appraisal:", error); | |
| toast.error("Failed to save appraisal"); | |
| } finally { | |
| setIsSaving(false); | |
| } | |
| }; | |
| // Extract refresh logic to its own function for reuse | |
| const refreshData = async () => { | |
| try { | |
| const answersResponse = await getAssessmentAnswersByAppraisalId(appraisal.appraisalId); | |
| const answers = answersResponse?.data || []; | |
| // Update answerId for questions that were newly created | |
| setQuestionsByArea(prev => { | |
| const updated = { ...prev }; | |
| Object.keys(updated).forEach(areaTitle => { | |
| updated[areaTitle] = updated[areaTitle].map(q => { | |
| const answer = answers.find(a => a.questionId === q.questionId); | |
| if (answer) { | |
| return { ...q, answerId: answer.answerId }; | |
| } | |
| return q; | |
| }); | |
| }); | |
| return updated; | |
| }); | |
| // Refresh competency ratings | |
| const ratingsResponse = await getCompetencyRatingsByAppraisalId(appraisal.appraisalId); | |
| const ratings = ratingsResponse?.data || []; | |
| setCompetenciesWithIndicators(prev => { | |
| return prev.map(competency => { | |
| return { | |
| ...competency, | |
| indicators: competency.indicators.map(indicator => { | |
| const rating = ratings.find(r => r.indicatorId === indicator.indicatorId); | |
| if (rating) { | |
| return { ...indicator, ratingId: rating.ratingId }; | |
| } | |
| return indicator; | |
| }), | |
| }; | |
| }); | |
| }); | |
| // Refresh training needs if necessary | |
| await fetchTrainingNeeds(); | |
| // Refresh HR remarks if applicable | |
| if (isHR) { | |
| await fetchHrRemarks(); | |
| } | |
| } catch (error) { | |
| console.error("Error refreshing data:", error); | |
| } | |
| }; | |
| const handleSubmit = async () => { | |
| try { | |
| await saveAnswers(); | |
| // Data is already refreshed inside saveAnswers | |
| onSubmit(); | |
| } catch (error) { | |
| console.error("Error during submission:", error); | |
| toast.error("Failed to complete submission"); | |
| } | |
| }; | |
| // Debug: Log when competenciesWithIndicators changes | |
| useEffect(() => { | |
| console.log("competenciesWithIndicators state changed:", competenciesWithIndicators); | |
| console.log("Number of competencies with indicators:", competenciesWithIndicators.length); | |
| // Check if any competencies have zero indicators | |
| const emptyCompetencies = competenciesWithIndicators.filter(comp => !comp.indicators || comp.indicators.length === 0); | |
| if (emptyCompetencies.length > 0) { | |
| console.warn(`Found ${emptyCompetencies.length} competencies with no indicators:`, | |
| emptyCompetencies.map(c => `${c.competencyId}: ${c.title}`)); | |
| } | |
| // Check total number of indicators across all competencies | |
| const totalIndicators = competenciesWithIndicators.reduce((sum, comp) => sum + (comp.indicators?.length || 0), 0); | |
| console.log(`Total number of indicators across all competencies: ${totalIndicators}`); | |
| }, [competenciesWithIndicators]); | |
| if (isLoading) { | |
| return ( | |
| <div className="flex items-center justify-center h-64"> | |
| <svg | |
| className="animate-spin h-8 w-8 text-primary" | |
| xmlns="http://www.w3.org/2000/svg" | |
| fill="none" | |
| viewBox="0 0 24 24" | |
| > | |
| <circle | |
| className="opacity-25" | |
| cx="12" | |
| cy="12" | |
| r="10" | |
| stroke="currentColor" | |
| strokeWidth="4" | |
| ></circle> | |
| <path | |
| className="opacity-75" | |
| fill="currentColor" | |
| d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" | |
| ></path> | |
| </svg> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div className="space-y-6"> | |
| <Tabs value={activeTab} onValueChange={setActiveTab}> | |
| <TabsList className="grid w-full grid-cols-5"> | |
| <TabsTrigger value="areas">Assessment Areas</TabsTrigger> | |
| <TabsTrigger value="competencies">Competencies</TabsTrigger> | |
| <TabsTrigger value="training">Training Needs</TabsTrigger> | |
| {isHR && <TabsTrigger value="hr">HR Remarks</TabsTrigger>} | |
| <TabsTrigger value="summary">Summary</TabsTrigger> | |
| </TabsList> | |
| {/* Assessment Areas */} | |
| <TabsContent value="areas"> | |
| <div className="space-y-6 mt-6"> | |
| {Object.entries(questionsByArea).map(([areaTitle, questions]) => ( | |
| <Card key={areaTitle}> | |
| <CardHeader> | |
| <CardTitle>{areaTitle}</CardTitle> | |
| <CardDescription> | |
| Provide your inputs for these assessment questions. | |
| </CardDescription> | |
| </CardHeader> | |
| <CardContent> | |
| <div className="space-y-8"> | |
| {questions.map((question) => ( | |
| <div key={question.questionId} className="space-y-4"> | |
| <div> | |
| <h4 className="font-medium">{question.questionText}</h4> | |
| </div> | |
| <div className="space-y-4"> | |
| <div> | |
| <label className="text-sm font-medium text-muted-foreground mb-1 block"> | |
| Self Assessment | |
| </label> | |
| <Textarea | |
| value={question.selfRemarks} | |
| onChange={(e) => | |
| handleQuestionAnswerChange( | |
| question.questionId, | |
| "selfRemarks", | |
| e.target.value | |
| ) | |
| } | |
| disabled={isAppraiser || appraisal.status !== "Draft"} | |
| placeholder="Provide your self assessment..." | |
| className="min-h-[100px]" | |
| /> | |
| </div> | |
| <div> | |
| <label className="text-sm font-medium text-muted-foreground mb-1 block"> | |
| Manager's Assessment | |
| </label> | |
| <Textarea | |
| value={question.appraiserRemarks} | |
| onChange={(e) => | |
| handleQuestionAnswerChange( | |
| question.questionId, | |
| "appraiserRemarks", | |
| e.target.value | |
| ) | |
| } | |
| disabled={!isAppraiser || appraisal.status === "Draft"} | |
| placeholder="Provide your feedback as a manager..." | |
| className="min-h-[100px]" | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </CardContent> | |
| </Card> | |
| ))} | |
| </div> | |
| </TabsContent> | |
| {/* Competencies */} | |
| <TabsContent value="competencies"> | |
| <div className="mb-4 flex justify-between items-center"> | |
| <h2 className="text-2xl font-bold">Competencies</h2> | |
| <div className="flex items-center gap-2"> | |
| {(() => { | |
| const missingRatings = competenciesWithIndicators.reduce((count, competency) => { | |
| const unratedIndicators = competency.indicators.filter( | |
| indicator => indicator.ratingId === undefined | |
| ); | |
| return count + unratedIndicators.length; | |
| }, 0); | |
| return missingRatings > 0 ? ( | |
| <Badge variant="destructive" className="mr-2"> | |
| {missingRatings} missing ratings | |
| </Badge> | |
| ) : null; | |
| })()} | |
| <Button | |
| onClick={async () => { | |
| const missingRatings = competenciesWithIndicators.reduce((count, competency) => { | |
| const unratedIndicators = competency.indicators.filter( | |
| indicator => indicator.ratingId === undefined | |
| ); | |
| return count + unratedIndicators.length; | |
| }, 0); | |
| if (missingRatings === 0) { | |
| toast.info("All competency indicators already have ratings"); | |
| return; | |
| } | |
| setIsSaving(true); | |
| try { | |
| // Get the latest ratings to avoid duplicates | |
| const latestRatingsResponse = await getCompetencyRatingsByAppraisalId(appraisal.appraisalId); | |
| const latestRatings = latestRatingsResponse?.data || []; | |
| const savePromises: Promise<any>[] = []; | |
| let createdCount = 0; | |
| competenciesWithIndicators.forEach(competency => { | |
| competency.indicators.forEach(indicator => { | |
| // Only create ratings for indicators that don't have a ratingId | |
| if (indicator.ratingId === undefined) { | |
| // Double check against latest ratings to avoid duplicates | |
| const existingRating = latestRatings.find(r => r.indicatorId === indicator.indicatorId); | |
| if (!existingRating) { | |
| createdCount++; | |
| savePromises.push( | |
| createCompetencyRating({ | |
| appraisalId: appraisal.appraisalId, | |
| indicatorId: indicator.indicatorId, | |
| selfRating: indicator.selfRating, | |
| appraiserRating: indicator.appraiserRating, | |
| weightedScore: indicator.weightedScore, | |
| appraiserRemark: indicator.appraiserRemark, | |
| }) | |
| ); | |
| } | |
| } | |
| }); | |
| }); | |
| if (savePromises.length === 0) { | |
| toast.info("No new ratings to create"); | |
| setIsSaving(false); | |
| return; | |
| } | |
| const results = await Promise.all(savePromises); | |
| const failures = results.filter(result => !result.success); | |
| if (failures.length > 0) { | |
| console.error("Some ratings failed to create:", failures); | |
| toast.error(`Created ${results.length - failures.length} ratings, but ${failures.length} failed`); | |
| } else { | |
| toast.success(`Created ${results.length} new competency ratings`); | |
| } | |
| // Refresh data to update the UI | |
| await refreshData(); | |
| } catch (error) { | |
| console.error("Error creating missing ratings:", error); | |
| toast.error("Failed to create missing ratings"); | |
| } finally { | |
| setIsSaving(false); | |
| } | |
| }} | |
| variant="outline" | |
| disabled={isSaving} | |
| > | |
| {isSaving ? "Saving..." : "Save Missing Ratings"} | |
| </Button> | |
| </div> | |
| </div> | |
| <div className="space-y-6 mt-6"> | |
| {competenciesWithIndicators.map((competency) => { | |
| // Debug log for problematic competencies | |
| if (competency.title.includes("Client Management") || competency.title.includes("Technical Knowledge")) { | |
| console.log(`Debug - Rendering competency: ${competency.title}`, { | |
| competencyId: competency.competencyId, | |
| indicators: competency.indicators, | |
| indicatorCount: competency.indicators.length | |
| }); | |
| } | |
| return ( | |
| <Card key={competency.competencyId}> | |
| <CardHeader> | |
| <div className="flex items-center justify-between"> | |
| <div> | |
| <CardTitle>{competency.title}</CardTitle> | |
| <CardDescription> | |
| Rate yourself on these indicators (Scale: 1-5) | |
| {competency.indicators.length === 0 && ( | |
| <span className="block text-red-500 mt-1"> | |
| Warning: No indicators found for this competency | |
| </span> | |
| )} | |
| </CardDescription> | |
| </div> | |
| <div className="flex items-center space-x-2"> | |
| <Badge>{competency.category}</Badge> | |
| <Badge variant="outline">Weight: {competency.weightage}%</Badge> | |
| </div> | |
| </div> | |
| </CardHeader> | |
| <CardContent> | |
| <div className="space-y-6"> | |
| {competency.indicators.map((indicator) => ( | |
| <div key={indicator.indicatorId} className="space-y-4 border-b pb-6 mb-6 last:border-0 last:pb-0 last:mb-0"> | |
| <div> | |
| <h4 className="font-medium">{indicator.indicatorText}</h4> | |
| {indicator.ratingId === undefined && ( | |
| <Badge variant="outline" className="ml-2 bg-yellow-100 text-yellow-800 border-yellow-200"> | |
| No rating saved | |
| </Badge> | |
| )} | |
| </div> | |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> | |
| <div> | |
| <label className="text-sm font-medium text-muted-foreground mb-2 block"> | |
| Self Rating (1-5) | |
| </label> | |
| <div className="flex space-x-2"> | |
| {[1, 2, 3, 4, 5].map((rating) => ( | |
| <Button | |
| key={rating} | |
| type="button" | |
| variant={indicator.selfRating === rating ? "default" : "outline"} | |
| size="sm" | |
| onClick={() => | |
| handleCompetencyRatingChange( | |
| indicator.indicatorId, | |
| competency.competencyId, | |
| "selfRating", | |
| rating | |
| ) | |
| } | |
| disabled={isAppraiser || appraisal.status !== "Draft"} | |
| className={indicator.selfRating === rating | |
| ? getRatingButtonClass(rating) | |
| : ""} | |
| > | |
| {rating} | |
| </Button> | |
| ))} | |
| </div> | |
| <div className="mt-1 text-sm text-muted-foreground"> | |
| {getRatingLabel(indicator.selfRating)} | |
| </div> | |
| </div> | |
| <div> | |
| <label className="text-sm font-medium text-muted-foreground mb-2 block"> | |
| Manager Rating (1-5) | |
| </label> | |
| <div className="flex space-x-2"> | |
| {[1, 2, 3, 4, 5].map((rating) => ( | |
| <Button | |
| key={rating} | |
| type="button" | |
| variant={indicator.appraiserRating === rating ? "default" : "outline"} | |
| size="sm" | |
| onClick={() => | |
| handleCompetencyRatingChange( | |
| indicator.indicatorId, | |
| competency.competencyId, | |
| "appraiserRating", | |
| rating | |
| ) | |
| } | |
| disabled={!isAppraiser || appraisal.status === "Draft"} | |
| className={indicator.appraiserRating === rating | |
| ? getRatingButtonClass(rating) | |
| : ""} | |
| > | |
| {rating} | |
| </Button> | |
| ))} | |
| </div> | |
| <div className="mt-1 text-sm text-muted-foreground"> | |
| {getRatingLabel(indicator.appraiserRating)} | |
| </div> | |
| </div> | |
| </div> | |
| <div> | |
| <label className="text-sm font-medium text-muted-foreground mb-1 block"> | |
| Manager's Comments | |
| </label> | |
| <Textarea | |
| value={indicator.appraiserRemark} | |
| onChange={(e) => | |
| handleCompetencyRatingChange( | |
| indicator.indicatorId, | |
| competency.competencyId, | |
| "appraiserRemark", | |
| e.target.value | |
| ) | |
| } | |
| onBlur={() => { | |
| // Save the comment when the textarea loses focus | |
| // This will be silent (no success toast) | |
| if (indicator.ratingId) { | |
| updateCompetencyRating({ | |
| ratingId: indicator.ratingId, | |
| appraisalId: appraisal.appraisalId, | |
| indicatorId: indicator.indicatorId, | |
| selfRating: indicator.selfRating, | |
| appraiserRating: indicator.appraiserRating, | |
| weightedScore: indicator.weightedScore, | |
| appraiserRemark: indicator.appraiserRemark, | |
| }).then(result => { | |
| // Check if result has success property explicitly set to false | |
| const isExplicitFailure = result && result.success === false; | |
| // Check if it's a successful response - either has success=true or contains ratingId | |
| const isSuccessfulResponse = (result && result.success === true) || | |
| (result && result.data && 'ratingId' in result.data) || | |
| (result && typeof result === 'object' && 'ratingId' in result); | |
| if (isSuccessfulResponse || (result && typeof result === 'object' && 'indicatorId' in result)) { | |
| console.log("Comment saved successfully"); | |
| } else if (isExplicitFailure) { | |
| console.error("Failed to save comment:", result); | |
| toast.error("Failed to save comment"); | |
| } else { | |
| console.warn("Received unexpected response when saving comment:", result); | |
| } | |
| }).catch(error => { | |
| console.error("Error saving comment:", error); | |
| toast.error("Error saving comment"); | |
| }); | |
| } | |
| }} | |
| disabled={!isAppraiser || appraisal.status === "Draft"} | |
| placeholder="Provide your feedback as a manager..." | |
| /> | |
| </div> | |
| {indicator.appraiserRating > 0 && ( | |
| <div className="bg-slate-50 dark:bg-slate-900 p-3 rounded-md"> | |
| <div className="flex justify-between items-center"> | |
| <span className="text-sm font-medium">Weighted Score:</span> | |
| <Badge variant="outline" className="font-mono"> | |
| {indicator.weightedScore.toFixed(2)} | |
| <span className="text-muted-foreground ml-1 text-xs"> | |
| ({indicator.appraiserRating} × {competency.weightage}%) | |
| </span> | |
| </Badge> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ))} | |
| </div> | |
| </CardContent> | |
| <CardFooter className="bg-slate-50 dark:bg-slate-900 border-t"> | |
| <div className="w-full flex justify-end"> | |
| <Button | |
| size="sm" | |
| variant="ghost" | |
| className="text-xs text-muted-foreground" | |
| onClick={async () => { | |
| // Add ability to save all ratings for this specific competency | |
| setIsSaving(true); | |
| try { | |
| const unsavedIndicators = competency.indicators.filter(i => i.ratingId === undefined); | |
| if (unsavedIndicators.length === 0) { | |
| toast.info("All indicators already have ratings"); | |
| setIsSaving(false); | |
| return; | |
| } | |
| const promises = unsavedIndicators.map(indicator => | |
| createCompetencyRating({ | |
| appraisalId: appraisal.appraisalId, | |
| indicatorId: indicator.indicatorId, | |
| selfRating: indicator.selfRating, | |
| appraiserRating: indicator.appraiserRating, | |
| weightedScore: indicator.weightedScore, | |
| appraiserRemark: indicator.appraiserRemark | |
| }) | |
| ); | |
| const results = await Promise.all(promises); | |
| const successCount = results.filter(r => r.success).length; | |
| if (successCount === promises.length) { | |
| toast.success(`Saved all ${successCount} ratings for ${competency.title}`); | |
| } else { | |
| toast.warning(`Saved ${successCount} of ${promises.length} ratings`); | |
| } | |
| await refreshData(); | |
| } catch (error) { | |
| console.error("Error saving ratings:", error); | |
| toast.error("Failed to save ratings"); | |
| } finally { | |
| setIsSaving(false); | |
| } | |
| }} | |
| > | |
| {isSaving ? "Saving..." : "Save All Ratings for This Competency"} | |
| </Button> | |
| </div> | |
| </CardFooter> | |
| </Card> | |
| ); | |
| })} | |
| </div> | |
| </TabsContent> | |
| {/* Training Needs */} | |
| <TabsContent value="training"> | |
| <Card className="mt-6"> | |
| <CardHeader> | |
| <CardTitle>Training & Development Needs</CardTitle> | |
| <CardDescription> | |
| Identify areas where additional training would benefit your development. | |
| </CardDescription> | |
| </CardHeader> | |
| <CardContent> | |
| <div className="space-y-4"> | |
| {trainingNeeds.length === 0 ? ( | |
| <p className="text-muted-foreground">No training needs identified yet.</p> | |
| ) : ( | |
| trainingNeeds.map((need) => ( | |
| <div key={need.trainingId} className="flex items-start justify-between space-x-2 p-3 border rounded-md"> | |
| <p>{need.needDescription}</p> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => handleDeleteTrainingNeed(need.trainingId)} | |
| disabled={isSaving || isAppraiser || appraisal.status !== "Draft"} | |
| > | |
| Remove | |
| </Button> | |
| </div> | |
| )) | |
| )} | |
| </div> | |
| {!isAppraiser && appraisal.status === "Draft" && ( | |
| <div className="mt-6 space-y-2"> | |
| <Textarea | |
| value={newTrainingNeed} | |
| onChange={(e) => setNewTrainingNeed(e.target.value)} | |
| placeholder="Describe a training need..." | |
| className="mb-2" | |
| /> | |
| <Button | |
| onClick={handleAddTrainingNeed} | |
| disabled={!newTrainingNeed.trim() || isSaving} | |
| > | |
| Add Training Need | |
| </Button> | |
| </div> | |
| )} | |
| </CardContent> | |
| </Card> | |
| </TabsContent> | |
| {/* HR Remarks */} | |
| {isHR && ( | |
| <TabsContent value="hr"> | |
| <Card className="mt-6"> | |
| <CardHeader> | |
| <CardTitle>HR Remarks</CardTitle> | |
| <CardDescription> | |
| Add HR comments and notes about this appraisal. | |
| </CardDescription> | |
| </CardHeader> | |
| <CardContent> | |
| <Textarea | |
| value={hrRemarks?.remarks || ""} | |
| onChange={(e) => handleHrRemarksChange(e.target.value)} | |
| placeholder="Add HR remarks here..." | |
| className="min-h-[200px]" | |
| /> | |
| </CardContent> | |
| </Card> | |
| </TabsContent> | |
| )} | |
| {/* Summary */} | |
| <TabsContent value="summary"> | |
| <Card className="mt-6"> | |
| <CardHeader> | |
| <CardTitle>Appraisal Summary</CardTitle> | |
| <CardDescription> | |
| Review your appraisal before submitting. | |
| </CardDescription> | |
| </CardHeader> | |
| <CardContent> | |
| <div className="space-y-4"> | |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> | |
| <div> | |
| <h3 className="text-sm font-medium text-muted-foreground">Employee</h3> | |
| <p className="font-medium">{`${employee.firstName} ${employee.lastName}`}</p> | |
| </div> | |
| <div> | |
| <h3 className="text-sm font-medium text-muted-foreground">Manager</h3> | |
| <p className="font-medium">{`${appraiser.firstName} ${appraiser.lastName}`}</p> | |
| </div> | |
| </div> | |
| <div> | |
| <h3 className="text-lg font-medium mt-4">Overall Score</h3> | |
| <div className="mt-2"> | |
| {(() => { | |
| const totalScore = competenciesWithIndicators.reduce( | |
| (sum, competency) => | |
| sum + | |
| competency.indicators.reduce( | |
| (indicatorSum, indicator) => indicatorSum + indicator.weightedScore, | |
| 0 | |
| ), | |
| 0 | |
| ); | |
| return ( | |
| <div className="flex items-center space-x-2"> | |
| <Badge className="text-lg py-1 px-3">{totalScore.toFixed(2)}</Badge> | |
| <span className="text-muted-foreground">out of 5</span> | |
| </div> | |
| ); | |
| })()} | |
| </div> | |
| </div> | |
| </div> | |
| </CardContent> | |
| <CardFooter className="flex justify-between"> | |
| <Button | |
| variant="outline" | |
| onClick={saveAnswers} | |
| disabled={isSaving} | |
| > | |
| {isSaving ? "Saving..." : "Save Draft"} | |
| </Button> | |
| <Button | |
| onClick={handleSubmit} | |
| disabled={isSaving} | |
| > | |
| {isSaving ? "Submitting..." : "Submit Appraisal"} | |
| </Button> | |
| </CardFooter> | |
| </Card> | |
| </TabsContent> | |
| </Tabs> | |
| </div> | |
| ); | |
| }); |