This commit is contained in:
natib21
2026-07-10 11:25:59 +00:00
parent 2696ca7498
commit e6e44e773b
1146 changed files with 200266 additions and 90527 deletions

View File

@@ -0,0 +1,969 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { useToast } from "@/shared/common/ui/use-toast";
import { useTranslation } from "react-i18next";
// Import your existing components
import { Button } from "@/shared/common/ui/button";
import { Badge } from "@/shared/common/ui/badge";
import { Card, CardContent } from "@/shared/common/ui/card";
import { Progress } from "@/shared/common/ui/progress";
import { cn } from "@/shared/lib/utils";
import {
AlertCircleIcon,
ArrowLeftIcon,
ChartBarIcon,
ClockIcon,
HourglassIcon,
Loader2,
PlayCircleIcon,
Settings2Icon,
TicketIcon,
UserIcon,
AlertTriangleIcon,
PhoneIcon,
CalendarIcon,
BuildingIcon,
Activity,
Zap,
Users,
CheckCircle,
RefreshCw,
TrendingUp,
BarChart3,
Phone,
Clock,
} from "lucide-react";
import { getApiErrorMessage } from "@/record-management/services/api/approvalService";
import {
useCallExecution,
useMyActiveExecution,
} from "@/queue-management/hooks/useExecution";
// Types
enum TransferTypes {
REASSIGN = "reassign",
TRANSFER = "transfer",
}
enum TicketStatus {
WAITING = "waiting",
CALLED = "called",
SERVING = "serving",
COMPLETED = "completed",
TRANSFERRED = "transferred",
SKIPPED = "skipped",
}
interface Ticket {
id: string;
ticketNumber: string;
serviceId: string;
service: {
name: string;
category: {
name: string;
};
};
counterId: string;
counter: {
windowNumber: string;
};
status: TicketStatus;
priority: "low" | "normal" | "high" | "urgent";
calledAt?: string;
servedAt?: string;
completedAt?: string;
transferredFrom?: any;
transferredFromId?: string | null;
transferType?: TransferTypes | null;
handlerId?: string;
handler?: {
firstName: string;
lastName: string;
windowNumber: string;
};
estimatedWaitTime?: number;
customerNotes?: string;
customerName?: string;
customerPhone?: string;
queueGroup?: {
customerName?: {
en: string;
am: string;
};
customerPhone?: string;
};
}
const ActiveCallManagement = () => {
const navigate = useNavigate();
const { toast } = useToast();
const { t } = useTranslation();
// State
const [activeTicket, setActiveTicket] = useState<Ticket | null>(null);
const [serviceTime, setServiceTime] = useState(0);
const [isTimeExceeded, setIsTimeExceeded] = useState(false);
const [waitTime, setWaitTime] = useState(0);
const { mutate: callExecution, isPending: isCalling } = useCallExecution();
// Fetch active execution to show notifications
const { data: myActiveExecutionData, refetch: refetchActiveExecution } =
useMyActiveExecution();
// Auto-refresh active execution data every 30 seconds
useEffect(() => {
const interval = setInterval(() => {
refetchActiveExecution();
}, 30000); // 30 seconds
return () => clearInterval(interval);
}, [refetchActiveExecution]);
// Update activeTicket when myActiveExecutionData changes
useEffect(() => {
if (myActiveExecutionData) {
// Extract customer information
const customerName =
myActiveExecutionData.queue?.queueGroup?.customerName?.en ||
myActiveExecutionData.queue?.queueGroup?.customerName?.am ||
t("activeCallManagement.customer");
const customerPhone =
myActiveExecutionData.queue?.queueGroup?.customerPhone ||
t("common.na", "N/A");
// Map execution data to Ticket format
const mappedTicket: Ticket = {
id: myActiveExecutionData.id,
ticketNumber: myActiveExecutionData.queue?.positionInQueue
? `${myActiveExecutionData.service?.prefix || "T"}-${
myActiveExecutionData.queue.positionInQueue
}`
: myActiveExecutionData.queueNumber || t("common.na", "N/A"),
serviceId: myActiveExecutionData.serviceId || "",
service: {
name:
myActiveExecutionData.service?.name?.en ||
t("activeCallManagement.service"),
category: {
name:
myActiveExecutionData.service?.name?.en ||
t("activeCallManagement.category"),
},
},
counterId: myActiveExecutionData.employeePositionId || "",
counter: {
windowNumber: myActiveExecutionData.caseWorkerName?.en || "1",
},
status:
myActiveExecutionData.status === "STARTED"
? TicketStatus.SERVING
: myActiveExecutionData.status === "STOPPED"
? TicketStatus.CALLED
: TicketStatus.COMPLETED,
priority: "normal" as const,
calledAt: myActiveExecutionData.calledAt,
servedAt: myActiveExecutionData.startedAt,
completedAt: myActiveExecutionData.endedAt,
transferredFrom: undefined,
transferredFromId: myActiveExecutionData.parentExecutionId,
transferType: null,
handlerId: myActiveExecutionData.caseWorkerId,
handler: myActiveExecutionData.caseWorkerName
? {
firstName:
myActiveExecutionData.caseWorkerName.en.split(" ")[0] || "",
lastName:
myActiveExecutionData.caseWorkerName.en.split(" ")[1] || "",
windowNumber: "1",
}
: undefined,
estimatedWaitTime: myActiveExecutionData.service?.estimatedTime,
customerNotes: myActiveExecutionData.queue?.queueGroup?.customerPhone
? `Customer: ${myActiveExecutionData.queue.queueGroup.customerName.en} (${myActiveExecutionData.queue.queueGroup.customerPhone})`
: undefined,
customerName: customerName,
customerPhone: customerPhone,
queueGroup: myActiveExecutionData.queue?.queueGroup,
};
setActiveTicket(mappedTicket);
} else {
// Clear active ticket if no execution data
setActiveTicket(null);
}
}, [myActiveExecutionData, t]);
useEffect(() => {
let interval: number;
if (activeTicket?.status === TicketStatus.SERVING) {
interval = window.setInterval(() => {
setServiceTime((prev) => {
const newTime = prev + 1;
// Check if time exceeded estimated time (convert minutes to seconds)
const estimatedSeconds = (activeTicket.estimatedWaitTime || 0) * 60;
setIsTimeExceeded(newTime > estimatedSeconds && estimatedSeconds > 0);
return newTime;
});
}, 1000);
}
return () => clearInterval(interval);
}, [activeTicket?.status, activeTicket?.estimatedWaitTime]);
useEffect(() => {
let interval: number;
if (activeTicket?.status === TicketStatus.CALLED) {
interval = window.setInterval(() => {
setWaitTime((prev) => prev + 1);
}, 1000);
}
return () => clearInterval(interval);
}, [activeTicket?.status]);
// Handlers
const handleCall = useCallback(() => {
callExecution(undefined, {
onSuccess: (data) => {
setActiveTicket(data);
setWaitTime(0);
toast({
title: t("activeCallManagement.ticketCalled"),
description: `${t("activeCallManagement.ticket")} ${
data.ticketNumber
} ${t("activeCallManagement.ticketCalledMessage")}`,
variant: "default",
});
},
onError: (error) => {
toast({
title: t("activeCallManagement.callFailed"),
description: getApiErrorMessage(
error,
t("activeCallManagement.callFailedMessage")
),
variant: "destructive",
});
},
});
}, [callExecution, toast, t]);
// Helper functions
const formatTime = useCallback((seconds: number) => {
const h = Math.floor(seconds / 3600)
.toString()
.padStart(2, "0");
const m = Math.floor((seconds % 3600) / 60)
.toString()
.padStart(2, "0");
const s = (seconds % 60).toString().padStart(2, "0");
return `${h}:${m}:${s}`;
}, []);
const getPriorityColor = useCallback((priority: string) => {
switch (priority) {
case "urgent":
return "bg-red-100 text-red-800 border-red-200";
case "high":
return "bg-orange-100 text-orange-800 border-orange-200";
case "normal":
return "bg-blue-100 text-blue-800 border-blue-200";
default:
return "bg-gray-100 text-gray-800 border-gray-200";
}
}, []);
// Format date for display
const formatDate = useCallback((dateString?: string) => {
if (!dateString) return t("common.na", "N/A");
try {
const date = new Date(dateString);
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(date);
} catch {
return dateString;
}
}, []);
// Memoized components
const TicketDisplay = useMemo(() => {
if (!activeTicket) {
return (
<Card className="h-full flex items-center justify-center border-dashed border-2 border-gray-200 bg-gradient-to-br from-gray-50 to-white rounded-2xl">
<CardContent className="py-16 text-center">
<div className="relative inline-block mb-6">
<div className="h-32 w-32 rounded-full bg-gradient-to-r from-blue-100 to-blue-50 flex items-center justify-center animate-pulse">
<TicketIcon className="h-16 w-16 text-blue-300" />
</div>
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-12 h-12 bg-blue-500 rounded-full flex items-center justify-center shadow-lg">
<PlayCircleIcon className="h-6 w-6 text-white" />
</div>
</div>
</div>
<h3 className="text-2xl font-bold text-gray-900 mb-3">
{t("activeCallManagement.readyToServe")}
</h3>
<p className="text-gray-600 max-w-md mx-auto mb-8 text-lg">
{t("activeCallManagement.noActiveCustomer")}
</p>
<div className="flex items-center justify-center gap-4 text-sm text-gray-500">
<div className="flex items-center gap-2">
<Users className="w-4 h-4" />
<span>
{t(
"activeCallManagement.waitingCustomers",
"Waiting customers"
)}
</span>
</div>
<div className="w-1 h-1 bg-gray-300 rounded-full"></div>
<div className="flex items-center gap-2">
<Clock className="w-4 h-4" />
<span>
{t(
"activeCallManagement.realTimeUpdates",
"Real-time updates"
)}
</span>
</div>
</div>
</CardContent>
</Card>
);
}
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Customer Information Card */}
<Card className="bg-white/90 backdrop-blur-sm border-0 shadow-lg rounded-2xl overflow-hidden">
<div
className={cn(
"p-6 text-white",
activeTicket.status === TicketStatus.CALLED
? "bg-gradient-to-r from-yellow-500 to-orange-500"
: "bg-gradient-to-r from-purple-500 to-indigo-500"
)}
>
<div className="flex justify-between items-center">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center">
<UserIcon className="w-6 h-6" />
</div>
<div>
<h3 className="font-bold text-lg">
{activeTicket.status === TicketStatus.CALLED
? t("activeCallManagement.customerCalled")
: t("activeCallManagement.currentlyServing")}
</h3>
<p className="text-white/90 text-sm">
{activeTicket.status === TicketStatus.CALLED
? t("activeCallManagement.waitingAtCounter")
: t("activeCallManagement.assistingCustomer")}
</p>
</div>
</div>
<Badge className="bg-white/20 backdrop-blur-sm text-white border-0 px-3 py-1">
<Phone className="h-3 w-3 mr-1" />
{activeTicket.status === TicketStatus.CALLED
? t("activeCallManagement.called")
: t("activeCallManagement.serving")}
</Badge>
</div>
</div>
<CardContent className="p-6">
<div className="space-y-6">
{/* Customer Name and Ticket */}
<div className="text-center p-6 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl">
<div className="text-sm text-blue-600 font-medium mb-2">
{t("activeCallManagement.customer")}
</div>
<h2 className="text-3xl font-bold text-blue-900 mb-2">
{activeTicket.customerName ||
t("activeCallManagement.customer")}
</h2>
<div className="inline-flex items-center gap-2 bg-white px-4 py-2 rounded-full border border-blue-200">
<TicketIcon className="h-4 w-4 text-blue-600" />
<span className="text-sm font-medium text-blue-800">
{t("activeCallManagement.ticket")} #
{activeTicket.ticketNumber}
</span>
</div>
</div>
{/* Customer Details Grid */}
<div className="grid grid-cols-1 gap-4">
<div className="flex items-center justify-between p-4 bg-green-50 rounded-lg border border-green-200">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
<PhoneIcon className="h-5 w-5 text-primary-600" />
</div>
<div>
<div className="text-sm text-primary-600 font-medium">
{t("activeCallManagement.phoneNumber")}
</div>
<div className="font-semibold text-green-900">
{activeTicket.customerPhone || t("common.na", "N/A")}
</div>
</div>
</div>
</div>
<div className="flex items-center justify-between p-4 bg-purple-50 rounded-lg border border-purple-200">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
<CalendarIcon className="h-5 w-5 text-purple-600" />
</div>
<div>
<div className="text-sm text-purple-600 font-medium">
{t("activeCallManagement.calledAt", "Called At")}
</div>
<div className="font-semibold text-purple-900">
{formatDate(activeTicket.calledAt)}
</div>
</div>
</div>
</div>
</div>
{/* Priority Badge */}
{activeTicket.priority && activeTicket.priority !== "normal" && (
<div className="flex items-center justify-center">
<Badge
className={cn(
"px-4 py-2 text-sm font-semibold",
getPriorityColor(activeTicket.priority)
)}
>
<AlertTriangleIcon className="h-4 w-4 mr-2" />
{activeTicket.priority.toUpperCase()}{" "}
{t("activeCallManagement.priority", "PRIORITY")}
</Badge>
</div>
)}
</div>
</CardContent>
</Card>
{/* Service Information Card */}
<Card className="bg-white/90 backdrop-blur-sm border-0 shadow-lg rounded-2xl overflow-hidden">
<div className="p-6 bg-gradient-to-r from-blue-600 to-cyan-500 text-white">
<div className="flex justify-between items-center">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center">
<Settings2Icon className="w-6 h-6" />
</div>
<div>
<h3 className="font-bold text-lg">
{t("activeCallManagement.serviceInformation")}
</h3>
<p className="text-white/90 text-sm">
{t("activeCallManagement.currentServiceDetails")}
</p>
</div>
</div>
<Badge className="bg-white/20 backdrop-blur-sm text-white border-0 px-3 py-1">
<Settings2Icon className="h-3 w-3 mr-1" />
{t("activeCallManagement.service")}
</Badge>
</div>
</div>
<CardContent className="p-6">
<div className="space-y-6">
{/* Service Name Display */}
<div className="text-center p-6 bg-gradient-to-r from-cyan-50 to-blue-50 rounded-xl">
<div className="text-sm text-cyan-600 font-medium mb-2">
{t(
"activeCallManagement.currentlyServingLabel",
"Currently Serving"
)}
</div>
<h2 className="text-2xl font-bold text-cyan-900 mb-4">
{typeof activeTicket.service?.name === "object"
? (activeTicket.service?.name as any)?.en ||
(activeTicket.service?.name as any)?.am ||
t("activeCallManagement.service")
: activeTicket.service?.name}
</h2>
<div className="inline-flex items-center gap-2 bg-white px-4 py-2 rounded-full border border-cyan-200">
<BuildingIcon className="h-4 w-4 text-cyan-600" />
<span className="text-sm font-medium text-cyan-800">
{t("activeCallManagement.window")}{" "}
{activeTicket.counter?.windowNumber ||
t("common.na", "N/A")}
</span>
</div>
</div>
{/* Service Details */}
<div className="grid grid-cols-1 gap-4">
<div className="flex items-center justify-between p-4 bg-orange-50 rounded-lg border border-orange-200">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-orange-100 rounded-lg flex items-center justify-center">
<ClockIcon className="h-5 w-5 text-orange-600" />
</div>
<div>
<div className="text-sm text-orange-600 font-medium">
{t("activeCallManagement.estimatedTime", "Est. Time")}
</div>
<div className="font-semibold text-orange-900">
{activeTicket.estimatedWaitTime
? `${activeTicket.estimatedWaitTime} ${t(
"activeCallManagement.minutes",
"minutes"
)}`
: t(
"activeCallManagement.notSpecified",
"Not specified"
)}
</div>
</div>
</div>
</div>
<div className="flex items-center justify-between p-4 bg-indigo-50 rounded-lg border border-indigo-200">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-indigo-100 rounded-lg flex items-center justify-center">
<ChartBarIcon className="h-5 w-5 text-indigo-600" />
</div>
<div>
<div className="text-sm text-indigo-600 font-medium">
{t("activeCallManagement.status")}
</div>
<div className="font-semibold text-indigo-900 capitalize">
{activeTicket.status === TicketStatus.SERVING
? t("activeCallManagement.inProgress")
: activeTicket.status === TicketStatus.CALLED
? t("activeCallManagement.waiting")
: activeTicket.status}
</div>
</div>
</div>
</div>
</div>
{/* Service Start Time */}
{activeTicket.servedAt && (
<div className="p-4 bg-gradient-to-r from-green-50 to-emerald-50 rounded-lg border border-green-200">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
<CheckCircle className="h-5 w-5 text-primary-600" />
</div>
<div>
<div className="text-sm font-medium text-green-800">
{t("activeCallManagement.serviceStarted")}
</div>
<div className="text-sm text-primary-700">
{formatDate(activeTicket.servedAt)}
</div>
</div>
</div>
</div>
)}
{/* Customer Notes */}
{activeTicket.customerNotes && (
<div className="p-4 bg-gradient-to-r from-amber-50 to-yellow-50 rounded-lg border border-amber-200">
<div className="flex items-start gap-3">
<div className="w-10 h-10 bg-amber-100 rounded-lg flex items-center justify-center mt-0.5">
<AlertCircleIcon className="h-5 w-5 text-amber-600" />
</div>
<div>
<div className="text-sm font-medium text-amber-800 mb-1">
{t("activeCallManagement.customerNotes")}
</div>
<div className="text-sm text-amber-700">
{activeTicket.customerNotes}
</div>
</div>
</div>
</div>
)}
</div>
</CardContent>
</Card>
</div>
);
}, [activeTicket, handleCall, isCalling, getPriorityColor, formatDate, t]);
const ServingTime = () => {
if (!activeTicket) return null;
const time =
activeTicket.status === TicketStatus.SERVING ? serviceTime : waitTime;
const isServing = activeTicket.status === TicketStatus.SERVING;
const maxTime = (activeTicket.estimatedWaitTime || 5) * 60;
const progressValue = Math.min((time / maxTime) * 100, 100);
const isOverdue = isTimeExceeded;
return (
<Card className="bg-white/90 backdrop-blur-sm border-0 shadow-lg rounded-2xl overflow-hidden">
<div
className={cn(
"p-6",
isServing
? isOverdue
? "bg-gradient-to-r from-red-500 to-rose-500"
: "bg-gradient-to-r from-green-500 to-emerald-500"
: isOverdue
? "bg-gradient-to-r from-red-500 to-rose-500"
: "bg-gradient-to-r from-yellow-500 to-amber-500"
)}
>
<div className="flex items-center justify-between text-white">
<div className="flex items-center gap-4">
<div className="w-16 h-16 bg-white/20 rounded-2xl flex items-center justify-center">
<ClockIcon className="w-8 h-8" />
</div>
<div>
<h3 className="text-2xl font-bold mb-1">
{isServing
? t("activeCallManagement.serviceTime")
: t("activeCallManagement.customerWaitTime")}
</h3>
<p className="text-white/90">
{isServing
? isOverdue
? `⚠️ ${t(
"activeCallManagement.exceedingEstimatedTime"
)} ${activeTicket.customerName}`
: `${t("activeCallManagement.assisting")} ${
activeTicket.customerName
}`
: `${activeTicket.customerName} ${t(
"activeCallManagement.waitingAtCounterLabel"
)}`}
</p>
</div>
</div>
<div className="text-right">
<div className="text-5xl font-bold font-mono mb-2">
{formatTime(time)}
</div>
<div className="flex items-center gap-2">
<Badge
className={cn(
"px-3 py-1.5 text-sm font-semibold",
isOverdue
? "bg-white/20 text-white"
: "bg-white/20 text-white"
)}
>
{progressValue.toFixed(0)}%
</Badge>
<span className="text-sm text-white/80">
{Math.floor(maxTime / 60)}m {t("activeCallManagement.target")}
</span>
</div>
</div>
</div>
</div>
<div className="bg-gray-50 p-6">
<div className="flex justify-between items-center mb-4">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<ClockIcon className="w-5 h-5 text-gray-600" />
<span className="text-sm font-medium text-gray-700">
{t("activeCallManagement.timeElapsed")}
</span>
</div>
<div className="text-lg font-mono font-semibold text-gray-900">
{Math.floor(time / 60)}m {time % 60}s
</div>
</div>
<div className="flex items-center gap-2">
{isOverdue && (
<Badge variant="destructive" className="gap-1">
<AlertTriangleIcon className="w-3 h-3" />
{t("activeCallManagement.overdue", "Overdue")}
</Badge>
)}
<Badge
variant={isServing ? "success" : "warning"}
className="gap-1"
>
<Activity className="w-3 h-3" />
{isServing
? t("activeCallManagement.serving")
: t("activeCallManagement.waiting")}
</Badge>
</div>
</div>
<Progress
value={progressValue}
className={cn(
"h-3 rounded-full",
isOverdue
? "bg-red-100 [&>div]:bg-gradient-to-r [&>div]:from-red-500 [&>div]:to-rose-500"
: isServing
? "bg-green-100 [&>div]:bg-gradient-to-r [&>div]:from-green-500 [&>div]:to-emerald-500"
: "bg-yellow-100 [&>div]:bg-gradient-to-r [&>div]:from-yellow-500 [&>div]:to-amber-500"
)}
/>
<div className="flex justify-between text-xs text-gray-500 mt-2">
<span>0m</span>
<span>{Math.floor(maxTime / 60)}m</span>
</div>
</div>
</Card>
);
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 p-4 md:p-6">
<div className="max-w-7xl mx-auto">
{/* Enhanced Header */}
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-6 mb-8">
<div className="flex items-center gap-4">
<Button
variant="outline"
size="sm"
onClick={() => navigate(-1)}
className="border-gray-300 hover:border-blue-400 hover:bg-blue-50 gap-2"
>
<ArrowLeftIcon className="h-4 w-4" />
{t("activeCallManagement.back", "Back")}
</Button>
<div className="flex items-center gap-4">
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-cyan-500 rounded-2xl flex items-center justify-center shadow-lg">
<Activity className="w-8 h-8 text-white" />
</div>
<div>
<h1 className="text-4xl font-bold bg-gradient-to-r from-blue-600 via-cyan-600 to-indigo-600 bg-clip-text text-transparent mb-2">
{t("activeCallManagement.title", "Active Call Management")}
</h1>
<p className="text-gray-600 text-lg flex items-center gap-2">
<Zap className="w-5 h-5 text-blue-500" />
{t(
"activeCallManagement.description",
"Manage active customer calls and service execution in real-time"
)}
</p>
</div>
</div>
</div>
{/* Real-time Status and Controls */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 px-4 py-2 bg-white/80 backdrop-blur-sm rounded-xl shadow-sm border">
<div className="w-3 h-3 rounded-full bg-green-500 animate-pulse" />
<span className="text-sm font-medium text-gray-700">
{t("activeCallManagement.realTimeUpdates", "Real-time Updates")}
</span>
</div>
<div className="flex items-center gap-3">
<div className="text-right">
<p className="text-sm text-gray-500">
{t("activeCallManagement.serviceCounter", "Service Counter")}
</p>
<Badge className="bg-gradient-to-r from-blue-500 to-blue-600 text-white px-3 py-1.5">
{t("activeCallManagement.window", "Window")}{" "}
{activeTicket?.counter?.windowNumber || t("common.na", "N/A")}
</Badge>
</div>
<div className="h-8 w-px bg-gray-300" />
<div className="text-right">
<p className="text-sm text-gray-500">
{t("activeCallManagement.status", "Status")}
</p>
<Badge
className={cn(
"px-3 py-1.5",
activeTicket
? "bg-gradient-to-r from-green-500 to-emerald-500 text-white"
: "bg-gray-100 text-gray-800"
)}
>
{activeTicket
? t("activeCallManagement.active", "Active")
: t("activeCallManagement.ready", "Ready")}
</Badge>
</div>
</div>
</div>
</div>
{/* Enhanced Main Content */}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Left Sidebar - Quick Actions & Stats */}
<div className="lg:col-span-1 space-y-6">
{/* Quick Actions Card */}
<Card className="bg-white/90 backdrop-blur-sm border-0 shadow-lg rounded-2xl overflow-hidden">
<div className="p-6 bg-gradient-to-r from-blue-600 to-blue-500 text-white">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-white/20 rounded-xl flex items-center justify-center">
<Zap className="w-5 h-5" />
</div>
<div>
<h3 className="font-semibold text-lg">
{t("activeCallManagement.quickActions")}
</h3>
<p className="text-blue-100 text-sm">
{t("activeCallManagement.commonOperations")}
</p>
</div>
</div>
</div>
<CardContent className="p-6 space-y-4">
{/* Call Next Customer Button */}
<Button
className="w-full bg-gradient-to-r from-green-500 to-emerald-500 hover:from-primary hover:to-emerald-600 text-white shadow-lg hover:shadow-xl transition-all duration-200 h-12"
onClick={() => handleCall()}
disabled={isCalling || !!activeTicket}
>
{isCalling ? (
<>
<Loader2 className="h-5 w-5 mr-2 animate-spin" />
{t("activeCallManagement.calling")}
</>
) : (
<>
<PlayCircleIcon className="h-5 w-5 mr-2" />
{t("activeCallManagement.callNextCustomer")}
</>
)}
</Button>
{/* Action Buttons Grid */}
<div className="grid grid-cols-1 gap-3">
<Button
variant="outline"
className="border-gray-300 text-gray-700 hover:bg-gray-50 h-10 gap-2"
disabled={!activeTicket}
>
<HourglassIcon className="h-4 w-4" />
{t("activeCallManagement.hold")}
</Button>
<Button
variant="outline"
className="border-gray-300 text-gray-700 hover:bg-gray-50 h-10 gap-2"
disabled={!activeTicket}
>
<Settings2Icon className="h-4 w-4" />
{t("activeCallManagement.transfer")}
</Button>
<Button
variant="outline"
onClick={() => {
refetchActiveExecution();
}}
className="border-gray-300 text-gray-700 hover:bg-gray-50 h-10 gap-2"
>
<RefreshCw className="h-4 w-4" />
{t("activeCallManagement.refresh", "Refresh")}
</Button>
</div>
</CardContent>
</Card>
{/* Customer Stats Card */}
{activeTicket && (
<Card className="bg-white/90 backdrop-blur-sm border-0 shadow-lg rounded-2xl overflow-hidden">
<div className="p-6 bg-gradient-to-r from-purple-600 to-violet-500 text-white">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-white/20 rounded-xl flex items-center justify-center">
<BarChart3 className="w-5 h-5" />
</div>
<div>
<h3 className="font-semibold text-lg">
{t("activeCallManagement.customerStats")}
</h3>
<p className="text-purple-100 text-sm">
{t(
"activeCallManagement.realTimeMetrics",
"Real-time metrics"
)}
</p>
</div>
</div>
</div>
<CardContent className="p-6">
<div className="space-y-4">
<div className="flex items-center justify-between p-3 bg-blue-50 rounded-lg">
<div className="flex items-center gap-2">
<Settings2Icon className="h-4 w-4 text-blue-600" />
<span className="text-sm text-blue-700 font-medium">
{t("activeCallManagement.currentService")}
</span>
</div>
<span className="font-medium text-blue-900 text-sm">
{typeof activeTicket.service?.name === "object"
? (activeTicket.service?.name as any)?.en ||
(activeTicket.service?.name as any)?.am ||
t("common.na", "N/A")
: activeTicket.service?.name}
</span>
</div>
<div className="flex items-center justify-between p-3 bg-yellow-50 rounded-lg">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-yellow-600" />
<span className="text-sm text-yellow-700 font-medium">
{t("activeCallManagement.waitTime")}
</span>
</div>
<span className="font-medium text-yellow-900 font-mono">
{formatTime(waitTime)}
</span>
</div>
<div className="flex items-center justify-between p-3 bg-green-50 rounded-lg">
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-primary-600" />
<span className="text-sm text-primary-700 font-medium">
{t("activeCallManagement.serviceTime")}
</span>
</div>
<span className="font-medium text-green-900 font-mono">
{formatTime(serviceTime)}
</span>
</div>
<div className="flex items-center justify-between p-3 bg-purple-50 rounded-lg">
<div className="flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-purple-600" />
<span className="text-sm text-purple-700 font-medium">
{t("activeCallManagement.priority")}
</span>
</div>
<Badge
className={cn(
"px-2 py-0.5",
getPriorityColor(activeTicket.priority)
)}
>
{activeTicket.priority}
</Badge>
</div>
</div>
</CardContent>
</Card>
)}
</div>
{/* Main Content Area */}
<div className="lg:col-span-3 space-y-6">
{/* Timer Card */}
{activeTicket && <ServingTime />}
{/* Customer and Service Display */}
{TicketDisplay}
</div>
</div>
</div>
</div>
);
};
export default ActiveCallManagement;

View File

