pmtool / src /components /appraisal /AppraisalForm.tsx
devarshia5's picture
Upload 487 files
d97b8f9 verified
Raw
History Blame Contribute Delete
58 kB
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>
);
});