mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 22:18:12 +00:00
fix ui
This commit is contained in:
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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")
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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")
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user