@@ -0,0 +1,803 @@
import { useForm, useWatch } from "react-hook-form";
import { motion } from "framer-motion";
import {
Calendar,
Target,
Users,
FileText,
Plus,
ArrowLeft,
User,
} from "lucide-react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useEffect, useMemo } from "react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useLocalizedName } from "@/shared/common/localizedName";
import { useAuth } from "@/shared/context/AuthContext";
import { useUnitContext } from "@/shared/context/UnitContext";
import { Button } from "@/shared/common/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/common/ui/card";
import { Alert, AlertDescription, AlertTitle } from "@/shared/common/ui/alert";
import { Info } from "lucide-react";
import { GenericForm } from "@/performance-management/utils/generic-form";
import { TimeFrame } from "@/performance-management/utils/shared-field-types";
import {
useEmployeePlanMutations,
useEmployeePositions,
usePositionsByUnit,
useEmployeePlan,
useServices,
useEmployeePlanByPlanId,
} from "@/performance-management/hooks/useEmployeePlan";
import { FormField } from "@/performance-management/utils/shared-field-types";
import { useMinePlan } from "@/performance-management/hooks/usePlans";
// Define PlanType enum for employee plans
export enum EmployeePlanType {
NUMBER = "number",
PERCENTAGE = "percent",
BOOLEAN = "boolean",
}
export interface EmployeePlanFormValues {
parentEmployeePlanId?: string;
employeePositionId: string;
planId: string;
serviceId: string;
planType: EmployeePlanType;
expectedQuantity: number;
week: string | number;
month: string | number;
quarter: string | number;
timeframe: string;
}
interface EmployeePlanFormProps {
parentEmployeePlanId?: string;
onSuccess?: () => void;
}
export default function EmployeePlanForm({
parentEmployeePlanId: propParentId,
onSuccess,
}: EmployeePlanFormProps = {}) {
const navigate = useNavigate();
const { t } = useTranslation();
const {
id,
employeePositionId: pathEmployeePositionId,
planId: pathPlanId,
serviceId: pathServiceId,
} = useParams<{
id: string;
employeePositionId?: string;
planId?: string;
serviceId?: string;
}>();
const [searchParams] = useSearchParams();
const urlParentId = searchParams.get("parentEmployeePlanId");
const urlEmployeePositionId = searchParams.get("employeePositionId");
const urlPlanId = searchParams.get("planId");
const urlServiceId = searchParams.get("serviceId");
const parentId = propParentId || urlParentId;
const employeePositionId = urlEmployeePositionId || pathEmployeePositionId;
const planId = urlPlanId || pathPlanId;
const serviceId = urlServiceId || pathServiceId;
const { user, selectedPositionId } = useAuth();
const localizedName = useLocalizedName();
const isEdit = !!id;
// Pick the employee record matching the globally-selected position, so a
// user with positions in multiple orgs uses the right org. Falls back to
// the first employee record when nothing is selected.
const organizationId = useMemo(() => {
const employees = user?.employee ?? [];
if (selectedPositionId) {
const match = employees.find((emp) =>
emp.positions?.some(
(p) =>
p.employeePositionId === selectedPositionId ||
p.id === selectedPositionId,
),
);
if (match?.organizationId) return match.organizationId;
}
return employees[0]?.organizationId;
}, [user, selectedPositionId]);
const unitContext = useUnitContext();
const unitsQuery = unitContext?.getList(organizationId!, {
take: 300,
skip: 0,
});
const unitId = unitsQuery?.data?.data?.items?.[0]?.id ?? "";
const { data: positionsData } = usePositionsByUnit(unitId);
const { data: employeePlanDetail } = useEmployeePlan(id || "");
const { create: createMutation, update: updateMutation } =
useEmployeePlanMutations();
const { data: myPlans } = useMinePlan();
console.log("my plans ", myPlans);
//
const { data: employeePositionsData } = useEmployeePositions();
console.log("employee positoin ", employeePositionsData);
// Fetch services and parent employee plan details
const { data: servicesData } = useServices();
console.log("services ", servicesData);
// Fetch parent employee plan detail if parentId is present in URL
const { data: urlParentEmployeePlanDetail } = useEmployeePlan(parentId || "");
const normalizeTimeframe = (val: unknown): string => {
if (!val) return TimeFrame.YEAR;
const str = String(val).toLowerCase();
if (str.includes("year")) return TimeFrame.YEAR;
if (str.includes("quarter")) return TimeFrame.QUARTER;
if (str.includes("month")) return TimeFrame.MONTH;
if (str.includes("week")) return TimeFrame.WEEK;
if (str.includes("day")) return TimeFrame.DAY;
return TimeFrame.YEAR;
};
const initialEmployeePlanFormValues = useMemo(
() => ({
parentEmployeePlanId:
employeePlanDetail?.data?.parentEmployeePlanId || parentId || undefined,
employeePositionId:
employeePlanDetail?.data?.employeePositionId ||
employeePositionId ||
"",
planId: employeePlanDetail?.data?.planId || planId || "",
serviceId: employeePlanDetail?.data?.serviceId || serviceId || "",
planType: employeePlanDetail?.data?.planType || EmployeePlanType.NUMBER,
expectedQuantity: employeePlanDetail?.data?.expectedQuantity || 0,
week: employeePlanDetail?.data?.week?.toString() || "0",
month: employeePlanDetail?.data?.month?.toString() || "0",
quarter: employeePlanDetail?.data?.quarter?.toString() || "0",
timeframe: (() => {
const rawTimeframe = employeePlanDetail?.data?.timeframe;
if (rawTimeframe) return normalizeTimeframe(rawTimeframe);
// Fallback for sub-plan creation when parent data is missing but parentId is present
const parent = urlParentEmployeePlanDetail?.data;
if (!parentId || !parent) {
return parentId ? TimeFrame.QUARTER : TimeFrame.YEAR;
}
const pt = normalizeTimeframe(parent.timeframe);
if (pt === TimeFrame.YEAR) return TimeFrame.QUARTER;
if (pt === TimeFrame.QUARTER) return TimeFrame.QUARTER;
if (pt === TimeFrame.MONTH) return TimeFrame.WEEK;
if (pt === TimeFrame.WEEK) return TimeFrame.DAY;
return TimeFrame.YEAR;
})(),
}),
[
employeePlanDetail?.data,
parentId,
employeePositionId,
planId,
serviceId,
urlParentEmployeePlanDetail?.data,
]
);
const form = useForm<EmployeePlanFormValues>({
defaultValues: initialEmployeePlanFormValues,
values: isEdit || !!parentId ? initialEmployeePlanFormValues : undefined,
});
const watchedPlanId = useWatch({
control: form.control,
name: "planId",
});
const { data: parentEmployeePlanDetail } = useEmployeePlanByPlanId(
watchedPlanId || ""
);
const positionOptions =
positionsData?.data?.items?.map(
(position: { id: string; title: string }) => ({
value: position.id,
label: position.title,
icon: User,
})
) || [];
const employeePositionOptions =
employeePositionsData?.items?.flatMap((emp) =>
emp.employeePositions.map((empPos) => ({
value: empPos.id, // employeePositionId
label: `${emp.name.en} ${empPos.position?.name?.am}`,
icon: User,
}))
) || [];
// Mock data - replace with your actual data fetching
const planOptions =
myPlans?.data?.items?.map(
(plan: { id: string; name: { am: string; en: string } }) => ({
value: plan.id,
label: localizedName(plan.name),
icon: FileText,
})
) || [];
const serviceOptions =
servicesData?.data?.items?.map(
(service: { id: string; name: { am: string; en: string } }) => ({
value: service.id,
label: localizedName(service.name),
icon: Users,
})
) || [];
// Determine allowed timeframe options based on parent employee plan
const timeframeOptions = useMemo(() => {
// Current parent detail either from URL (parentId) or watched result
const parent =
urlParentEmployeePlanDetail?.data || parentEmployeePlanDetail?.data;
if (!parentId && !parent) {
return [
{
value: TimeFrame.YEAR,
label: t("employeePlans.yearly", "Yearly"),
},
];
}
const parentTimeframe = normalizeTimeframe(parent?.timeframe);
switch (parentTimeframe) {
case TimeFrame.YEAR:
return [
{
value: TimeFrame.YEAR,
label: t("employeePlans.yearly", "Yearly"),
},
{
value: TimeFrame.QUARTER,
label: t("employeePlans.quarterly", "Quarterly"),
},
];
case TimeFrame.QUARTER:
return [
{
value: TimeFrame.QUARTER,
label: t("employeePlans.quarterly", "Quarterly"),
},
];
case TimeFrame.MONTH:
return [
{
value: TimeFrame.WEEK,
label: t("employeePlans.weekly", "Weekly"),
},
];
case TimeFrame.WEEK:
return [
{
value: TimeFrame.DAY,
label: t("employeePlans.daily", "Daily"),
},
];
default:
return [
{
value: TimeFrame.YEAR,
label: t("employeePlans.yearly", "Yearly"),
},
];
}
}, [
parentId,
parentEmployeePlanDetail?.data,
urlParentEmployeePlanDetail?.data,
t,
]);
// Auto-select timeframe if there's only one option
useEffect(() => {
if (timeframeOptions.length === 1) {
const currentVal = form.getValues("timeframe");
if (currentVal !== timeframeOptions[0].value) {
form.setValue("timeframe", timeframeOptions[0].value as string, {
shouldValidate: true,
shouldDirty: true,
});
}
}
}, [timeframeOptions, form]);
useEffect(() => {
const current = form.getValues("timeframe");
const isValid = timeframeOptions.some((opt) => opt.value === current);
if (!isValid && timeframeOptions.length > 0) {
form.setValue("timeframe", timeframeOptions[0].value, {
shouldValidate: true,
shouldDirty: true,
});
}
}, [timeframeOptions, form]);
const planTypeOptions = Object.values(EmployeePlanType).map((type) => ({
value: type,
label: type.charAt(0).toUpperCase() + type.slice(1).toLowerCase(),
}));
// TODO: Fetch employee plans for parent selection
const employeePlanOptions =
parentEmployeePlanDetail?.data.items?.map(
(plan: { id: string; name: { am: string; en: string } }) => ({
value: plan.id,
label: localizedName(plan.name),
icon: FileText,
})
) || [];
const watchedTimeframe = useWatch({
control: form.control,
name: "timeframe",
});
// Reset hidden fields when timeframe changes
useEffect(() => {
if (watchedTimeframe === TimeFrame.YEAR) {
form.setValue("quarter", "0");
form.setValue("month", "0");
form.setValue("week", "0");
} else if (watchedTimeframe === TimeFrame.QUARTER) {
form.setValue("month", "0");
form.setValue("week", "0");
} else if (watchedTimeframe === TimeFrame.MONTH) {
form.setValue("week", "0");
}
// For WEEK and DAY, no reset needed as they inherit from parent timeframes
}, [watchedTimeframe, form]);
// Inherit context from parent employee plan
useEffect(() => {
if (parentId && parentEmployeePlanDetail?.data && !isEdit) {
const parent = parentEmployeePlanDetail.data;
// Inherit timeframe context
switch (parent.timeframe) {
case TimeFrame.YEAR:
form.setValue("timeframe", TimeFrame.QUARTER);
break;
case TimeFrame.QUARTER:
form.setValue("timeframe", TimeFrame.QUARTER);
if (parent.quarter)
form.setValue("quarter", parent.quarter.toString());
break;
case TimeFrame.MONTH:
form.setValue("timeframe", TimeFrame.WEEK);
if (parent.quarter)
form.setValue("quarter", parent.quarter.toString());
if (parent.month) form.setValue("month", parent.month.toString());
break;
case TimeFrame.WEEK:
form.setValue("timeframe", TimeFrame.DAY);
if (parent.quarter)
form.setValue("quarter", parent.quarter.toString());
if (parent.month) form.setValue("month", parent.month.toString());
if (parent.week) form.setValue("week", parent.week.toString());
break;
default:
break;
}
}
}, [parentId, parentEmployeePlanDetail, isEdit, form]);
const handleSubmit = (values: EmployeePlanFormValues) => {
// Prepare timeframe-specific fields based on selected timeframe
const getTimeframeFields = (): {
quarter: number;
month: number;
week: number;
} => {
const fields = {
quarter: 0,
month: 0,
week: 0,
};
switch (values.timeframe) {
case TimeFrame.QUARTER:
fields.quarter = Number(values.quarter);
break;
case TimeFrame.MONTH:
fields.quarter = Number(values.quarter);
fields.month = Number(values.month);
break;
case TimeFrame.WEEK:
fields.quarter = Number(values.quarter);
fields.month = Number(values.month);
fields.week = Number(values.week);
break;
case TimeFrame.DAY:
fields.quarter = Number(values.quarter);
fields.month = Number(values.month);
fields.week = Number(values.week);
break;
// For YEAR, all fields remain 0
}
return fields;
};
const dto = {
id: isEdit ? id : undefined,
parentEmployeePlanId: values.parentEmployeePlanId,
employeePositionId: values.employeePositionId,
planId: values.planId,
serviceId: values.serviceId,
planType: values.planType,
expectedQuantity: Number(values.expectedQuantity),
timeframe: values.timeframe,
...getTimeframeFields(),
};
const mutation = isEdit ? updateMutation : createMutation;
mutation.mutate(dto, {
onSuccess: () => {
toast.success(
isEdit
? t(
"employeePlans.planUpdatedSuccess",
"Employee plan updated successfully!"
)
: t(
"employeePlans.planCreatedSuccess",
"Employee plan created successfully!"
),
{
description: `${t("employeePlans.plan", "Employee plan")} ${
isEdit
? t("common.updated", "updated")
: t("common.created", "created")
}.`,
}
);
onSuccess?.();
if (!isEdit) {
navigate(-1);
}
},
onError: (error: { message?: string }) => {
toast.error(
isEdit
? t(
"employeePlans.planUpdateFailed",
"Failed to update employee plan"
)
: t(
"employeePlans.planCreateFailed",
"Failed to create employee plan"
),
{
description:
error?.message ||
t("common.checkInput", "Please check your input and try again."),
}
);
},
});
};
// Define form fields
const employeePlanFormFields: FormField<EmployeePlanFormValues>[] = [
// Basic Assignment Section
{
name: "employeePositionId",
label: t("employeePlans.employeePosition", "Employee Position"),
type: "select",
options:
employeePositionOptions.length > 0
? employeePositionOptions
: positionOptions,
required: true,
icon: User,
section: t("employeePlans.assignment", "Assignment"),
colSpan: 6,
},
{
name: "serviceId",
label: t("employeePlans.service", "Service"),
type: "select",
options: serviceOptions,
required: true,
icon: Users,
section: t("employeePlans.assignment", "Assignment"),
colSpan: 6,
},
{
name: "planId",
label: t("employeePlans.plan", "Plan"),
type: "select",
options: planOptions,
required: true,
icon: FileText,
section: t("employeePlans.assignment", "Assignment"),
colSpan: 6,
},
{
name: "parentEmployeePlanId",
label: t("employeePlans.parentEmployeePlan", "Parent Employee Plan"),
type: "select",
options: employeePlanOptions,
icon: FileText,
section: t("employeePlans.assignment", "Assignment"),
disabled: !!parentId,
colSpan: 6,
defaultValue: parentId || undefined,
},
// Plan Details Section
{
name: "planType",
label: t("employeePlans.planType", "Plan Type"),
type: "select",
options: planTypeOptions,
required: true,
icon: Target,
section: t("employeePlans.planDetails", "Plan Details"),
colSpan: 4,
},
{
name: "expectedQuantity",
label: t("employeePlans.expectedQuantity", "Expected Quantity"),
type: "number",
required: true,
icon: Target,
section: t("employeePlans.planDetails", "Plan Details"),
colSpan: 4,
},
{
name: "timeframe",
label: t("employeePlans.timeFrame", "Time Frame"),
type: "select",
options: timeframeOptions,
required: true,
disabled: timeframeOptions.length === 1,
icon: Calendar,
section: t("employeePlans.planDetails", "Plan Details"),
colSpan: 4,
},
// Timeframe Context Section
{
name: "quarter",
label: t("employeePlans.quarter", "Quarter"),
type: "number",
placeholder: t("employeePlans.enterQuarter", "Enter quarter (1-4)"),
icon: Calendar,
section: t("employeePlans.timeframeContext", "Timeframe Context"),
hidden:
watchedTimeframe === TimeFrame.YEAR &&
(!form.getValues("quarter") ||
String(form.getValues("quarter")) === "0"),
colSpan: 4,
min: 1,
max: 4,
},
{
name: "month",
label: t("employeePlans.month", "Month"),
type: "number",
placeholder: t("employeePlans.enterMonth", "Enter month (1-12)"),
icon: Calendar,
section: t("employeePlans.timeframeContext", "Timeframe Context"),
hidden:
![TimeFrame.MONTH, TimeFrame.WEEK, TimeFrame.DAY].includes(
watchedTimeframe as TimeFrame
) &&
(!form.getValues("month") || String(form.getValues("month")) === "0"),
colSpan: 4,
min: 1,
max: 12,
},
{
name: "week",
label: t("employeePlans.week", "Week"),
type: "number",
placeholder: t("employeePlans.enterWeek", "Enter week (1-52)"),
icon: Calendar,
section: t("employeePlans.timeframeContext", "Timeframe Context"),
hidden:
![TimeFrame.WEEK, TimeFrame.DAY].includes(
watchedTimeframe as TimeFrame
) &&
(!form.getValues("week") || String(form.getValues("week")) === "0"),
colSpan: 4,
min: 1,
max: 52,
},
];
// Filter fields based on timeframe
const filteredFields = employeePlanFormFields.filter((field) => {
if (field.hidden) return false;
return true;
});
const handleCancel = () => {
navigate(-1);
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 p-4 sm:p-6">
<div className="max-w-4xl mx-auto">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center justify-between mb-8">
<div className="flex items-center space-x-4">
<Button
variant="outline"
size="sm"
onClick={() => navigate(-1)}
className="border-gray-300 text-gray-700 hover:bg-gray-50">
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
<div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-gray-800 to-gray-600 bg-clip-text text-transparent">
{isEdit ? "Edit Employee Plan" : "Create Employee Plan"}
</h1>
<p className="text-gray-600 mt-1">
{isEdit
? "Update employee plan assignment and details"
: "Assign a plan to an employee with specific targets"}
</p>
</div>
</div>
<div className="flex items-center space-x-2 bg-white/80 backdrop-blur-sm rounded-2xl px-4 py-2 shadow-sm border border-gray-200">
<div
className={`w-2 h-2 rounded-full ${
isEdit ? "bg-amber-500" : "bg-green-500"
} animate-pulse`}></div>
<span className="text-sm font-medium text-gray-700">
{isEdit ? "Editing Mode" : "Creation Mode"}
</span>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}>
<Card className="bg-white/80 backdrop-blur-sm border-0 shadow-xl overflow-hidden">
<CardHeader className="bg-gradient-to-r from-blue-500 to-cyan-500 text-white pb-6">
<div className="flex items-center space-x-3">
<div className="w-12 h-12 bg-white/20 rounded-2xl flex items-center justify-center backdrop-blur-sm">
{isEdit ? (
<FileText className="h-6 w-6 text-white" />
) : (
<Plus className="h-6 w-6 text-white" />
)}
</div>
<div>
<CardTitle className="text-2xl font-bold text-white">
{isEdit
? t(
"employeePlans.updateEmployeePlan",
"Update Employee Plan"
)
: t(
"employeePlans.newEmployeePlanAssignment",
"New Employee Plan Assignment"
)}
</CardTitle>
<CardDescription className="text-blue-100">
{isEdit
? t(
"employeePlans.modifyPlanDetails",
"Modify the employee plan details below"
)
: t(
"employeePlans.assignPlanWithTimeframe",
"Assign plan to employee with specific timeframe and targets"
)}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="p-6">
{/* Parent Plan Info Alert */}
{parentId && parentEmployeePlanDetail?.data && (
<Alert className="mb-6 bg-blue-50/80 border-blue-200/50 backdrop-blur-sm">
<Info className="h-4 w-4 text-blue-600" />
<AlertTitle className="text-blue-900 font-semibold">
{t(
"employeePlans.creatingSubEmployeePlan",
"Creating Sub-Employee Plan"
)}
</AlertTitle>
<AlertDescription className="text-blue-700">
{t(
"employeePlans.subPlanUnder",
"This employee plan will be created under"
)}
:{" "}
<strong className="text-blue-900">
{parentEmployeePlanDetail.data.name ||
t("employeePlans.parentPlan", "Parent Plan")}
</strong>
</AlertDescription>
</Alert>
)}
<GenericForm
form={form}
fields={filteredFields}
onSubmit={handleSubmit}
onCancel={handleCancel}
isSubmitting={
createMutation.isPending || updateMutation.isPending
}
submitButtonText={
isEdit
? t(
"employeePlans.updateEmployeePlan",
"Update Employee Plan"
)
: t(
"employeePlans.createEmployeePlan",
"Create Employee Plan"
)
}
cancelButtonText={t("employeePlans.cancel", "Cancel")}
successMessage={
isEdit
? t(
"employeePlans.planUpdatedSuccess",
"Employee plan updated successfully!"
)
: t(
"employeePlans.planCreatedSuccess",
"Employee plan created successfully!"
)
}
errorMessage={
isEdit
? t(
"employeePlans.planUpdateFailed",
"Failed to update employee plan"
)
: t(
"employeePlans.planCreateFailed",
"Failed to create employee plan"
)
}
/>
</CardContent>
</Card>
</motion.div>
</div>
</div>
);
}

View File

@@ -0,0 +1,317 @@
import { useState, useEffect } from "react";
import { Button } from "@/shared/common/ui/button";
import { Input } from "@/shared/common/ui/input";
import { Label } from "@/shared/common/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import { Loader2, ArrowLeft } from "lucide-react";
import { useToast } from "@/shared/common/ui/use-toast";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/common/ui/card";
import {
updateEmployeePlan,
getEmployeePlan,
getPlansByUnit,
} from "@/performance-management/services/api/employeePlanService";
const EditEmployeePlanPage = () => {
const { id } = useParams<{ id: string }>();
const { toast } = useToast();
const { t } = useTranslation();
const navigate = useNavigate();
const [isSaving, setIsSaving] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isLoadingPlans, setIsLoadingPlans] = useState(false);
const [plans, setPlans] = useState<any[]>([]);
const [formData, setFormData] = useState({
planId: "",
employeePositionId: "",
serviceId: "",
timeframe: "",
expectedQuantity: 0,
planType: "number" as "number" | "percent",
});
// Fetch employee plan data when component mounts
useEffect(() => {
if (id) {
fetchEmployeePlan(id);
fetchPlans();
}
}, [id]);
const fetchEmployeePlan = async (planId: string) => {
setIsLoading(true);
try {
const response = await getEmployeePlan(planId);
const plan = response.data;
// Convert ISO dates to YYYY-MM-DD format for input fields
setFormData({
planId: plan.planId || "",
employeePositionId: plan.employeePositionId || "",
serviceId: plan.serviceId || "",
timeframe: plan.timeframe || "",
expectedQuantity: plan.expectedQuantity || 0,
planType: plan.planType || "number",
});
} catch (error) {
console.error("Failed to fetch employee plan:", error);
toast({
title: "Error",
description: "Failed to fetch employee plan data",
variant: "destructive",
});
navigate("/performance-management/employee-plans");
} finally {
setIsLoading(false);
}
};
const fetchPlans = async () => {
setIsLoadingPlans(true);
try {
const response = await getPlansByUnit("");
const plansData = response.data?.items || [];
setPlans(Array.isArray(plansData) ? plansData : []);
} catch (error) {
console.error("Failed to fetch plans:", error);
toast({
title: "Error",
description: "Failed to fetch plans",
variant: "destructive",
});
} finally {
setIsLoadingPlans(false);
}
};
const handleChange = (field: string, value: any) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!id) {
toast({
title: "Error",
description: "Employee plan ID is missing",
variant: "destructive",
});
return;
}
setIsSaving(true);
try {
await updateEmployeePlan(id, {
parentEmployeePlanId: formData.planId,
employeePositionId: formData.employeePositionId,
planId: formData.planId,
serviceId: formData.serviceId,
planType: formData.planType,
expectedQuantity: Number(formData.expectedQuantity),
timeframe: formData.timeframe,
});
toast({
title: "Success",
description: "Employee plan updated successfully",
variant: "default",
});
// Navigate back to list
navigate("/performance-management/employee-plans");
} catch (error: any) {
console.error("Failed to update employee plan:", error);
const errorMessage =
error.response?.data?.message ||
error.message ||
"Failed to update employee plan";
toast({
title: "Error",
description: errorMessage,
variant: "destructive",
});
} finally {
setIsSaving(false);
}
};
if (isLoading) {
return (
<div className="p-6 flex items-center justify-center min-h-screen">
<Loader2 className="h-8 w-8 animate-spin text-purple-600" />
</div>
);
}
return (
<div className="p-6 max-w-4xl mx-auto">
<div className="mb-6">
<Button
variant="ghost"
onClick={() => navigate("/performance-management/employee-plans")}
className="mb-4"
>
<ArrowLeft className="h-4 w-4 mr-2" />
{t("common.back", "Back")}
</Button>
<h1 className="text-2xl font-bold text-gray-900">
{t("employeePlans.editPlan", "Edit Employee Plan")}
</h1>
<p className="text-gray-600 mt-1">
{t(
"employeePlans.editDescription",
"Update the details of this employee performance plan"
)}
</p>
</div>
<Card>
<CardHeader>
<CardTitle>
{t("employeePlans.planDetails", "Plan Details")}
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Plan Selection */}
<div className="space-y-2">
<Label htmlFor="planId">
{t("employeePlans.plan", "Plan")}
<span className="text-red-500">*</span>
</Label>
{isLoadingPlans ? (
<div className="flex items-center gap-2 text-gray-500">
<Loader2 className="h-4 w-4 animate-spin" />
{t("loading", "Loading plans...")}
</div>
) : plans.length > 0 ? (
<Select
value={formData.planId}
onValueChange={(value) => handleChange("planId", value)}
required
>
<SelectTrigger>
<SelectValue placeholder="Select a plan" />
</SelectTrigger>
<SelectContent>
{plans.map((plan: any) => (
<SelectItem key={plan.id} value={plan.id}>
{typeof plan.name === "string"
? plan.name
: plan.name?.en || plan.name?.am || plan.id}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<p className="text-sm text-gray-500">
{t("employeePlans.noPlans", "No plans available")}
</p>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Start Date */}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Expected Quantity */}
<div className="space-y-2">
<Label htmlFor="expectedQuantity">
{t("employeePlans.expectedQuantity", "Expected Quantity")}
</Label>
<Input
id="expectedQuantity"
type="number"
min="0"
step="0.01"
value={formData.expectedQuantity}
onChange={(e) =>
handleChange(
"expectedQuantity",
parseFloat(e.target.value) || 0
)
}
placeholder="Enter expected quantity"
/>
</div>
{/* Plan Type */}
<div className="space-y-2">
<Label htmlFor="planType">
{t("employeePlans.planType", "Plan Type")}
</Label>
<Select
value={formData.planType}
onValueChange={(value) => handleChange("planType", value)}
>
<SelectTrigger>
<SelectValue placeholder="Select plan type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="number">
{t("employeePlans.number", "Number")}
</SelectItem>
<SelectItem value="percent">
{t("employeePlans.percentage", "Percent")}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex justify-end gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={() =>
navigate("/performance-management/employee-plans")
}
disabled={isSaving}
>
{t("common.cancel", "Cancel")}
</Button>
<Button
type="submit"
disabled={isSaving}
className="bg-purple-600 hover:bg-purple-700"
>
{isSaving ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{t("saving", "Saving...")}
</>
) : (
t("employeePlans.update", "Update Plan")
)}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
};
export default EditEmployeePlanPage;

View File

@@ -0,0 +1,203 @@
import { Users, AlertTriangle, CheckCircle2 } from "lucide-react";
import { useQueries, useQuery } from "@tanstack/react-query";
import {
getChildPositions,
getEmployeesUnderPosition,
} from "@/user-management/services/api/positionService";
import { useLocalizedName } from "@/shared/common/localizedName";
import { useAuth } from "@/shared/context/AuthContext";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/common/ui/card";
import { Skeleton } from "@/shared/common/ui/skeleton";
import { Avatar, AvatarFallback } from "@/shared/common/ui/avatar";
import { Badge } from "@/shared/common/ui/badge";
import type { PositionDto } from "@/user-management/dto/positions/positionDto";
/**
* Direct reports of the currently-active position.
*
* 1. GET /positions/children/:myPositionId → direct child positions
* (each item has parentPositionId === myPositionId)
* 2. For each child position, GET /positions/current/:childPositionId/employees
* → employees holding that position
*
* This bypasses the buggy /employees/immediate-child endpoint which was
* returning peers (rows sharing the same position_id) instead of children.
*/
export const MyTeamPanel = () => {
const { user, selectedPositionId } = useAuth();
const localizedName = useLocalizedName();
// Resolve the active position so we can use its `id` (not employeePositionId).
const allPositions =
user?.employee?.flatMap((emp) => emp.positions ?? []) ?? [];
const activePosition =
allPositions.find(
(p) =>
p.employeePositionId === selectedPositionId ||
p.id === selectedPositionId,
) ?? allPositions[0];
const myPositionId = activePosition?.id;
const {
data: childrenResp,
isLoading: childrenLoading,
error: childrenError,
} = useQuery({
queryKey: ["positionChildren", myPositionId],
queryFn: () => getChildPositions(myPositionId!),
enabled: !!myPositionId,
});
const childPositions: PositionDto[] = childrenResp?.data?.items ?? [];
// Fetch employees per direct-child position in parallel.
const employeeQueries = useQueries({
queries: childPositions.map((cp) => ({
queryKey: ["positionEmployees", cp.id],
queryFn: () => getEmployeesUnderPosition(cp.id),
enabled: !!cp.id,
})),
});
if (childrenLoading) {
return <Skeleton className="h-32 w-full rounded-xl mb-6" />;
}
if (childrenError) {
return (
<Card className="mb-6 border-red-200 bg-red-50">
<CardContent className="p-4 text-sm text-red-700">
Failed to load child positions:{" "}
{(childrenError as Error).message}
</CardContent>
</Card>
);
}
const totalEmployees = employeeQueries.reduce(
(sum, q) => sum + ((q.data?.data?.items?.length ?? q.data?.data?.length) || 0),
0,
);
return (
<Card className="mb-6">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Users className="h-5 w-5" />
My Team
<Badge variant="secondary" className="ml-1">
{totalEmployees}
</Badge>
</CardTitle>
<div className="flex flex-wrap gap-2 text-xs mt-2 text-gray-500">
<span>
Acting as position id: <code>{myPositionId ?? "—"}</code>
</span>
<span>·</span>
<span>
{childPositions.length} direct child position
{childPositions.length === 1 ? "" : "s"}
</span>
</div>
</CardHeader>
<CardContent>
{childPositions.length === 0 ? (
<p className="text-sm text-gray-500">
No child positions exist under your current position.
</p>
) : (
<div className="space-y-4">
{childPositions.map((cp, idx) => {
const empQuery = employeeQueries[idx];
const employeesRaw =
empQuery?.data?.data?.items ?? empQuery?.data?.data ?? [];
const employees = Array.isArray(employeesRaw) ? employeesRaw : [];
// Sanity-check: every returned position should have
// parentPositionId === myPositionId. Anything else is suspicious.
const isCorrectChild = cp.parentPositionId === myPositionId;
return (
<div
key={cp.id}
className={`rounded-xl border p-3 ${
isCorrectChild
? "bg-white"
: "bg-amber-50 border-amber-200"
}`}
>
<div className="flex items-center gap-2 mb-3">
<Badge variant="outline">
{localizedName(cp.name) || cp.key}
</Badge>
{!isCorrectChild && (
<span
className="inline-flex items-center gap-1 text-xs text-amber-700"
title={`Expected parentPositionId=${myPositionId}, got ${cp.parentPositionId}`}
>
<AlertTriangle className="h-3 w-3" /> wrong parent
</span>
)}
<span className="text-xs text-gray-500 ml-auto">
{empQuery?.isLoading
? "loading…"
: `${employees.length} employee${employees.length === 1 ? "" : "s"}`}
</span>
</div>
{empQuery?.error ? (
<p className="text-xs text-red-600">
Failed to load employees for this position
</p>
) : employees.length === 0 && !empQuery?.isLoading ? (
<p className="text-xs text-gray-500">
No employees currently hold this position.
</p>
) : (
<ul className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-2">
{employees.map((emp: any) => {
const name = localizedName(emp.name);
const initials = name
?.split(" ")
.map((s: string) => s[0])
.filter(Boolean)
.slice(0, 2)
.join("")
.toUpperCase();
return (
<li
key={emp.id}
className="flex items-center gap-2 rounded-lg border bg-white p-2 shadow-sm"
>
<Avatar className="h-8 w-8">
<AvatarFallback>
{initials || "?"}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="font-medium truncate text-xs">
{name}
</p>
</div>
<CheckCircle2 className="h-3 w-3 text-green-500 ml-auto" />
</li>
);
})}
</ul>
)}
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,693 @@
import { useState, useEffect } from "react";
import { useNavigate, useLocation, useParams } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/shared/common/ui/button";
import {
Loader2,
Target,
Hash,
Percent,
ArrowLeft,
Save,
Check,
CalendarDays,
Grid,
List,
} from "lucide-react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/common/ui/table";
import { useToast } from "@/shared/common/ui/use-toast";
import { Badge } from "@/shared/common/ui/badge";
import {
ExtendedEmployeePlanDto,
useEmployeePlanMutations,
useSubEmployeePlanByPlanId,
} from "@/performance-management/hooks/useEmployeePlan";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
import { useTranslation } from "react-i18next";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/common/ui/card";
import { Tabs, TabsList, TabsTrigger } from "@/shared/common/ui/tabs";
import { Label } from "@/shared/common/ui/label";
import { Input } from "@/shared/common/ui/input";
import { cn } from "@/shared/lib/utils";
// Interface for the data passed via navigation state
interface NavigationStateData {
parentEmployeePlanId: string;
employeePositionId: string;
planId: string;
serviceId: string;
planType: "number" | "percent" | string;
currentQuantity: number;
expectedQuantity: number;
month: number;
timeframe: string;
planName: string;
serviceName: string;
weight: number;
status: string;
}
// Week data interface
interface WeekData {
weekNumber: number;
planned: number;
employeePlanId: string | null;
}
const WeeklyPlanAssignmentPage = () => {
const navigate = useNavigate();
const location = useLocation();
const { t } = useTranslation();
const { yearId, monthId } = useParams();
const { toast } = useToast();
const { handleError } = useErrorHandler(t);
const [activeTab, setActiveTab] = useState<"table" | "calendar">("table");
const [isLoadingExistingPlans, setIsLoadingExistingPlans] = useState(false);
const queryClient = useQueryClient();
const { create, update } = useEmployeePlanMutations();
// Get data from navigation state
const navigationState = location.state as NavigationStateData;
// Destructure all necessary data from navigation state
const {
parentEmployeePlanId,
employeePositionId,
planId,
serviceId,
planType: initialPlanType,
expectedQuantity,
planName,
serviceName,
status,
month: monthFromPlan,
} = navigationState;
// Get month from URL param or plan data
const monthFromUrl = monthId ? parseInt(monthId) : monthFromPlan;
const [weeks, setWeeks] = useState<WeekData[]>([
{ weekNumber: 1, planned: 0, employeePlanId: null },
{ weekNumber: 2, planned: 0, employeePlanId: null },
{ weekNumber: 3, planned: 0, employeePlanId: null },
{ weekNumber: 4, planned: 0, employeePlanId: null },
]);
const [selectedPlanType, setSelectedPlanType] = useState<
"number" | "percent"
>(initialPlanType === "percent" ? "percent" : "number");
const [isSaving, setIsSaving] = useState(false);
const { data: existingPlanData, isLoading: isPlanLoading } =
useSubEmployeePlanByPlanId(navigationState.parentEmployeePlanId);
// Fetch existing weekly plans for this month.
// API shape:
// { "<monthNumber>": { "week_1": { id, quantity }, "week_2": { id, quantity }, ... } }
useEffect(() => {
if (!existingPlanData?.data || !monthFromUrl) return;
const monthData = existingPlanData.data[monthFromUrl.toString()];
if (!monthData) return;
// Build the weeks list dynamically so we render all weeks the API knows
// about (some months have 5 weeks, others 4).
const weekEntries = Object.keys(monthData)
.filter((k) => k.startsWith("week_"))
.map((k) => {
const weekNumber = parseInt(k.replace("week_", ""), 10);
const cell = monthData[k] as
| { id: string | null; quantity: number }
| number
| null
| undefined;
// Defensive: handle both the new shape ({ id, quantity }) and any
// legacy shape that may have returned a bare number.
if (cell && typeof cell === "object") {
return {
weekNumber,
planned: Number(cell.quantity) || 0,
employeePlanId: cell.id ?? null,
};
}
return {
weekNumber,
planned: typeof cell === "number" ? cell : 0,
employeePlanId: null,
};
})
.sort((a, b) => a.weekNumber - b.weekNumber);
if (weekEntries.length > 0) {
setWeeks(weekEntries);
}
}, [existingPlanData, monthFromUrl]);
// Sync loading state
useEffect(() => {
setIsLoadingExistingPlans(isPlanLoading);
}, [isPlanLoading]);
// Calculate total planned
const totalPlanned = weeks.reduce((sum, week) => sum + week.planned, 0);
// Update a week's value in local state
const updateWeekValue = (weekNumber: number, value: string) => {
const newValue = parseInt(value) || 0;
setWeeks((prevWeeks) =>
prevWeeks.map((w) =>
w.weekNumber === weekNumber ? { ...w, planned: newValue } : w,
),
);
};
// Quick set helper
const quickSetForAllWeeks = (value: number) => {
setWeeks((prevWeeks) => prevWeeks.map((w) => ({ ...w, planned: value })));
};
// Save all changes
const handleSaveAll = async () => {
if (!planId || !monthFromUrl) return;
// Check if plan is already accepted
if (status === "accepted") {
toast({
title: t("plans.planAlreadyAccepted", "Plan Already Accepted"),
description: t(
"plans.cannotEditAcceptedPlan",
"This plan has already been accepted and cannot be edited. Please contact your administrator if you need to make changes.",
),
variant: "destructive",
});
return;
}
setIsSaving(true);
try {
// Run sequentially so each create's returned id can be threaded into the
// next iteration's local state — prevents a duplicate-key error on a
// second save attempt where the previous create succeeded but the local
// state still shows employeePlanId = null.
for (const week of weeks) {
const employeePlanData: ExtendedEmployeePlanDto = {
employeePositionId,
planId,
serviceId,
planType: selectedPlanType === "number" ? "number" : selectedPlanType,
expectedQuantity: week.planned,
timeframe: "weekly",
week: week.weekNumber,
month: monthFromUrl,
parentEmployeePlanId,
};
try {
if (week.employeePlanId) {
await update.mutateAsync({
...employeePlanData,
id: week.employeePlanId,
});
} else {
const response = await create.mutateAsync(employeePlanData);
const newId =
(response as any)?.data?.id ?? (response as any)?.id;
if (newId) {
// Persist the new id so subsequent saves PUT instead of POST.
setWeeks((prev) =>
prev.map((w) =>
w.weekNumber === week.weekNumber
? { ...w, employeePlanId: newId }
: w,
),
);
week.employeePlanId = newId;
}
}
} catch (err: any) {
// 400 with duplicate-key constraint → the row already exists in the
// DB even though our local state didn't have its id. Refetch and
// bail out of this iteration; the user can re-save and the latest
// ids will be in state.
const message: string =
err?.response?.data?.message ?? err?.message ?? "";
const isDuplicate =
err?.response?.status === 400 &&
(message.toLowerCase().includes("duplicate") ||
err?.response?.data?.exception?.driverError?.code === "23505");
if (isDuplicate) {
await queryClient.invalidateQueries({
queryKey: ["employee-plans", planId],
});
throw new Error(
"This week already has a saved plan. Refreshing — please try save again.",
);
}
throw err;
}
}
queryClient.invalidateQueries({
queryKey: ["employee-plans", planId],
});
toast({
title: "Success",
description: "All weekly targets have been saved successfully.",
});
} catch (error) {
console.error("Failed to save plans:", error);
handleError(error);
} finally {
setIsSaving(false);
}
};
// Get month name
const getMonthName = (monthNum: number): string => {
const monthNames = [
"September",
"October",
"November",
"December",
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
];
return monthNames[monthNum - 1] || `Month ${monthNum}`;
};
if (!navigationState) {
return (
<div className="p-6">
<Card className="border-red-200 bg-red-50">
<CardContent className="pt-6 text-center">
<h3 className="text-lg font-semibold text-red-700">
Missing Plan Data
</h3>
<p className="text-red-600 mt-2">
Please go back and select a plan to continue.
</p>
<Button
onClick={() =>
navigate(`/performance-management/${yearId}/employee-plans`)
}
className="mt-4"
>
<ArrowLeft className="h-4 w-4 mr-2" /> Back to Plans
</Button>
</CardContent>
</Card>
</div>
);
}
return (
<div className="p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<Button
variant="outline"
size="sm"
onClick={() =>
navigate(`/performance-management/${yearId}/employee-plans`)
}
>
<ArrowLeft className="h-4 w-4 mr-2" /> Back to Plans
</Button>
<div>
<h1 className="text-2xl font-bold text-gray-900">
Weekly Plan Assignment - {planName}
</h1>
<p className="text-gray-600 mt-1">
Set weekly targets for {getMonthName(monthFromUrl)} (4 weeks)
</p>
</div>
</div>
<div className="flex items-center gap-3">
<Button
onClick={handleSaveAll}
disabled={
isSaving ||
create.isPending ||
update.isPending ||
status === "accepted"
}
className="bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 text-white shadow-lg"
>
{isSaving ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Saving...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
Save Changes
</>
)}
</Button>
</div>
</div>
{/* Alert for Accepted Plans */}
{status === "accepted" && (
<Card className="mb-6 border-red-200 bg-red-50">
<CardContent className="pt-6">
<div className="flex items-start gap-4">
<div className="text-red-600 mt-0.5">
<svg
className="h-5 w-5"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="flex-1">
<h3 className="font-semibold text-red-900">
{t("plans.planAlreadyAccepted", "Plan Already Accepted")}
</h3>
<p className="text-red-700 text-sm mt-1">
{t(
"plans.cannotEditAcceptedPlan",
"This plan has already been accepted and cannot be edited. Please contact your administrator if you need to make changes.",
)}
</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
{[
{
title: "Service",
value: serviceName,
icon: Target,
color: "blue",
},
{
title: "Monthly Target",
value: expectedQuantity,
icon: selectedPlanType === "number" ? Hash : Percent,
color: "green",
subtitle: selectedPlanType === "number" ? "Number" : "Number",
},
{
title: "Current Total",
value: totalPlanned,
icon: Target,
color: "purple",
},
{
title: "Month",
value: getMonthName(monthFromUrl),
icon: CalendarDays,
color: "red",
subtitle: `${weeks.length} weeks`,
},
].map((card, idx) => (
<Card key={idx}>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">
{card.title}
</p>
<p className="text-2xl font-bold">{card.value}</p>
{card.subtitle && (
<p className="text-xs text-gray-500 mt-1">
{card.subtitle}
</p>
)}
</div>
<div className={`p-2 bg-${card.color}-100 rounded-lg`}>
<card.icon className={`h-6 w-6 text-${card.color}-600`} />
</div>
</div>
</CardContent>
</Card>
))}
</div>
{/* View Toggle */}
<Card className="mb-6">
<CardContent className="pt-6">
<div className="flex flex-col space-y-4">
<Tabs
value={activeTab}
onValueChange={(v: string) =>
setActiveTab(v as "table" | "calendar")
}
>
<TabsList className="grid w-full max-w-md grid-cols-2">
<TabsTrigger value="table" className="flex items-center gap-2">
<List className="h-4 w-4" /> Table View
</TabsTrigger>
<TabsTrigger
value="calendar"
className="flex items-center gap-2"
>
<Grid className="h-4 w-4" /> Calendar View
</TabsTrigger>
</TabsList>
</Tabs>
{/* Plan Type Selection */}
<div className="flex items-center gap-4">
<div>
<Label>Plan Type</Label>
<div className="flex gap-2 mt-1">
<Button
variant={
selectedPlanType === "number" ? "default" : "outline"
}
size="sm"
onClick={() => setSelectedPlanType("number")}
className="gap-2"
>
<Hash className="h-4 w-4" /> Number
</Button>
<Button
variant={
selectedPlanType === "number" ? "default" : "outline"
}
size="sm"
onClick={() => setSelectedPlanType("number")}
className="gap-2"
>
<Percent className="h-4 w-4" /> Percent
</Button>
</div>
</div>
<div className="ml-auto">
<div className="flex gap-2 items-center">
<p className="text-sm text-gray-500 mr-2">Quick Set:</p>
<Button
variant="outline"
size="sm"
onClick={() => quickSetForAllWeeks(10)}
>
10
</Button>
<Button
variant="outline"
size="sm"
onClick={() => quickSetForAllWeeks(25)}
>
25
</Button>
<Button
variant="outline"
size="sm"
onClick={() => quickSetForAllWeeks(50)}
>
50
</Button>
<Button
variant="outline"
size="sm"
onClick={() => quickSetForAllWeeks(0)}
>
Clear
</Button>
</div>
</div>
</div>
{/* Month Info */}
<div className="mt-4 p-4 bg-gray-50 rounded-lg">
<div className="flex items-center justify-between">
<div>
<h4 className="font-semibold text-gray-900">
{getMonthName(monthFromUrl)} Weekly Planning
</h4>
<p className="text-sm text-gray-600">
Set weekly targets below. Don't forget to save your changes.
</p>
</div>
<Badge
variant={status === "accepted" ? "default" : "secondary"}
>
Status: {status}
</Badge>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Loading State */}
{isLoadingExistingPlans ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-purple-600" />
<span className="ml-2 text-gray-600">
Loading existing weekly plans...
</span>
</div>
) : activeTab === "table" ? (
<Card className="overflow-hidden">
<CardHeader>
<div className="flex justify-between items-center">
<div>
<CardTitle>
Weekly Targets for {getMonthName(monthFromUrl)}
</CardTitle>
<CardDescription>
Enter targets for each week directly in the input fields.
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="font-medium">
{selectedPlanType === "number" ? "Units" : "Percent"}
</Badge>
<Badge className="bg-blue-100 text-blue-800 border-blue-200">
Total Planned: {totalPlanned}
</Badge>
</div>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[250px]">Week</TableHead>
<TableHead className="text-center">Week 1</TableHead>
<TableHead className="text-center">Week 2</TableHead>
<TableHead className="text-center">Week 3</TableHead>
<TableHead className="text-center">Week 4</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow className="hover:bg-gray-50">
<TableCell>
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-purple-100 rounded-full flex items-center justify-center">
<span className="text-purple-600 font-medium">M</span>
</div>
<div>
<p className="font-medium text-gray-900">
My Weekly Targets
</p>
<p className="text-sm text-gray-500">
Month {monthFromUrl}
</p>
</div>
</div>
</TableCell>
{/* Weekly Cells */}
{weeks.map((week) => (
<TableCell
key={week.weekNumber}
className="text-center p-2"
>
<Input
type="number"
min="0"
value={week.planned || ""}
onChange={(e) =>
updateWeekValue(week.weekNumber, e.target.value)
}
className={cn(
"h-10 w-24 text-center mx-auto transition-all",
week.planned > 0
? "border-purple-300 bg-purple-50 font-semibold text-purple-900"
: "border-gray-200 text-gray-500",
)}
/>
</TableCell>
))}
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2 text-sm text-gray-500">
{totalPlanned > 0 ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<div className="h-4 w-4" />
)}
<span>Total: {totalPlanned}</span>
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<CardTitle>Calendar View</CardTitle>
<CardDescription>
Coming soon: Interactive calendar view for weekly planning
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-center py-12">
<CalendarDays className="h-12 w-12 mx-auto text-gray-400 mb-4" />
<h3 className="text-lg font-semibold text-gray-900">
Calendar View in Development
</h3>
<p className="text-gray-600 mt-2">
This feature will be available in the next update.
</p>
</div>
</CardContent>
</Card>
)}
</div>
);
};
export default WeeklyPlanAssignmentPage;

View File

@@ -0,0 +1,207 @@
import React, { useEffect } from "react";
import { useParams } from "react-router-dom";
import { useMetabaseDashboard } from "../hooks/useMetabaseDashboard";
import { usePlanYear } from "../hooks/usePlanYear";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/common/ui/card";
import { Button } from "@/shared/common/ui/button";
import { Badge } from "@/shared/common/ui/badge";
import { Alert, AlertDescription } from "@/shared/common/ui/alert";
import { DashboardDropdown } from "@/shared/components/ui/DashboardDropdown";
import {
RefreshCw,
BarChart3,
AlertCircle,
ExternalLink,
Loader2,
Calendar,
} from "lucide-react";
import { useLocalizedName } from "@/shared/common/localizedName";
import { getFormattedDashboardLabel } from "../utils/dashboardUtils";
interface MetabaseDashboardProps {
className?: string;
planYearId?: string;
}
export const MetabaseDashboard: React.FC<MetabaseDashboardProps> = ({
className = "",
planYearId,
}) => {
const { yearId } = useParams<{ yearId: string }>();
const currentPlanYearId = planYearId || yearId;
const localizedName = useLocalizedName();
const {
dashboards,
selectedDashboard,
iframeUrl,
loading,
error,
fetchDashboards,
selectDashboard,
getFilteredDashboardUrl,
} = useMetabaseDashboard();
// Fetch plan year details if we have a plan year ID
const { data: planYearData } = usePlanYear(currentPlanYearId || "");
const planYear = planYearData?.data;
const handleRefresh = () => {
if (selectedDashboard && currentPlanYearId) {
getFilteredDashboardUrl(selectedDashboard.id, currentPlanYearId);
} else {
fetchDashboards();
}
};
// Auto-load filtered dashboard when plan year or selected dashboard changes
useEffect(() => {
if (selectedDashboard && currentPlanYearId) {
getFilteredDashboardUrl(selectedDashboard.id, currentPlanYearId);
}
}, [selectedDashboard, currentPlanYearId, getFilteredDashboardUrl]);
const openInNewTab = () => {
if (iframeUrl) {
window.open(iframeUrl, "_blank");
}
};
if (error) {
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-red-600">
<AlertCircle className="h-5 w-5" />
Dashboard Error
</CardTitle>
</CardHeader>
<CardContent>
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
<Button onClick={handleRefresh} className="mt-4" variant="outline">
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</CardContent>
</Card>
);
}
return (
<div className={`space-y-4 ${className}`}>
{/* Dashboard Header */}
<Card>
<CardHeader>
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div className="flex items-center gap-3">
<BarChart3 className="h-6 w-6 text-indigo-600" />
<div>
<p className="text-sm text-muted-foreground mt-1">
{planYear
? `${planYear.year} - ${localizedName(
planYear.name
)} Performance Insights`
: "Real-time performance insights powered by Tria"}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<DashboardDropdown
dashboards={dashboards}
selectedDashboard={selectedDashboard}
onSelect={selectDashboard}
isLoading={loading}
/>
<Button
variant="outline"
size="icon"
onClick={handleRefresh}
disabled={loading}
>
<RefreshCw
className={`h-4 w-4 ${loading ? "animate-spin" : ""}`}
/>
</Button>
{iframeUrl && (
<Button
variant="outline"
size="icon"
onClick={openInNewTab}
title="Open in new tab"
>
<ExternalLink className="h-4 w-4" />
</Button>
)}
</div>
</div>
<div className="flex items-center gap-2 mt-2 flex-wrap">
{planYear && (
<Badge variant="secondary" className="bg-green-50 text-primary-700">
<Calendar className="h-3 w-3 mr-1" />
{planYear.year} - {localizedName(planYear.name)}
</Badge>
)}
{selectedDashboard && (
<>
<Badge
variant="secondary"
className="bg-indigo-50 text-indigo-700"
>
{getFormattedDashboardLabel(selectedDashboard.name)}
</Badge>
</>
)}
</div>
</CardHeader>
</Card>
{/* Dashboard Content */}
<Card className="min-h-[600px]">
<CardContent className="p-0">
{loading && !iframeUrl ? (
<div className="flex items-center justify-center h-96">
<div className="text-center">
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-4 text-indigo-600" />
<p className="text-muted-foreground">Loading dashboard...</p>
</div>
</div>
) : iframeUrl ? (
<div className="relative w-full h-[600px] rounded-lg overflow-hidden">
<iframe
src={iframeUrl}
className="w-full h-full border-0"
title={`${getFormattedDashboardLabel(selectedDashboard?.name) || "Performance"} Dashboard`}
allow="fullscreen"
loading="lazy"
/>
</div>
) : (
<div className="flex items-center justify-center h-96">
<div className="text-center">
<BarChart3 className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
<p className="text-muted-foreground">
{dashboards.length === 0
? "No dashboards available"
: "Select a dashboard to view analytics"}
</p>
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
};

View File

@@ -0,0 +1,32 @@
import React from "react";
import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { MetabaseDashboard } from "./MetabaseDashboard";
export const PerformanceDashboardPage = () => {
const { yearId } = useParams<{ yearId: string }>();
const { t } = useTranslation();
return (
<div className="p-4 md:p-6 space-y-6">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-800">
<span className="bg-gradient-to-r from-indigo-600 to-purple-600 bg-clip-text text-transparent">
{t(
"performanceDashboard.title",
"Performance Management Dashboard",
)}
</span>
</h1>
<p className="text-muted-foreground text-sm mt-1">
{t(
"performanceDashboard.description",
"Comprehensive performance analytics and insights",
)}
</p>
</div>
</div>
<MetabaseDashboard planYearId={yearId} />
</div>
);
};

View File

@@ -0,0 +1,902 @@
import { useForm, useWatch } from "react-hook-form";
import { motion } from "framer-motion";
import { Calendar, Target, FileText, ArrowLeft } from "lucide-react";
import {
useLocation,
useNavigate,
useParams,
useSearchParams,
} from "react-router-dom";
import { useEffect, useMemo } from "react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import {
usePlan,
usePlanMutations,
} from "@/performance-management/hooks/usePlans";
import { useLocalizedName } from "@/shared/common/localizedName";
import { useAuth } from "@/shared/context/AuthContext";
import { useUnitContext } from "@/shared/context/UnitContext";
import { usePositions } from "@/user-management/hooks/usePosition";
import {
usePlanYears,
usePlanYearById,
} from "@/performance-management/hooks/usePlanYear";
import { Button } from "@/shared/common/ui/button";
import { Card, CardContent } from "@/shared/common/ui/card";
import { Alert, AlertDescription, AlertTitle } from "@/shared/common/ui/alert";
import { Info } from "lucide-react";
import { GenericForm } from "@/performance-management/utils/generic-form"; // Adjust import path as needed
import PlanType, {
getDayList,
getMonthList,
getQuarterList,
getWeekList,
getPlanTypeOptions,
} from "@/performance-management/utils/plan-unit";
import {
FormField,
TimeFrame,
} from "@/performance-management/utils/shared-field-types";
export interface PlanFormValues {
nameAm: string;
nameEn: string;
descriptionAm: string;
descriptionEn: string;
positionId: string;
parentPlanId?: string;
planType: PlanType;
planYearId: string;
// initialGoal: string;
// expectedGoal: string;
weight: string;
month: string | number;
week: string | number;
day: string | number;
quarter: string | number;
timeframe: string | number;
planFor?: string;
}
// Removed local TimeFrame enum definition
interface PlanFormProps {
parentPlanId?: string;
onSuccess?: () => void;
}
export default function PlanForm({
parentPlanId: propParentId,
onSuccess,
}: PlanFormProps = {}) {
const navigate = useNavigate();
const { t } = useTranslation();
const {
id,
yearId: pathYearId,
positionId,
planId,
} = useParams<{
id: string;
yearId?: string;
positionId?: string;
planId?: string;
}>();
console.log("positon id", positionId);
const [searchParams] = useSearchParams();
const urlParentId =
searchParams.get("parentPlanId") || searchParams.get("parentId");
const queryYearId = searchParams.get("yearId");
// Get yearId from query params first (UUID), then fallback to path params
const urlYearId = queryYearId || pathYearId;
const location = useLocation();
const planFor = location.state?.planFor as string | undefined;
console.log("plan for in form", planFor);
const parentId =
propParentId || urlParentId || (planId === "root" ? undefined : planId);
const { user } = useAuth();
const localizedName = useLocalizedName();
const isEdit = !!id;
const organizationId =
user?.employee?.length && user.employee.length > 0
? user.employee[0].organizationId
: undefined;
const unitContext = useUnitContext();
const unitsQuery = unitContext?.getList(organizationId!, {
take: 300,
skip: 0,
});
const unitId = unitsQuery?.data?.data?.items?.[0]?.id ?? "";
// Fetch the specific plan being edited
const { data: planDetail } = usePlan(id ?? "");
// Fetch parent plan data if parentId is present
const { data: parentPlanDetail } = usePlan(parentId ?? "");
const { usePositionListByUnitId } = usePositions();
usePositionListByUnitId(unitId, { take: 100, skip: 0 });
const { data: planYearsData } = usePlanYears(organizationId ?? "", {
take: 100,
});
// Fetch specific plan year if yearId is provided in URL
const { data: currentPlanYearData } = usePlanYearById(urlYearId ?? "");
const { create: createMutation, update: updateMutation } = usePlanMutations();
const normalizeTimeframe = (val: unknown): string => {
if (!val) return TimeFrame.YEAR;
const str = String(val).toLowerCase();
if (str.includes("year")) return TimeFrame.YEAR;
if (str.includes("quarter")) return TimeFrame.QUARTER;
if (str.includes("month")) return TimeFrame.MONTH;
if (str.includes("week")) return TimeFrame.WEEK;
if (str.includes("day")) return TimeFrame.DAY;
return TimeFrame.YEAR;
};
const initialPlanFormValues = useMemo(
() => ({
nameAm: planDetail?.data?.name?.am || "",
nameEn: planDetail?.data?.name?.en || "",
descriptionAm: planDetail?.data?.description?.am || "",
descriptionEn: planDetail?.data?.description?.en || "",
positionId: positionId || "",
parentPlanId:
planDetail?.data?.parentPlanId || parentId || planId || undefined,
planType: planDetail?.data?.planType || "percent",
planYearId: planDetail?.data?.planYearId || urlYearId || "",
// initialGoal: planDetail?.data?.initialGoal?.toString() || "",
// expectedGoal: planDetail?.data?.expectedGoal?.toString() || "",
weight: planDetail?.data?.weight?.toString() || "",
month: planDetail?.data?.month?.toString() || "0",
week: planDetail?.data?.week?.toString() || "0",
day: planDetail?.data?.day?.toString() || "0",
quarter: planDetail?.data?.quarter?.toString() || "0",
timeframe: (() => {
const rawTimeframe = planDetail?.data?.timeframe;
if (rawTimeframe) return normalizeTimeframe(rawTimeframe);
// Fallback for sub-plan creation when parent data is missing but parentId is present
if (!parentId || !parentPlanDetail?.data) {
return parentId ? TimeFrame.QUARTER : TimeFrame.YEAR;
}
const pt = normalizeTimeframe(parentPlanDetail.data.timeframe);
if (pt === TimeFrame.YEAR) return TimeFrame.QUARTER;
if (pt === TimeFrame.QUARTER) return TimeFrame.QUARTER;
if (pt === TimeFrame.MONTH) return TimeFrame.WEEK;
if (pt === TimeFrame.WEEK) return TimeFrame.DAY;
return TimeFrame.YEAR;
})(),
}),
[
planDetail?.data,
positionId,
parentId,
planId,
urlYearId,
parentPlanDetail?.data,
],
);
const form = useForm<PlanFormValues>({
defaultValues: initialPlanFormValues,
values: isEdit || !!parentId ? initialPlanFormValues : undefined,
});
const watchedParentPlanId = useWatch({
control: form.control,
name: "parentPlanId",
});
const { data: watchedParentPlanDetail } = usePlan(
watchedParentPlanId && watchedParentPlanId !== "root"
? watchedParentPlanId
: "",
);
const effectiveParentDetail = watchedParentPlanDetail || parentPlanDetail;
const planOptions =
planDetail?.data?.items?.map(
(plan: { id: string; name: { am: string; en: string } }) => ({
label: localizedName(plan.name),
value: plan.id,
icon: FileText,
}),
) || [];
// Add parent plan to options if it exists and isn't already in the list
if (parentId && parentPlanDetail?.data) {
const parentPlanExists = planOptions.some(
(option: { label: string; value: string }) => option.value === parentId,
);
if (!parentPlanExists) {
planOptions.unshift({
label: localizedName(parentPlanDetail.data.name),
value: parentId,
icon: FileText,
});
}
}
// If yearId is in URL, show only that year; otherwise show all years
const planYearOptions =
urlYearId && currentPlanYearData?.data
? [
{
value: currentPlanYearData.data.id,
label: `${currentPlanYearData.data.year}`,
icon: Calendar,
},
]
: planYearsData?.data?.items?.map(
(year: { id: string; year: number }) => ({
value: year.id,
label: `${year.year}`,
icon: Calendar,
}),
) || [];
const planTypeOptions = useMemo(() => getPlanTypeOptions(), []);
const timeframeOptions = useMemo(() => {
const parent = effectiveParentDetail?.data;
// If no parent plan, only allow Yearly
if (!parentId && !parent && !watchedParentPlanId) {
return [
{
value: TimeFrame.YEAR,
label: t("plans.Yearly"),
},
];
}
const parentTimeframe = normalizeTimeframe(parent?.timeframe);
// Based on parent timeframe, allow the next level down
switch (parentTimeframe) {
case TimeFrame.YEAR:
return [
{
value: TimeFrame.YEAR,
label: t("plans.Yearly", "Yearly"),
},
{
value: TimeFrame.QUARTER,
label: t("plans.Quarterly"),
},
];
case TimeFrame.QUARTER:
return [
{
value: TimeFrame.QUARTER,
label: t("plans.Quarterly", "Quarterly"),
},
];
case TimeFrame.MONTH:
return [
{
value: TimeFrame.WEEK,
label: t("plans.Weekly"),
},
];
case TimeFrame.WEEK:
return [
{
value: TimeFrame.DAY,
label: t("plans.Daily"),
},
];
default:
// Fallback to yearly if parent timeframe is unknown
return [
{
value: TimeFrame.YEAR,
label: t("plans.Yearly"),
},
];
}
}, [parentId, effectiveParentDetail?.data, watchedParentPlanId, t]);
// Memoize the list options to ensure they update when language changes
const monthListOptions = useMemo(() => getMonthList(t), [t]);
const weekListOptions = useMemo(() => getWeekList(t), [t]);
const dayListOptions = useMemo(() => getDayList(t), [t]);
const quarterListOptions = useMemo(() => getQuarterList(t), [t]);
const watchedTimeframe = useWatch({
control: form.control,
name: "timeframe",
});
// Reset hidden fields when timeframe changes, but preserve context for "drill-down" scenarios
useEffect(() => {
if (watchedTimeframe === TimeFrame.QUARTER) {
form.setValue("month", "0");
form.setValue("week", "0");
form.setValue("day", "0");
} else if (watchedTimeframe === TimeFrame.MONTH) {
form.setValue("week", "0");
form.setValue("day", "0");
} else if (watchedTimeframe === TimeFrame.WEEK) {
form.setValue("day", "0");
} else if (watchedTimeframe === TimeFrame.YEAR) {
form.setValue("quarter", "0");
form.setValue("month", "0");
form.setValue("week", "0");
form.setValue("day", "0");
}
}, [watchedTimeframe, form]);
// Auto-select timeframe if there's only one option
useEffect(() => {
if (timeframeOptions.length === 1) {
const currentVal = form.getValues("timeframe");
if (currentVal !== timeframeOptions[0].value) {
form.setValue("timeframe", timeframeOptions[0].value, {
shouldValidate: true,
shouldDirty: true,
});
}
}
}, [timeframeOptions, form]);
useEffect(() => {
const current = form.getValues("timeframe");
const isValid = timeframeOptions.some((opt) => opt.value === current);
if (!isValid && timeframeOptions.length > 0) {
form.setValue("timeframe", timeframeOptions[0].value, {
shouldValidate: true,
shouldDirty: true,
});
}
}, [timeframeOptions, form]);
// Inherit timeframe context from parent plan
useEffect(() => {
if (parentId && parentPlanDetail?.data && !isEdit) {
const parent = parentPlanDetail.data;
// Inherit Plan Year
if (parent.planYearId) {
form.setValue("planYearId", parent.planYearId);
}
// Determine Child Timeframe and Context based on Parent
switch (parent.timeframe) {
case TimeFrame.YEAR:
form.setValue("timeframe", TimeFrame.QUARTER);
break;
case TimeFrame.QUARTER:
form.setValue("timeframe", TimeFrame.QUARTER);
if (parent.quarter) {
form.setValue("quarter", parent.quarter.toString());
}
break;
case TimeFrame.MONTH:
form.setValue("timeframe", TimeFrame.WEEK);
if (parent.quarter) {
form.setValue("quarter", parent.quarter.toString());
}
if (parent.month) {
form.setValue("month", parent.month.toString());
}
break;
case TimeFrame.WEEK:
form.setValue("timeframe", TimeFrame.DAY);
if (parent.quarter) {
form.setValue("quarter", parent.quarter.toString());
}
if (parent.month) {
form.setValue("month", parent.month.toString());
}
if (parent.week) {
form.setValue("week", parent.week.toString());
}
break;
default:
break;
}
}
}, [parentId, parentPlanDetail, isEdit, form]);
// Auto-populate plan year when yearId is in URL or when plan year data loads
useEffect(() => {
// If yearId is in URL, use the current plan year data
if (urlYearId && currentPlanYearData?.data) {
form.setValue("planYearId", currentPlanYearData.data.id);
} else if (planYearsData?.data?.items) {
const currentVal = form.getValues("planYearId");
// If current value is a year number (e.g. "2022"), try to find the UUID
const matched = planYearsData.data.items.find(
(y: { id: string; year: number }) =>
y.year.toString() === currentVal || y.id === currentVal,
);
if (matched && matched.id !== currentVal) {
form.setValue("planYearId", matched.id);
}
}
}, [form, planYearsData, urlYearId, currentPlanYearData]);
// Helper function to convert TimeFrame enum to number
// (Removed - not currently used)
const handleSubmit = (values: PlanFormValues) => {
// Check if plan is already accepted (case-insensitive)
const planStatus = planDetail?.data?.status?.toLowerCase();
console.log("Plan status:", planStatus, "Plan detail:", planDetail?.data);
if (isEdit && planStatus === "accepted") {
toast.error(t("plans.planAlreadyAccepted", "Plan is already accepted"), {
description: t(
"plans.cannotEditAcceptedPlan",
"You cannot edit a plan that has already been accepted",
),
});
return;
}
// Create base DTO without timeframe-specific fields
const baseDto = {
id: isEdit ? id : undefined,
name: {
am: values.nameAm,
en: values.nameEn,
},
description: {
am: values.descriptionAm,
en: values.descriptionEn,
},
positionId: positionId || values.positionId || undefined,
parentPlanId:
planId === "root" || !values.parentPlanId
? undefined
: (values.parentPlanId as string),
planType: values.planType || PlanType.PERCENTAGE,
planYearId: values.planYearId,
timeframe: values.timeframe as string,
// initialGoal: Number(values.initialGoal),
// expectedGoal: Number(values.expectedGoal),
weight: Number(values.weight),
planFor: planFor || values.planFor || undefined,
};
// Add timeframe-specific fields based on the selected timeframe
let timeframeSpecificFields = {};
switch (values.timeframe) {
case TimeFrame.YEAR:
// Yearly plans don't need quarter, month, week, day
timeframeSpecificFields = {
quarter: undefined,
month: undefined,
week: undefined,
day: undefined,
};
break;
case TimeFrame.QUARTER:
// Quarterly plans need quarter only
timeframeSpecificFields = {
quarter: Number(values.quarter),
month: undefined,
week: undefined,
day: undefined,
};
break;
case TimeFrame.MONTH:
// Monthly plans need quarter and month
timeframeSpecificFields = {
quarter: undefined,
month: Number(values.month),
week: undefined,
day: undefined,
};
break;
case TimeFrame.WEEK:
// Weekly plans need quarter, month, and week
timeframeSpecificFields = {
quarter: undefined,
month: undefined,
week: Number(values.week),
day: undefined,
};
break;
case TimeFrame.DAY:
// Daily plans need all timeframe fields
timeframeSpecificFields = {
quarter: undefined,
month: undefined,
week: undefined,
day: Number(values.day),
};
break;
default:
// Fallback - set all to 0
timeframeSpecificFields = {
quarter: undefined,
month: undefined,
week: undefined,
day: undefined,
};
}
// Combine base DTO with timeframe-specific fields
const dto = {
...baseDto,
...timeframeSpecificFields,
};
const mutation = isEdit ? updateMutation : createMutation;
mutation.mutate(dto, {
onSuccess: () => {
toast.success(
isEdit
? t("plans.planUpdatedSuccess", "Plan updated successfully!")
: t("plans.planCreatedSuccess", "Plan created successfully!"),
{
description: `"${values.nameEn}" ${t(
"common.hasBeenText",
"has been",
)} ${
isEdit
? t("common.updated", "updated")
: t("common.created", "created")
}.`,
},
);
onSuccess?.();
if (!isEdit) {
// After creating a plan, navigate to the plans-table view for the
// appropriate year / position / parent plan. Use path params first
// (if the form was opened via a route containing them), otherwise
// fall back to the submitted form values.
const targetYear = urlYearId || values.planYearId || pathYearId;
const targetPosition = positionId || values.positionId;
const targetPlan =
planId || (values.parentPlanId as string) || parentId;
if (targetYear && targetPosition && targetPlan) {
navigate(
`/performance-management/${targetYear}/plans-table/${targetPosition}/${targetPlan}`,
);
} else if (targetYear && targetPosition) {
navigate(
`/performance-management/${targetYear}/plans-table/${targetPosition}`,
);
} else if (targetYear) {
navigate(`/performance-management/${targetYear}/plans`);
} else {
navigate(`/performance-management`);
}
}
},
});
};
// Define form fields with sections
const planFormFields: FormField<PlanFormValues>[] = [
// Basic Information Section
{
name: "nameAm",
label: t("plans.nameAmharic", "Name (Amharic)"),
type: "text",
placeholder: t("plans.planNameAmharic", "የዕቅድ ስም አማርኛ"),
required: true,
icon: FileText,
section: t("plans.basicInformation", "Basic Information"),
colSpan: 6,
},
{
name: "nameEn",
label: t("plans.nameEnglish", "Name (English)"),
type: "text",
placeholder: t("plans.planNameEnglish", "Plan Name in English"),
required: true,
icon: FileText,
section: t("plans.basicInformation", "Basic Information"),
colSpan: 6,
},
{
name: "descriptionEn",
label: t("plans.descriptionEnglish", "Description (English)"),
type: "textarea",
placeholder: t(
"plans.describePlanEnglish",
"Describe the plan in English...",
),
required: true,
icon: FileText,
section: t("plans.basicInformation", "Basic Information"),
colSpan: 12,
},
{
name: "descriptionAm",
label: t("plans.descriptionAmharic", "Description (Amharic)"),
type: "textarea",
placeholder: t("plans.describePlanAmharic", "ዕቅዱን በአማርኛ ይግለጹ..."),
required: true,
icon: FileText,
section: t("plans.basicInformation", "Basic Information"),
colSpan: 12,
},
// Organization Section
{
name: "planYearId",
label: t("plans.planYear", "Plan Year"),
type: "select",
options: planYearOptions,
required: true,
icon: Calendar,
section: t("plans.organization", "Organization"),
colSpan: 4,
disabled: !!urlYearId, // Disable if yearId is in URL params
},
{
name: "timeframe",
label: t("plans.timeFrame", "Time Frame"),
type: "select",
options: timeframeOptions,
placeholder: t("plans.enterTimeFrame", "Enter time frame"),
required: true,
disabled: timeframeOptions.length === 1,
icon: Calendar,
section: t("plans.organization", "Organization"),
colSpan: 4,
},
// {
// name: "initialGoal",
// label: t("plans.initialGoal", "Initial Goal"),
// type: "number",
// required: true,
// icon: Target,
// section: t("plans.organization", "Organization"),
// colSpan: 2,
// },
// {
// name: "expectedGoal",
// label: t("plans.expectedGoal", "Expected Goal"),
// type: "number",
// placeholder: t("plans.enterExpectedGoal", "Enter expected goal"),
// icon: Users,
// section: t("plans.organization", "Organization"),
// colSpan: 2,
// },
// Plan Details Section
{
name: "parentPlanId",
label: t("plans.parentPlan", "Parent Plan"),
type: "select",
options: planOptions,
icon: FileText,
section: t("plans.planDetails", "Plan Details"),
disabled: !!parentId || !!planId,
colSpan: 4,
defaultValue: parentId || planId || undefined,
},
{
name: "weight",
label: t("plans.weight", "Weight"),
type: "number",
placeholder: t("plans.enterWeight", "Enter weight"),
icon: Target,
section: t("plans.planDetails", "Plan Details"),
colSpan: 2,
},
{
name: "month",
label: t("plans.month", "Month"),
type: "select",
placeholder: t("plans.enterMonth", "Enter month"),
icon: Calendar,
section: t("plans.planDetails", "Plan Details"),
hidden:
watchedTimeframe !== TimeFrame.MONTH &&
(!form.getValues("month") || form.getValues("month") === "0"),
colSpan: 3,
options: monthListOptions,
disabled: parentPlanDetail?.data?.timeframe === TimeFrame.MONTH,
},
{
name: "week",
label: t("plans.week", "Week"),
type: "select",
placeholder: t("plans.enterWeek", "Enter week"),
icon: Calendar,
section: t("plans.planDetails", "Plan Details"),
hidden:
watchedTimeframe !== TimeFrame.WEEK &&
(!form.getValues("week") || form.getValues("week") === "0"),
colSpan: 4,
options: weekListOptions,
disabled: parentPlanDetail?.data?.timeframe === TimeFrame.WEEK,
},
{
name: "day",
label: t("plans.day", "Day"),
type: "select",
placeholder: t("plans.enterDay", "Enter day"),
icon: Calendar,
section: t("plans.planDetails", "Plan Details"),
hidden: watchedTimeframe !== TimeFrame.DAY,
colSpan: 4,
options: dayListOptions,
disabled: parentPlanDetail?.data?.timeframe === TimeFrame.DAY,
},
{
name: "quarter",
label: t("plans.quarter", "Quarter"),
type: "select",
placeholder: t("plans.enterQuarter", "Enter quarter"),
icon: Calendar,
section: t("plans.planDetails", "Plan Details"),
hidden:
watchedTimeframe !== TimeFrame.QUARTER &&
(!form.getValues("quarter") || form.getValues("quarter") === "0"),
colSpan: 4,
options: quarterListOptions,
disabled: parentPlanDetail?.data?.timeframe === TimeFrame.QUARTER,
},
{
name: "planType",
label: t("plans.planType", "Plan Type"),
type: "select",
options: planTypeOptions,
icon: FileText,
section: t("plans.planDetails", "Plan Details"),
colSpan: 4,
},
];
// Filter fields based on timeframe
const filteredFields = planFormFields.filter((field) => {
if (field.hidden) return false;
return true;
});
const handleCancel = () => {
navigate(-1);
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 p-4 sm:p-6">
<div className="max-w-4xl mx-auto">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center justify-between mb-8"
>
<div className="flex items-center space-x-4">
<Button
variant="outline"
size="sm"
onClick={() => navigate(-1)}
className="border-gray-300 text-gray-700 hover:bg-gray-50"
>
<ArrowLeft className="h-4 w-4 mr-2" />
{t("common.back", "Back")}
</Button>
<div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-gray-800 to-gray-600 bg-clip-text text-transparent">
{isEdit
? t("plans.editPlan", "Edit Plan")
: t("plans.createNewPlan", "Create New Plan")}
</h1>
</div>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<Card className="bg-white/80 backdrop-blur-sm border-0 shadow-xl overflow-hidden">
<CardContent className="p-6">
{/* Plan Already Accepted Alert */}
{isEdit &&
planDetail?.data?.status?.toLowerCase() === "accepted" && (
<Alert className="mb-6 bg-red-50/80 border-red-200/50 backdrop-blur-sm">
<Info className="h-4 w-4 text-red-600" />
<AlertTitle className="text-red-900 font-semibold">
{t("plans.planAlreadyAccepted", "Plan Already Accepted")}
</AlertTitle>
<AlertDescription className="text-red-700">
{t(
"plans.cannotEditAcceptedPlan",
"This plan has already been accepted and cannot be edited. Please contact your administrator if you need to make changes.",
)}
</AlertDescription>
</Alert>
)}
{/* Parent Plan Info Alert */}
{parentId && parentPlanDetail?.data && (
<Alert className="mb-6 bg-blue-50/80 border-blue-200/50 backdrop-blur-sm">
<Info className="h-4 w-4 text-blue-600" />
<AlertTitle className="text-blue-900 font-semibold">
{t("plans.creatingSubPlan", "Creating Sub-Plan")}
</AlertTitle>
<AlertDescription className="text-blue-700">
{t(
"plans.subPlanDescription",
"This plan will be created as a sub-plan under:",
)}{" "}
<strong className="text-blue-900">
{localizedName(parentPlanDetail.data.name)}
</strong>
</AlertDescription>
</Alert>
)}
<GenericForm
form={form}
fields={filteredFields.map((field) => ({
...field,
disabled:
field.disabled ||
(isEdit &&
planDetail?.data?.status?.toLowerCase() === "accepted"),
}))}
onSubmit={handleSubmit}
onCancel={handleCancel}
isSubmitting={
createMutation.isPending || updateMutation.isPending
}
submitButtonText={
isEdit
? t("plans.updatePlan", "Update Plan")
: t("plans.createPlan", "Create Plan")
}
cancelButtonText={t("common.cancel", "Cancel")}
successMessage={
isEdit
? t(
"plans.planUpdatedSuccessfully",
"Plan updated successfully!",
)
: t(
"plans.planCreatedSuccessfully",
"Plan created successfully!",
)
}
errorMessage={
isEdit
? t("plans.failedToUpdatePlan", "Failed to update plan")
: t("plans.failedToCreatePlan", "Failed to create plan")
}
/>
</CardContent>
</Card>
</motion.div>
</div>
</div>
);
}

View File

@@ -0,0 +1,317 @@
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import {
ArrowLeftIcon,
EditIcon,
CalendarIcon,
PlusIcon,
XIcon,
} from "lucide-react";
import { Button } from "@/shared/common/ui/button";
import { Card, CardContent } from "@/shared/common/ui/card";
import { Skeleton } from "@/shared/common/ui/skeleton";
import { Separator } from "@/shared/common/ui/separator";
import { usePlan, checkIsUnitAdmin } from "../../hooks/usePlans";
import { useLocalizedName } from "@/shared/common/localizedName";
import { useAuth } from "@/shared/context/AuthContext";
import { useMemo, useState } from "react";
import PlanForm from "./PlanForm";
const PlanDetails = () => {
const { id } = useParams();
const navigate = useNavigate();
const localizedName = useLocalizedName();
const { user } = useAuth();
const { yearId } = useParams();
const [searchParams] = useSearchParams();
const positionId = searchParams.get("positionId");
// Check if user is unit admin
const isUnitAdmin = useMemo(() => checkIsUnitAdmin(user), [user]);
// State to control sub-plan form visibility
const [showSubPlanForm, setShowSubPlanForm] = useState(false);
// TanStack Query hook
const { data, isLoading, isError, error, refetch } = usePlan(id || "");
const plan = data?.data;
if (isLoading) {
return (
<div className="p-6 space-y-6">
<Skeleton className="h-8 w-2/5" />
<Card>
<CardContent className="p-6 space-y-4">
<Skeleton className="h-5 w-1/4" />
<Skeleton className="h-9 w-3/5" />
<Skeleton className="h-5 w-1/4" />
<Skeleton className="h-9 w-3/5" />
<Skeleton className="h-5 w-1/4" />
<Skeleton className="h-9 w-3/5" />
</CardContent>
</Card>
<div className="flex gap-4">
<Skeleton className="h-9 w-28" />
<Skeleton className="h-9 w-28" />
</div>
</div>
);
}
if (isError) {
return (
<div className="p-6 space-y-6">
<Card>
<CardContent className="p-6">
<div className="text-red-600 text-lg mb-4">
Error loading plan details
</div>
<div className="text-muted-foreground mb-4">
{error?.message || "Failed to load plan data. Please try again."}
</div>
<div className="flex gap-4">
<Button onClick={() => refetch()} variant="outline">
Retry
</Button>
<Button
variant="ghost"
onClick={() => navigate("/performance-management/plans")}
className="flex items-center gap-2">
<ArrowLeftIcon className="h-4 w-4" />
Back to List
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
if (!plan) {
return (
<div className="p-6 space-y-6">
<Card>
<CardContent className="p-6">
<div className="text-lg mb-4">Plan not found</div>
<Button
variant="ghost"
onClick={() =>
navigate(
`/performance-management/${yearId}/plans-table/${positionId}`
)
}
className="flex items-center gap-2">
<ArrowLeftIcon className="h-4 w-4" />
Back to List
</Button>
</CardContent>
</Card>
</div>
);
}
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<div>
<h2 className="text-2xl font-bold">Plan Details</h2>
<p className="text-muted-foreground">Plan ID: {plan.id}</p>
</div>
<Button
variant="ghost"
onClick={() =>
navigate(
`/performance-management/${yearId}/plans-table/${positionId}`
)
}
className="flex items-center gap-2">
<ArrowLeftIcon className="h-4 w-4" />
Back to List
</Button>
</div>
<Card>
<CardContent className="p-6 space-y-6">
{/* Plan Name */}
<div>
<div className="text-sm text-muted-foreground mb-2">Plan Name</div>
<div className="p-3 border rounded-md bg-muted/50">
<div className="text-base font-semibold">
{localizedName(plan.name)}
</div>
</div>
</div>
{/* Description */}
<div>
<div className="text-sm text-muted-foreground mb-2">
Description
</div>
<div className="p-3 border rounded-md bg-muted/50">
<div className="text-base">{localizedName(plan.description)}</div>
</div>
</div>
{/* Plan Details */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<div className="text-sm text-muted-foreground mb-2">
Calendar Year
</div>
<div className="p-3 border rounded-md bg-muted/50">
<div className="flex items-center gap-2">
<CalendarIcon className="h-4 w-4 text-blue-600" />
<div className="text-base font-semibold">
{plan.calendarYear}
</div>
</div>
</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-2">
Start Date
</div>
<div className="p-3 border rounded-md bg-muted/50">
<div className="text-base">
{new Date(plan.startDate).toLocaleDateString()}
</div>
</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-2">End Date</div>
<div className="p-3 border rounded-md bg-muted/50">
<div className="text-base">
{new Date(plan.endDate).toLocaleDateString()}
</div>
</div>
</div>
</div>
{/* Related IDs */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<div className="text-sm text-muted-foreground mb-2">
Position ID
</div>
<div className="p-3 border rounded-md bg-muted/50">
<div
className="text-base font-mono truncate"
title={plan.positionId}>
{plan.positionId}
</div>
</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-2">Unit ID</div>
<div className="p-3 border rounded-md bg-muted/50">
<div
className="text-base font-mono truncate"
title={plan.unitId}>
{plan.unitId}
</div>
</div>
</div>
</div>
<Separator />
{/* Timestamps */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<div className="text-sm text-muted-foreground mb-2">
Created At
</div>
<div className="p-3 border rounded-md bg-muted/50">
<div className="text-base">
{plan.createdAt
? new Date(plan.createdAt).toLocaleString()
: "N/A"}
</div>
</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-2">
Last Updated
</div>
<div className="p-3 border rounded-md bg-muted/50">
<div className="text-base">
{plan.updatedAt
? new Date(plan.updatedAt).toLocaleString()
: "N/A"}
</div>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex justify-end pt-4 gap-4">
<Button variant="outline" onClick={() => navigate(-1)}>
Back to Plans
</Button>
{/* Show Edit button only for Unit Admins */}
{isUnitAdmin && (
<Button
onClick={() =>
navigate(`/performance-management/plans/${id}/edit`)
}
className="flex items-center gap-2">
<EditIcon className="h-4 w-4" />
Edit Plan
</Button>
)}
{/* Show Add Sub-Plan button only for Non-Unit Admins */}
{/* {!isUnitAdmin && (
<Button
onClick={() => setShowSubPlanForm(!showSubPlanForm)}
className="flex items-center gap-2 bg-gradient-to-r from-purple-500 to-violet-600 hover:from-purple-600 hover:to-violet-700 text-white">
{showSubPlanForm ? (
<>
<XIcon className="h-4 w-4" />
Cancel
</>
) : (
<>
<PlusIcon className="h-4 w-4" />
Add Sub-Plan
</>
)}
</Button>
)} */}
</div>
</CardContent>
</Card>
{/* Inline Sub-Plan Form */}
{!isUnitAdmin && showSubPlanForm && (
<Card className="border-purple-200 border-2">
<CardContent className="p-6">
<div className="mb-4">
<h3 className="text-xl font-bold text-purple-700">
Create Sub-Plan
</h3>
<p className="text-sm text-muted-foreground">
Creating a sub-plan under:{" "}
<span className="font-semibold">
{localizedName(plan.name)}
</span>
</p>
</div>
<Separator className="mb-6" />
<PlanForm
parentPlanId={id}
onSuccess={() => {
setShowSubPlanForm(false);
refetch();
}}
/>
</CardContent>
</Card>
)}
</div>
);
};
export default PlanDetails;

View File

@@ -0,0 +1,111 @@
import React, { useEffect, useMemo } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useAuth } from "@/shared/context/AuthContext";
import { getUserPositionType } from "@/performance-management/utils/positionTypeUtils";
import { EPositionTypeKey } from "@/shared/dto/user/meDto";
import { Skeleton } from "@/shared/common/ui/skeleton";
import {
checkIsPlanInitiator,
useSectorList,
} from "@/performance-management/hooks/usePlans";
import { SectorListView } from "./SectorListView";
import { useAuthUser } from "@/record-management/hooks/useAuthUser";
const PlanHierarchy: React.FC = () => {
const { positionId, yearId } = useParams<{
positionId?: string;
yearId?: string;
}>();
const navigate = useNavigate();
const { user, selectedPositionId } = useAuth();
const { isLoading: isAuthLoading } = useAuthUser();
const isInitiator = useMemo(
() => checkIsPlanInitiator(user, selectedPositionId || undefined),
[user, selectedPositionId]
);
const positionType = getUserPositionType(user);
// Fetch sector list
const { data: sectorData, isLoading, error } = useSectorList(yearId);
console.log("sectorData", sectorData);
// Redirect non-initiators to their position-filtered plans
useEffect(() => {
if (isAuthLoading || !yearId) return;
// If user is not an initiator, redirect them to their own plans
if (!isInitiator) {
// Deputy users go to "my-plans"
if (positionType === EPositionTypeKey.DEPUTY) {
navigate(`/performance-management/${yearId}/my-plans`, {
replace: true,
});
return;
}
// Other users (directors, team leaders, employees) also go to my-plans
navigate(`/performance-management/${yearId}/my-plans`, {
replace: true,
});
return;
}
// If positionId is provided, redirect to the plans table with that position filter
if (positionId) {
navigate(`/performance-management/${yearId}/plans-table/${positionId}`, {
replace: true,
});
}
}, [positionId, yearId, navigate, positionType, isInitiator, isAuthLoading]);
if (isLoading || isAuthLoading) {
return (
<div className="space-y-6 p-6">
<Skeleton className="h-64 w-full rounded-lg" />
<Skeleton className="h-96 w-full rounded-lg" />
</div>
);
}
if (error) {
return (
<div className="p-6">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<h3 className="text-red-800 dark:text-red-200 font-semibold mb-2">
Error Loading Hierarchy
</h3>
<p className="text-red-600 dark:text-red-400">
{error instanceof Error
? error.message
: "Failed to load plan hierarchy"}
</p>
</div>
</div>
);
}
if (!positionType) {
return (
<div className="p-6">
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<h3 className="text-yellow-800 dark:text-yellow-200 font-semibold mb-2">
Position Type Not Found
</h3>
<p className="text-yellow-600 dark:text-yellow-400">
Your user account does not have a valid position type assigned.
Please contact your administrator.
</p>
</div>
</div>
);
}
// Show sector list
return (
<div className="p-6">
<SectorListView sectors={sectorData?.data?.items || []} />
</div>
);
};
export default PlanHierarchy;

View File

@@ -0,0 +1,119 @@
import React, { useEffect, useState, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "@/shared/common/ui/button";
import { ArrowLeft } from "lucide-react";
import { Skeleton } from "@/shared/common/ui/skeleton";
import { getPlanList } from "@/performance-management/services/api/planService";
import { UnifiedHierarchyView } from "./UnifiedHierarchyView";
import { EPositionTypeKey } from "@/shared/dto/user/meDto";
interface SectorDetailViewProps {
positionId: string;
positionType: EPositionTypeKey;
}
export const SectorDetailView: React.FC<SectorDetailViewProps> = ({
positionId,
positionType,
}) => {
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [hierarchyData, setHierarchyData] = useState<any>(null);
const [error, setError] = useState<string | null>(null);
const fetchSectorPlans = useCallback(async () => {
try {
setLoading(true);
setError(null);
// Fetch plans for this specific position
const response = await getPlanList(positionId, {
positionId,
take: 100,
skip: 0,
});
setHierarchyData(response?.data);
} catch (err: any) {
console.error("Failed to fetch sector plans:", err);
setError(err.message || "Failed to load sector plans");
} finally {
setLoading(false);
}
}, [positionId]);
useEffect(() => {
fetchSectorPlans();
}, [fetchSectorPlans]);
const handleAddPlan = (targetId?: string) => {
console.log("Add plan clicked for:", targetId || "current level");
// TODO: Implement plan creation modal
};
const handleManageWeekly = () => {
console.log("Manage weekly tasks clicked");
// TODO: Implement weekly tasks management modal
};
const handleViewTeamLead = (teamLeadId: string) => {
console.log("View team lead:", teamLeadId);
// TODO: Implement drill-down navigation
};
const handleViewEmployee = (employeeId: string) => {
console.log("View employee:", employeeId);
// TODO: Implement drill-down navigation
};
const handleBackToSectors = () => {
navigate("/performance-management/plans");
};
if (loading) {
return (
<div className="space-y-6 p-6">
<Skeleton className="h-64 w-full rounded-lg" />
<Skeleton className="h-96 w-full rounded-lg" />
</div>
);
}
if (error) {
return (
<div className="p-6">
<Button
variant="outline"
onClick={handleBackToSectors}
className="mb-4">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Sectors
</Button>
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<h3 className="text-red-800 dark:text-red-200 font-semibold mb-2">
Error Loading Sector Plans
</h3>
<p className="text-red-600 dark:text-red-400">{error}</p>
</div>
</div>
);
}
return (
<div className="p-6">
<Button variant="outline" onClick={handleBackToSectors} className="mb-6">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Sectors
</Button>
<UnifiedHierarchyView
positionType={positionType}
data={hierarchyData}
onAddPlan={handleAddPlan}
onManageWeekly={handleManageWeekly}
onViewTeamLead={handleViewTeamLead}
onViewEmployee={handleViewEmployee}
/>
</div>
);
};

View File

@@ -0,0 +1,355 @@
import React from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
Building2,
Target,
TrendingUp,
Users,
ArrowRight,
Star,
Calendar,
} from "lucide-react";
import { motion } from "framer-motion";
import { SectorList } from "@/performance-management/types/planTypes";
import { useLocalizedName } from "@/shared/common/localizedName";
import { usePlanYears } from "@/performance-management/hooks/usePlanYear";
import { useAuth } from "@/shared/context/AuthContext";
import { getUserPositionType } from "@/performance-management/utils/positionTypeUtils";
interface SectorListViewProps {
sectors: SectorList[];
}
export const SectorListView: React.FC<SectorListViewProps> = ({ sectors }) => {
const navigate = useNavigate();
const { yearId } = useParams<{ yearId: string }>();
const localizedName = useLocalizedName();
const { t } = useTranslation();
// const { data: planYear } = usePlanYears(organizationId);
const handleSectorClick = (positionId: string) => {
if (yearId) {
navigate(`/performance-management/${yearId}/plans-table/${positionId}`);
}
};
const getSectorColor = (index: number) => {
const colors = [
"from-blue-500 to-cyan-500",
"from-purple-500 to-pink-500",
"from-green-500 to-emerald-500",
"from-orange-500 to-red-500",
"from-indigo-500 to-blue-500",
"from-teal-500 to-green-500",
];
return colors[index % colors.length];
};
const getRankBadge = (rank: number) => {
if (rank === 1)
return "bg-gradient-to-r from-yellow-400 to-amber-500 text-white";
if (rank === 2)
return "bg-gradient-to-r from-gray-400 to-gray-500 text-white";
if (rank === 3)
return "bg-gradient-to-r from-amber-600 to-orange-500 text-white";
return "bg-gradient-to-r from-slate-400 to-slate-500 text-white";
};
const getPerformanceColor = (weight: number) => {
if (weight >= 90) return "text-green-500";
if (weight >= 75) return "text-blue-500";
if (weight >= 60) return "text-yellow-500";
return "text-red-500";
};
if (!sectors || sectors.length === 0) {
return (
<div className="min-h-[400px] flex items-center justify-center bg-gradient-to-br from-slate-50 to-blue-50 rounded-2xl">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="text-center"
>
<div className="w-20 h-20 bg-white/80 backdrop-blur-sm rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg border border-white/20">
<Building2 className="h-10 w-10 text-gray-400" />
</div>
<h3 className="text-xl font-semibold text-gray-700 mb-2">
{t("sectorListView.noSectorsAvailable", "No Sectors Available")}
</h3>
<p className="text-gray-500 max-w-sm">
{t(
"sectorListView.noSectorsMessage",
"There are no sectors to display at this time. Sectors will appear here once they are added to the system."
)}
</p>
</motion.div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4"
>
<div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-gray-800 to-gray-600 bg-clip-text text-transparent">
{t("sectorListView.title", "Sector Overview")}
</h1>
<p className="text-gray-600 mt-2">
{t(
"sectorListView.description",
"Manage and monitor performance across all sectors"
)}
</p>
</div>
<div className="flex items-center gap-3 bg-white/80 backdrop-blur-sm rounded-2xl px-4 py-2 shadow-sm border border-gray-200">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
<span className="text-sm font-medium text-gray-700">
{sectors.length}{" "}
{sectors.length === 1
? t("sectorListView.sector", "Sector")
: t("sectorListView.sectors", "Sectors")}{" "}
{t("sectorListView.active", "Active")}
</span>
</div>
</motion.div>
{/* Stats Summary */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8"
>
<div className="bg-white/80 backdrop-blur-sm rounded-2xl p-6 shadow-lg border border-gray-200/50">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-600 text-sm font-medium">
{t("sectorListView.totalPlans", "Total Plans")}
</p>
<p className="text-3xl font-bold text-gray-900">
{sectors.reduce(
(sum, sector) => sum + Number(sector.no_of_plans || 0),
0
)}
</p>
</div>
<div className="w-12 h-12 bg-blue-500/10 rounded-2xl flex items-center justify-center">
<Target className="h-6 w-6 text-blue-500" />
</div>
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-2xl p-6 shadow-lg border border-gray-200/50">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-600 text-sm font-medium">
{t("sectorListView.avgWeight", "Avg. Weight")}
</p>
<p className="text-3xl font-bold text-gray-900">
{Math.round(
sectors.reduce(
(sum, sector) => sum + Number(sector.total_weight || 0),
0
) / sectors.length
)}
%
</p>
</div>
<div className="w-12 h-12 bg-green-500/10 rounded-2xl flex items-center justify-center">
<TrendingUp className="h-6 w-6 text-green-500" />
</div>
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-2xl p-6 shadow-lg border border-gray-200/50">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-600 text-sm font-medium">
{t("sectorListView.activeSectors", "Active Sectors")}
</p>
<p className="text-3xl font-bold text-gray-900">
{sectors.filter((s) => Number(s.no_of_plans) > 0).length}
</p>
</div>
<div className="w-12 h-12 bg-purple-500/10 rounded-2xl flex items-center justify-center">
<Users className="h-6 w-6 text-purple-500" />
</div>
</div>
</div>
</motion.div>
{/* Sectors Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{sectors.map((sector, index) => (
<motion.div
key={sector.position_id}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: index * 0.1 }}
onClick={() => handleSectorClick(sector.position_id)}
className="group bg-white/80 backdrop-blur-sm rounded-2xl shadow-lg hover:shadow-2xl cursor-pointer transform hover:-translate-y-2 transition-all duration-300 border border-gray-200/50 hover:border-purple-300 overflow-hidden"
>
{/* Header with Gradient */}
<div
className={`bg-gradient-to-r ${getSectorColor(
index
)} p-6 relative overflow-hidden`}
>
<div className="absolute top-4 right-4">
<div
className={`px-3 py-1 rounded-full text-xs font-bold ${getRankBadge(
sector.position_rank
)} shadow-lg`}
>
#{sector.position_rank}
</div>
</div>
<div className="flex items-center justify-between mb-4">
<div className="w-12 h-12 bg-white/20 rounded-2xl flex items-center justify-center backdrop-blur-sm">
<Building2 className="h-6 w-6 text-white" />
</div>
<motion.div
whileHover={{ x: 5 }}
className="opacity-0 group-hover:opacity-100 transition-opacity duration-300"
>
<ArrowRight className="h-5 w-5 text-white/80" />
</motion.div>
</div>
<h3 className="text-xl font-bold text-white line-clamp-2 leading-tight">
{localizedName(sector.position_name)}
</h3>
</div>
{/* Content */}
<div className="p-6 space-y-4">
{/* Plans Count */}
<div className="flex items-center justify-between p-3 bg-blue-50 rounded-xl">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-500/10 rounded-lg flex items-center justify-center">
<Target className="h-5 w-5 text-blue-600" />
</div>
<div>
<p className="text-sm font-medium text-gray-600">
{t("sectorListView.plans", "Plans")}
</p>
<p className="text-lg font-bold text-gray-900">
{sector.no_of_plans || 0}
</p>
</div>
</div>
{sector.no_of_plans > 0 && (
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
)}
</div>
{/* Total Weight */}
{sector.total_weight !== null && (
<div className="flex items-center justify-between p-3 bg-green-50 rounded-xl">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-green-500/10 rounded-lg flex items-center justify-center">
<TrendingUp className="h-5 w-5 text-primary-600" />
</div>
<div>
<p className="text-sm font-medium text-gray-600">
{t("sectorListView.weight", "Weight")}
</p>
<p
className={`text-lg font-bold ${getPerformanceColor(
sector.total_weight
)}`}
>
{sector.total_weight}%
</p>
</div>
</div>
{/* Performance Indicator */}
<div className="flex items-center gap-1">
{[...Array(5)].map((_, i) => (
<Star
key={i}
className={`h-4 w-4 ${
i < Math.floor(sector.total_weight! / 20)
? "text-yellow-400 fill-current"
: "text-gray-300"
}`}
/>
))}
</div>
</div>
)}
{/* Additional Info */}
<div className="flex items-center justify-between text-sm text-gray-500 pt-2">
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
<span>{t("sectorListView.active", "Active")}</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span className="font-medium">
{t("sectorListView.online", "Online")}
</span>
</div>
</div>
</div>
{/* Hover Effect Overlay */}
<div className="absolute inset-0 bg-gradient-to-br from-purple-500/5 to-blue-500/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300 rounded-2xl pointer-events-none"></div>
</motion.div>
))}
</div>
{/* Footer Summary */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className="bg-gradient-to-r from-blue-50 to-purple-50 rounded-2xl p-6 border border-blue-200/50 mt-8"
>
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-800">
{t("sectorListView.performanceSummary", "Performance Summary")}
</h3>
<p className="text-gray-600 text-sm">
{t(
"sectorListView.performanceSummaryDescription",
"All sectors are actively monitored and updated in real-time"
)}
</p>
</div>
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
<span className="text-gray-700">
{t("sectorListView.onTrack", "On Track")}
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-yellow-500 rounded-full"></div>
<span className="text-gray-700">
{t("sectorListView.needsAttention", "Needs Attention")}
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-red-500 rounded-full"></div>
<span className="text-gray-700">
{t("sectorListView.atRisk", "At Risk")}
</span>
</div>
</div>
</div>
</motion.div>
</div>
);
};

View File

@@ -0,0 +1,446 @@
import React from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
Building2,
Target,
TrendingUp,
Users,
ArrowRight,
Star,
Calendar,
ArrowLeft,
PlusIcon,
} from "lucide-react";
import { motion } from "framer-motion";
import { useLocalizedName } from "@/shared/common/localizedName";
import { useSubPlans } from "@/performance-management/hooks/usePlans";
import { Button } from "@/shared/common/ui/button";
import { Skeleton } from "@/shared/common/ui/skeleton";
import { SectorList } from "@/performance-management/types/planTypes";
import { EPositionTypeKey } from "@/shared/dto/user/meDto";
import { useTranslation } from "react-i18next";
interface SubPlanListViewProps {
positionType: EPositionTypeKey;
}
export const SubPlanListView: React.FC<SubPlanListViewProps> = ({
positionType,
}) => {
const navigate = useNavigate();
const { yearId, planId } = useParams<{ yearId: string; planId: string }>();
const localizedName = useLocalizedName();
const { t } = useTranslation();
const POSITION_TYPE_LABELS = {
[EPositionTypeKey.DEPUTY]: {
singular: t("subPlanListView.department"),
plural: t("subPlanListView.departments"),
title: t("subPlanListView.departmentOverview"),
description: t("subPlanListView.departmentDescription"),
},
[EPositionTypeKey.DIRECTOR]: {
singular: t("subPlanListView.director"),
plural: t("subPlanListView.directors"),
title: t("subPlanListView.directorOverview"),
description: t("subPlanListView.directorDescription"),
},
[EPositionTypeKey.TEAM_LEADER]: {
singular: t("subPlanListView.teamLeader"),
plural: t("subPlanListView.teamLeaders"),
title: t("subPlanListView.teamLeaderOverview"),
description: t("subPlanListView.teamLeaderDescription"),
},
[EPositionTypeKey.EMPLOYEE]: {
singular: t("subPlanListView.employee"),
plural: t("subPlanListView.employees"),
title: t("subPlanListView.employeeOverview"),
description: t("subPlanListView.employeeDescription"),
},
} as const;
const { data, isLoading, error } = useSubPlans(planId || "");
const items: SectorList[] = data?.data?.items || [];
const labels =
POSITION_TYPE_LABELS[positionType as keyof typeof POSITION_TYPE_LABELS] ||
POSITION_TYPE_LABELS[EPositionTypeKey.DEPUTY];
const handleItemClick = (positionId: string, planId: string) => {
if (yearId) {
// Navigate to plans table for this sub-plan
navigate(
`/performance-management/${yearId}/plans-table/${positionId}/${planId}`
);
}
};
const handleCreateSubPlan = (item: SectorList) => {
if (yearId && planId) {
// Navigate to create plan with both positionId and parentPlanId
navigate(
`/performance-management/${yearId}/plans/new/${item.position_id}?parentPlanId=${planId}`
);
}
};
const getSectorColor = (index: number) => {
const colors = [
"from-blue-500 to-cyan-500",
"from-purple-500 to-pink-500",
"from-green-500 to-emerald-500",
"from-orange-500 to-red-500",
"from-indigo-500 to-blue-500",
"from-teal-500 to-green-500",
];
return colors[index % colors.length];
};
const getRankBadge = (rank: number) => {
if (rank === 1)
return "bg-gradient-to-r from-yellow-400 to-amber-500 text-white";
if (rank === 2)
return "bg-gradient-to-r from-gray-400 to-gray-500 text-white";
if (rank === 3)
return "bg-gradient-to-r from-amber-600 to-orange-500 text-white";
return "bg-gradient-to-r from-slate-400 to-slate-500 text-white";
};
const getPerformanceColor = (weight: number) => {
if (weight >= 90) return "text-green-500";
if (weight >= 75) return "text-blue-500";
if (weight >= 60) return "text-yellow-500";
return "text-red-500";
};
if (isLoading) {
return (
<div className="p-6 space-y-6">
<Skeleton className="h-64 w-full rounded-lg" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Skeleton className="h-40 w-full rounded-lg" />
<Skeleton className="h-40 w-full rounded-lg" />
<Skeleton className="h-40 w-full rounded-lg" />
</div>
</div>
);
}
if (error) {
return (
<div className="p-6">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<h3 className="text-red-800 dark:text-red-200 font-semibold mb-2">
{t("subPlanListView.errorLoading")} {labels.plural}
</h3>
<p className="text-red-600 dark:text-red-400">
{error instanceof Error
? error.message
: `${t(
"subPlanListView.failedToLoad"
)} ${labels.plural.toLowerCase()}`}
</p>
<Button
variant="outline"
onClick={() => navigate(-1)}
className="mt-4 border-red-200 text-red-600 hover:bg-red-100"
>
<ArrowLeft className="mr-2 h-4 w-4" />
{t("subPlanListView.goBack")}
</Button>
</div>
</div>
);
}
if (!items || items.length === 0) {
return (
<div className="p-6">
<Button variant="outline" onClick={() => navigate(-1)} className="mb-6">
<ArrowLeft className="mr-2 h-4 w-4" />
{t("subPlanListView.back")}
</Button>
<div className="min-h-[400px] flex items-center justify-center bg-gradient-to-br from-slate-50 to-blue-50 rounded-2xl">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="text-center"
>
<div className="w-20 h-20 bg-white/80 backdrop-blur-sm rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg border border-white/20">
<Building2 className="h-10 w-10 text-gray-400" />
</div>
<h3 className="text-xl font-semibold text-gray-700 mb-2">
{t("subPlanListView.noAvailable", { type: labels.plural })}
</h3>
<p className="text-gray-500 max-w-sm">
{t("subPlanListView.noSubPlansToDisplay", {
type: labels.plural.toLowerCase(),
})}
</p>
</motion.div>
</div>
</div>
);
}
return (
<div className="p-6 space-y-6">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4"
>
<div>
<div className="flex items-center gap-2 mb-2">
<Button
variant="ghost"
size="sm"
onClick={() => navigate(-1)}
className="text-gray-500 hover:text-gray-700 -ml-2"
>
<ArrowLeft className="h-4 w-4 mr-1" />
{t("subPlanListView.back")}
</Button>
</div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-gray-800 to-gray-600 bg-clip-text text-transparent">
{labels.title}
</h1>
<p className="text-gray-600 mt-2">{labels.description}</p>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-3 bg-white/80 backdrop-blur-sm rounded-2xl px-4 py-2 shadow-sm border border-gray-200">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
<span className="text-sm font-medium text-gray-700">
{items.length}{" "}
{items.length === 1 ? labels.singular : labels.plural}{" "}
{t("subPlanListView.active")}
</span>
</div>
</div>
</motion.div>
{/* Stats Summary */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8"
>
<div className="bg-white/80 backdrop-blur-sm rounded-2xl p-6 shadow-lg border border-gray-200/50">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-600 text-sm font-medium">
{t("subPlanListView.totalPlans")}
</p>
<p className="text-3xl font-bold text-gray-900">
{items.reduce(
(sum: number, item: SectorList) =>
sum + (Number(item.no_of_plans) || 0),
0
)}
</p>
</div>
<div className="w-12 h-12 bg-blue-500/10 rounded-2xl flex items-center justify-center">
<Target className="h-6 w-6 text-blue-500" />
</div>
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-2xl p-6 shadow-lg border border-gray-200/50">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-600 text-sm font-medium">
{t("subPlanListView.avgWeight")}
</p>
<p className="text-3xl font-bold text-gray-900">
{items.length > 0
? Math.round(
items.reduce(
(sum: number, item: SectorList) =>
sum + (Number(item.total_weight) || 0),
0
) / items.length
)
: 0}
%
</p>
</div>
<div className="w-12 h-12 bg-green-500/10 rounded-2xl flex items-center justify-center">
<TrendingUp className="h-6 w-6 text-green-500" />
</div>
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-2xl p-6 shadow-lg border border-gray-200/50">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-600 text-sm font-medium">
{t("subPlanListView.active")} {labels.plural}
</p>
<p className="text-3xl font-bold text-gray-900">
{
items.filter((s: SectorList) => Number(s.no_of_plans) > 0)
.length
}
</p>
</div>
<div className="w-12 h-12 bg-purple-500/10 rounded-2xl flex items-center justify-center">
<Users className="h-6 w-6 text-purple-500" />
</div>
</div>
</div>
</motion.div>
{/* Items Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{items.map((item: SectorList, index: number) => (
<motion.div
key={item.position_id}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: index * 0.1 }}
className="group bg-white/80 backdrop-blur-sm rounded-2xl shadow-lg hover:shadow-2xl transform hover:-translate-y-2 transition-all duration-300 border border-gray-200/50 hover:border-purple-300 overflow-hidden"
>
{/* Header with Gradient */}
<div
className={`bg-gradient-to-r ${getSectorColor(
index
)} p-6 relative overflow-hidden`}
>
<div className="absolute top-4 right-4">
<div
className={`px-3 py-1 rounded-full text-xs font-bold ${getRankBadge(
item.position_rank
)} shadow-lg`}
>
#{item.position_rank}
</div>
</div>
<div className="flex items-center justify-between mb-4">
<div className="w-12 h-12 bg-white/20 rounded-2xl flex items-center justify-center backdrop-blur-sm">
<Building2 className="h-6 w-6 text-white" />
</div>
<motion.div
whileHover={{ x: 5 }}
className="opacity-0 group-hover:opacity-100 transition-opacity duration-300"
>
<ArrowRight className="h-5 w-5 text-white/80" />
</motion.div>
</div>
<h3 className="text-xl font-bold text-white line-clamp-2 leading-tight">
{localizedName(item.position_name)}
</h3>
</div>
{/* Content */}
<div className="p-6 space-y-4">
{/* Plans Count */}
<div className="flex items-center justify-between p-3 bg-blue-50 rounded-xl">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-500/10 rounded-lg flex items-center justify-center">
<Target className="h-5 w-5 text-blue-600" />
</div>
<div>
<p className="text-sm font-medium text-gray-600">
{t("subPlanListView.totalPlans")}
</p>
<p className="text-lg font-bold text-gray-900">
{item.no_of_plans || 0}
</p>
</div>
</div>
{Number(item.no_of_plans) > 0 && (
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
)}
</div>
{/* Total Weight */}
{item.total_weight !== null && (
<div className="flex items-center justify-between p-3 bg-green-50 rounded-xl">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-green-500/10 rounded-lg flex items-center justify-center">
<TrendingUp className="h-5 w-5 text-primary-600" />
</div>
<div>
<p className="text-sm font-medium text-gray-600">
{t("subPlanListView.avgWeight")}
</p>
<p
className={`text-lg font-bold ${getPerformanceColor(
item.total_weight
)}`}
>
{item.total_weight}%
</p>
</div>
</div>
{/* Performance Indicator */}
<div className="flex items-center gap-1">
{[...Array(5)].map((_, i) => (
<Star
key={i}
className={`h-4 w-4 ${
i < Math.floor((item.total_weight || 0) / 20)
? "text-yellow-400 fill-current"
: "text-gray-300"
}`}
/>
))}
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex items-center gap-2 pt-4">
<Button
onClick={(e) => {
e.stopPropagation();
handleItemClick(item.position_id, planId as string);
}}
variant="outline"
size="sm"
className="flex-1 border-blue-200 text-blue-600 hover:bg-blue-50"
>
{t("subPlanListView.viewPlans")}
</Button>
<Button
onClick={(e) => {
e.stopPropagation();
handleCreateSubPlan(item);
}}
size="sm"
className="flex-1 bg-gradient-to-r from-purple-500 to-violet-600 hover:from-purple-600 hover:to-violet-700 text-white"
>
<PlusIcon className="h-4 w-4 mr-1" />
{t("subPlanListView.createPlan")}
</Button>
</div>
{/* Additional Info */}
<div className="flex items-center justify-between text-sm text-gray-500 pt-2">
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
<span>{t("subPlanListView.active")}</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span className="font-medium">
{t("subPlanListView.online")}
</span>
</div>
</div>
</div>
{/* Hover Effect Overlay */}
<div className="absolute inset-0 bg-gradient-to-br from-purple-500/5 to-blue-500/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300 rounded-2xl pointer-events-none"></div>
</motion.div>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,373 @@
import React, { useState } from "react";
import { Button } from "@/shared/common/ui/button";
import {
Plus,
ChevronDown,
ChevronRight,
User,
Calendar,
CheckCircle2,
} from "lucide-react";
import { PlansTable } from "./shared/PlansTable";
import { EPositionTypeKey } from "@/shared/dto/user/meDto";
import {
WeeklyTask,
Employee,
TeamLead,
Directorate,
} from "@/performance-management/types/planHierarchyTypes";
// Props for the unified view
interface UnifiedHierarchyViewProps {
positionType: EPositionTypeKey;
data: any; // Will be typed based on API response
onAddPlan: (targetId?: string) => void;
onManageWeekly?: () => void;
onViewTeamLead?: (teamLeadId: string) => void;
onViewEmployee?: (employeeId: string) => void;
}
export const UnifiedHierarchyView: React.FC<UnifiedHierarchyViewProps> = ({
positionType,
data,
onAddPlan,
onManageWeekly,
onViewTeamLead,
onViewEmployee,
}) => {
const [expandedDirectorate, setExpandedDirectorate] = useState<string | null>(
null
);
const toggleDirectorate = (id: string) => {
setExpandedDirectorate((prev) => (prev === id ? null : id));
};
// Deputy (Sector) View
if (positionType === EPositionTypeKey.DEPUTY) {
return (
<div className="space-y-6">
{/* Sector Plans Overview */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
Sector Plans
</h2>
<Button onClick={() => onAddPlan()}>
<Plus className="mr-2 h-4 w-4" />
Add Sector Plan
</Button>
</div>
<PlansTable plans={data?.plans || []} editable />
</div>
{/* Directorates Section */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
Directorates
</h2>
<div className="space-y-2">
{(data?.directorates || []).map((dir: Directorate) => (
<div
key={dir.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg">
{/* Directorate Header */}
<div
className="flex items-center justify-between p-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800"
onClick={() => toggleDirectorate(dir.id)}>
<div className="flex items-center gap-3">
{expandedDirectorate === dir.id ? (
<ChevronDown className="h-5 w-5 text-gray-500" />
) : (
<ChevronRight className="h-5 w-5 text-gray-500" />
)}
<div>
<h3 className="font-medium text-gray-900 dark:text-gray-100">
{dir.name}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
{dir.planCount} plans {dir.progress}% complete
</p>
</div>
</div>
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
onAddPlan(dir.id);
}}>
<Plus className="h-4 w-4 mr-1" />
Add Plan
</Button>
</div>
{/* Expanded Directorate Plans */}
{expandedDirectorate === dir.id && (
<div className="p-4 border-t border-gray-200 dark:border-gray-700">
{dir.plans?.length > 0 ? (
<PlansTable plans={dir.plans} />
) : (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
No plans added yet
</div>
)}
</div>
)}
</div>
))}
</div>
</div>
</div>
);
}
// Director (Directorate) View
if (positionType === EPositionTypeKey.DIRECTOR) {
return (
<div className="space-y-6">
{/* Directorate Plans */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
My Plans
</h2>
<Button onClick={() => onAddPlan()}>
<Plus className="mr-2 h-4 w-4" />
Add Plan
</Button>
</div>
<PlansTable plans={data?.plans || []} editable />
</div>
{/* Team Leads List */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
Team Leads
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{(data?.teamLeads || []).map((tl: TeamLead) => (
<div
key={tl.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:shadow-md transition cursor-pointer"
onClick={() => onViewTeamLead?.(tl.id)}>
<h3 className="font-medium text-gray-900 dark:text-gray-100 mb-2">
{tl.name}
</h3>
<div className="space-y-1 text-sm text-gray-600 dark:text-gray-400">
<p>{tl.planCount} plans assigned</p>
<div className="flex items-center gap-2">
<div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="bg-purple-600 h-2 rounded-full transition-all"
style={{ width: `${tl.progress}%` }}
/>
</div>
<span>{tl.progress}%</span>
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
}
// Team Leader View
if (positionType === EPositionTypeKey.TEAM_LEADER) {
return (
<div className="space-y-6">
{/* Team Lead Plans */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
My Plans
</h2>
<Button onClick={() => onAddPlan()}>
<Plus className="mr-2 h-4 w-4" />
Add Plan
</Button>
</div>
<PlansTable plans={data?.plans || []} />
</div>
{/* Weekly Milestones */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
Weekly Milestones
</h2>
<Button onClick={onManageWeekly} variant="outline">
Manage Weekly Tasks
</Button>
</div>
<div className="space-y-2">
{(data?.weeklyMilestones || []).length > 0 ? (
data.weeklyMilestones.map((milestone: string, index: number) => (
<div
key={index}
className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded">
<div className="h-2 w-2 rounded-full bg-purple-600"></div>
<span className="text-sm text-gray-700 dark:text-gray-300">
{milestone}
</span>
</div>
))
) : (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
No weekly milestones set
</div>
)}
</div>
</div>
{/* Team Members */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
Team Members
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{(data?.employees || []).map((employee: Employee) => (
<div
key={employee.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:shadow-md transition cursor-pointer"
onClick={() => onViewEmployee?.(employee.id)}>
<div className="flex items-start gap-3">
<div className="p-2 bg-purple-100 dark:bg-purple-900 rounded">
<User className="h-5 w-5 text-purple-600 dark:text-purple-300" />
</div>
<div className="flex-1">
<h3 className="font-medium text-gray-900 dark:text-gray-100">
{employee.name}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
{employee.position}
</p>
<div className="mt-2 space-y-1 text-xs text-gray-500 dark:text-gray-400">
<p>
{employee.planCount} plans {employee.weeklyTasksCount}{" "}
weekly tasks
</p>
</div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
}
// Employee View
if (positionType === EPositionTypeKey.EMPLOYEE) {
return (
<div className="space-y-6">
{/* Weekly Expectations */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center gap-2">
<Calendar className="h-5 w-5" />
My Weekly Expectations
</h2>
{(data?.weeklyTasks || []).length > 0 ? (
<div className="space-y-3">
{data.weeklyTasks.map((task: WeeklyTask) => (
<div
key={task.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-start justify-between mb-2">
<h3 className="font-medium text-gray-900 dark:text-gray-100">
{task.description}
</h3>
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${
task.status === "completed"
? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-primary-300"
: task.status === "in-progress"
? "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300"
: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
}`}>
{task.status}
</span>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
<strong>Expected Outcome:</strong> {task.expectedOutcome}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
<strong>Deadline:</strong> {task.deadline}
</p>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
No weekly tasks assigned yet
</div>
)}
</div>
{/* Assigned Plans */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center gap-2">
<CheckCircle2 className="h-5 w-5" />
Assigned Plans
</h2>
<PlansTable plans={data?.plans || []} />
</div>
{/* Performance Summary */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
Performance Summary
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
<div className="text-sm text-purple-600 dark:text-purple-400 mb-1">
Total Plans
</div>
<div className="text-2xl font-bold text-purple-900 dark:text-purple-100">
{data?.plans?.length || 0}
</div>
</div>
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<div className="text-sm text-blue-600 dark:text-blue-400 mb-1">
Weekly Tasks
</div>
<div className="text-2xl font-bold text-blue-900 dark:text-blue-100">
{data?.weeklyTasks?.length || 0}
</div>
</div>
<div className="p-4 bg-green-50 dark:bg-primary-900/20 rounded-lg">
<div className="text-sm text-primary-600 dark:text-primary-400 mb-1">
Completed
</div>
<div className="text-2xl font-bold text-green-900 dark:text-green-100">
{data?.weeklyTasks?.filter(
(t: WeeklyTask) => t.status === "completed"
).length || 0}
</div>
</div>
</div>
</div>
</div>
);
}
// Fallback for unsupported position types
return (
<div className="p-6">
<div className="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h3 className="text-gray-800 dark:text-gray-200 font-semibold mb-2">
Unsupported Position Type
</h3>
<p className="text-gray-600 dark:text-gray-400">
Your position type is not supported in the plan hierarchy view.
</p>
</div>
</div>
);
};

View File

@@ -0,0 +1,136 @@
import React from "react";
import { Button } from "@/shared/common/ui/button";
import { Edit, Trash2 } from "lucide-react";
import { Plan } from "@/performance-management/types/planHierarchyTypes";
interface PlansTableProps {
plans: Plan[];
editable?: boolean;
onEdit?: (planId: string) => void;
onDelete?: (planId: string) => void;
}
const StatusBadge: React.FC<{ status: Plan["status"] }> = ({ status }) => {
const statusColors = {
pending:
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300",
"in-progress":
"bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300",
completed:
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-primary-300",
overdue: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300",
};
return (
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${statusColors[status]}`}>
{status}
</span>
);
};
const ProgressBar: React.FC<{ progress: number }> = ({ progress }) => {
return (
<div className="w-full">
<div className="flex items-center gap-2">
<div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="bg-purple-600 h-2 rounded-full transition-all"
style={{ width: `${progress}%` }}
/>
</div>
<span className="text-sm text-gray-600 dark:text-gray-400 min-w-[3rem]">
{progress}%
</span>
</div>
</div>
);
};
export const PlansTable: React.FC<PlansTableProps> = ({
plans,
editable = false,
onEdit,
onDelete,
}) => {
if (!plans || plans.length === 0) {
return (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
No plans available
</div>
);
}
return (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-100 dark:bg-gray-800">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-200">
Plan Name
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-200">
Weight
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-200">
Timeline
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-200">
Status
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-200">
Progress
</th>
{editable && (
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-200">
Actions
</th>
)}
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{plans.map((plan) => (
<tr
key={plan.id}
className="hover:bg-gray-50 dark:hover:bg-gray-800">
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
{plan.name}
</td>
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
{plan.weight}
</td>
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
{plan.timeline}
</td>
<td className="px-4 py-3">
<StatusBadge status={plan.status} />
</td>
<td className="px-4 py-3">
<ProgressBar progress={plan.progress} />
</td>
{editable && (
<td className="px-4 py-3">
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => onEdit?.(plan.id)}>
<Edit className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => onDelete?.(plan.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
);
};

View File

@@ -0,0 +1,100 @@
import React, { useState } from "react";
import { Button } from "@/shared/common/ui/button";
import { Plus, ChevronDown, ChevronRight } from "lucide-react";
import { PlansTable } from "../shared/PlansTable";
import { DeputyData } from "@/performance-management/types/planHierarchyTypes";
interface DeputyViewProps {
sectorData: DeputyData;
onAddPlan: (targetId?: string) => void;
}
export const DeputyView: React.FC<DeputyViewProps> = ({
sectorData,
onAddPlan,
}) => {
const [expandedDirectorate, setExpandedDirectorate] = useState<string | null>(
null
);
const toggleDirectorate = (id: string) => {
setExpandedDirectorate((prev) => (prev === id ? null : id));
};
return (
<div className="space-y-6">
{/* Sector Plans Overview */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
Sector Plans
</h2>
<Button onClick={() => onAddPlan()}>
<Plus className="mr-2 h-4 w-4" />
Add Sector Plan
</Button>
</div>
<PlansTable plans={sectorData.plans} editable />
</div>
{/* Directorates Section */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
Directorates
</h2>
{/* Directorates List */}
<div className="space-y-2">
{sectorData.directorates.map((dir) => (
<div
key={dir.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg">
{/* Directorate Header */}
<div
className="flex items-center justify-between p-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800"
onClick={() => toggleDirectorate(dir.id)}>
<div className="flex items-center gap-3">
{expandedDirectorate === dir.id ? (
<ChevronDown className="h-5 w-5 text-gray-500" />
) : (
<ChevronRight className="h-5 w-5 text-gray-500" />
)}
<div>
<h3 className="font-medium text-gray-900 dark:text-gray-100">
{dir.name}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
{dir.planCount} plans {dir.progress}% complete
</p>
</div>
</div>
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
onAddPlan(dir.id);
}}>
<Plus className="h-4 w-4 mr-1" />
Add Plan
</Button>
</div>
{/* Expanded Directorate Plans */}
{expandedDirectorate === dir.id && (
<div className="p-4 border-t border-gray-200 dark:border-gray-700">
{dir.plans.length > 0 ? (
<PlansTable plans={dir.plans} />
) : (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
No plans added yet
</div>
)}
</div>
)}
</div>
))}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,67 @@
import React from "react";
import { Button } from "@/shared/common/ui/button";
import { Plus } from "lucide-react";
import { PlansTable } from "../shared/PlansTable";
import { DirectorData } from "@/performance-management/types/planHierarchyTypes";
interface DirectorViewProps {
directorateData: DirectorData;
onAddPlan: () => void;
onViewTeamLead: (teamLeadId: string) => void;
}
export const DirectorView: React.FC<DirectorViewProps> = ({
directorateData,
onAddPlan,
onViewTeamLead,
}) => {
return (
<div className="space-y-6">
{/* Directorate Plans */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
My Plans
</h2>
<Button onClick={onAddPlan}>
<Plus className="mr-2 h-4 w-4" />
Add Plan
</Button>
</div>
<PlansTable plans={directorateData.plans} editable />
</div>
{/* Team Leads List */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
Team Leads
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{directorateData.teamLeads.map((tl) => (
<div
key={tl.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:shadow-md transition cursor-pointer"
onClick={() => onViewTeamLead(tl.id)}>
<h3 className="font-medium text-gray-900 dark:text-gray-100 mb-2">
{tl.name}
</h3>
<div className="space-y-1 text-sm text-gray-600 dark:text-gray-400">
<p>{tl.planCount} plans assigned</p>
<div className="flex items-center gap-2">
<div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="bg-purple-600 h-2 rounded-full transition-all"
style={{ width: `${tl.progress}%` }}
/>
</div>
<span>{tl.progress}%</span>
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,103 @@
import React from "react";
import { Calendar, CheckCircle2 } from "lucide-react";
import { PlansTable } from "../shared/PlansTable";
import { EmployeeData } from "@/performance-management/types/planHierarchyTypes";
interface EmployeeViewProps {
employeeData: EmployeeData;
}
export const EmployeeView: React.FC<EmployeeViewProps> = ({ employeeData }) => {
return (
<div className="space-y-6">
{/* Weekly Expectations */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center gap-2">
<Calendar className="h-5 w-5" />
My Weekly Expectations
</h2>
{employeeData.weeklyTasks.length > 0 ? (
<div className="space-y-3">
{employeeData.weeklyTasks.map((task) => (
<div
key={task.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-start justify-between mb-2">
<h3 className="font-medium text-gray-900 dark:text-gray-100">
{task.description}
</h3>
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${
task.status === "completed"
? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-primary-300"
: task.status === "in-progress"
? "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300"
: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
}`}>
{task.status}
</span>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
<strong>Expected Outcome:</strong> {task.expectedOutcome}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
<strong>Deadline:</strong> {task.deadline}
</p>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
No weekly tasks assigned yet
</div>
)}
</div>
{/* Assigned Plans */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center gap-2">
<CheckCircle2 className="h-5 w-5" />
Assigned Plans
</h2>
<PlansTable plans={employeeData.plans} />
</div>
{/* Performance Summary */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
Performance Summary
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
<div className="text-sm text-purple-600 dark:text-purple-400 mb-1">
Total Plans
</div>
<div className="text-2xl font-bold text-purple-900 dark:text-purple-100">
{employeeData.plans.length}
</div>
</div>
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<div className="text-sm text-blue-600 dark:text-blue-400 mb-1">
Weekly Tasks
</div>
<div className="text-2xl font-bold text-blue-900 dark:text-blue-100">
{employeeData.weeklyTasks.length}
</div>
</div>
<div className="p-4 bg-green-50 dark:bg-primary-900/20 rounded-lg">
<div className="text-sm text-primary-600 dark:text-primary-400 mb-1">
Completed
</div>
<div className="text-2xl font-bold text-green-900 dark:text-green-100">
{
employeeData.weeklyTasks.filter((t) => t.status === "completed")
.length
}
</div>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,103 @@
import React from "react";
import { Button } from "@/shared/common/ui/button";
import { Plus, User } from "lucide-react";
import { PlansTable } from "../shared/PlansTable";
import { TeamLeaderData } from "@/performance-management/types/planHierarchyTypes";
interface TeamLeaderViewProps {
teamLeaderData: TeamLeaderData;
onAddPlan: () => void;
onManageWeekly: () => void;
onViewEmployee: (employeeId: string) => void;
}
export const TeamLeaderView: React.FC<TeamLeaderViewProps> = ({
teamLeaderData,
onAddPlan,
onManageWeekly,
onViewEmployee,
}) => {
return (
<div className="space-y-6">
{/* Team Lead Plans */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
My Plans
</h2>
<Button onClick={onAddPlan}>
<Plus className="mr-2 h-4 w-4" />
Add Plan
</Button>
</div>
<PlansTable plans={teamLeaderData.plans} />
</div>
{/* Weekly Milestones */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
Weekly Milestones
</h2>
<Button onClick={onManageWeekly} variant="outline">
Manage Weekly Tasks
</Button>
</div>
<div className="space-y-2">
{teamLeaderData.weeklyMilestones.length > 0 ? (
teamLeaderData.weeklyMilestones.map((milestone, index) => (
<div
key={index}
className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded">
<div className="h-2 w-2 rounded-full bg-purple-600"></div>
<span className="text-sm text-gray-700 dark:text-gray-300">
{milestone}
</span>
</div>
))
) : (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
No weekly milestones set
</div>
)}
</div>
</div>
{/* Team Members */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
Team Members
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{teamLeaderData.employees.map((employee) => (
<div
key={employee.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:shadow-md transition cursor-pointer"
onClick={() => onViewEmployee(employee.id)}>
<div className="flex items-start gap-3">
<div className="p-2 bg-purple-100 dark:bg-purple-900 rounded">
<User className="h-5 w-5 text-purple-600 dark:text-purple-300" />
</div>
<div className="flex-1">
<h3 className="font-medium text-gray-900 dark:text-gray-100">
{employee.name}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
{employee.position}
</p>
<div className="mt-2 space-y-1 text-xs text-gray-500 dark:text-gray-400">
<p>
{employee.planCount} plans {employee.weeklyTasksCount}{" "}
weekly tasks
</p>
</div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,140 @@
import { Calendar, Edit, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { PlanYear } from "../../types/planYearTypes";
import { Button } from "@/shared/common/ui/button";
import { Badge } from "@/shared/common/ui/badge";
import { useLocalizedName } from "@/shared/common/localizedName";
import { useNavigate, useLocation } from "react-router-dom";
interface PlanYearCardProps {
planYear: PlanYear;
onEdit?: (id: string) => void;
onDelete?: (id: string) => void;
isUnitAdmin?: boolean;
}
export const PlanYearCard = ({
planYear,
onEdit,
onDelete,
isUnitAdmin = false,
}: PlanYearCardProps) => {
const localizedName = useLocalizedName();
const navigate = useNavigate();
const location = useLocation();
const { t } = useTranslation();
console.log("plan yera ", planYear.id);
const handleCardClick = () => {
// Check if we're in the OKR context (objective-management)
const isOKRContext = location.pathname.startsWith("/objective-management");
const planYearLabel = localizedName(planYear.name) || String(planYear.year);
const routeState = {
planYearName: planYearLabel,
planYearLabel,
planYear: {
id: planYear.id,
year: planYear.year,
name: planYear.name,
},
};
if (isOKRContext) {
navigate(`/objective-management/${planYear.id}/dashboard`, {
state: routeState,
});
} else {
navigate(`/performance-management/${planYear.id}/dashboard`, {
state: routeState,
});
}
};
const handleEdit = (e: React.MouseEvent) => {
e.stopPropagation();
onEdit?.(planYear.id);
};
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation();
onDelete?.(planYear.id);
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
};
return (
<div
onClick={handleCardClick}
className="group relative bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6 hover:shadow-lg hover:border-purple-300 dark:hover:border-purple-600 transition-all cursor-pointer"
>
{/* Active Badge */}
{planYear.isActive && (
<div className="absolute top-3 right-3">
<Badge className="bg-green-500 hover:bg-primary-600 text-white">
{t("planYearList.active", "Active")}
</Badge>
</div>
)}
{/* Year Number - Large Display */}
<div className="flex items-center gap-3 mb-4">
<div className="p-3 bg-purple-100 dark:bg-purple-900 rounded-lg">
<Calendar className="h-6 w-6 text-purple-600 dark:text-purple-300" />
</div>
<h3 className="text-3xl font-bold text-gray-900 dark:text-white">
{planYear.year}
</h3>
</div>
{/* Name */}
<h4 className="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-2">
{localizedName(planYear.name)}
</h4>
{/* Description */}
{planYear.description && (
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4 line-clamp-2">
{localizedName(planYear.description)}
</p>
)}
{/* Actions - Only visible to unit admins */}
{isUnitAdmin && (
<div className="flex gap-2 pt-4 border-t border-gray-200 dark:border-gray-700 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="outline"
size="sm"
onClick={handleEdit}
className="flex-1"
>
<Edit className="h-4 w-4 mr-1" />
{t("planYearList.edit", "Edit")}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDelete}
className="flex-1 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20"
>
<Trash2 className="h-4 w-4 mr-1" />
{t("planYearList.delete", "Delete")}
</Button>
</div>
)}
{/* Click indicator */}
<div className="absolute bottom-3 right-3 opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-xs text-purple-600 dark:text-purple-400 font-medium">
{t("planYearList.viewPlans", "View Plans")}
</span>
</div>
</div>
);
};

View File

@@ -0,0 +1,151 @@
import { useForm } from "react-hook-form";
import { useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { GenericForm } from "@/performance-management/utils/generic-form";
import { FormField } from "@/performance-management/utils/shared-field-types";
import {
usePlanYear,
usePlanYearMutations,
usePlanYears,
} from "@/performance-management/hooks/usePlanYear";
import { PlanYearFormValues } from "@/performance-management/types/planYearTypes";
import { useAuth } from "@/shared/context/AuthContext";
import { generateEthiopianYearOptions } from "@/performance-management/utils/EthiopianYear";
import { useMemo } from "react";
export default function PlanYearForm() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { t } = useTranslation();
const isEdit = !!id;
const { data: planYearData } = usePlanYear(id || "");
const { create, update } = usePlanYearMutations();
const { user } = useAuth();
const organizationId =
user?.employee?.length && user.employee.length > 0
? user.employee[0].organizationId
: undefined;
// Fetch all plan years to check which years are taken
const { data: allPlanYears } = usePlanYears(organizationId || "");
const yearOptions = useMemo(() => {
const baseOptions = generateEthiopianYearOptions();
// Get taken years (excluding current plan year if editing)
const takenYears = new Set(
allPlanYears?.data?.items
?.filter((planYear: { id: string | undefined }) => planYear.id !== id)
.map((planYear: { year: any }) => planYear.year) || []
);
// Mark taken years as disabled
return baseOptions.map((option) => ({
...option,
disabled: takenYears.has(Number(option.value)),
}));
}, [allPlanYears, id]);
const initialValues: PlanYearFormValues = {
year: Number(yearOptions[0].value),
nameAm: planYearData?.data?.name?.am || "",
nameEn: planYearData?.data?.name?.en || "",
descriptionAm: "",
descriptionEn: "",
organizationId: organizationId || "",
};
const form = useForm<PlanYearFormValues>({
defaultValues: initialValues,
values: initialValues,
});
const planYearFormFields: FormField<PlanYearFormValues>[] = [
{
name: "year",
label: t("planYear.year", "Year"),
type: "year-picker",
placeholder: t("planYear.enterYear", "Enter year (e.g., 2025)"),
required: true,
defaultValue: Number(yearOptions[0].value),
calendarType: "habesha",
language: "am",
minYear: !isEdit ? Number(yearOptions[0].value) : undefined,
colSpan: 12,
},
{
name: "nameAm",
label: t("planYear.nameAmharic", "Name (Amharic)"),
type: "text",
placeholder: t("planYear.enterAmharicName", "Enter Amharic name"),
required: true,
colSpan: 6,
},
{
name: "nameEn",
label: t("planYear.nameEnglish", "Name (English)"),
type: "text",
placeholder: t("planYear.enterEnglishName", "Enter English name"),
required: true,
colSpan: 6,
},
];
const handleSubmit = (values: PlanYearFormValues) => {
const dto = {
id: isEdit ? id : undefined,
year: Number(values.year),
name: {
am: values.nameAm,
en: values.nameEn,
},
organizationId: organizationId,
};
const mutation = isEdit ? update : create;
mutation.mutate(dto, {
onSuccess: () => {
navigate("/performance-management/plan-years");
},
});
};
const handleCancel = () => {
navigate("/performance-management/plan-years");
};
return (
<div className="container mx-auto px-4 py-8">
<GenericForm<PlanYearFormValues>
form={form}
fields={planYearFormFields}
onSubmit={handleSubmit}
onCancel={handleCancel}
title={
isEdit
? t("planYear.editPlanYear", "Edit Plan Year")
: t("planYear.createPlanYear", "Create Plan Year")
}
description={
isEdit
? t(
"planYear.updatePlanYearInfo",
"Update the plan year information"
)
: t(
"planYear.createPlanYearInfo",
"Create a new plan year to organize your plans"
)
}
submitButtonText={
isEdit
? t("planYear.updatePlanYear", "Update Plan Year")
: t("planYear.createPlanYear", "Create Plan Year")
}
cancelButtonText={t("common.cancel", "Cancel")}
/>
</div>
);
}

View File

@@ -0,0 +1,266 @@
import { useState, useMemo } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { PlusIcon, RefreshCw } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/shared/common/ui/button";
import { Skeleton } from "@/shared/common/ui/skeleton";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/common/ui/alert-dialog";
import { PlanYearCard } from "./PlanYearCard";
import {
usePlanYears,
usePlanYearMutations,
} from "@/performance-management/hooks/usePlanYear";
import { PlanYear } from "@/performance-management/types/planYearTypes";
import { useAuth } from "@/shared/context/AuthContext";
import {
checkIsPlanInitiator,
checkIsUnitAdmin,
} from "@/performance-management/hooks/usePlans";
import { useLocalizedName } from "@/shared/common/localizedName";
export default function PlanYearList() {
const navigate = useNavigate();
const location = useLocation();
const queryClient = useQueryClient();
const { t } = useTranslation();
const { user } = useAuth();
const localizedName = useLocalizedName();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [selectedPlanYear, setSelectedPlanYear] = useState<PlanYear | null>(
null
);
const basePath = useMemo(() => {
const segments = location.pathname.startsWith("/objective");
if (segments) {
return "/objective-management";
}
return "/performance-management";
}, [location.pathname]);
const organizationId: string =
user?.employee?.length && user.employee.length > 0
? user.employee[0].organizationId
: "";
// Check if user is unit admin
const isUnitAdmin = useMemo(() => checkIsPlanInitiator(user), [user]);
console.log("isUnitAdmin", isUnitAdmin);
const { data, isLoading, isFetching, isError, error, refetch } = usePlanYears(
organizationId,
{
take: 100,
skip: 0,
}
);
const { delete: deletePlanYear } = usePlanYearMutations();
const planYears = data?.data?.items || [];
const handleEdit = (id: string) => {
navigate(`${basePath}/plan-years/${id}/edit`);
};
const handleRefresh = () => {
queryClient.invalidateQueries({
queryKey: ["planYears", "list", organizationId],
});
};
const handleDelete = (id: string) => {
const planYear = planYears.find((py: PlanYear) => py.id === id);
if (planYear) {
setSelectedPlanYear(planYear);
setIsDeleteDialogOpen(true);
}
};
const confirmDelete = () => {
if (!selectedPlanYear) return;
deletePlanYear.mutate(selectedPlanYear.id, {
onSuccess: () => {
setIsDeleteDialogOpen(false);
setSelectedPlanYear(null);
},
});
};
if (isLoading) {
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center mb-6">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-10 w-40" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3, 4, 5, 6].map((i) => (
<Skeleton key={i} className="h-64 w-full rounded-lg" />
))}
</div>
</div>
);
}
if (isError) {
return (
<div className="p-6 space-y-4">
<div className="text-destructive text-lg font-semibold">
{t("planYear.loadingError", "Error loading plan years")}
</div>
<div className="text-muted-foreground">
{error?.message ||
t(
"planYear.loadingErrorMessage",
"Failed to load plan years. Please try again."
)}
</div>
<Button onClick={() => refetch()}>
{t("planYear.retry", "Retry")}
</Button>
</div>
);
}
return (
<div className="p-6">
{/* Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
{t("planYearList.title", "Plan Years")}
</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{t("planYearList.description", "Manage and organize plans by year")}
</p>
</div>
<div className="flex flex-wrap gap-2">
{isUnitAdmin && (
<Button
onClick={() => navigate(`${basePath}/plan-years/new`)}
className="bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700 text-white">
<PlusIcon className="h-4 w-4 mr-2" />
{t("planYearList.createPlanYear", "Create Plan Year")}
</Button>
)}
<Button
variant="outline"
onClick={handleRefresh}
disabled={isFetching}
className="gap-2"
>
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} />
{t("common.refresh", "Refresh")}
</Button>
</div>
</div>
{/* Plan Years Grid */}
{planYears.length === 0 ? (
<div className="text-center py-12">
<div className="text-gray-400 dark:text-gray-500 mb-4">
<svg
className="mx-auto h-12 w-12"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
{t("planYearList.noPlanYears", "No plan years yet")}
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-4">
{t(
"planYearList.getStarted",
"Get started by creating your first plan year"
)}
</p>
{isUnitAdmin && (
<Button
onClick={() => navigate("/performance-management/plan-years/new")}
className="bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700 text-white">
<PlusIcon className="h-4 w-4 mr-2" />
{t("planYearList.createPlanYear", "Create Plan Year")}
</Button>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{planYears.map((planYear: PlanYear) => (
<PlanYearCard
key={planYear.id}
planYear={planYear}
onEdit={handleEdit}
onDelete={handleDelete}
isUnitAdmin={isUnitAdmin}
/>
))}
</div>
)}
{/* Delete Confirmation Dialog */}
<AlertDialog
open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("planYearList.confirmDeletion", "Confirm Deletion")}
</AlertDialogTitle>
<AlertDialogDescription>
{t(
"planYearList.deleteConfirmation",
'Are you sure you want to delete the plan year "{{year}} - {{name}}"? This action cannot be undone and may affect associated plans.',
{
year: selectedPlanYear?.year,
name: localizedName(selectedPlanYear?.name),
}
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
onClick={() => {
setIsDeleteDialogOpen(false);
setSelectedPlanYear(null);
}}
disabled={deletePlanYear.isPending}>
{t("common.cancel", "Cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
disabled={deletePlanYear.isPending}
className="bg-red-600 hover:bg-red-700">
{deletePlanYear.isPending ? (
<>
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent mr-2" />
{t("planYearList.deleting", "Deleting...")}
</>
) : (
t("planYearList.delete", "Delete")
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,166 @@
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeftIcon, EditIcon, ClockIcon } from "lucide-react";
import { Button } from "@/shared/common/ui/button";
import { Card, CardContent } from "@/shared/common/ui/card";
import { Skeleton } from "@/shared/common/ui/skeleton";
import { Separator } from "@/shared/common/ui/separator";
import { useService } from "@/performance-management/hooks/useServiceHook"; // your hook
import { useLocalizedName } from "@/shared/common/localizedName";
const ServiceDetails = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const localizedName = useLocalizedName();
const { data: service, isLoading, isError, refetch } = useService(id || "");
if (isLoading) {
return (
<div className="p-6 space-y-6">
<Skeleton className="h-8 w-2/5" />
<Card>
<CardContent className="p-6 space-y-4">
<Skeleton className="h-5 w-1/4" />
<Skeleton className="h-9 w-3/5" />
<Skeleton className="h-5 w-1/4" />
<Skeleton className="h-9 w-3/5" />
</CardContent>
</Card>
<div className="flex gap-4">
<Skeleton className="h-9 w-28" />
<Skeleton className="h-9 w-28" />
</div>
</div>
);
}
if (isError || !service) {
return (
<div className="p-6 space-y-6">
<Card>
<CardContent className="p-6">
<div className="text-red-600 text-lg mb-4">
{isError ? "Error loading service details" : "Service not found"}
</div>
<div className="flex gap-4">
<Button onClick={() => refetch()} variant="outline">
Retry
</Button>
<Button
variant="ghost"
onClick={() => navigate("/performance-management/services")}
className="flex items-center gap-2">
<ArrowLeftIcon className="h-4 w-4" />
Back to List
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-2xl font-bold">Service Details</h2>
<Button
variant="ghost"
onClick={() => navigate("/performance-management/services")}
className="flex items-center gap-2">
<ArrowLeftIcon className="h-4 w-4" />
Back to List
</Button>
</div>
<Card>
<CardContent className="p-6 space-y-6">
{/* Service Name */}
<div>
<div className="text-sm text-muted-foreground mb-2">
Service Name
</div>
<div className="p-3 border rounded-md bg-muted/50">
<div className="text-base">
{localizedName(service.data.name)}
</div>
</div>
</div>
{/* Description */}
<div>
<div className="text-sm text-muted-foreground mb-2">
Description
</div>
<div className="p-3 border rounded-md bg-muted/50">
<div className="text-base">
{localizedName(service.data.description)}
</div>
</div>
</div>
{/* Category */}
<div>
<div className="text-sm text-muted-foreground mb-2">Created At</div>
<div className="p-3 border rounded-md bg-muted/50">
<div className="text-base">
{service.data.amharicCreatedAt || "N/A"}
</div>
</div>
</div>
{/* Estimated Duration and Status */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<div className="text-sm text-muted-foreground mb-2">
Estimated Duration
</div>
<div className="p-3 border rounded-md bg-muted/50">
<div className="flex items-center gap-2">
<ClockIcon className="h-4 w-4" />
<div className="text-base">
{service.data.estimatedTime}{" "}
{service.data.estimatedTimeUnit?.toLowerCase()}
</div>
</div>
</div>
</div>
</div>
<Separator />
{/* Timestamps */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<div className="text-sm text-muted-foreground mb-2">
Created At
</div>
<div className="p-3 border rounded-md bg-muted/50">
<div className="text-base">
{service.data.createdAt
? new Date(service.data.createdAt).toLocaleString()
: "N/A"}
</div>
</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-2">
Last Updated
</div>
<div className="p-3 border rounded-md bg-muted/50">
<div className="text-base">
{service.data.updatedAt
? new Date(service.data.updatedAt).toLocaleString()
: "N/A"}
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
};
export default ServiceDetails;

View File

@@ -0,0 +1,338 @@
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import DateMeasurementUnit from "@/performance-management/utils/data-measurement-unit";
import { GenericForm } from "@/performance-management/utils/generic-form";
import { FormField } from "@/performance-management/utils/shared-field-types";
import {
useService,
useServiceMutations,
useServiceList,
} from "@/performance-management/hooks/useServiceHook";
import { useLocalizedName } from "@/shared/common/localizedName";
import { useServiceCategoryList } from "@/performance-management/hooks/useServiceCategory";
import { useAuth } from "@/shared/context/AuthContext";
import { useState } from "react";
// Validation schema factory
const createServiceSchema = (t: (key: string) => string) =>
z.object({
slug: z.string().optional(),
prefix: z.string().optional(),
nameAm: z
.string()
.min(1, t("services.validation.nameAmRequired"))
.max(100, t("services.validation.nameMaxLength")),
nameEn: z
.string()
.min(1, t("services.validation.nameEnRequired"))
.max(100, t("services.validation.nameMaxLength")),
descriptionAm: z
.string()
.min(1, t("services.validation.descriptionAmRequired"))
.max(500, t("services.validation.descriptionMaxLength")),
descriptionEn: z
.string()
.min(1, t("services.validation.descriptionEnRequired"))
.max(500, t("services.validation.descriptionMaxLength")),
organizationId: z
.string()
.min(1, t("services.validation.organizationRequired")),
serviceCategoryId: z
.string()
.min(1, t("services.validation.categoryRequired")),
parentServiceId: z.string().optional(),
estimatedTime: z
.coerce.number()
.min(1, t("services.validation.estimatedTimeMin"))
.max(1440, t("services.validation.estimatedTimeMax")), // Max 24 hours in minutes
estimatedTimeUnit: z.nativeEnum(DateMeasurementUnit),
});
interface ServiceFormValues {
slug?: string;
prefix?: string;
nameAm: string;
nameEn: string;
descriptionAm: string;
descriptionEn: string;
organizationId: string;
serviceCategoryId: string;
parentServiceId?: string;
estimatedTime: number;
estimatedTimeUnit: DateMeasurementUnit;
}
export default function ServiceForm() {
const { id } = useParams<{ id: string }>(); // Get id from URL
const isEdit = !!id;
const { t } = useTranslation();
const localizedName = useLocalizedName();
const { user } = useAuth();
const [selectedCategoryId, setSelectedCategoryId] = useState<string>("");
const organizationId =
user?.employee?.length && user.employee.length > 0
? user.employee[0].organizationId
: "";
// Fetch service categories
const { data: serviceCategoriesResponse } =
useServiceCategoryList(organizationId);
// Fetch parent services based on selected category
const { data: parentServicesResponse } = useServiceList(
organizationId,
selectedCategoryId || undefined
);
// Debug: Log the response to see its structure
console.log("Service Categories Response:", serviceCategoriesResponse);
console.log(
"serviceCategoriesResponse?.data:",
serviceCategoriesResponse?.data
);
// Extract service categories from response - handle different response structures
let serviceCategories = [];
if (serviceCategoriesResponse?.data) {
// Try different possible structures
if (Array.isArray(serviceCategoriesResponse.data)) {
serviceCategories = serviceCategoriesResponse.data;
} else if (serviceCategoriesResponse.data.data) {
serviceCategories = Array.isArray(serviceCategoriesResponse.data.data)
? serviceCategoriesResponse.data.data
: [];
} else if (serviceCategoriesResponse.data.items) {
serviceCategories = Array.isArray(serviceCategoriesResponse.data.items)
? serviceCategoriesResponse.data.items
: [];
}
}
console.log("Extracted Service Categories:", serviceCategories);
// Extract parent services
let parentServices = [];
if (parentServicesResponse?.data) {
if (Array.isArray(parentServicesResponse.data)) {
parentServices = parentServicesResponse.data;
} else if (parentServicesResponse.data.data) {
parentServices = Array.isArray(parentServicesResponse.data.data)
? parentServicesResponse.data.data
: [];
} else if (parentServicesResponse.data.items) {
parentServices = Array.isArray(parentServicesResponse.data.items)
? parentServicesResponse.data.items
: [];
}
}
// Fetch single service (for edit)
const { data: serviceDetail } = useService(id ?? "");
const { create: createMutation, update: updateMutation } =
useServiceMutations();
const initialValues: ServiceFormValues = {
slug: serviceDetail?.data?.slug || "",
prefix: serviceDetail?.data?.prefix || "",
nameAm: serviceDetail?.data?.name?.am || "",
nameEn: serviceDetail?.data?.name?.en || "",
descriptionAm: serviceDetail?.data?.description?.am || "",
descriptionEn: serviceDetail?.data?.description?.en || "",
organizationId: serviceDetail?.data?.organizationId || organizationId,
serviceCategoryId: serviceDetail?.data?.serviceCategoryId || "",
parentServiceId: serviceDetail?.data?.parentServiceId || undefined,
estimatedTime: serviceDetail?.data?.estimatedTime || 0,
estimatedTimeUnit:
serviceDetail?.data?.estimatedTimeUnit || DateMeasurementUnit.MINUTES,
};
// Update selected category when service detail loads or form changes
if (serviceDetail?.data?.serviceCategoryId && !selectedCategoryId) {
setSelectedCategoryId(serviceDetail.data.serviceCategoryId);
}
const fields: FormField<ServiceFormValues>[] = [
{
name: "slug",
label: t("services.slug", "Slug"),
type: "text",
placeholder: t("services.enterSlug", "Enter slug"),
required: false,
},
{
name: "prefix",
label: t("services.prefix", "Prefix"),
type: "text",
placeholder: t("services.enterPrefix", "Enter prefix"),
required: false,
},
{
name: "serviceCategoryId",
label: t("services.serviceCategoryField", "Service Category"),
type: "select",
placeholder: t(
"services.selectServiceCategory",
"Select Service Category"
),
required: true,
options: (() => {
const options = Array.isArray(serviceCategories)
? serviceCategories.map((category) => {
console.log("Processing category:", category);
return {
value: category.id,
label: localizedName(category.name),
};
})
: [];
console.log("Generated dropdown options:", options);
return options;
})(),
onChange: (value: string) => {
setSelectedCategoryId(value);
form.setValue("parentServiceId", undefined); // Reset parent service when category changes
},
},
{
name: "parentServiceId",
label: t("services.parentService", "Parent Service (Optional)"),
type: "select",
placeholder: t(
"services.parentServiceOptional",
"Parent Service (Optional)"
),
required: false,
options: parentServices.map(
(service: { id: string; name: { en: string; am: string } }) => ({
value: service.id,
label: localizedName(service.name),
})
),
},
{
name: "nameAm",
label: t("services.nameAm", "Name (Amharic)"),
type: "text",
placeholder: t("services.nameAmPlaceholder", "Enter Amharic name"),
required: true,
},
{
name: "nameEn",
label: t("services.nameEn", "Name (English)"),
type: "text",
placeholder: t("services.nameEnPlaceholder", "Enter English name"),
required: true,
},
{
name: "descriptionAm",
label: t("services.descriptionAm", "Description (Amharic)"),
type: "textarea",
placeholder: t(
"services.descriptionAmPlaceholder",
"Enter Amharic description"
),
required: true,
},
{
name: "descriptionEn",
label: t("services.descriptionEn", "Description (English)"),
type: "textarea",
placeholder: t(
"services.descriptionEnPlaceholder",
"Enter English description"
),
required: true,
},
{
name: "estimatedTime",
label: t("services.estimatedTime", "Estimated Time"),
type: "number",
placeholder: t("services.enterEstimatedTime", "Enter estimated time"),
},
{
name: "estimatedTimeUnit",
label: t("services.timeUnit", "Time Unit"),
type: "select",
options: Object.values(DateMeasurementUnit).map((unit) => ({
value: unit,
label: t(`services.timeUnits.${unit.toLowerCase()}`, unit),
})),
},
];
const form = useForm<ServiceFormValues>({
resolver: zodResolver(createServiceSchema(t)),
defaultValues: initialValues,
values: initialValues, // Important for Edit Mode
});
const handleSubmit = (values: ServiceFormValues) => {
const dto = {
id: isEdit ? id : undefined, // Include id for updates
slug: values.slug || "",
prefix: values.prefix || "",
name: {
am: values.nameAm,
en: values.nameEn,
},
description: {
am: values.descriptionAm,
en: values.descriptionEn,
},
organizationId: values.organizationId,
serviceCategoryId: values.serviceCategoryId,
parentServiceId: values.parentServiceId || undefined,
estimatedTime: Number(values.estimatedTime) || 0,
estimatedTimeUnit: values.estimatedTimeUnit,
};
if (isEdit) {
updateMutation.mutate(dto);
} else {
createMutation.mutate(dto);
}
};
return (
<GenericForm<ServiceFormValues>
form={form}
fields={fields}
onSubmit={handleSubmit}
submitButtonText={
isEdit
? t("services.updateService", "Update Service")
: t("services.createService", "Create Service")
}
title={
isEdit
? t("services.editTitle", "Edit Service")
: t("services.createTitle", "Create Service")
}
description={
isEdit
? t("services.editDescription", "Update the service information")
: t(
"services.createDescription",
"Add a new service to your organization"
)
}
successMessage={
isEdit
? t("services.updateSuccess", "Service updated successfully")
: t("services.createSuccess", "Service created successfully")
}
errorMessage={
isEdit
? t("services.updateError", "Failed to update service")
: t("services.createError", "Failed to create service")
}
/>
);
}

View File

@@ -0,0 +1,393 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
DownloadIcon,
EditIcon,
TrashIcon,
EyeIcon,
PlusIcon,
} from "lucide-react";
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
import { IServiceResponse } from "../../types/servicetypes";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/common/ui/alert-dialog";
import { useToast } from "@/shared/common/ui/use-toast";
import { ColumnDef } from "@tanstack/react-table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/shared/common/ui/tooltip";
import { Button } from "@/shared/common/ui/button";
import { Skeleton } from "@/shared/common/ui/skeleton";
import {
useServiceList,
useServiceMutations,
} from "@/performance-management/hooks/useServiceHook";
import { useAuth } from "@/shared/context/AuthContext";
import { useLocalizedName } from "@/shared/common/localizedName";
const ServicesTable = () => {
const navigate = useNavigate();
const { t } = useTranslation();
const { toast } = useToast();
const [pageIndex, setPageIndex] = useState(0);
const [selectedService, setSelectedService] =
useState<IServiceResponse | null>(null);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const { user } = useAuth();
const organizationId =
user?.employee?.length && user.employee.length > 0
? user.employee[0].organizationId
: undefined;
const localizedName = useLocalizedName();
// ✅ SAFELY HANDLE employee ARRAY
// Use the organizationId directly from user's employee data
const { data, isLoading, isError, refetch } = useServiceList(
organizationId || ""
);
const { delete: deleteMutation } = useServiceMutations();
const handleDelete = async () => {
if (!selectedService) return;
deleteMutation.mutate(selectedService.id, {
onSuccess: () => {
toast({
title: t("services.deleteSuccess", "Deleted"),
description: t(
"services.deleteSuccessMessage",
"Service deleted successfully"
),
});
setIsDeleteDialogOpen(false);
setSelectedService(null);
refetch();
},
onError: () => {
toast({
title: t("common.error", "Error"),
description: t("services.deleteError", "Failed to delete service"),
variant: "destructive",
});
},
});
};
const services = (data?.data?.items || []).sort(
(a: IServiceResponse, b: IServiceResponse) => {
const dateA = new Date(a.createdAt).getTime();
const dateB = new Date(b.createdAt).getTime();
return dateB - dateA; // Descending order (newest first)
}
);
const columns: ColumnDef<IServiceResponse>[] = [
{
accessorKey: "name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto p-0 font-semibold"
>
{t("services.serviceName", "Service Name")}
{column.getIsSorted() === "asc"
? " ↑"
: column.getIsSorted() === "desc"
? " ↓"
: ""}
</Button>
);
},
cell: ({ row }) => localizedName(row.original.name),
size: 200,
enableSorting: true,
sortingFn: (rowA, rowB) => {
const nameA = localizedName(rowA.original.name).toLowerCase();
const nameB = localizedName(rowB.original.name).toLowerCase();
return nameA.localeCompare(nameB);
},
},
{
accessorKey: "description",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto p-0 font-semibold"
>
{t("services.description", "Description")}
{column.getIsSorted() === "asc"
? " ↑"
: column.getIsSorted() === "desc"
? " ↓"
: ""}
</Button>
);
},
cell: ({ row }) => (
<div
className="max-w-[300px] truncate"
title={localizedName(row.original.description)}
>
{localizedName(row.original.description)}
</div>
),
enableSorting: true,
sortingFn: (rowA, rowB) => {
const descA = localizedName(rowA.original.description).toLowerCase();
const descB = localizedName(rowB.original.description).toLowerCase();
return descA.localeCompare(descB);
},
},
{
accessorKey: "estimatedTime",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto p-0 font-semibold"
>
{t("services.estimatedTime", "Estimated Time")}
{column.getIsSorted() === "asc"
? " ↑"
: column.getIsSorted() === "desc"
? " ↓"
: ""}
</Button>
);
},
cell: ({ row }) =>
`${row.original.estimatedTime} ${t(
`services.timeUnits.${row.original.estimatedTimeUnit.toLowerCase()}`,
row.original.estimatedTimeUnit
)}`,
enableSorting: true,
sortingFn: (rowA, rowB) => {
return rowA.original.estimatedTime - rowB.original.estimatedTime;
},
},
{
accessorKey: "createdAt",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto p-0 font-semibold"
>
{t("services.createdAt", "Created At")}
{column.getIsSorted() === "asc"
? " ↑"
: column.getIsSorted() === "desc"
? " ↓"
: " ↓"}
</Button>
);
},
cell: ({ row }) => new Date(row.original.createdAt).toLocaleString(),
enableSorting: true,
sortingFn: (rowA, rowB) => {
const dateA = new Date(rowA.original.createdAt).getTime();
const dateB = new Date(rowB.original.createdAt).getTime();
return dateA - dateB;
},
},
{
id: "actions",
header: t("common.actions", "Actions"),
cell: ({ row }) => (
<div className="flex gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() =>
navigate(
`/performance-management/services/${row.original.id}`
)
}
>
<EyeIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{t("services.viewDetails", "View details")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() =>
navigate(
`/performance-management/services/${row.original.id}/edit`
)
}
>
<EditIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.edit", "Edit")}</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => {
setSelectedService(row.original);
setIsDeleteDialogOpen(true);
}}
disabled={deleteMutation.isPending}
>
<TrashIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.delete", "Delete")}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
),
},
];
const extraToolbar = (
<div className="flex gap-2">
<Button
onClick={() => navigate(`/performance-management/services/new`)}
className="bg-gradient-to-r from-purple-500 to-violet-600 text-white"
>
<PlusIcon className="h-4 w-4 mr-2" />
{t("services.createService", "Create Service")}
</Button>
<Button
variant="outline"
onClick={() =>
toast({
title: t("services.exporting", "Exporting"),
description: t(
"services.preparingData",
"Preparing service data..."
),
})
}
>
<DownloadIcon className="h-4 w-4 mr-2" />
{t("services.exportAll", "Export All")}
</Button>
</div>
);
// ⬇️ LOADING UI
if (isLoading)
return (
<div className="p-6 space-y-6">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
);
// ⬇️ ERROR UI
if (isError)
return (
<div className="p-6 space-y-4">
<p className="text-destructive font-semibold">
{t("services.loadError", "Failed to load services")}
</p>
<Button onClick={() => refetch()}>{t("common.retry", "Retry")}</Button>
</div>
);
return (
<div className="p-6">
<AdvancedTable<IServiceResponse, unknown>
columns={columns}
data={services}
tableName={t("services.title", "Service Management")}
extraToolbar={extraToolbar}
toolBarPosition="right"
itemCount={services.length}
pageIndex={pageIndex}
onPageChange={setPageIndex}
nextFunction={() => setPageIndex((p) => p + 1)}
prevFunction={() => setPageIndex((p) => Math.max(0, p - 1))}
refresh={refetch}
/>
{/* DELETE DIALOG */}
<AlertDialog
open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("services.deleteTitle", "Confirm Deletion")}
</AlertDialogTitle>
<AlertDialogDescription>
{t(
"services.deleteConfirmation",
'Are you sure you want to delete "{{name}}"? This action cannot be undone.',
{ name: localizedName(selectedService?.name) }
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel", "Cancel")}
</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 hover:bg-red-700"
disabled={deleteMutation.isPending}
onClick={handleDelete}
>
{deleteMutation.isPending
? t("common.deleting", "Deleting...")
: t("common.delete", "Delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
export default ServicesTable;

View File

@@ -0,0 +1,201 @@
import { useState } from "react";
import { Input } from "@/shared/common/ui/input";
import { Search } from "lucide-react";
import { OrganizationList } from "./lists/OrganizationList";
import { UnitList } from "./lists/UnitList";
import { DepartmentList } from "./lists/DepartmentList";
import { PositionServicesList } from "./lists/PositionServicesList";
import { useOrganizationsData } from "@/user-management/userManagement/hooks/useOrganizationsData";
import { useUnitsData } from "@/user-management/userManagement/hooks/useUnitsData";
import { useDepartmentsData } from "@/user-management/userManagement/hooks/useDepartmentsData";
import { useSelectionHandlers } from "@/user-management/userManagement/handlers/useSelectionHandlers";
import { Breadcrumb } from "@/user-management/userManagement/components/Breadcrumb";
import { useAuth } from "@/shared/context/AuthContext";
import { useLocalizedName } from "@/shared/common/localizedName";
import { t } from "i18next";
import { AssignServiceDialog } from "./dialogs/AssignServiceDialog";
const ServiceAssignmentPage = () => {
const [searchQuery, setSearchQuery] = useState("");
const [unitSearchQuery, setUnitSearchQuery] = useState("");
const [departmentSearchQuery, setDepartmentSearchQuery] = useState("");
const { user } = useAuth();
const localizedName = useLocalizedName();
// Dialog state for service assignment
const [isAssignDialogOpen, setIsAssignDialogOpen] = useState(false);
const [selectedPositionId, setSelectedPositionId] = useState<string>("");
const [selectedPositionName, setSelectedPositionName] = useState<string>("");
// Get organization ID from user
const organizationId =
user?.employee && user.employee.length > 0
? user.employee[0].organizationId
: undefined;
// Hooks for data loading
const { organizations, isLoading: orgsLoading } = useOrganizationsData({
currentOrgId: organizationId,
});
const {
selectedOrgId,
selectedUnitId,
selectedDepartmentId,
breadcrumb,
handleSelectOrganization,
handleSelectUnit,
handleSelectDepartment,
} = useSelectionHandlers(organizations, localizedName);
const [take] = useState(100);
const [skip] = useState(0);
const {
units,
isLoading: unitsLoading,
setUnits,
} = useUnitsData(selectedOrgId, { take, skip });
const {
departments,
isLoading: deptsLoading,
setDepartments,
positions,
} = useDepartmentsData(selectedUnitId);
// Handle assign service action
const handleAssignService = (positionId: string, positionName: string) => {
setSelectedPositionId(positionId);
setSelectedPositionName(positionName);
setIsAssignDialogOpen(true);
};
return (
<div className="h-full flex flex-col">
<div className="p-4 border-b">
<div className="flex justify-between items-center mb-4">
<h1 className="text-xl font-semibold text-gray-800">
{t("organization.serviceAssignment", "Service Assignment")}
</h1>
</div>
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-gray-500" />
<Input
placeholder="Search organizations, units, departments..."
className="pl-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
<div className="mt-4">
<Breadcrumb breadcrumb={breadcrumb} hasTeamMembers={false} />
</div>
</div>
{/* Position Services List */}
<div className="px-4 pb-4">
<PositionServicesList positionId={selectedDepartmentId} />
</div>
{/* Desktop view - grid layout */}
<div className="flex-1 p-4 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="border rounded-md p-3 bg-white shadow-sm">
<h3 className="font-medium text-gray-700 mb-3 border-b pb-2">
{t("organization.organizations")}
</h3>
<OrganizationList
selectedOrgId={selectedOrgId}
organizations={organizations}
isLoading={orgsLoading}
searchQuery={searchQuery}
onSelectOrganization={(id: string) => {
setUnits([]);
setDepartments([]);
handleSelectOrganization(id);
}}
/>
</div>
<div className="border rounded-md p-3 bg-white shadow-sm">
<h3 className="font-medium text-gray-700 mb-3 border-b pb-2">
{t("contentManagement.Units")}
</h3>
<div className="mb-3">
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-gray-500" />
<Input
placeholder={t("search.searchUnits", "Search units...")}
className="pl-8 text-sm"
value={unitSearchQuery}
onChange={(e) => setUnitSearchQuery(e.target.value)}
/>
</div>
</div>
<UnitList
organizationId={selectedOrgId}
selectedUnitId={selectedUnitId}
onSelectUnit={(id: string) => {
setDepartments([]);
handleSelectUnit(id, units);
}}
units={units}
isLoading={unitsLoading}
searchQuery={unitSearchQuery}
/>
</div>
<div className="border rounded-md p-3 bg-white shadow-sm">
<h3 className="font-medium text-gray-700 mb-3 border-b pb-2">
{t("userIncoming.Departments")}
</h3>
<div className="mb-3">
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-gray-500" />
<Input
placeholder={t(
"search.searchDepartments",
"Search departments..."
)}
className="pl-8 text-sm"
value={departmentSearchQuery}
onChange={(e) => setDepartmentSearchQuery(e.target.value)}
/>
</div>
</div>
<DepartmentList
unitId={selectedUnitId}
selectedDepartmentId={selectedDepartmentId}
onSelectDepartment={(id: string) => {
handleSelectDepartment(id, departments);
}}
departments={departments}
positions={positions}
isLoading={deptsLoading}
searchQuery={departmentSearchQuery}
onAssignService={handleAssignService}
/>
</div>
</div>
{/* Assign Service Dialog */}
<AssignServiceDialog
isOpen={isAssignDialogOpen}
onClose={() => {
setIsAssignDialogOpen(false);
setSelectedPositionId("");
setSelectedPositionName("");
}}
positionId={selectedPositionId}
positionName={selectedPositionName}
organizationId={selectedOrgId}
/>
</div>
);
};
export default ServiceAssignmentPage;

View File

@@ -0,0 +1,595 @@
import { useState, useEffect, useCallback } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/common/ui/dialog";
import { Button } from "@/shared/common/ui/button";
import { Checkbox } from "@/shared/common/ui/checkbox";
import { Label } from "@/shared/common/ui/label";
import { Input } from "@/shared/common/ui/input";
import {
Search,
Loader2,
ChevronRight,
ChevronDown,
Folder,
FileText,
} from "lucide-react";
import { useToast } from "@/shared/common/ui/use-toast";
import { t } from "i18next";
import { ScrollArea } from "@/shared/common/ui/scroll-area";
import {
assignServicesToPosition,
getAssignedServices,
} from "@/performance-management/services/api/serviceAssignmentService";
import { useLocalizedName } from "@/shared/common/localizedName";
import { useServiceCategoryList } from "@/performance-management/hooks/useServiceCategory";
import { useServiceList } from "@/performance-management/hooks/useServiceHook";
interface AssignServiceDialogProps {
isOpen: boolean;
onClose: () => void;
positionId: string;
positionName: string;
organizationId: string;
}
interface ServiceCategory {
id: string;
name: Record<string, string>;
description?: Record<string, string>;
}
interface Service {
id: string;
name: Record<string, string>;
description?: Record<string, string>;
serviceCategoryId?: string;
parentServiceId?: string;
childServices?: Service[];
}
export const AssignServiceDialog = ({
isOpen,
onClose,
positionId,
positionName,
organizationId,
}: AssignServiceDialogProps) => {
const { toast } = useToast();
const localizedName = useLocalizedName();
const [selectedServiceIds, setSelectedServiceIds] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
new Set()
);
const [expandedServices, setExpandedServices] = useState<Set<string>>(
new Set()
);
const [alreadyAssignedServiceIds, setAlreadyAssignedServiceIds] = useState<
string[]
>([]);
// Fetch service categories
const { data: serviceCategoriesResponse, isLoading: categoriesLoading } =
useServiceCategoryList(organizationId);
// Fetch all services (without category filter for search)
const { data: allServicesResponse } = useServiceList(organizationId);
// Helper function to safely render text that might be localized
const renderText = (
text: Record<string, string> | string | undefined
): string => {
if (!text) return "";
if (typeof text === "string") return text;
if (typeof text === "object") {
const textObj = text as Record<string, string>;
if (textObj.en || textObj.am) {
return localizedName({ am: textObj.am || "", en: textObj.en || "" });
}
}
return String(text);
};
// Extract service categories
let serviceCategories: ServiceCategory[] = [];
if (serviceCategoriesResponse?.data) {
if (Array.isArray(serviceCategoriesResponse.data)) {
serviceCategories = serviceCategoriesResponse.data;
} else if (serviceCategoriesResponse.data.data) {
serviceCategories = Array.isArray(serviceCategoriesResponse.data.data)
? serviceCategoriesResponse.data.data
: [];
} else if (serviceCategoriesResponse.data.items) {
serviceCategories = Array.isArray(serviceCategoriesResponse.data.items)
? serviceCategoriesResponse.data.items
: [];
}
}
// Extract all services
let allServices: Service[] = [];
if (allServicesResponse?.data) {
if (Array.isArray(allServicesResponse.data)) {
allServices = allServicesResponse.data;
} else if (allServicesResponse.data.data) {
allServices = Array.isArray(allServicesResponse.data.data)
? allServicesResponse.data.data
: [];
} else if (allServicesResponse.data.items) {
allServices = Array.isArray(allServicesResponse.data.items)
? allServicesResponse.data.items
: [];
}
}
// Build parent-child relationships
const servicesWithChildren = allServices.map((service) => {
const childServices = allServices.filter(
(s) => s.parentServiceId === service.id
);
return {
...service,
childServices: childServices.length > 0 ? childServices : undefined,
};
});
// Fetch assigned service when dialog opens
const fetchAssignedService = useCallback(async () => {
try {
const response = await getAssignedServices(positionId);
let assignedServices = response.data?.data || response.data || [];
if (!Array.isArray(assignedServices)) {
assignedServices = assignedServices.items || [];
}
if (Array.isArray(assignedServices) && assignedServices.length > 0) {
const serviceIds = assignedServices
.map(
(service: { id?: string; serviceId?: string; firstId?: string }) =>
service.id || service.serviceId || service.firstId
)
.filter((id: string | undefined): id is string => !!id);
setSelectedServiceIds(serviceIds);
setAlreadyAssignedServiceIds(serviceIds);
// Auto-expand categories containing the selected services
serviceIds.forEach((serviceId: string) => {
const service = allServices.find((s) => s.id === serviceId);
if (service?.serviceCategoryId) {
setExpandedCategories((prev) =>
new Set(prev).add(service.serviceCategoryId!)
);
}
// Auto-expand parent services if this is a child service
if (service?.parentServiceId) {
setExpandedServices((prev) =>
new Set(prev).add(service.parentServiceId!)
);
}
});
} else {
setSelectedServiceIds([]);
setAlreadyAssignedServiceIds([]);
}
} catch (error) {
console.error("Failed to fetch assigned service:", error);
setSelectedServiceIds([]);
setAlreadyAssignedServiceIds([]);
}
}, [positionId, allServices]);
useEffect(() => {
if (isOpen && positionId) {
fetchAssignedService();
}
}, [isOpen, positionId, fetchAssignedService]);
const handleToggleService = (serviceId: string) => {
// Check if service is already assigned
if (alreadyAssignedServiceIds.includes(serviceId)) {
toast({
title: "Warning",
description: "Service already assigned to this position",
variant: "destructive",
});
return;
}
setSelectedServiceIds((prev) => {
if (prev.includes(serviceId)) {
return prev.filter((id) => id !== serviceId);
} else {
return [...prev, serviceId];
}
});
};
const handleSave = async () => {
// Filter out already assigned services
const newServiceIds = selectedServiceIds.filter(
(id) => !alreadyAssignedServiceIds.includes(id)
);
if (newServiceIds.length === 0) {
toast({
title: "Warning",
description: "Please select at least one new service to assign",
variant: "destructive",
});
return;
}
setIsSaving(true);
try {
await assignServicesToPosition(positionId, newServiceIds);
toast({
title: "Success",
description: `${newServiceIds.length} service(s) assigned to ${positionName} successfully`,
variant: "default",
});
onClose();
} catch (error: unknown) {
console.error("Failed to assign service:", error);
const errorMessage =
(
error as {
response?: { data?: { message?: string } };
message?: string;
}
).response?.data?.message ||
(error as { message?: string }).message ||
"Failed to assign service";
toast({
title: "Error",
description: errorMessage,
variant: "destructive",
});
} finally {
setIsSaving(false);
}
};
const handleClearSelection = () => {
setSelectedServiceIds([]);
};
const toggleCategory = (categoryId: string) => {
setExpandedCategories((prev) => {
const newSet = new Set(prev);
if (newSet.has(categoryId)) {
newSet.delete(categoryId);
} else {
newSet.add(categoryId);
}
return newSet;
});
};
const toggleService = (serviceId: string) => {
setExpandedServices((prev) => {
const newSet = new Set(prev);
if (newSet.has(serviceId)) {
newSet.delete(serviceId);
} else {
newSet.add(serviceId);
}
return newSet;
});
};
// Group services by category (only parent services)
const parentServicesByCategory = servicesWithChildren
.filter((service) => !service.parentServiceId)
.reduce((acc, service) => {
const categoryId = service.serviceCategoryId || "uncategorized";
if (!acc[categoryId]) {
acc[categoryId] = [];
}
acc[categoryId].push(service);
return acc;
}, {} as Record<string, Service[]>);
// Filter categories and services based on search
const filteredData = searchQuery
? {
categories: serviceCategories.filter((cat) =>
renderText(cat.name).toLowerCase().includes(searchQuery.toLowerCase())
),
services: servicesWithChildren.filter((service) => {
const searchLower = searchQuery.toLowerCase();
const serviceName = renderText(service.name).toLowerCase();
const serviceDesc = renderText(service.description).toLowerCase();
return (
serviceName.includes(searchLower) ||
serviceDesc.includes(searchLower)
);
}),
}
: { categories: serviceCategories, services: servicesWithChildren };
// Auto-expand categories when searching
useEffect(() => {
if (searchQuery && filteredData.services.length > 0) {
const categoriesToExpand = new Set<string>();
const servicesToExpand = new Set<string>();
filteredData.services.forEach((service) => {
if (service.serviceCategoryId) {
categoriesToExpand.add(service.serviceCategoryId);
}
// If it's a child service, expand its parent
if (service.parentServiceId) {
servicesToExpand.add(service.parentServiceId);
}
});
setExpandedCategories(categoriesToExpand);
setExpandedServices(servicesToExpand);
}
}, [searchQuery, filteredData.services]);
const renderService = (service: Service, isChild: boolean = false) => {
const isExpanded = expandedServices.has(service.id);
const hasChildren =
service.childServices && service.childServices.length > 0;
const isAlreadyAssigned = alreadyAssignedServiceIds.includes(service.id);
return (
<div key={service.id} className="space-y-1">
<div
className={`flex items-start gap-3 p-3 rounded-lg border transition-colors ${
isChild ? "ml-6" : ""
} ${
selectedServiceIds.includes(service.id)
? "border-purple-500 bg-purple-50"
: isAlreadyAssigned
? "border-gray-300 bg-gray-50 opacity-60"
: "border-gray-200 hover:bg-gray-50"
} ${hasChildren ? "cursor-pointer" : ""}`}
>
{hasChildren && (
<div
onClick={() => toggleService(service.id)}
className="cursor-pointer pt-1"
>
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-gray-500" />
) : (
<ChevronRight className="h-4 w-4 text-gray-500" />
)}
</div>
)}
<Checkbox
checked={selectedServiceIds.includes(service.id)}
onCheckedChange={() => handleToggleService(service.id)}
id={`service-${service.id}`}
className="mt-1"
disabled={isAlreadyAssigned}
/>
<FileText className="h-4 w-4 text-blue-500 mt-1" />
<div
className="flex-1"
onClick={() =>
!isAlreadyAssigned && handleToggleService(service.id)
}
>
<Label
htmlFor={`service-${service.id}`}
className={`cursor-pointer block ${
isAlreadyAssigned ? "cursor-not-allowed" : ""
}`}
>
<div className="font-medium text-gray-900">
{renderText(service.name)}
{isAlreadyAssigned && (
<span className="ml-2 text-xs text-gray-500">
(Already Assigned)
</span>
)}
</div>
{service.description && (
<div className="text-sm text-gray-600 mt-1 line-clamp-2">
{renderText(service.description)}
</div>
)}
</Label>
</div>
</div>
{/* Render child services */}
{hasChildren && isExpanded && (
<div className="ml-6 space-y-2 mt-1">
{service.childServices!.map((childService) =>
renderService(childService, true)
)}
</div>
)}
</div>
);
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>{t("assignServices", "Assign Services")}</DialogTitle>
<DialogDescription>
{t(
"assignServicesDescription",
`Select one or more services to assign to position: ${positionName}`
)}
</DialogDescription>
</DialogHeader>
<div className="flex-1 flex flex-col min-h-0 gap-4">
{/* Search */}
<div className="relative shrink-0">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-gray-500" />
<Input
placeholder={t(
"searchServices",
"Search services or categories..."
)}
className="pl-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{/* Tree View */}
<ScrollArea className="flex-1 border rounded-md p-2 min-h-0">
<div className="max-h-[400px]">
{categoriesLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-purple-600" />
</div>
) : filteredData.categories.length === 0 &&
filteredData.services.length === 0 ? (
<div className="text-center py-8 text-gray-500">
{t("noServicesFound", "No services found")}
</div>
) : (
<div>
<div className="space-y-1 pr-3">
{filteredData.categories.map((category) => {
const categoryServices = searchQuery
? filteredData.services.filter(
(s) =>
s.serviceCategoryId === category.id &&
!s.parentServiceId
)
: parentServicesByCategory[category.id] || [];
const isExpanded = expandedCategories.has(category.id);
if (searchQuery && categoryServices.length === 0) {
return null;
}
return (
<div key={category.id} className="space-y-1">
{/* Category Header */}
<div
className="flex items-center gap-2 p-2 rounded-md hover:bg-gray-100 cursor-pointer"
onClick={() => toggleCategory(category.id)}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-gray-500" />
) : (
<ChevronRight className="h-4 w-4 text-gray-500" />
)}
<Folder className="h-4 w-4 text-purple-500" />
<span className="font-medium text-gray-900">
{renderText(category.name)}
</span>
<span className="text-xs text-gray-500 ml-auto">
({categoryServices.length})
</span>
</div>
{/* Services under this category */}
{isExpanded && categoryServices.length > 0 && (
<div className="ml-6 space-y-2 mt-1">
{categoryServices.map((service) =>
renderService(service)
)}
</div>
)}
</div>
);
})}
{/* Uncategorized services */}
{parentServicesByCategory["uncategorized"] &&
parentServicesByCategory["uncategorized"].length > 0 && (
<div className="space-y-1">
<div
className="flex items-center gap-2 p-2 rounded-md hover:bg-gray-100 cursor-pointer"
onClick={() => toggleCategory("uncategorized")}
>
{expandedCategories.has("uncategorized") ? (
<ChevronDown className="h-4 w-4 text-gray-500" />
) : (
<ChevronRight className="h-4 w-4 text-gray-500" />
)}
<Folder className="h-4 w-4 text-gray-400" />
<span className="font-medium text-gray-700">
Uncategorized
</span>
<span className="text-xs text-gray-500 ml-auto">
(
{parentServicesByCategory["uncategorized"].length}
)
</span>
</div>
{expandedCategories.has("uncategorized") && (
<div className="ml-6 space-y-2 mt-1">
{parentServicesByCategory["uncategorized"].map(
(service) => renderService(service)
)}
</div>
)}
</div>
)}
</div>
</div>
)}
</div>
</ScrollArea>
{/* Selected service info */}
<div className="flex items-center justify-between text-sm shrink-0">
<div className="text-gray-600">
{selectedServiceIds.length > 0
? t(
"servicesSelected",
`${selectedServiceIds.length} service(s) selected`
)
: t("noServiceSelected", "No services selected")}
</div>
{selectedServiceIds.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={handleClearSelection}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
{t("clearSelection", "Clear Selection")}
</Button>
)}
</div>
</div>
<DialogFooter className="shrink-0">
<Button variant="outline" onClick={onClose} disabled={isSaving}>
{t("common.cancel", "Cancel")}
</Button>
<Button
onClick={handleSave}
disabled={isSaving || selectedServiceIds.length === 0}
className="bg-purple-600 hover:bg-purple-700"
>
{isSaving ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{t("saving", "Saving...")}
</>
) : (
t("assignServices", "Assign Services")
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,192 @@
import {
ChevronDown,
ChevronRight,
Briefcase,
MoreVertical,
} from "lucide-react";
import { Skeleton } from "@/shared/common/ui/skeleton";
import { useLocalizedName } from "@/shared/common/localizedName";
import { useState } from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/common/ui/dropdown-menu";
import { Button } from "@/shared/common/ui/button";
import { t } from "i18next";
import { PositionDto } from "@/user-management/dto/positions/positionDto";
interface DepartmentListProps {
unitId: string;
selectedDepartmentId: string;
onSelectDepartment: (id: string) => void;
departments: any[];
positions: PositionDto[];
isLoading: boolean;
searchQuery: string;
onAssignService?: (positionId: string, positionName: string) => void;
}
export const DepartmentList = ({
unitId,
selectedDepartmentId,
onSelectDepartment,
departments,
positions,
isLoading,
searchQuery,
onAssignService,
}: DepartmentListProps) => {
const localizedName = useLocalizedName();
const [expandedPositions, setExpandedPositions] = useState<Set<string>>(
new Set()
);
const togglePosition = (positionId: string) => {
setExpandedPositions((prev) => {
const newSet = new Set(prev);
if (newSet.has(positionId)) {
newSet.delete(positionId);
} else {
newSet.add(positionId);
}
return newSet;
});
};
// Recursive function to render position hierarchy
const renderPosition = (position: PositionDto, level: number = 0) => {
const hasSubPositions =
position.subPositions && position.subPositions.length > 0;
const isExpanded = expandedPositions.has(position.id);
return (
<div key={position.id} className="space-y-1">
<div
className={`flex items-center gap-2 p-2 rounded-lg border transition-colors ${
selectedDepartmentId === position.id
? "bg-purple-50 border-purple-500"
: "bg-white border-gray-200 hover:bg-gray-50"
}`}
style={{ marginLeft: `${level * 16}px` }}
>
{hasSubPositions ? (
<button
onClick={() => togglePosition(position.id)}
className="p-1 hover:bg-gray-200 rounded flex-shrink-0"
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
) : (
<div className="w-6 flex-shrink-0" />
)}
<button
onClick={() => onSelectDepartment(position.id)}
className="flex-1 flex items-center gap-2 text-left min-w-0"
>
<Briefcase className="h-4 w-4 flex-shrink-0" />
<span className="font-medium truncate">
{localizedName(position.name)}
</span>
</button>
{hasSubPositions && (
<span className="text-xs text-gray-500 px-2 flex-shrink-0">
{position.subPositions.length}
</span>
)}
{onAssignService && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 flex-shrink-0"
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
onAssignService(position.id, localizedName(position.name))
}
>
{t("assignService", "Assign Service")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{/* Render sub-positions recursively */}
{isExpanded && hasSubPositions && (
<div className="space-y-1">
{position.subPositions.map((subPosition) =>
renderPosition(subPosition, level + 1)
)}
</div>
)}
</div>
);
};
if (!unitId) {
return (
<div className="text-center py-8 text-gray-500">
{t("selectUnitFirst", "Select a unit first")}
</div>
);
}
if (isLoading) {
return (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
);
}
// Filter positions based on search query
const filterPositions = (positions: PositionDto[]): PositionDto[] => {
if (!searchQuery) return positions;
return positions.filter((position) => {
const matchesSearch = localizedName(position.name)
.toLowerCase()
.includes(searchQuery.toLowerCase());
// Also check sub-positions
const hasMatchingSubPosition =
position.subPositions &&
filterPositions(position.subPositions).length > 0;
return matchesSearch || hasMatchingSubPosition;
});
};
const filteredPositions = filterPositions(positions);
if (filteredPositions.length === 0) {
return (
<div className="text-center py-8 text-gray-500">
{t("noPositionsFound", "No positions found")}
</div>
);
}
return (
<div className="space-y-2">
{filteredPositions.map((position) => renderPosition(position))}
</div>
);
};

View File

@@ -0,0 +1,72 @@
import { Building2 } from "lucide-react";
import { Skeleton } from "@/shared/common/ui/skeleton";
import { useLocalizedName } from "@/shared/common/localizedName";
interface Organization {
id: string;
name: {
en: string;
am: string;
};
}
interface OrganizationListProps {
selectedOrgId: string;
organizations: Organization[];
isLoading: boolean;
searchQuery: string;
onSelectOrganization: (id: string) => void;
}
export const OrganizationList = ({
selectedOrgId,
organizations,
isLoading,
searchQuery,
onSelectOrganization,
}: OrganizationListProps) => {
const localizedName = useLocalizedName();
const filteredOrganizations = organizations.filter((org) =>
localizedName(org.name).toLowerCase().includes(searchQuery.toLowerCase())
);
if (isLoading) {
return (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
);
}
if (filteredOrganizations.length === 0) {
return (
<div className="text-center py-8 text-gray-500">
No organizations found
</div>
);
}
return (
<div className="space-y-2">
{filteredOrganizations.map((org) => (
<button
key={org.id}
onClick={() => onSelectOrganization(org.id)}
className={`w-full text-left p-3 rounded-lg border transition-colors ${
selectedOrgId === org.id
? "bg-purple-50 border-purple-500 text-purple-700"
: "bg-white border-gray-200 hover:bg-gray-50"
}`}
>
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4" />
<span className="font-medium">{localizedName(org.name)}</span>
</div>
</button>
))}
</div>
);
};

View File

@@ -0,0 +1,360 @@
import React, { useEffect, useState } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/common/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/common/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/common/ui/alert-dialog";
import { Button } from "@/shared/common/ui/button";
import {
MoreVertical,
Clock,
ChevronDown,
ChevronRight,
Trash2,
} from "lucide-react";
import {
getPositionServices,
deletePositionService,
} from "@/performance-management/services/api/serviceAssignmentService";
import { useLocalizedName } from "@/shared/common/localizedName";
import { Skeleton } from "@/shared/common/ui/skeleton";
import { useToast } from "@/shared/common/ui/use-toast";
import { CreateWorkingHoursModal } from "@/performance-management/components/WorkingHours/CreateWorkingHoursModal";
import { EditWorkingHoursModal } from "@/performance-management/components/WorkingHours/EditWorkingHoursModal";
import { WorkingDaysTable } from "@/performance-management/components/WorkingHours/WorkingDaysTable";
import { useWorkingHoursMutations } from "@/performance-management/hooks/useWorkingHoursHook";
import { WorkingHoursResponse } from "@/performance-management/types/workingHoursTypes";
import { t } from "i18next";
interface PositionServicesListProps {
positionId: string;
}
export const PositionServicesList = ({
positionId,
}: PositionServicesListProps) => {
const [services, setServices] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isWorkingHoursModalOpen, setIsWorkingHoursModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [selectedPositionServiceId, setSelectedPositionServiceId] =
useState<string>("");
const [editingWorkingDay, setEditingWorkingDay] =
useState<WorkingHoursResponse | null>(null);
const [expandedServiceId, setExpandedServiceId] = useState<string | null>(
null
);
const [refreshTrigger, setRefreshTrigger] = useState(0);
const [deletingServiceId, setDeletingServiceId] = useState<string | null>(
null
);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isDeletingService, setIsDeletingService] = useState(false);
const localizedName = useLocalizedName();
const { toast } = useToast();
const { delete: deleteWorkingHours } = useWorkingHoursMutations();
const handleCreateWorkingHours = (positionServiceId: string) => {
setSelectedPositionServiceId(positionServiceId);
setIsWorkingHoursModalOpen(true);
};
const handleWorkingHoursSuccess = () => {
setIsWorkingHoursModalOpen(false);
setSelectedPositionServiceId("");
setRefreshTrigger((prev) => prev + 1);
};
const handleEditWorkingDay = (workingDay: WorkingHoursResponse) => {
setEditingWorkingDay(workingDay);
setIsEditModalOpen(true);
};
const handleEditSuccess = () => {
setIsEditModalOpen(false);
setEditingWorkingDay(null);
setRefreshTrigger((prev) => prev + 1);
};
const handleDeleteWorkingDay = async (id: string) => {
deleteWorkingHours.mutate(id, {
onSuccess: () => {
toast({
title: "Success",
description: "Working hours deleted successfully",
});
setRefreshTrigger((prev) => prev + 1);
},
onError: (error: any) => {
toast({
title: "Error",
description:
error?.response?.data?.message || "Failed to delete working hours",
variant: "destructive",
});
},
});
};
const toggleExpand = (serviceId: string) => {
setExpandedServiceId(expandedServiceId === serviceId ? null : serviceId);
};
const handleDeleteService = (positionServiceId: string) => {
setDeletingServiceId(positionServiceId);
setIsDeleteDialogOpen(true);
};
const confirmDeleteService = async () => {
if (!deletingServiceId) return;
setIsDeletingService(true);
try {
await deletePositionService(deletingServiceId);
toast({
title: "Success",
description: "Service removed from position successfully",
});
// Refresh the services list
const response = await getPositionServices(positionId);
const data = response.data;
if (data && Array.isArray(data.items)) {
setServices(data.items);
} else if (Array.isArray(data)) {
setServices(data);
} else {
setServices([]);
}
setIsDeleteDialogOpen(false);
setDeletingServiceId(null);
} catch (error: any) {
toast({
title: "Error",
description:
error?.response?.data?.message || "Failed to remove service",
variant: "destructive",
});
} finally {
setIsDeletingService(false);
}
};
useEffect(() => {
const fetchServices = async () => {
if (!positionId) {
setServices([]);
return;
}
setIsLoading(true);
try {
const response = await getPositionServices(positionId);
// Handle both array and single object response
const data = response.data;
if (data && Array.isArray(data.items)) {
setServices(data.items);
} else if (Array.isArray(data)) {
setServices(data);
} else if (data) {
setServices([data]);
} else {
setServices([]);
}
} catch (error) {
console.error("Failed to fetch position services", error);
setServices([]);
} finally {
setIsLoading(false);
}
};
fetchServices();
}, [positionId]);
if (!positionId) {
return null;
}
if (isLoading) {
return (
<div className="space-y-2 border rounded-md p-4 bg-white mt-4">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
);
}
if (services.length === 0) {
return (
<div className="text-center py-8 text-gray-500 border rounded-md bg-white mt-4">
{t("noServicesFound", "No services found for this position")}
</div>
);
}
return (
<div className="border rounded-md bg-white overflow-hidden mt-4 shadow-sm">
<div className="p-3 border-b bg-gray-50">
<h3 className="font-medium text-gray-700">
{t("associatedServices", "Associated Services")}
</h3>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12"></TableHead>
<TableHead>{t("name", "Name")}</TableHead>
<TableHead>{t("slug", "Slug")}</TableHead>
<TableHead>{t("description", "Description")}</TableHead>
<TableHead className="text-right">
{t("actions", "Actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{services.map((positionService) => (
<React.Fragment key={positionService.id}>
<TableRow className="cursor-pointer hover:bg-gray-50">
<TableCell onClick={() => toggleExpand(positionService.id)}>
<Button variant="ghost" size="sm" className="p-0 h-6 w-6">
{expandedServiceId === positionService.id ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
</TableCell>
<TableCell
className="font-medium"
onClick={() => toggleExpand(positionService.id)}
>
{localizedName(positionService.service?.name)}
</TableCell>
<TableCell onClick={() => toggleExpand(positionService.id)}>
{positionService.service?.slug}
</TableCell>
<TableCell onClick={() => toggleExpand(positionService.id)}>
{localizedName(positionService.service?.description)}
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
handleCreateWorkingHours(positionService.id)
}
>
<Clock className="h-4 w-4 mr-2" />
{t("createWorkingHours", "Create Working Hours")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDeleteService(positionService.id)}
className="text-red-600 focus:text-red-600"
>
<Trash2 className="h-4 w-4 mr-2" />
{t("removeService", "Remove Service")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
{expandedServiceId === positionService.id && (
<TableRow>
<TableCell colSpan={5} className="bg-gray-50 p-4">
<div className="space-y-2">
<h4 className="font-semibold text-sm text-gray-700">
{t("workingDays", "Working Days")}
</h4>
<WorkingDaysTable
positionServiceId={positionService.id}
onEdit={handleEditWorkingDay}
onDelete={handleDeleteWorkingDay}
refreshTrigger={refreshTrigger}
/>
</div>
</TableCell>
</TableRow>
)}
</React.Fragment>
))}
</TableBody>
</Table>
{/* Create Working Hours Modal */}
<CreateWorkingHoursModal
open={isWorkingHoursModalOpen}
onClose={() => {
setIsWorkingHoursModalOpen(false);
setSelectedPositionServiceId("");
}}
onSuccess={handleWorkingHoursSuccess}
positionServiceId={selectedPositionServiceId}
/>
{/* Edit Working Hours Modal */}
<EditWorkingHoursModal
open={isEditModalOpen}
onClose={() => {
setIsEditModalOpen(false);
setEditingWorkingDay(null);
}}
onSuccess={handleEditSuccess}
workingDay={editingWorkingDay}
/>
{/* Delete Service Confirmation Dialog */}
<AlertDialog
open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove Service from Position</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to remove this service from the position?
This will also delete all associated working hours. This action
cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeletingService}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDeleteService}
disabled={isDeletingService}
className="bg-red-600 hover:bg-red-700"
>
{isDeletingService ? "Removing..." : "Remove"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};

View File

@@ -0,0 +1,78 @@
import { Building } from "lucide-react";
import { Skeleton } from "@/shared/common/ui/skeleton";
import { useLocalizedName } from "@/shared/common/localizedName";
interface Unit {
id: string;
name: {
en: string;
am: string;
};
}
interface UnitListProps {
organizationId: string;
selectedUnitId: string;
onSelectUnit: (id: string) => void;
units: Unit[];
isLoading: boolean;
searchQuery: string;
}
export const UnitList = ({
organizationId,
selectedUnitId,
onSelectUnit,
units,
isLoading,
searchQuery,
}: UnitListProps) => {
const localizedName = useLocalizedName();
const filteredUnits = units.filter((unit) =>
localizedName(unit.name).toLowerCase().includes(searchQuery.toLowerCase())
);
if (!organizationId) {
return (
<div className="text-center py-8 text-gray-500">
Select an organization first
</div>
);
}
if (isLoading) {
return (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
);
}
if (filteredUnits.length === 0) {
return <div className="text-center py-8 text-gray-500">No units found</div>;
}
return (
<div className="space-y-2">
{filteredUnits.map((unit) => (
<button
key={unit.id}
onClick={() => onSelectUnit(unit.id)}
className={`w-full text-left p-3 rounded-lg border transition-colors ${
selectedUnitId === unit.id
? "bg-purple-50 border-purple-500 text-purple-700"
: "bg-white border-gray-200 hover:bg-gray-50"
}`}
>
<div className="flex items-center gap-2">
<Building className="h-4 w-4" />
<span className="font-medium">{localizedName(unit.name)}</span>
</div>
</button>
))}
</div>
);
};

View File

@@ -0,0 +1,195 @@
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useTranslation } from "react-i18next";
import { useAuth } from "@/shared/context/AuthContext";
import { GenericForm } from "@/performance-management/utils/generic-form";
import { FormField } from "@/performance-management/utils/shared-field-types";
import { useServiceCategoryMutations } from "@/performance-management/hooks/useServiceCategory";
import {
CreateServiceCategoryRequest,
ServiceCategory,
} from "@/performance-management/services/api/serviceCategoryService";
import { useNavigate } from "react-router-dom";
import { useEffect } from "react";
// Validation schema factory
const createServiceCategorySchema = (t: (key: string) => string) =>
z.object({
slug: z.string().optional(),
nameEn: z
.string()
.min(1, t("serviceCategory.validation.nameEnRequired"))
.max(100, t("serviceCategory.validation.nameMaxLength")),
nameAm: z
.string()
.min(1, t("serviceCategory.validation.nameAmRequired"))
.max(100, t("serviceCategory.validation.nameMaxLength")),
descriptionEn: z
.string()
.min(1, t("serviceCategory.validation.descriptionEnRequired"))
.max(500, t("serviceCategory.validation.descriptionMaxLength")),
descriptionAm: z
.string()
.min(1, t("serviceCategory.validation.descriptionAmRequired"))
.max(500, t("serviceCategory.validation.descriptionMaxLength")),
});
interface ServiceCategoryFormValues {
slug?: string;
nameEn: string;
nameAm: string;
descriptionEn: string;
descriptionAm: string;
}
interface ServiceCategoryFormProps {
category?: ServiceCategory;
onSuccess?: () => void;
onCancel?: () => void;
}
export const ServiceCategoryForm = ({
category,
onSuccess,
onCancel,
}: ServiceCategoryFormProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { user } = useAuth();
const { create, update } = useServiceCategoryMutations();
const organizationId = user?.employee?.[0]?.organizationId;
const isEditing = !!category;
const form = useForm<ServiceCategoryFormValues>({
resolver: zodResolver(createServiceCategorySchema(t)),
defaultValues: {
slug: category?.slug || "",
nameEn: category?.name?.en || "",
nameAm: category?.name?.am || "",
descriptionEn: category?.description?.en || "",
descriptionAm: category?.description?.am || "",
},
});
// Reset form when category changes
useEffect(() => {
if (category) {
form.reset({
slug: category.slug,
nameEn: category.name?.en || "",
nameAm: category.name?.am || "",
descriptionEn: category.description?.en || "",
descriptionAm: category.description?.am || "",
});
}
}, [category, form]);
const serviceCategoryFormFields: FormField<ServiceCategoryFormValues>[] = [
{
name: "slug",
label: t("serviceCategory.slug"),
type: "text",
placeholder: t("serviceCategory.slugPlaceholder"),
description: t("serviceCategory.slugDescription"),
required: false,
},
{
name: "nameEn",
label: t("serviceCategory.nameEn"),
type: "text",
placeholder: t("serviceCategory.nameEnPlaceholder"),
required: true,
},
{
name: "nameAm",
label: t("serviceCategory.nameAm"),
type: "text",
placeholder: t("serviceCategory.nameAmPlaceholder"),
required: true,
},
{
name: "descriptionEn",
label: t("serviceCategory.descriptionEn"),
type: "textarea",
placeholder: t("serviceCategory.descriptionEnPlaceholder"),
required: true,
rows: 4,
},
{
name: "descriptionAm",
label: t("serviceCategory.descriptionAm"),
type: "textarea",
placeholder: t("serviceCategory.descriptionAmPlaceholder"),
required: true,
rows: 4,
},
];
const handleSubmit = async (values: ServiceCategoryFormValues) => {
if (!organizationId) {
throw new Error(t("serviceCategory.organizationRequired"));
}
const requestData: CreateServiceCategoryRequest = {
slug: values.slug || "",
name: {
en: values.nameEn,
am: values.nameAm,
},
description: {
en: values.descriptionEn,
am: values.descriptionAm,
},
organizationId,
};
if (isEditing && category) {
await update.mutateAsync({ id: category.id, data: requestData });
} else {
await create.mutateAsync(requestData);
}
onSuccess?.();
};
const handleCancel = () => {
if (onCancel) {
onCancel();
} else {
navigate(-1);
}
};
return (
<GenericForm<ServiceCategoryFormValues>
form={form}
fields={serviceCategoryFormFields}
onSubmit={handleSubmit}
onCancel={handleCancel}
isSubmitting={create.isPending || update.isPending}
submitButtonText={
isEditing
? t("serviceCategory.updateCategory")
: t("serviceCategory.createCategory")
}
cancelButtonText={t("common.cancel")}
successMessage={
isEditing
? t("serviceCategory.updateSuccess")
: t("serviceCategory.createSuccess")
}
errorMessage={
isEditing
? t("serviceCategory.updateError")
: t("serviceCategory.createError")
}
title={
isEditing
? t("serviceCategory.editTitle")
: t("serviceCategory.createTitle")
}
/>
);
};

View File

@@ -0,0 +1,308 @@
import { useState } from "react";
import { Pencil, Trash2 } from "lucide-react";
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
import { Button } from "@/shared/common/ui/button";
import { Skeleton } from "@/shared/common/ui/skeleton";
import { useLocalizedName } from "@/shared/common/localizedName";
import { ColumnDef } from "@tanstack/react-table";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/common/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/common/ui/alert-dialog";
import { Input } from "@/shared/common/ui/input";
import { Label } from "@/shared/common/ui/label";
import { Textarea } from "@/shared/common/ui/textarea";
import {
useServiceCategoryList,
useServiceCategoryMutations,
} from "../../hooks/useServiceCategory";
import { ServiceCategory } from "../../services/api/serviceCategoryService";
interface ServiceCategoryListProps {
organizationId: string;
extraToolbar?: React.ReactNode;
}
export const ServiceCategoryList = ({
organizationId,
extraToolbar,
}: ServiceCategoryListProps) => {
const [pageIndex, setPageIndex] = useState(0);
const [isEditOpen, setIsEditOpen] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [selectedCategory, setSelectedCategory] =
useState<ServiceCategory | null>(null);
const localizedName = useLocalizedName();
// Form state
const [formData, setFormData] = useState({
slug: "",
name: { am: "", en: "" },
description: { am: "", en: "" },
});
const { data, isLoading, isError, refetch } =
useServiceCategoryList(organizationId);
const { update, delete: deleteCategory } = useServiceCategoryMutations();
const handleEdit = (category: ServiceCategory) => {
setSelectedCategory(category);
setFormData({
slug: category.slug,
name: category.name,
description: category.description,
});
setIsEditOpen(true);
};
const handleUpdate = async (e: React.FormEvent) => {
e.preventDefault();
if (!organizationId || !selectedCategory) return;
update.mutate(
{
id: selectedCategory.id,
data: {
...formData,
organizationId,
},
},
{
onSuccess: () => {
setIsEditOpen(false);
setSelectedCategory(null);
setFormData({
slug: "",
name: { am: "", en: "" },
description: { am: "", en: "" },
});
},
}
);
};
const handleDeleteClick = (category: ServiceCategory) => {
setSelectedCategory(category);
setIsDeleteOpen(true);
};
const handleDelete = () => {
if (!selectedCategory) return;
deleteCategory.mutate(selectedCategory.id, {
onSuccess: () => {
setIsDeleteOpen(false);
setSelectedCategory(null);
},
});
};
const columns: ColumnDef<ServiceCategory>[] = [
{
accessorKey: "slug",
header: "Slug",
},
{
accessorKey: "name",
header: "Name",
cell: ({ row }) => localizedName(row.original.name),
},
{
accessorKey: "description",
header: "Description",
cell: ({ row }) => localizedName(row.original.description),
},
{
id: "actions",
header: "Actions",
cell: ({ row }) => (
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(row.original)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteClick(row.original)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
),
},
];
if (isLoading)
return (
<div className="p-6 space-y-6">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
);
if (isError)
return (
<div className="p-6 space-y-4">
<p className="text-destructive font-semibold">
Failed to load service categories
</p>
<Button onClick={() => refetch()}>Retry</Button>
</div>
);
const serviceCategories = data?.data?.items || [];
return (
<div className="p-6">
<AdvancedTable<ServiceCategory, unknown>
columns={columns}
data={serviceCategories}
tableName="Service Categories"
extraToolbar={extraToolbar}
toolBarPosition="right"
itemCount={serviceCategories.length}
pageIndex={pageIndex}
onPageChange={setPageIndex}
nextFunction={() => setPageIndex((p) => p + 1)}
prevFunction={() => setPageIndex((p) => Math.max(0, p - 1))}
refresh={refetch}
/>
{/* Edit Dialog */}
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Service Category</DialogTitle>
</DialogHeader>
<form onSubmit={handleUpdate} className="space-y-4">
<div>
<Label>Slug</Label>
<Input
value={formData.slug}
onChange={(e) =>
setFormData({ ...formData, slug: e.target.value })
}
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Name (English)</Label>
<Input
value={formData.name.en}
onChange={(e) =>
setFormData({
...formData,
name: { ...formData.name, en: e.target.value },
})
}
required
/>
</div>
<div>
<Label>Name (Amharic)</Label>
<Input
value={formData.name.am}
onChange={(e) =>
setFormData({
...formData,
name: { ...formData.name, am: e.target.value },
})
}
required
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Description (English)</Label>
<Textarea
value={formData.description.en}
onChange={(e) =>
setFormData({
...formData,
description: {
...formData.description,
en: e.target.value,
},
})
}
required
/>
</div>
<div>
<Label>Description (Amharic)</Label>
<Textarea
value={formData.description.am}
onChange={(e) =>
setFormData({
...formData,
description: {
...formData.description,
am: e.target.value,
},
})
}
required
/>
</div>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => setIsEditOpen(false)}
>
Cancel
</Button>
<Button type="submit" disabled={update.isPending}>
{update.isPending ? "Updating..." : "Update"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<AlertDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Service Category</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "
{selectedCategory && localizedName(selectedCategory.name)}"? This
action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={deleteCategory.isPending}
className="bg-destructive text-white hover:bg-destructive/90"
>
{deleteCategory.isPending ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};

View File

@@ -0,0 +1,183 @@
import { useState } from "react";
import { PlusIcon } from "lucide-react";
import { Button } from "@/shared/common/ui/button";
import { useAuth } from "@/shared/context/AuthContext";
import { useUnitContext } from "@/shared/context/UnitContext";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/common/ui/dialog";
import { Input } from "@/shared/common/ui/input";
import { Label } from "@/shared/common/ui/label";
import { Textarea } from "@/shared/common/ui/textarea";
import { useServiceCategoryMutations } from "../../hooks/useServiceCategory";
import { ServiceCategoryList } from "./ServiceCategoryList";
export const ServiceCategoryPage = () => {
const [isCreateOpen, setIsCreateOpen] = useState(false);
const { user } = useAuth();
// Form state
const [formData, setFormData] = useState({
slug: "",
name: { am: "", en: "" },
description: { am: "", en: "" },
});
const organizationId =
user?.employee?.length && user.employee.length > 0
? user.employee[0].organizationId
: undefined;
const unitContext = useUnitContext();
const unitsQuery = unitContext?.getList(organizationId!, {
take: 300,
skip: 0,
});
const { create } = useServiceCategoryMutations();
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!organizationId) return;
create.mutate(
{
...formData,
organizationId: organizationId,
},
{
onSuccess: () => {
setIsCreateOpen(false);
setFormData({
slug: "",
name: { am: "", en: "" },
description: { am: "", en: "" },
});
},
}
);
};
const extraToolbar = (
<div className="flex gap-2">
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogTrigger asChild>
<Button className="bg-gradient-to-r from-purple-500 to-violet-600 text-white">
<PlusIcon className="h-4 w-4 mr-2" />
Create Service Category
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Service Category</DialogTitle>
</DialogHeader>
<form onSubmit={handleCreate} className="space-y-4">
<div>
<Label>Slug</Label>
<Input
value={formData.slug}
onChange={(e) =>
setFormData({ ...formData, slug: e.target.value })
}
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Name (English)</Label>
<Input
value={formData.name.en}
onChange={(e) =>
setFormData({
...formData,
name: { ...formData.name, en: e.target.value },
})
}
required
/>
</div>
<div>
<Label>Name (Amharic)</Label>
<Input
value={formData.name.am}
onChange={(e) =>
setFormData({
...formData,
name: { ...formData.name, am: e.target.value },
})
}
required
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Description (English)</Label>
<Textarea
value={formData.description.en}
onChange={(e) =>
setFormData({
...formData,
description: {
...formData.description,
en: e.target.value,
},
})
}
required
/>
</div>
<div>
<Label>Description (Amharic)</Label>
<Textarea
value={formData.description.am}
onChange={(e) =>
setFormData({
...formData,
description: {
...formData.description,
am: e.target.value,
},
})
}
required
/>
</div>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => setIsCreateOpen(false)}
>
Cancel
</Button>
<Button type="submit" disabled={create.isPending}>
{create.isPending ? "Creating..." : "Create"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
);
if (!organizationId) {
return (
<div className="p-6 space-y-4">
<p className="text-destructive font-semibold">No organization found</p>
</div>
);
}
return (
<ServiceCategoryList
organizationId={organizationId}
extraToolbar={extraToolbar}
/>
);
};

View File

@@ -0,0 +1,226 @@
import { useState, useEffect } from "react";
import { useForm } from "react-hook-form";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/shared/common/ui/dialog";
import { Button } from "@/shared/common/ui/button";
import { Input } from "@/shared/common/ui/input";
import { Label } from "@/shared/common/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import { toast } from "sonner";
import { useWorkingHoursMutations } from "@/performance-management/hooks/useWorkingHoursHook";
import { CreateWorkingHoursDto, WeekDay } from "../../types/workingHoursTypes";
interface CreateWorkingHoursModalProps {
open: boolean;
onClose: () => void;
onSuccess: () => void;
positionServiceId?: string;
}
const weekDays: (WeekDay | "all")[] = [
"all",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday",
];
export const CreateWorkingHoursModal = ({
open,
onClose,
onSuccess,
positionServiceId = "",
}: CreateWorkingHoursModalProps) => {
const { create } = useWorkingHoursMutations();
const [selectedWeekDay, setSelectedWeekDay] = useState<WeekDay | "all">(
"all"
);
const {
register,
handleSubmit,
reset,
setValue,
formState: { errors },
} = useForm<CreateWorkingHoursDto>({
defaultValues: {
slug: "",
weekDay: "monday",
positionServiceId: positionServiceId,
startTime: "09:00",
endTime: "17:00",
},
});
// Update form when positionServiceId prop changes
useEffect(() => {
if (positionServiceId) {
setValue("positionServiceId", positionServiceId);
}
}, [positionServiceId, setValue]);
const onSubmit = async (data: CreateWorkingHoursDto) => {
// If "all" is selected, create working hours for all days
if (selectedWeekDay === "all") {
const allDays: WeekDay[] = [
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday",
];
let successCount = 0;
let errorCount = 0;
for (const day of allDays) {
const payload = {
...data,
weekDay: day,
};
try {
await create.mutateAsync(payload);
successCount++;
} catch (error) {
errorCount++;
console.error(`Failed to create working hours for ${day}:`, error);
}
}
if (successCount > 0) {
toast.success(`Working hours created for ${successCount} day(s)${errorCount > 0 ? `, ${errorCount} failed` : ""}`);
reset();
onClose();
onSuccess();
} else {
toast.error("Failed to create working hours for all days");
}
} else {
// Single day creation
const payload = {
...data,
weekDay: selectedWeekDay as WeekDay,
};
create.mutate(payload, {
onSuccess: () => {
reset();
onClose();
onSuccess();
},
});
}
};
const handleClose = () => {
reset();
onClose();
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Create Working Hours</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="slug">Slug</Label>
<Input
id="slug"
{...register("slug", { required: "Slug is required" })}
placeholder="e.g., morning-shift"
/>
{errors.slug && (
<p className="text-sm text-red-500">{errors.slug.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="weekDay">Week Day</Label>
<Select
value={selectedWeekDay}
onValueChange={(value) =>
setSelectedWeekDay(value as WeekDay | "all")
}
>
<SelectTrigger>
<SelectValue placeholder="Select a day" />
</SelectTrigger>
<SelectContent>
{weekDays.map((day) => (
<SelectItem key={day} value={day}>
<span className="capitalize">
{day === "all" ? "All Days" : day}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="startTime">Start Time</Label>
<Input
id="startTime"
type="time"
{...register("startTime", {
required: "Start time is required",
})}
/>
{errors.startTime && (
<p className="text-sm text-red-500">
{errors.startTime.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="endTime">End Time</Label>
<Input
id="endTime"
type="time"
{...register("endTime", { required: "End time is required" })}
/>
{errors.endTime && (
<p className="text-sm text-red-500">{errors.endTime.message}</p>
)}
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button
type="submit"
disabled={create.isPending}
className="bg-gradient-to-r from-purple-500 to-violet-600 text-white"
>
{create.isPending ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,190 @@
import { useState, useEffect } from "react";
import { useForm } from "react-hook-form";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/shared/common/ui/dialog";
import { Button } from "@/shared/common/ui/button";
import { Input } from "@/shared/common/ui/input";
import { Label } from "@/shared/common/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import { useWorkingHoursMutations } from "@/performance-management/hooks/useWorkingHoursHook";
import {
CreateWorkingHoursDto,
WeekDay,
WorkingHoursResponse,
} from "../../types/workingHoursTypes";
interface EditWorkingHoursModalProps {
open: boolean;
onClose: () => void;
onSuccess: () => void;
workingDay: WorkingHoursResponse | null;
}
const weekDays: WeekDay[] = [
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday",
];
export const EditWorkingHoursModal = ({
open,
onClose,
onSuccess,
workingDay,
}: EditWorkingHoursModalProps) => {
const { update } = useWorkingHoursMutations();
const [selectedWeekDay, setSelectedWeekDay] = useState<WeekDay>("monday");
const {
register,
handleSubmit,
reset,
setValue,
formState: { errors },
} = useForm<CreateWorkingHoursDto>({
defaultValues: {
slug: "",
weekDay: "monday",
positionServiceId: "",
startTime: "09:00",
endTime: "17:00",
},
});
// Update form when workingDay changes
useEffect(() => {
if (workingDay) {
setValue("slug", workingDay.slug);
setValue("positionServiceId", workingDay.positionServiceId);
setValue("startTime", workingDay.startTime.substring(0, 5)); // Remove seconds
setValue("endTime", workingDay.endTime.substring(0, 5)); // Remove seconds
setSelectedWeekDay(workingDay.weekDay);
}
}, [workingDay, setValue]);
const onSubmit = async (data: CreateWorkingHoursDto) => {
if (!workingDay?.id) return;
const payload = {
...data,
weekDay: selectedWeekDay,
};
update.mutate(
{ id: workingDay.id, data: payload },
{
onSuccess: () => {
reset();
onClose();
onSuccess();
},
}
);
};
const handleClose = () => {
reset();
onClose();
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Edit Working Hours</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="slug">Slug</Label>
<Input
id="slug"
{...register("slug", { required: "Slug is required" })}
placeholder="e.g., morning-shift"
/>
{errors.slug && (
<p className="text-sm text-red-500">{errors.slug.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="weekDay">Week Day</Label>
<Select
value={selectedWeekDay}
onValueChange={(value) => setSelectedWeekDay(value as WeekDay)}
>
<SelectTrigger>
<SelectValue placeholder="Select a day" />
</SelectTrigger>
<SelectContent>
{weekDays.map((day) => (
<SelectItem key={day} value={day}>
<span className="capitalize">{day}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="startTime">Start Time</Label>
<Input
id="startTime"
type="time"
{...register("startTime", {
required: "Start time is required",
})}
/>
{errors.startTime && (
<p className="text-sm text-red-500">
{errors.startTime.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="endTime">End Time</Label>
<Input
id="endTime"
type="time"
{...register("endTime", { required: "End time is required" })}
/>
{errors.endTime && (
<p className="text-sm text-red-500">{errors.endTime.message}</p>
)}
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button
type="submit"
disabled={update.isPending}
className="bg-gradient-to-r from-purple-500 to-violet-600 text-white"
>
{update.isPending ? "Updating..." : "Update"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,166 @@
import { useEffect, useState } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/common/ui/table";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/shared/common/ui/alert-dialog";
import { Button } from "@/shared/common/ui/button";
import { Skeleton } from "@/shared/common/ui/skeleton";
import { Edit, Trash2 } from "lucide-react";
import { getWorkingHoursByPositionServiceId } from "@/performance-management/services/api/workingHoursService";
import { WorkingHoursResponse } from "@/performance-management/types/workingHoursTypes";
import { t } from "i18next";
interface WorkingDaysTableProps {
positionServiceId: string;
onEdit?: (workingDay: WorkingHoursResponse) => void;
onDelete?: (id: string) => void;
refreshTrigger?: number;
}
export const WorkingDaysTable = ({
positionServiceId,
onEdit,
onDelete,
refreshTrigger,
}: WorkingDaysTableProps) => {
const [workingDays, setWorkingDays] = useState<WorkingHoursResponse[]>([]);
const [isLoading, setIsLoading] = useState(false);
const fetchWorkingDays = async () => {
if (!positionServiceId) {
setWorkingDays([]);
return;
}
setIsLoading(true);
try {
const response = await getWorkingHoursByPositionServiceId(
positionServiceId
);
const data = response.data;
if (data && Array.isArray(data.items)) {
setWorkingDays(data.items);
} else if (Array.isArray(data)) {
setWorkingDays(data);
} else {
setWorkingDays([]);
}
} catch (error) {
console.error("Failed to fetch working days", error);
setWorkingDays([]);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchWorkingDays();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [positionServiceId, refreshTrigger]);
if (isLoading) {
return (
<div className="space-y-2 p-4">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
);
}
if (workingDays.length === 0) {
return (
<div className="text-center py-8 text-gray-500">
{t("noWorkingDaysFound", "No working days configured for this service")}
</div>
);
}
return (
<div className="border rounded-md bg-white overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("weekDay", "Week Day")}</TableHead>
<TableHead>{t("slug", "Slug")}</TableHead>
<TableHead>{t("startTime", "Start Time")}</TableHead>
<TableHead>{t("endTime", "End Time")}</TableHead>
<TableHead className="text-right">
{t("actions", "Actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{workingDays.map((day) => (
<TableRow key={day.id}>
<TableCell className="font-medium capitalize">
{day.weekDay}
</TableCell>
<TableCell>{day.slug}</TableCell>
<TableCell>{day.startTime}</TableCell>
<TableCell>{day.endTime}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
{onEdit && (
<Button
variant="ghost"
size="sm"
onClick={() => onEdit(day)}
>
<Edit className="h-4 w-4" />
</Button>
)}
{onDelete && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm">
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Delete Working Hours
</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this working hours
configuration? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => onDelete(day.id)}
className="bg-red-600 hover:bg-red-700"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
};

View File

@@ -0,0 +1,199 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { EditIcon, TrashIcon, PlusIcon } from "lucide-react";
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
import { WorkingHoursResponse } from "../../types/workingHoursTypes";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/common/ui/alert-dialog";
import { ColumnDef } from "@tanstack/react-table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/shared/common/ui/tooltip";
import { Button } from "@/shared/common/ui/button";
import { Skeleton } from "@/shared/common/ui/skeleton";
import {
useWorkingHoursList,
useWorkingHoursMutations,
} from "@/performance-management/hooks/useWorkingHoursHook";
import { CreateWorkingHoursModal } from "./CreateWorkingHoursModal";
const WorkingHoursTable = () => {
const [pageIndex, setPageIndex] = useState(0);
const [selectedHours, setSelectedHours] =
useState<WorkingHoursResponse | null>(null);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const { data, isLoading, isError, refetch } = useWorkingHoursList();
const { delete: deleteMutation } = useWorkingHoursMutations();
const handleDelete = async () => {
if (!selectedHours) return;
deleteMutation.mutate(selectedHours.id, {
onSuccess: () => {
setIsDeleteDialogOpen(false);
setSelectedHours(null);
refetch();
},
});
};
const workingHours = data?.data?.items || data?.data || [];
const columns: ColumnDef<WorkingHoursResponse>[] = [
{
accessorKey: "slug",
header: "Slug",
size: 150,
},
{
accessorKey: "weekDay",
header: "Week Day",
cell: ({ row }) => (
<span className="capitalize">{row.original.weekDay}</span>
),
},
{
accessorKey: "startTime",
header: "Start Time",
},
{
accessorKey: "endTime",
header: "End Time",
},
{
accessorKey: "positionServiceId",
header: "Position Service ID",
cell: ({ row }) => (
<div
className="max-w-[200px] truncate"
title={row.original.positionServiceId}
>
{row.original.positionServiceId}
</div>
),
},
{
id: "actions",
header: "Actions",
cell: ({ row }) => (
<div className="flex gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => {
setSelectedHours(row.original);
setIsDeleteDialogOpen(true);
}}
disabled={deleteMutation.isPending}
>
<TrashIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
),
},
];
const extraToolbar = (
<div className="flex gap-2">
<Button
onClick={() => setIsCreateModalOpen(true)}
className="bg-gradient-to-r from-purple-500 to-violet-600 text-white"
>
<PlusIcon className="h-4 w-4 mr-2" />
Create Working Hours
</Button>
</div>
);
if (isLoading)
return (
<div className="p-6 space-y-6">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
);
if (isError)
return (
<div className="p-6 space-y-4">
<p className="text-destructive font-semibold">
Failed to load working hours
</p>
<Button onClick={() => refetch()}>Retry</Button>
</div>
);
return (
<div className="p-6">
<AdvancedTable<WorkingHoursResponse, unknown>
columns={columns}
data={workingHours}
tableName="Working Hours Management"
extraToolbar={extraToolbar}
toolBarPosition="right"
itemCount={workingHours.length}
pageIndex={pageIndex}
onPageChange={setPageIndex}
nextFunction={() => setPageIndex((p) => p + 1)}
prevFunction={() => setPageIndex((p) => Math.max(0, p - 1))}
refresh={refetch}
/>
<CreateWorkingHoursModal
open={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onSuccess={refetch}
/>
<AlertDialog
open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Deletion</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete working hours for "
{selectedHours?.weekDay}"? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 hover:bg-red-700"
disabled={deleteMutation.isPending}
onClick={handleDelete}
>
{deleteMutation.isPending ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
export default WorkingHoursTable;