user management ui

This commit is contained in:
yaschalew
2026-07-10 10:41:48 +03:00
parent dcb2d98503
commit 28a20923ff
595 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,745 @@
import { cn } from "@/super-admin/lib/utils";
import {
UserCircle,
Mail,
Check,
Clock,
AlertCircle,
UserPen,
RefreshCw,
MoreVertical,
Phone,
Eye,
PhoneCall,
UserMinus,
Briefcase,
} from "lucide-react";
import { Badge } from "@/shared/common/ui/badge";
import { Button } from "@/shared/common/ui/button";
import { TeamMemberDto } from "../dto/teamMember/teamMember";
import { useEffect, useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/common/ui/dialog";
import { Avatar, AvatarFallback } from "@/shared/common/ui/avatar";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/shared/common/ui/tooltip";
import { motion } from "framer-motion";
import { Input } from "@/shared/common/ui/input";
import { t } from "i18next";
import { toast } from "sonner";
import { updateProfile } from "../services/api/employeePositionsService";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/common/ui/dropdown-menu";
import { useLocalizedName } from "@/shared/common/localizedName";
import { EmployeePositionsDialog } from "./dialogs/EmployeePositionsDialog";
interface TeamMembersProps {
departmentId: string;
departmentName: string;
employees: TeamMemberDto[];
isLoading: boolean;
searchQuery?: string;
onEditEmployee?: (employee: TeamMemberDto) => void;
onInviteEmployee?: (employee: TeamMemberDto) => void;
onDeleteEmployee?: (
departmentId: string,
departmentName: string,
teamMemberId: string,
teamMemberName: string,
) => void;
onDeactivateTeamMember?: (userId: string, teamMemberName: string) => void;
employeePositions?: Array<{
[key: string]: Array<{
id: string;
employeeId: string;
position: {
id: string;
name: {
am: string;
en: string;
};
};
}>;
}>;
}
type UserStatus = "accepted" | "pending" | "Not Invited";
const statusStyles: Record<
UserStatus,
{ bg: string; text: string; icon: React.ReactNode }
> = {
accepted: {
bg: "bg-primary-100 dark:bg-primary-900/40",
text: "text-primary-800 dark:text-primary-300",
icon: <Check className="h-3 w-3 mr-1" />,
},
pending: {
bg: "bg-yellow-100 dark:bg-yellow-900/40",
text: "text-yellow-800 dark:text-yellow-300",
icon: <Clock className="h-3 w-3 mr-1" />,
},
"Not Invited": {
bg: "bg-gray-100 dark:bg-gray-700",
text: "text-gray-800 dark:text-gray-300",
icon: <AlertCircle className="h-3 w-3 mr-1" />,
},
};
export const TeamMembers = ({
departmentId,
departmentName,
employees = [],
isLoading = false,
searchQuery = "",
onEditEmployee,
onInviteEmployee,
onDeleteEmployee,
onDeactivateTeamMember,
employeePositions,
}: TeamMembersProps) => {
const [selectedEmployee, setSelectedEmployee] =
useState<TeamMemberDto | null>(null);
const [showUserDetails, setShowUserDetails] = useState(false);
const [showEditPage, setEditPage] = useState(false);
const [showPositionsDialog, setShowPositionsDialog] = useState(false);
const [userName, setUserName] = useState<string>("");
const [email, setEmail] = useState<string>("");
const [englishName, setEnglishName] = useState<string>("");
const [amharicName, setAmharicName] = useState<string>("");
const [phoneNumber, setPhoneNumber] = useState<string>("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [emploeeId, setEmployeeId] = useState("");
const [errors, setErrors] = useState<{ [key: string]: string }>({});
const localizedName = useLocalizedName();
// Filter employees based on search query
const filteredEmployees = employees.filter((employee) => {
if (!searchQuery) return true;
const name = localizedName(employee.user.name);
const email = employee.user.email || "";
const username = employee.user.username || "";
const phoneNumber = employee.user.phoneNumber || "";
const query = searchQuery.toLowerCase();
return (
name.toLowerCase().includes(query) ||
email.toLowerCase().includes(query) ||
username.toLowerCase().includes(query) ||
phoneNumber.toLowerCase().includes(query)
);
});
const renderStatusBadge = (status: string) => {
const fallback = {
bg: "bg-gray-100",
text: "text-gray-700",
icon: null,
};
const style =
(statusStyles as Record<string, typeof fallback>)[status] ?? fallback;
return (
<Badge
className={cn(
"text-xs flex items-center px-2 py-1",
style.bg,
style.text,
)}>
{style.icon}
<span>{status}</span>
</Badge>
);
};
const getUserStatus = (employee: TeamMemberDto): UserStatus => {
if (employee.user.hasSetPassword) {
return "accepted";
}
return "pending";
};
const handleResendClick = (employee: TeamMemberDto) => {
if (onInviteEmployee) {
onInviteEmployee(employee);
}
};
const handleViewUserDetail = (employee: TeamMemberDto) => {
setSelectedEmployee(employee);
setShowUserDetails(true);
};
const handleShowUserDetails = (employee: TeamMemberDto) => {
setSelectedEmployee(employee);
setShowUserDetails(true);
};
const handleEditPage = (employee: TeamMemberDto) => {
setSelectedEmployee(employee);
setEditPage(true);
setEmployeeId(employee?.user?.id);
};
const handleShowPositions = (employee: TeamMemberDto) => {
setSelectedEmployee(employee);
setShowPositionsDialog(true);
};
const getInitials = (name: string) => {
const splittedName = name.trim().split(" ");
return splittedName.length === 1
? splittedName[0][0]
: `${splittedName[0][0]}${splittedName[1][0]}`;
};
useEffect(() => {
if (showEditPage && selectedEmployee) {
setUserName(selectedEmployee?.user.username ?? "");
setEmail(selectedEmployee?.user.email ?? "");
setEnglishName(selectedEmployee?.user.name.en ?? "");
setAmharicName(selectedEmployee?.user.name.am ?? "");
setPhoneNumber(selectedEmployee?.user.phoneNumber ?? "");
setErrors({});
setError("");
}
}, [showEditPage, selectedEmployee]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const newErrors: { [key: string]: string } = {};
if (!email?.trim()) newErrors.email = t("profile.emailRequired");
if (!userName?.trim()) newErrors.userName = t("profile.usernameRequired");
if (!phoneNumber?.trim())
newErrors.phoneNumber = t("profile.phoneRequired");
if (!englishName?.trim())
newErrors.englishName = t("profile.englishNameRequired");
if (!amharicName?.trim())
newErrors.amharicName = t("profile.amharicNameRequired");
setErrors(newErrors);
if (Object.keys(newErrors).length > 0) return;
const phoneRegex = /^(\+2519\d{8}|09\d{8})$/;
if (!phoneRegex.test(phoneNumber ?? "")) {
setError(t("profile.invalidPhoneFormat"));
toast.error(t("profile.invalidPhoneFormat"), {
description: t("profile.validPhoneFormat"),
});
return;
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email ?? "")) {
setError(t("profile.invalidEmailFormat"));
toast.error(t("profile.invalidEmailFormat"), {
description: t("profile.validEmailFormat"),
});
return;
}
setLoading(true);
try {
await updateProfile(
{
email,
username: userName,
phoneNumber,
name: {
am: amharicName,
en: englishName,
},
},
emploeeId,
);
toast.success(t("profile.profileUpdateSuccess"), {
description: t("profile.profileUpdated"),
});
} catch (error: any) {
const message = error?.message || t("profile.profileUpdateFailed");
setError(message);
toast.error(t("profile.profileUpdateFailed"), {
description: message,
});
} finally {
setLoading(false);
}
};
const ActionDropdown = ({ employee }: { employee: TeamMemberDto }) => {
const status = getUserStatus(employee);
const isPending = status === "pending";
const isNotInvited = status === "Not Invited";
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-gray-100 dark:hover:bg-gray-700"
onClick={(e) => e.stopPropagation()}>
<MoreVertical className="h-4 w-4 dark:text-gray-400" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48 dark:bg-gray-700 dark:border-gray-600">
{/* Edit Option */}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleShowUserDetails(employee);
}}
className="cursor-pointer dark:text-gray-200 dark:hover:bg-gray-600">
<Eye className="h-4 w-4 mr-2" />
{t("viewDetail.viewDetail")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleEditPage(employee);
}}
className="cursor-pointer dark:text-gray-200 dark:hover:bg-gray-600">
<UserPen className="h-4 w-4 mr-2" />
{t("common.Edit")}
</DropdownMenuItem>
{/* Show Positions Option */}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleShowPositions(employee);
}}
className="cursor-pointer dark:text-gray-200 dark:hover:bg-gray-600">
<Briefcase className="h-4 w-4 mr-2" />
{t("positions.viewPositions")}
</DropdownMenuItem>
{/* Resend/Invite Option */}
{onInviteEmployee && (isPending || isNotInvited) && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleResendClick(employee);
}}
className="cursor-pointer dark:text-gray-200 dark:hover:bg-gray-600">
<RefreshCw className="h-4 w-4 mr-2" />
{isPending ? t("Resend Invite") : t("Send Invite")}
</DropdownMenuItem>
)}
{/* Remove Option */}
{onDeleteEmployee && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
const memberName =
typeof employee.user.name === "string"
? employee.user.name
: (employee.user.name as any)?.en ||
(employee.user.name as any)?.am ||
"User";
onDeleteEmployee(
departmentId,
departmentName,
employee.id,
memberName,
);
}}
className="cursor-pointer text-red-600 focus:text-red-600 dark:focus:text-red-400">
<AlertCircle className="h-4 w-4 mr-2" />
{t("signatureUpload.remove")}
</DropdownMenuItem>
)}
{onDeactivateTeamMember && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
const memberName =
typeof employee.user.name === "string"
? employee.user.name
: (employee.user.name as any)?.en ||
(employee.user.name as any)?.am ||
"User";
onDeactivateTeamMember(employee.id, memberName);
}}
className="cursor-pointer text-red-600 focus:text-red-600 dark:focus:text-red-400">
<UserMinus className="h-4 w-4 mr-2" />
{t("common.DeactivateUser")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
};
const renderEmployeeItem = (employee: TeamMemberDto) => {
const status = getUserStatus(employee);
return (
<div
key={employee.user.id}
className="flex items-center justify-between p-4 border-b border-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors rounded-md gap-3 group">
{/* Left side - Employee info */}
<div
className="flex items-center gap-3 cursor-pointer flex-1 min-w-0"
onClick={() => handleShowUserDetails(employee)}>
<Avatar className="h-10 w-10 border border-gray-200 dark:border-gray-600 shrink-0">
<AvatarFallback className="bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
{getInitials(localizedName(employee.user.name)).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-sm font-medium text-gray-800 dark:text-gray-200 truncate">
{localizedName(employee.user.name)}
</span>
<div className="flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400 truncate">
<span className="flex items-center gap-1 shrink-0">
<UserCircle className="h-3 w-3" />
{employee.user.userType || "Employee"}
</span>
<span className="flex items-center gap-1 shrink-0">
<Phone className="h-3 w-3" />
{employee.user.phoneNumber || "Employee"}
</span>
</div>
<div className="mt-1">{renderStatusBadge(status)}</div>
</div>
</div>
{/* Right side - Action dropdown */}
<div className="flex items-center gap-2">
{/* Status-specific (optional - you can remove this if you want all actions in dropdown) */}
{/* Three dots dropdown menu */}
<ActionDropdown employee={employee} />
</div>
</div>
);
};
const renderContent = () => {
if (isLoading) {
return (
<div className="flex justify-center py-10">
<div className="flex flex-col items-center">
<div className="w-8 h-8 border-4 border-t-blue-500 border-blue-200 rounded-full animate-spin mb-2"></div>
<p className="text-sm text-gray-500 dark:text-gray-400">Loading team members...</p>
</div>
</div>
);
}
if (filteredEmployees.length > 0) {
return (
<div className="space-y-2">
{filteredEmployees.map(renderEmployeeItem)}
</div>
);
}
if (employees.length > 0 && filteredEmployees.length === 0) {
return (
<div className="text-center py-10 text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/50 rounded-md">
<UserCircle className="h-12 w-12 mx-auto mb-2 text-gray-300 dark:text-gray-600" />
<p className="text-sm font-medium">{t("search.noResults")}</p>
<p className="text-xs mt-1 text-gray-400 dark:text-gray-500">
{t("search.tryDifferentKeywords")}
</p>
</div>
);
}
return (
<div className="text-center py-10 text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/50 rounded-md">
<UserCircle className="h-12 w-12 mx-auto mb-2 text-gray-300 dark:text-gray-600" />
<p className="text-sm font-medium">
{departmentId
? t("contentManagement.msg1")
: t("contentManagement.msg2")}
</p>
<p className="text-xs mt-1 text-gray-400 dark:text-gray-500">
{departmentId
? t("contentManagement.msg3")
: t("contentManagement.msg4")}
</p>
</div>
);
};
return (
<div className="w-full">
{renderContent()}
{/* User Details Modal */}
<Dialog
open={showUserDetails}
onOpenChange={(open) => setShowUserDetails(open)}>
<DialogContent className="sm:max-w-md dark:bg-gray-800">
<DialogHeader>
<DialogTitle className="text-center dark:text-white">
{t("contentManagement.userDetail")}
</DialogTitle>
</DialogHeader>
{selectedEmployee && (
<div className="flex flex-col items-center space-y-4 py-4">
<Avatar className="w-20 h-20 border-2 border-gray-200 dark:border-gray-600">
<AvatarFallback className="text-xl bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300">
{getInitials(
localizedName(selectedEmployee.user.name),
).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="text-center">
<h2 className="text-xl font-semibold text-gray-800 dark:text-white">
{localizedName(selectedEmployee.user.name)}
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
{selectedEmployee.user.userType || "Employee"}
</p>
{getUserStatus(selectedEmployee) && (
<div className="mt-2 flex justify-center">
{renderStatusBadge(getUserStatus(selectedEmployee))}
</div>
)}
</div>
<div className="w-full space-y-3 mt-4">
<div className="flex items-center gap-3 p-4 rounded-lg bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600">
<div className="p-2 rounded-lg bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-400">
<UserCircle className="w-5 h-5" />
</div>
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">
{t("organization.username")}
</p>
<p className="font-medium text-gray-800 dark:text-gray-200">
{selectedEmployee.user.username || "Not provided"}
</p>
</div>
</div>
<div className="flex items-center gap-3 p-4 rounded-lg bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600">
<div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-400">
<Mail className="w-5 h-5" />
</div>
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">
{t("organization.email")}
</p>
<p className="font-medium text-gray-800 dark:text-gray-200">
{selectedEmployee.user.email || "Not provided"}
</p>
</div>
</div>
<div className="flex items-center gap-3 p-4 rounded-lg bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600">
<div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-400">
<PhoneCall className="w-5 h-5" />
</div>
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">
{t("organization.phoneNumber")}
</p>
<p className="font-medium text-gray-800 dark:text-gray-200">
{selectedEmployee.user.phoneNumber}
</p>
</div>
</div>
</div>
</div>
)}
</DialogContent>
</Dialog>
{/* Edit Modal */}
<Dialog open={showEditPage} onOpenChange={(open) => setEditPage(open)}>
<DialogContent className="sm:max-w-lg max-h-[80vh] overflow-y-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden dark:bg-gray-800">
<DialogHeader>
<DialogTitle className="text-center dark:text-white">
{t("contentManagement.editEmployee")}
</DialogTitle>
</DialogHeader>
{selectedEmployee && (
<div className="flex flex-col items-center space-y-4 py-4">
<Avatar className="w-20 h-20 border-2 border-gray-200 dark:border-gray-600">
<AvatarFallback className="text-xl bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300">
{getInitials(
localizedName(selectedEmployee.user.name),
).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="text-center">
<h2 className="text-xl font-semibold text-gray-800 dark:text-white">
{localizedName(selectedEmployee.user.name)}
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
{selectedEmployee.user.userType || "Employee"}
</p>
{getUserStatus(selectedEmployee) && (
<div className="mt-2 flex justify-center">
{renderStatusBadge(getUserStatus(selectedEmployee))}
</div>
)}
</div>
<motion.form
initial="hidden"
animate="show"
className="space-y-5 mt-6 w-full"
onSubmit={handleSubmit}>
<div className="relative flex items-center gap-3 p-4 rounded-lg bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600">
<div className="p-2 rounded-lg bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-400 shrink-0">
<UserCircle className="w-5 h-5" />
</div>
<div className="flex-1">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">
{t("organization.username")}
</p>
<Input
placeholder={t("profile.username")}
value={userName}
onChange={(e) => setUserName(e.target.value)}
className="w-full dark:bg-gray-600 dark:text-gray-200 dark:border-gray-500"
/>
{errors.userName && (
<p className="text-sm text-red-600 mt-1">
{errors.userName}
</p>
)}
</div>
</div>
<div className="relative flex items-center gap-3 p-4 rounded-lg bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600">
<div className="p-2 rounded-lg bg-primary-100 dark:bg-primary-900 text-primary-700 dark:text-primary-400 shrink-0">
<UserCircle className="w-5 h-5" />
</div>
<div className="flex-1">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">
{t("profile.englishName")}
</p>
<Input
placeholder={t("profile.englishName")}
value={englishName}
onChange={(e) => setEnglishName(e.target.value)}
className="w-full dark:bg-gray-600 dark:text-gray-200 dark:border-gray-500"
/>
{errors.englishName && (
<p className="text-sm text-red-600 mt-1">
{errors.englishName}
</p>
)}
</div>
</div>
<div className="relative flex items-center gap-3 p-4 rounded-lg bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600">
<div className="p-2 rounded-lg bg-primary-100 dark:bg-primary-900 text-primary-700 dark:text-primary-400 shrink-0">
<UserCircle className="w-5 h-5" />
</div>
<div className="flex-1">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">
{t("profile.amharicName")}
</p>
<Input
placeholder={t("profile.amharicName")}
value={amharicName}
onChange={(e) => setAmharicName(e.target.value)}
className="w-full dark:bg-gray-600 dark:text-gray-200 dark:border-gray-500"
/>
{errors.amharicName && (
<p className="text-sm text-red-600 mt-1">
{errors.amharicName}
</p>
)}
</div>
</div>
<div className="relative flex items-center gap-3 p-4 rounded-lg bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600">
<div className="p-2 rounded-lg bg-primary-100 dark:bg-primary-900 text-primary-700 dark:text-primary-400 shrink-0">
<UserCircle className="w-5 h-5" />
</div>
<div className="flex-1">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">
{t("organization.email")}
</p>
<Input
placeholder={t("profile.email")}
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full dark:bg-gray-600 dark:text-gray-200 dark:border-gray-500"
/>
{errors.email && (
<p className="text-sm text-red-600 mt-1">
{errors.email}
</p>
)}
</div>
</div>
<div className="relative flex items-center gap-3 p-4 rounded-lg bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600">
<div className="p-2 rounded-lg bg-primary-100 dark:bg-primary-900 text-primary-700 dark:text-primary-400 shrink-0">
<UserCircle className="w-5 h-5" />
</div>
<div className="flex-1">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">
{t("organization.phoneNumber")}
</p>
<Input
placeholder={t("profile.phoneNumber")}
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value)}
className="w-full dark:bg-gray-600 dark:text-gray-200 dark:border-gray-500"
/>
{errors.phoneNumber && (
<p className="text-sm text-red-600 mt-1">
{errors.phoneNumber}
</p>
)}
</div>
</div>
<Button
type="submit"
className="w-full h-11 bg-primary hover:bg-primary-700 text-white font-medium text-sm shadow-md rounded-lg"
disabled={loading}>
{loading ? (
<span className="flex items-center justify-center">
<RefreshCw className="animate-spin h-5 w-5 mr-2" />
{t("profile.updating")}...
</span>
) : (
t("profile.updateProfile")
)}
</Button>
</motion.form>
</div>
)}
</DialogContent>
</Dialog>
{/* Employee Positions Dialog */}
{selectedEmployee && (
<EmployeePositionsDialog
isOpen={showPositionsDialog}
onClose={() => setShowPositionsDialog(false)}
employeeName={localizedName(selectedEmployee.user.name)}
positions={selectedEmployee.employeePositions || []}
onPositionRemoved={() => {
// Optionally refresh the positions if needed
setShowPositionsDialog(false);
}}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,164 @@
import { useState, useEffect } from "react";
import { Button } from "@/shared/common/ui/button";
import { Input } from "@/shared/common/ui/input";
import { Search } from "lucide-react";
import { Badge } from "@/shared/common/ui/badge";
interface User {
id: string;
name: string;
email: string;
role: string;
department?: string;
}
interface ViewUsersProps {
unitId: string;
onClose: () => void;
}
export function ViewUsers({ unitId, onClose }: ViewUsersProps) {
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => {
const fetchUsers = async () => {
setIsLoading(true);
try {
// In a real application, this would be an API call
// For now, simulate API call with a timeout
await new Promise((resolve) => setTimeout(resolve, 800));
// Mock data
setUsers([
{
id: "1",
name: "Abebe Kebede",
email: "abebe@example.com",
role: "Admin",
department: "Management",
},
{
id: "2",
name: "Tigist Haile",
email: "tigist@example.com",
role: "Manager",
department: "Finance",
},
{
id: "3",
name: "Dawit Mekonnen",
email: "dawit@example.com",
role: "Member",
department: "Operations",
},
{
id: "4",
name: "Hanna Girma",
email: "hanna@example.com",
role: "Member",
department: "HR",
},
]);
} catch (error) {
console.error("Failed to fetch users:", error);
setUsers([]);
} finally {
setIsLoading(false);
}
};
fetchUsers();
}, [unitId]);
const filteredUsers = users.filter((user) => {
if (!searchQuery) return true;
const query = searchQuery.toLowerCase();
return (
user.name.toLowerCase().includes(query) ||
user.email.toLowerCase().includes(query) ||
user.role.toLowerCase().includes(query) ||
(user.department && user.department.toLowerCase().includes(query))
);
});
const getRoleBadgeColor = (role: string) => {
switch (role.toLowerCase()) {
case "admin":
return "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300";
case "manager":
return "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300";
default:
return "bg-primary-100 text-primary-800 dark:bg-primary-900/40 dark:text-primary-300";
}
};
return (
<div className="space-y-4">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500 dark:text-gray-400" />
<Input
placeholder="Search users..."
className="pl-8 dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div className="border rounded-md overflow-hidden dark:border-gray-700">
{isLoading ? (
<div className="flex justify-center items-center p-8">
<p className="text-gray-500 dark:text-gray-400">Loading users...</p>
</div>
) : filteredUsers.length > 0 ? (
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Name
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Email
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Role
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Department
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{filteredUsers.map((user) => (
<tr key={user.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td className="px-4 py-2 whitespace-nowrap text-gray-900 dark:text-gray-100">{user.name}</td>
<td className="px-4 py-2 whitespace-nowrap text-gray-500 dark:text-gray-400">{user.email}</td>
<td className="px-4 py-2 whitespace-nowrap">
<Badge className={getRoleBadgeColor(user.role)}>
{user.role}
</Badge>
</td>
<td className="px-4 py-2 whitespace-nowrap text-gray-500 dark:text-gray-400">
{user.department || "-"}
</td>
</tr>
))}
</tbody>
</table>
) : (
<div className="flex justify-center items-center p-8 text-gray-500 dark:text-gray-400">
No users found
</div>
)}
</div>
<div className="flex justify-end">
<Button onClick={onClose} className="dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600">
Close
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,127 @@
import { useState } from "react";
import {
Archive,
MoreHorizontal,
Move,
Pencil,
Plus,
Trash,
UserCog,
UserPlus,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/shared/common/ui/dropdown-menu";
import { Button } from "@/shared/common/ui/button";
import { PositionDto } from "@/user-management/dto/positions/positionDto";
import { Name } from "../types";
import { useLocalizedName } from "@/shared/common/localizedName";
import { t } from "i18next";
interface Department {
id: string;
name: Name;
}
interface DepartmentActionsProps {
department: Department;
positions: PositionDto[];
onAddUser?: (departmentId: string, departmentName: string) => void;
onViewUsers?: (departmentId: string) => void;
onAddPosition?: (departmentId: string, positionType: string) => void;
onAddSubDepartment?: (departmentId: string, departmentName: string) => void;
onDelegate?: (departmentId: string) => void;
onAssignUser?: (departmentId: string, departmentName: string) => void;
onEditDepartment?: (departmentId: string, departmentName: string) => void;
onDeleteDepartment?: (departmentId: string, departmentName: string) => void;
onArchiveDepartment?: (departmentId: string, departmentName: string) => void;
onMoveDepartment?: (departmentId: string, departmentName: string) => void;
showAlways?: boolean;
}
export const DepartmentActions: React.FC<DepartmentActionsProps> = ({
department,
positions,
onAddUser,
onAddPosition,
onAddSubDepartment,
onAssignUser,
onEditDepartment,
onDeleteDepartment,
onArchiveDepartment,
onMoveDepartment,
showAlways = false,
}) => {
const [isOpen, setIsOpen] = useState(false);
const localizedName = useLocalizedName();
const handleAction = (
action?: (departmentId: string, departmentName: string) => void
) => {
if (action) {
action(department.id, localizedName(department.name));
}
setIsOpen(false);
};
return (
<div
className={
showAlways ? "opacity-100" : "opacity-0 group-hover:opacity-100"
}>
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0 dark:hover:bg-gray-700">
<MoreHorizontal className="h-4 w-4 dark:text-gray-400" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="dark:bg-gray-700 dark:border-gray-600">
<DropdownMenuLabel className="dark:text-gray-200">Actions</DropdownMenuLabel>
<DropdownMenuItem onClick={() => handleAction(onAddUser)} className="dark:text-gray-200 dark:hover:bg-gray-600">
<UserPlus className="mr-2 h-4 w-4" />
{t("contentManagement.addUser")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction(onAssignUser)} className="dark:text-gray-200 dark:hover:bg-gray-600">
<UserCog className="mr-2 h-4 w-4" />
{t("organization.assignUser")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction(onAddSubDepartment)} className="dark:text-gray-200 dark:hover:bg-gray-600">
<Plus className="mr-2 h-4 w-4" />
{t("contentManagement.addSubdepartement")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction(onEditDepartment)} className="dark:text-gray-200 dark:hover:bg-gray-600">
<Pencil className="mr-2 h-4 w-4" />
{t("contentManagement.editDepartment")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleAction(onMoveDepartment)}
className="text-gray-800 dark:text-gray-300 focus:text-gray-900 dark:focus:text-gray-200">
<Move className="mr-2 h-4 w-4" />
{t("contentManagement.moveDepartment")}
</DropdownMenuItem>
{onArchiveDepartment && (
<DropdownMenuItem
onClick={() => handleAction(onArchiveDepartment)}
className="text-amber-700 focus:text-amber-800 dark:text-amber-400 dark:focus:text-amber-300">
<Archive className="mr-2 h-4 w-4" />
{t("contentManagement.archiveDepartment", "Archive Department")}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => handleAction(onDeleteDepartment)}
className="text-red-600 focus:text-red-700 dark:focus:text-red-400">
<Trash className="mr-2 h-4 w-4" />
{t("contentManagement.deleteDepartment")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
};

View File

@@ -0,0 +1,73 @@
import { useState } from "react";
import { MoreHorizontal, Plus } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/shared/common/ui/dropdown-menu";
import { Button } from "@/shared/common/ui/button";
import { t } from "i18next";
interface Organization {
id: string;
name: { am: string; en: string };
units?: any[];
isExpanded?: boolean;
}
interface OrganizationActionsProps {
organization: Organization;
onAddUnit?: (organizationId: string) => void;
showAlways?: boolean;
}
export const OrganizationActions: React.FC<OrganizationActionsProps> = ({
organization,
onAddUnit,
showAlways = false,
}) => {
const [dropdownOpen, setDropdownOpen] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const handleAction = (callback?: (organizationId: string) => void) => {
setDropdownOpen(false);
if (callback) {
callback(organization.id);
}
};
return (
<div
className="action-button"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className={`h-8 w-8 p-0 data-[state=open]:bg-muted ${
!showAlways && !isHovered && !dropdownOpen
? "opacity-0"
: "opacity-100"
} transition-opacity`}
aria-haspopup="menu"
aria-expanded={dropdownOpen}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[160px]">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem onClick={() => handleAction(onAddUnit)}>
<Plus className="mr-2 h-4 w-4" />
{t("contentManagement.addUnit")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
};

View File

@@ -0,0 +1,131 @@
import { useState } from "react";
import {
Building2,
MoreHorizontal,
Plus,
ShieldCheck,
Trash,
Trash2,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/shared/common/ui/dropdown-menu";
import { Button } from "@/shared/common/ui/button";
import { Name } from "../types";
import { useLocalizedName } from "@/shared/common/localizedName";
import { t } from "i18next";
import { useAuth } from "@/shared/context/AuthContext";
interface Unit {
id: string;
name: Name;
}
interface UnitActionsProps {
unit: Unit;
onAddDepartment?: (unitId: string) => void;
onManageUnitAdmin?: (unitId: string) => void;
onEditUnit?: (unitId: string) => void;
onArchiveUnit?: (unitId: string, unitName: string) => void;
onDeleteUnit?: (unitId: string, unitName: string) => void;
onAddFromSubCity?: (unitId: string) => void;
showAlways?: boolean;
}
export const UnitActions: React.FC<UnitActionsProps> = ({
unit,
onAddDepartment,
onManageUnitAdmin,
onEditUnit,
onArchiveUnit,
onDeleteUnit,
onAddFromSubCity,
showAlways = false,
}) => {
const { user } = useAuth();
const isUnitAdmin = user?.roles?.some((role) => role.key === "unit_admin");
const isOrganizationAdmin = user?.roles?.some(
(role) => role.key === "organization_admin",
);
const [dropdownOpen, setDropdownOpen] = useState(false);
const localizedName = useLocalizedName();
const handleAction = (callback?: (unitId: string) => void) => {
setDropdownOpen(false);
if (callback) {
callback(unit.id);
}
};
const handleDeleteAction = (
callback?: (unitId: string, unit: string) => void
) => {
setDropdownOpen(false);
if (callback) {
const name = localizedName(unit.name);
callback(unit.id, name);
}
};
return (
<div
className={
showAlways ? "opacity-100" : "opacity-0 group-hover:opacity-100"
}
>
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0 dark:hover:bg-gray-700">
<MoreHorizontal className="h-4 w-4 dark:text-gray-400" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[160px] dark:bg-gray-700 dark:border-gray-600">
<DropdownMenuLabel className="dark:text-gray-200">Actions</DropdownMenuLabel>
<DropdownMenuItem onClick={() => handleAction(onAddDepartment)} className="dark:text-gray-200 dark:hover:bg-gray-600">
<Plus className="mr-2 h-4 w-4" />
{t("contentManagement.addDepartment")}
</DropdownMenuItem>
{isOrganizationAdmin && onManageUnitAdmin && (
<DropdownMenuItem
onClick={() => handleAction(onManageUnitAdmin)}
className="dark:text-gray-200 dark:hover:bg-gray-600"
>
<ShieldCheck className="mr-2 h-4 w-4" />
{t("contentManagement.manageUnitAdmin", "Manage Unit Admin")}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => handleAction(onEditUnit)} className="dark:text-gray-200 dark:hover:bg-gray-600">
<Plus className="mr-2 h-4 w-4" />
{t("contentManagement.editUnit")}
</DropdownMenuItem>
{isUnitAdmin && onAddFromSubCity && (
<DropdownMenuItem onClick={() => handleAction(onAddFromSubCity)} className="dark:text-gray-200 dark:hover:bg-gray-600">
<Building2 className="mr-2 h-4 w-4" />
{t("contentManagement.addFromSubCity", "Add From Sub City")}
</DropdownMenuItem>
)}
{onDeleteUnit && (
<DropdownMenuItem
onClick={() => handleDeleteAction(onArchiveUnit)}
className="text-amber-700 focus:text-amber-800 dark:text-amber-400 dark:focus:text-amber-300"
>
<Trash className="mr-2 h-4 w-4" />
{t("contentManagement.archiveUnit", "Archive Unit")}
</DropdownMenuItem>
)}
{onDeleteUnit && (
<DropdownMenuItem
onClick={() => handleDeleteAction(onDeleteUnit)}
className="text-red-700 focus:text-red-800 dark:text-red-400 dark:focus:text-red-300"
>
<Trash2 className="mr-2 h-4 w-4" />
{t("contentManagement.deleteUnit", "Delete Unit")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
};

View File

@@ -0,0 +1,45 @@
import { useLocalizedName } from "@/shared/common/localizedName";
import { ChevronRight } from "lucide-react";
import type { FC } from "react";
interface BreadcrumbItem {
id: string;
name: { am: string; en: string };
}
interface BreadcrumbProps {
breadcrumb: BreadcrumbItem[];
hasTeamMembers: boolean;
}
export const Breadcrumb: FC<BreadcrumbProps> = ({
breadcrumb,
hasTeamMembers,
}) => {
const localizedName = useLocalizedName();
return (
<div className="flex items-center text-sm text-gray-500 dark:text-gray-400 mb-4 overflow-x-auto">
<span className="font-medium text-gray-700 dark:text-gray-300">User Management</span>
{breadcrumb.map((item, index) => (
<div key={item.id} className="flex items-center">
<ChevronRight className="h-4 w-4 mx-2 flex-shrink-0 text-gray-400 dark:text-gray-500" />
<span
className={
index === breadcrumb.length - 1 ? "font-medium text-gray-800 dark:text-gray-200" : ""
}
>
{localizedName(item.name)}
</span>
</div>
))}
{hasTeamMembers && (
<div className="flex items-center">
<ChevronRight className="h-4 w-4 mx-2 flex-shrink-0 text-gray-400 dark:text-gray-500" />
<span className="font-medium text-gray-800 dark:text-gray-200">Team Members</span>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,255 @@
import React, { useMemo } from "react";
import { Input } from "@/shared/common/ui/input";
import { Button } from "@/shared/common/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/common/ui/dialog";
import { Search, UserCircle, AlertCircle, X } from "lucide-react";
import {
useOrganizationEmployeeSearch,
OrganizationEmployee,
} from "../../hooks/useOrganizationEmployeeSearch";
import { t } from "i18next";
import i18n from "@/i18n";
import { toast } from "sonner";
import { TeamMembers } from "../TeamMembers";
import { TeamMemberDto } from "../../dto/teamMember/teamMember";
import { resendVerificationCode } from "@/shared/services/authService";
interface OrganizationEmployeeSearchProps {
organizationId?: string;
isOpen: boolean;
onClose: () => void;
}
// Convert EmployeeWithUnitDto to TeamMemberDto format
const convertToTeamMember = (
employee: OrganizationEmployee,
organizationId: string
): TeamMemberDto => {
const primaryPosition = employee.employeePositions?.[0];
const userName = employee.user?.name ?? { en: "", am: "" };
const positionName = primaryPosition?.position?.name ?? { en: "", am: "" };
return {
organizationId,
status: "Active",
id: employee.id,
isCurrent: true,
name: primaryPosition
? `${userName.en || userName.am} - ${positionName.en || positionName.am}`
: (userName.en || userName.am || null),
employeePositions: employee.employeePositions || [],
user: {
id: employee.user.id,
name: userName,
username: employee.user.email.split("@")[0],
email: employee.user.email,
userType: "employee",
sharepointId: null,
status: "pending",
phoneNumber: "",
hasSetPassword: false,
},
};
};
export const OrganizationEmployeeSearch: React.FC<
OrganizationEmployeeSearchProps
> = ({ organizationId, isOpen, onClose }) => {
const {
filteredEmployees,
totalCount,
filteredCount,
searchQuery,
setSearchQuery,
isLoading,
error,
} = useOrganizationEmployeeSearch(organizationId);
// Convert filtered employees to TeamMemberDto format
const convertedEmployees = useMemo(() => {
if (!organizationId) return [];
return filteredEmployees.map((employee) =>
convertToTeamMember(employee, organizationId)
);
}, [filteredEmployees, organizationId]);
// Action handlers for TeamMembers
const handleInviteEmployee = async (employee: TeamMemberDto) => {
const lang = i18n.language;
try {
// Show immediate feedback that action is being processed
if (employee.user.status === "pending") {
toast.loading(t("search.resendingVerification"));
} else {
toast.loading(t("search.sendingInvitation"));
}
await resendVerificationCode({
email: employee.user.email,
phoneNumber: employee.user.phoneNumber,
});
// Show success notification with more specific message for resend
const userName =
lang === "en" ? employee.user.name.en : employee.user.name.am;
if (employee.user.status === "pending") {
toast.success(t("search.verificationCodeResent"), {
description: t("search.verificationCodeSentTo", { name: userName }),
duration: 4000,
});
} else {
toast.success(t("search.invitationSent"), {
description: t("search.invitationSentTo", { name: userName }),
duration: 4000,
});
}
// Refresh the employee list to update statuses
// Note: We could add a refetch function here if needed
} catch (error: unknown) {
// Show error notification
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
const failureMessage =
employee.user.status === "pending"
? t("search.failedToResend")
: t("search.failedToSendInvitation");
toast.error("Error", {
description: `${failureMessage}: ${errorMessage}`,
duration: 4000,
});
}
};
const handleDeleteEmployee = (
departmentId: string,
departmentName: string,
teamMemberId: string,
teamMemberName: string
) => {
// Simple confirmation for remove action
const confirmed = window.confirm(
t("search.confirmRemoveEmployee", { name: teamMemberName })
);
if (confirmed) {
toast.info(t("search.removeFunctionalityNotImplemented"));
}
};
const renderContent = () => {
if (isLoading) {
return (
<div className="flex justify-center py-12">
<div className="flex flex-col items-center">
<div className="w-8 h-8 border-4 border-t-blue-500 border-blue-200 rounded-full animate-spin mb-2"></div>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t("search.searchingEmployees")}
</p>
</div>
</div>
);
}
if (error) {
return (
<div className="text-center py-12 text-red-500 dark:text-red-400">
<AlertCircle className="h-12 w-12 mx-auto mb-2" />
<p className="text-sm font-medium">
{t("search.errorLoadingEmployees")}
</p>
<p className="text-xs mt-1 dark:text-gray-400">
{error instanceof Error ? error.message : "Unknown error"}
</p>
</div>
);
}
if (filteredEmployees.length === 0 && searchQuery.trim()) {
return (
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
<Search className="h-12 w-12 mx-auto mb-2 text-gray-300 dark:text-gray-600" />
<p className="text-sm font-medium">{t("search.noEmployeesFound")}</p>
<p className="text-xs mt-1 text-gray-400 dark:text-gray-500">
{t("search.tryDifferentKeywords")}
</p>
</div>
);
}
if (filteredEmployees.length === 0) {
return (
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
<UserCircle className="h-12 w-12 mx-auto mb-2 text-gray-300 dark:text-gray-600" />
<p className="text-sm font-medium">
{t("search.startTypingToSearch")}
</p>
<p className="text-xs mt-1 text-gray-400 dark:text-gray-500">
{t("search.searchByNameEmailPhone")}
</p>
</div>
);
}
// Use TeamMembers component for consistent UI and actions
return (
<TeamMembers
departmentId="" // Not applicable for organization-wide search
departmentName={t("search.allEmployees")}
employees={convertedEmployees}
isLoading={false}
searchQuery="" // We handle search at this level
onInviteEmployee={handleInviteEmployee}
onDeleteEmployee={handleDeleteEmployee}
/>
);
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden dark:bg-gray-800">
<DialogHeader className="flex flex-row items-center justify-between">
<DialogTitle className="flex items-center gap-2 dark:text-white">
<Search className="h-5 w-5 dark:text-gray-400" />
{t("search.searchAllEmployees")}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Search Input */}
<div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-gray-400 dark:text-gray-500" />
<Input
placeholder={t("search.searchByNameEmailPhonePosition")}
className="pl-10 dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
autoFocus
/>
</div>
{/* Results Count */}
{searchQuery.trim() && (
<div className="text-sm text-gray-600 dark:text-gray-400">
{t("search.showingResults", {
count: filteredCount,
total: totalCount,
query: searchQuery,
})}
</div>
)}
{/* Results */}
<div className="max-h-96 overflow-y-auto">{renderContent()}</div>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,146 @@
import React, { useState } from "react";
import { Button } from "@/shared/common/ui/button";
import { X, ChevronLeft, ChevronRight } from "lucide-react";
import { t } from "i18next";
interface UnitSelectionModalProps {
units: unknown[];
isLoading: boolean;
onSelectUnit: (unitId: string) => void;
onClose: () => void;
}
const UNITS_PER_PAGE = 10;
export const UnitSelectionModal: React.FC<UnitSelectionModalProps> = ({
units,
isLoading,
onSelectUnit,
onClose,
}) => {
const [currentPage, setCurrentPage] = useState(0);
const typedUnits = units
.filter(
(unit): unit is { id: string; name: any; description?: string } =>
typeof unit === "object" &&
unit !== null &&
"id" in unit &&
"name" in unit
);
const totalPages = Math.ceil(typedUnits.length / UNITS_PER_PAGE);
const startIndex = currentPage * UNITS_PER_PAGE;
const endIndex = startIndex + UNITS_PER_PAGE;
const currentUnits = typedUnits.slice(startIndex, endIndex);
const handleNext = () => {
if (currentPage < totalPages - 1) {
setCurrentPage(currentPage + 1);
}
};
const handlePrevious = () => {
if (currentPage > 0) {
setCurrentPage(currentPage - 1);
}
};
return (
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-2xl w-full mx-4 shadow-xl">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold dark:text-white">
{t("selectUnit")}
</h3>
<button
onClick={onClose}
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 focus:outline-none dark:hover:bg-gray-700 rounded"
aria-label="Close"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Content */}
{isLoading ? (
<div className="text-center py-12">
<div className="flex flex-col items-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-2 dark:text-gray-400">{t("loadingUnits")}</p>
</div>
</div>
) : (
<>
{/* Units List */}
<div className="space-y-2 mb-4">
{currentUnits.length > 0 ? (
currentUnits.map((unit) => (
<button
key={unit.id}
onClick={() => onSelectUnit(unit.id)}
className="w-full text-left p-4 rounded border border-gray-200 hover:bg-gray-100 hover:shadow-sm transition-all focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer dark:border-gray-600 dark:hover:bg-gray-700 dark:focus:ring-blue-400"
>
<div className="font-medium dark:text-gray-200 truncate">
{unit.name?.en || unit.name}
</div>
{unit.description && (
<div className="text-sm text-gray-500 dark:text-gray-400 truncate mt-1">
{unit.description}
</div>
)}
</button>
))
) : (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<p>{t("noUnitsFound")}</p>
</div>
)}
</div>
{/* Pagination Info */}
{totalPages > 1 && (
<div className="text-center text-sm text-gray-600 dark:text-gray-400 mb-4">
{t("page", {
current: currentPage + 1,
total: totalPages,
defaultValue: `Page ${currentPage + 1} of ${totalPages}`,
})}
</div>
)}
{/* Footer with Pagination */}
<div className="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
<Button
variant="outline"
size="sm"
onClick={handlePrevious}
disabled={currentPage === 0}
className="dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
<ChevronLeft className="h-4 w-4 mr-1" />
{t("previous")}
</Button>
<span className="text-sm text-gray-600 dark:text-gray-400">
{typedUnits.length > 0
? `${startIndex + 1} - ${Math.min(endIndex, typedUnits.length)} of ${typedUnits.length}`
: "0"}
</span>
<Button
variant="outline"
size="sm"
onClick={handleNext}
disabled={currentPage >= totalPages - 1}
className="dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
{t("next")}
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
</>
)}
</div>
);
};

View File

@@ -0,0 +1,46 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/common/ui/dialog";
import { AssignUserForm } from "../forms/AssignUserForm";
interface AssignUserDialogProps {
isOpen: boolean;
onClose: () => void;
departmentId: string;
departmentName: string;
unitId: string;
}
export function AddAssignUserDialog({
isOpen,
onClose,
departmentId,
departmentName,
unitId,
}: AssignUserDialogProps) {
const handleSuccess = () => {
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-[600px] max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>Assign User to {departmentName}</DialogTitle>
</DialogHeader>
<div className="mt-4 overflow-y-auto flex-1">
<AssignUserForm
positionId={departmentId}
unitId={unitId}
positionName={departmentName}
onSuccess={handleSuccess}
onCancel={onClose}
/>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,41 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/common/ui/dialog";
import { AddDepartmentForm } from "../forms/AddDepartmentForm";
import { t } from "i18next";
interface DepartmentDialogProps {
isOpen: boolean;
onClose: () => void;
organizationId: string;
unitId: string;
}
export function AddDepartmentDialog({
isOpen,
onClose,
organizationId,
unitId,
}: DepartmentDialogProps) {
const handleSuccess = (departmentName: string) => {
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{t("contentManagement.addDepartment")}</DialogTitle>
</DialogHeader>
<AddDepartmentForm
unitId={unitId}
organizationId={organizationId}
onSuccess={handleSuccess}
onCancel={onClose}
/>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,39 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/common/ui/dialog";
import { t } from "i18next";
import { AddFromSubCityForm } from "../forms/AddFromSubCityForm";
interface AddFromSubCityDialogProps {
isOpen: boolean;
onClose: () => void;
parentUnitId: string;
}
export function AddFromSubCityDialog({
isOpen,
onClose,
parentUnitId,
}: AddFromSubCityDialogProps) {
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-[480px] max-w-[calc(100vw-2rem)] overflow-hidden">
<DialogHeader>
<DialogTitle className="truncate">
{t("contentManagement.addFromSubCity", "Add From Sub City")}
</DialogTitle>
</DialogHeader>
<div className="min-w-0">
<AddFromSubCityForm
parentUnitId={parentUnitId}
onSuccess={onClose}
onCancel={onClose}
/>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,47 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/common/ui/dialog";
import { AddSubDepartmentForm } from "../forms/AddSubDepartmentForm";
interface SubDepartmentDialogProps {
isOpen: boolean;
onClose: () => void;
departmentId: string;
departmentName: string;
organizationId: string;
unitId: string;
}
export function AddSubDepartmentDialog({
isOpen,
onClose,
departmentId,
organizationId,
unitId,
departmentName,
}: SubDepartmentDialogProps) {
const handleSuccess = (subDepartmentName: string) => {
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add New Sub-Department</DialogTitle>
</DialogHeader>
<AddSubDepartmentForm
departmentId={departmentId}
departmentName={departmentName}
organizationId={organizationId}
unitId={unitId}
onSuccess={handleSuccess}
onCancel={onClose}
/>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,39 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/common/ui/dialog";
import { t } from "i18next";
import { AddUnitForm } from "../forms/AddUnitForm";
interface UnitDialogProps {
isOpen: boolean;
onClose: () => void;
organizationId: string;
}
export function AddUnitDialog({
isOpen,
onClose,
organizationId,
}: UnitDialogProps) {
const handleSuccess = (unitName: string) => {
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{t("contentManagement.addNewUnit")}</DialogTitle>
</DialogHeader>
<AddUnitForm
organizationId={organizationId}
onSuccess={handleSuccess}
onCancel={onClose}
/>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,34 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/common/ui/dialog";
import { AddUserForm } from "../forms/AddUserForm";
interface UserDialogProps {
isOpen: boolean;
onClose: () => void;
unitId: string;
}
export function AddUserDialog({ isOpen, onClose, unitId }: UserDialogProps) {
const handleSuccess = () => {
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Add New User</DialogTitle>
</DialogHeader>
<AddUserForm
unitId={unitId}
onSuccess={handleSuccess}
onCancel={onClose}
/>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,46 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/common/ui/dialog";
import { AddUserUnderDepartmentForm } from "../forms/AddUserUnderDepartmentForm";
import { useTranslation } from "react-i18next";
interface AddUserUnderDepartmentDialogProps {
isOpen: boolean;
onClose: () => void;
unitId: string;
departmentId: string;
departmentName: string;
}
export function AddUserUnderDepartmentDialog({
isOpen,
onClose,
unitId,
departmentId,
departmentName,
}: AddUserUnderDepartmentDialogProps) {
const { t } = useTranslation();
const handleSuccess = () => {
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-[525px]">
<DialogHeader>
<DialogTitle>{t("userRecord.Add User Under Department")}</DialogTitle>
</DialogHeader>
<AddUserUnderDepartmentForm
unitId={unitId}
departmentId={departmentId}
departmentName={departmentName}
onSuccess={handleSuccess}
onCancel={onClose}
/>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,76 @@
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/common/ui/alert-dialog";
import { Button } from "@/shared/common/ui/button";
import { useEmployeePositions } from "@/user-management/hooks/useEmployeePostions";
import { useQueryClient } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
interface DeactivateTeamMemberDialogProps {
isOpen: boolean;
onClose: () => void;
userId: string;
teamMemberName: string;
}
export const DeactivateTeamMemberDialog: React.FC<
DeactivateTeamMemberDialogProps
> = ({ isOpen, onClose, userId, teamMemberName }) => {
const { deactivateUser, isDeActivatingUser } = useEmployeePositions();
const queryClient = useQueryClient();
const onDelete = async () => {
try {
await deactivateUser({
payload: userId,
successCallback: () => {
queryClient.invalidateQueries({
queryKey: ["positionEmployees"],
exact: true,
});
onClose();
},
});
} catch (error) {
console.error("Error deactivating user:", error);
}
};
return (
<AlertDialog open={isOpen} onOpenChange={onClose}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Deactivate team member from organization?
</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to deactivate <strong>{teamMemberName}</strong>? This will remove their access from the organization. This action can be reversed only by activating them again through the Archived Users section by contacting the administrator.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeActivatingUser}>
Cancel
</AlertDialogCancel>
<Button
variant="destructive"
onClick={onDelete}
disabled={isDeActivatingUser}>
{isDeActivatingUser && (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
)}
Deactivate
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
export default DeactivateTeamMemberDialog;

View File

@@ -0,0 +1,95 @@
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/common/ui/alert-dialog";
import { Button } from "@/shared/common/ui/button";
import { usePositions } from "@/user-management/hooks/usePosition";
import { Loader2, AlertTriangle } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import { toast } from "sonner";
interface DeleteDepartmentDialogProps {
departmentName: string;
departmentId: string;
isOpen: boolean;
onClose: () => void;
}
export const DeleteDepartmentDialog: React.FC<DeleteDepartmentDialogProps> = ({
departmentName,
departmentId,
isOpen,
onClose,
}) => {
const { t } = useTranslation();
const [isDeleting, setIsDeleting] = useState(false);
const { deletePosition: deletePositionMutation, isDeleting: isMutationDeleting } = usePositions();
const handleDelete = async () => {
setIsDeleting(true);
try {
await deletePositionMutation({
id: departmentId,
successCallback: () => {
toast.success(t("department.deletedSuccessfully", "Department removed successfully"));
onClose();
},
});
} catch (error: any) {
// Handle error - the mutation will already show toast via hook
// Just close loading state
} finally {
setIsDeleting(false);
}
};
return (
<AlertDialog open={isOpen} onOpenChange={onClose}>
<AlertDialogContent className="max-w-md">
<AlertDialogHeader>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 mt-0.5">
<AlertTriangle className="h-5 w-5 text-red-600 dark:text-red-400" />
</div>
<div className="flex-1">
<AlertDialogTitle className="text-lg font-semibold text-red-900 dark:text-red-100">
{t("department.deleteConfirm", "Delete Department?")}
</AlertDialogTitle>
<AlertDialogDescription className="mt-2 text-sm text-gray-700 dark:text-gray-300">
This will permanently delete <strong className="font-semibold text-gray-900 dark:text-gray-100">{departmentName}</strong> and
all its sub-items. This action cannot be undone.
</AlertDialogDescription>
</div>
</div>
</AlertDialogHeader>
<div className="bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800/50 rounded-lg p-3 my-2">
<p className="text-sm text-red-800 dark:text-red-200">
{t("department.deleteWarning", "Make sure all employees have been reassigned before deleting.")}
</p>
</div>
<AlertDialogFooter className="gap-2">
<AlertDialogCancel disabled={isDeleting || isMutationDeleting}>
{t("common.Cancel", "Cancel")}
</AlertDialogCancel>
<Button
variant="destructive"
onClick={handleDelete}
disabled={isDeleting || isMutationDeleting}
className="gap-2"
>
{(isDeleting || isMutationDeleting) && <Loader2 className="h-4 w-4 animate-spin" />}
{isDeleting || isMutationDeleting ? t("common.deleting", "Deleting...") : t("department.delete", "Delete")}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -0,0 +1,74 @@
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/common/ui/alert-dialog";
import { Button } from "@/shared/common/ui/button";
import { TeamMemberDto } from "@/user-management/dto/teamMember/teamMember";
import { useEmployeePositions } from "@/user-management/hooks/useEmployeePostions";
import { Loader2 } from "lucide-react";
interface DeleteTeamMemberDialogProps {
isOpen: boolean;
onClose: () => void;
departmentId: string;
departmentName: string;
teamMemberId: string;
teamMemberName: string;
}
export const DeleteTeamMemberDialog = ({
departmentId,
departmentName,
isOpen,
onClose,
teamMemberId,
teamMemberName,
}: DeleteTeamMemberDialogProps) => {
if (!teamMemberId) return null;
const { setInactive, isDeActivatingUser } = useEmployeePositions();
const payload = {
employeeId: teamMemberId,
positionId: departmentId,
};
const onDelete = () => {
setInactive({
payload,
successCallback: onClose,
});
};
return (
<AlertDialog open={isOpen} onOpenChange={onClose}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Remove team member from this position?
</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to remove <strong>{teamMemberName}</strong> from this position? They will be marked as inactive for this position. This action can be reversed by reassigning them to the position later.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeActivatingUser}>
Cancel
</AlertDialogCancel>
<Button
variant="destructive"
onClick={onDelete}
disabled={isDeActivatingUser}
>
{isDeActivatingUser && (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
)}
Remove
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -0,0 +1,68 @@
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/common/ui/alert-dialog";
import { Button } from "@/shared/common/ui/button";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { useUnit } from "@/user-management/hooks/useUnit";
import { useTranslation } from "react-i18next";
interface DeleteUnitDialogProps {
unitName: string;
unitId: string;
isOpen: boolean;
onClose: () => void;
}
export const DeleteUnitDialog: React.FC<DeleteUnitDialogProps> = ({
unitName,
unitId,
isOpen,
onClose,
}) => {
const { removeUnit, isDeleting } = useUnit();
const { t } = useTranslation();
const handleDelete = async () => {
try {
await removeUnit(unitId);
toast.success(t("archive.unitArchivedSuccess", "Unit archived successfully."));
onClose();
} catch {
// Hook's onError already routed through handleError; nothing to do here.
}
};
return (
<AlertDialog open={isOpen} onOpenChange={onClose}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("archive.archiveUnitConfirm", "Are you sure you want to archive this unit?")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("archive.archiveUnitDescription", "This action will archive the unit")} {" "}
<strong>{unitName}</strong>. {t("archive.archiveUnitCanBeRestored", "It can be restored later from the Archived Units section.")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>{t("common.Cancel")}</AlertDialogCancel>
<Button
variant="destructive"
onClick={handleDelete}
disabled={isDeleting}
>
{isDeleting && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
{t("archive.archiveUnit", "Archive Unit")}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -0,0 +1,42 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/common/ui/dialog";
import { EditDepartmentForm } from "../forms/EditDepartmentForm";
import { PositionDto } from "@/user-management/dto/positions/positionDto";
interface DepartmentDialogProps {
isOpen: boolean;
onClose: () => void;
postions: PositionDto[];
departmentId: string;
departmentName: string;
}
export function EditDepartmentDialog({
isOpen,
onClose,
postions,
departmentId,
departmentName,
}: DepartmentDialogProps) {
const handleSuccess = (departmentName: string) => {
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Edit Department {departmentName}</DialogTitle>
</DialogHeader>
<EditDepartmentForm
departmentId={departmentId}
onSuccess={handleSuccess}
onCancel={onClose}
/>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,62 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/common/ui/dialog";
import { Loader2 } from "lucide-react";
import { EditUnitForm } from "../forms/EditUnitForm";
import { UnitDto } from "@/user-management/dto/unit/unitDto";
import { useUnit } from "@/user-management/hooks/useUnit";
interface UnitDialogProps {
isOpen: boolean;
onClose: () => void;
units: UnitDto[];
unitId: string;
}
export function EditUnitDialog({
isOpen,
onClose,
units,
unitId,
}: UnitDialogProps) {
const handleSuccess = () => {
onClose();
};
const fromList = units?.find((p) => p.id === unitId);
// Related units (from /units/child-units/...) aren't in the top-level list,
// so fetch by id when we don't already have the full record.
const { getById } = useUnit();
const { data: byIdResp, isLoading } = getById(fromList ? "" : unitId);
const fetched = (byIdResp?.data ?? null) as UnitDto | null;
const currentUnit = fromList ?? fetched;
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="min-w-0 overflow-hidden sm:max-w-[425px]">
<DialogHeader className="min-w-0">
<DialogTitle className="max-w-full break-words pr-6 [overflow-wrap:anywhere]">
Edit Unit {currentUnit?.name?.en}
</DialogTitle>
</DialogHeader>
{!currentUnit ? (
<div className="flex justify-center py-6">
{isLoading ? (
<Loader2 className="h-5 w-5 animate-spin text-gray-500" />
) : (
<span className="text-sm text-gray-500">Unit not found.</span>
)}
</div>
) : (
<EditUnitForm
unit={currentUnit}
onSuccess={handleSuccess}
onCancel={onClose}
/>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,216 @@
import React, { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/common/ui/dialog";
import { Button } from "@/shared/common/ui/button";
import { Badge } from "@/shared/common/ui/badge";
import { AlertCircle, Trash2, Briefcase } from "lucide-react";
import { t } from "i18next";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { deleteEmployeePosition } from "../../services/api/employeePositionsService";
import { useLocalizedName } from "@/shared/common/localizedName";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/common/ui/alert-dialog";
export interface EmployeePosition {
id: string;
employeeId: string;
position: {
id: string;
name: {
am: string;
en: string;
};
};
}
interface EmployeePositionsDialogProps {
isOpen: boolean;
onClose: () => void;
employeeName: string;
positions: EmployeePosition[];
onPositionRemoved?: () => void;
}
export const EmployeePositionsDialog: React.FC<
EmployeePositionsDialogProps
> = ({ isOpen, onClose, employeeName, positions, onPositionRemoved }) => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [positionToDelete, setPositionToDelete] = useState<EmployeePosition | null>(null);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const localizedName = useLocalizedName();
const handleRemovePosition = async (position: EmployeePosition) => {
setLoading(true);
try {
await deleteEmployeePosition(position.id);
toast.success(t("positions.positionRemoved"), {
description: t("positions.employeeRemovedFromPosition", {
position: localizedName(position.position.name),
}),
duration: 4000,
});
setShowConfirmDialog(false);
setPositionToDelete(null);
// Call callback to refresh positions list
if (onPositionRemoved) {
onPositionRemoved();
}
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
// Check if it's a "Referenced Entity does not exist" error
if (errorMessage.includes("Referenced Entity")) {
toast.error(t("positions.positionAlreadyRemoved"), {
description: t("positions.positionMayHaveBeenRemovedAlready"),
duration: 4000,
});
// Still close the dialog and refresh
setShowConfirmDialog(false);
setPositionToDelete(null);
if (onPositionRemoved) {
onPositionRemoved();
}
} else {
toast.error(t("positions.failedToRemovePosition"), {
description: errorMessage,
duration: 4000,
});
}
} finally {
setLoading(false);
}
};
const handleOpenConfirm = (position: EmployeePosition) => {
setPositionToDelete(position);
setShowConfirmDialog(true);
};
return (
<>
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-lg dark:bg-gray-800">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 dark:text-white">
<Briefcase className="h-5 w-5 text-blue-600 dark:text-blue-400" />
{t("positions.employeePositions")}
</DialogTitle>
</DialogHeader>
<div className="py-4 space-y-4">
{/* Employee Name */}
<div className="px-4 py-3 bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">
{t("positions.employee")}
</p>
<p className="text-sm font-medium text-gray-800 dark:text-gray-200">
{employeeName}
</p>
</div>
{/* Positions List */}
{positions && positions.length > 0 ? (
<div className="space-y-3 max-h-96 overflow-y-auto">
{positions
.filter((position) => position && position.position && position.position.name)
.map((position) => (
<div
key={position.id}
className="flex items-center justify-between p-4 rounded-lg border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-700/50 hover:border-gray-300 dark:hover:border-gray-500 transition-colors">
<div className="flex items-center gap-3 flex-1">
<div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900/40 text-blue-600 dark:text-blue-400">
<Briefcase className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-gray-800 dark:text-gray-200 truncate">
{localizedName(position.position.name)}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t("positions.positionId")}: {position.id.slice(0, 8)}...
</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenConfirm(position)}
className="ml-2 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 dark:text-red-400 dark:hover:text-red-300"
disabled={loading}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 text-gray-500 dark:text-gray-400">
<AlertCircle className="h-12 w-12 mb-2 text-gray-300 dark:text-gray-600" />
<p className="text-sm font-medium">
{t("positions.noPositions")}
</p>
<p className="text-xs mt-1 text-gray-400 dark:text-gray-500">
{t("positions.employeeHasNoPositions")}
</p>
</div>
)}
</div>
<div className="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-600">
<Button
variant="outline"
onClick={onClose}
className="dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700">
{t("common.close")}
</Button>
</div>
</DialogContent>
</Dialog>
{/* Confirmation Dialog for Deletion */}
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<AlertDialogContent className="dark:bg-gray-800">
<AlertDialogHeader>
<AlertDialogTitle className="dark:text-white">
{t("positions.removeFromPosition")}
</AlertDialogTitle>
<AlertDialogDescription className="dark:text-gray-400">
{t("positions.confirmRemoveFromPosition", {
employee: employeeName,
position: positionToDelete
? localizedName(positionToDelete.position.name)
: "",
})}
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex justify-end gap-3 pt-4">
<AlertDialogCancel className="dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 dark:border-gray-600">
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={() =>
positionToDelete && handleRemovePosition(positionToDelete)
}
disabled={loading}
className="bg-red-600 hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-600">
{loading ? t("common.removing") : t("positions.remove")}
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialog>
</>
);
};

View File

@@ -0,0 +1,251 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, Plus, ShieldCheck } from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Button } from "@/shared/common/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/common/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/common/ui/table";
import { useLocalizedName } from "@/shared/common/localizedName";
import { AssignedAdminsDto } from "@/shared/dto/organization/orgAdminsDto";
import { AssignAdminDialog } from "@/super-admin/components/organizationAdmins/AssignAdminDialog";
import { AdminRemoveConfirm } from "@/super-admin/components/organizationAdmins/RemoveAdminConfirm";
import { fetchUnitAdminById } from "@/super-admin/services/api/organizationAdminService";
import { removeUnitAdminRole } from "@/super-admin/services/api/userRoleService";
interface ManageUnitAdminDialogProps {
isOpen: boolean;
onClose: () => void;
organizationId: string;
unitId: string;
}
const normalizeAdmins = (data: unknown): AssignedAdminsDto[] => {
if (Array.isArray(data)) return data as AssignedAdminsDto[];
if (
data &&
typeof data === "object" &&
"items" in data &&
Array.isArray((data as { items?: unknown[] }).items)
) {
return (data as { items: AssignedAdminsDto[] }).items;
}
return [];
};
export function ManageUnitAdminDialog({
isOpen,
onClose,
organizationId,
unitId,
}: ManageUnitAdminDialogProps) {
const { t } = useTranslation();
const localizedName = useLocalizedName();
const queryClient = useQueryClient();
const [isAssignDialogOpen, setIsAssignDialogOpen] = useState(false);
const [removingUserId, setRemovingUserId] = useState<string | null>(null);
const {
data: admins = [],
isLoading,
isError,
refetch,
} = useQuery({
queryKey: ["unitAdmins", unitId],
queryFn: async () => {
const response = await fetchUnitAdminById(unitId, {
take: 300,
skip: 0,
});
return normalizeAdmins(response.data);
},
enabled: isOpen && !!unitId,
staleTime: 0,
});
const handleRemove = async (userId: string) => {
setRemovingUserId(userId);
try {
await removeUnitAdminRole({ unitId, userId });
await queryClient.invalidateQueries({
queryKey: ["unitAdmins", unitId],
});
toast.success(
t(
"organization.unitAdminRemovedSuccess",
"Unit admin removed successfully",
),
);
} catch (error: any) {
toast.error(
t("organization.unitAdminRemoveFailed", "Failed to remove unit admin"),
{
description: error?.response?.data?.message,
},
);
} finally {
setRemovingUserId(null);
}
};
const handleAssigned = async () => {
setIsAssignDialogOpen(false);
await queryClient.invalidateQueries({
queryKey: ["unitAdmins", unitId],
});
};
return (
<>
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open && !isAssignDialogOpen) onClose();
}}
>
<DialogContent className="sm:max-w-[850px]">
<DialogHeader>
<DialogTitle>
{t("contentManagement.manageUnitAdmin", "Manage Unit Admin")}
</DialogTitle>
<DialogDescription>
{t(
"organization.manageUnitAdminInstructions",
"View, add, or remove administrators assigned to this unit.",
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium">
{t("organization.currentUnitAdmins", "Current unit admins")}
</h3>
<p className="text-sm text-muted-foreground">
{t(
"organization.currentUnitAdminsDescription",
"Users who can administer this unit.",
)}
</p>
</div>
<Button onClick={() => setIsAssignDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
{t("organization.addAdmin", "Add admin")}
</Button>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("organization.name", "Name")}</TableHead>
<TableHead>{t("common.email", "Email")}</TableHead>
<TableHead>{t("common.status", "Status")}</TableHead>
<TableHead className="text-right">
{t("common.actions", "Actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={4} className="h-32 text-center">
<Loader2 className="mx-auto h-6 w-6 animate-spin text-primary" />
</TableCell>
</TableRow>
) : isError ? (
<TableRow>
<TableCell colSpan={4} className="h-32 text-center">
<div className="space-y-3">
<p className="text-sm text-destructive">
{t(
"organization.errorLoadingAdmins",
"Failed to load admins.",
)}
</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => refetch()}
>
{t("organization.retry", "Retry")}
</Button>
</div>
</TableCell>
</TableRow>
) : admins.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="h-32 text-center">
<ShieldCheck className="mx-auto mb-2 h-7 w-7 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
{t(
"organization.noUnitAdminsAssigned",
"No admins are assigned to this unit.",
)}
</p>
</TableCell>
</TableRow>
) : (
admins.map((admin) => (
<TableRow key={admin.id}>
<TableCell className="font-medium">
{localizedName(admin.name) || admin.username}
</TableCell>
<TableCell>{admin.email || "—"}</TableCell>
<TableCell className="capitalize">
{admin.status || "—"}
</TableCell>
<TableCell className="text-right">
<AdminRemoveConfirm
onConfirm={() => handleRemove(admin.id)}
loading={removingUserId === admin.id}
/>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
{t("common.close", "Close")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{isAssignDialogOpen && (
<AssignAdminDialog
isOpen={isAssignDialogOpen}
onClose={() => setIsAssignDialogOpen(false)}
onSuccess={handleAssigned}
organizationId={organizationId}
unitId={unitId}
/>
)}
</>
);
}

View File

@@ -0,0 +1,43 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/common/ui/dialog";
import { MoveDepartmentForm } from "../forms/MoveDepartmentForm";
interface DepartmentDialogProps {
isOpen: boolean;
onClose: () => void;
organizationId: string;
unitId: string;
departmentId:string;
}
export function MoveDepartmentDialog({
isOpen,
onClose,
departmentId,
organizationId,
unitId,
}: DepartmentDialogProps) {
const handleSuccess = (departmentName: string) => {
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Move Department</DialogTitle>
</DialogHeader>
<MoveDepartmentForm
unitId={unitId}
organizationId={organizationId}
onSuccess={handleSuccess}
onCancel={onClose}
departmentId={departmentId}
/>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,82 @@
import { useLocalizedName } from "@/shared/common/localizedName";
import { Check, X, ClipboardCheck } from "lucide-react";
interface LocaleValidationDto {
am?: string;
en: string;
}
interface ApprovalCardProps {
id: string;
name: LocaleValidationDto;
unit?: LocaleValidationDto;
time: string;
onApprove: (id: string) => void;
onReject: (id: string) => void;
onClick: (id: string) => void;
isLoading?: boolean;
}
const ApprovalCard = ({
id,
name,
unit,
time,
onApprove,
onReject,
onClick,
isLoading,
}: ApprovalCardProps) => {
const localizedName = useLocalizedName();
return (
<div
className="flex items-start gap-3 p-3 rounded-md border bg-yellow-50 border-yellow-300 hover:shadow-sm transition-all cursor-pointer"
onClick={() => onClick(id)}
>
{/* Icon */}
<div className="mt-1">
<ClipboardCheck className="w-5 h-5 text-yellow-600" />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<h4 className="font-medium text-sm text-gray-900 break-all">
{localizedName(name as { am: string; en: string })}
</h4>
{unit && (
<p className="text-xs text-gray-600">
{localizedName(unit as { am: string; en: string })}
</p>
)}
<p className="text-[11px] text-gray-400 mt-1">{time}</p>
</div>
{/* Actions */}
<div
className="flex items-center gap-2"
onClick={(e) => e.stopPropagation()} // prevent card click
>
<button
onClick={() => onApprove(id)}
disabled={isLoading}
className="p-1.5 rounded-md bg-primary-100 hover:bg-primary-200 transition"
>
<Check className="w-4 h-4 text-primary-600" />
</button>
<button
onClick={() => onReject(id)}
disabled={isLoading}
className="p-1.5 rounded-md bg-red-100 hover:bg-red-200 transition"
>
<X className="w-4 h-4 text-red-600" />
</button>
</div>
</div>
);
};
export default ApprovalCard;

View File

@@ -0,0 +1,122 @@
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/common/ui/alert-dialog";
import { Button } from "@/shared/common/ui/button";
import { Loader2, AlertTriangle } from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { deleteUnit } from "@/user-management/services/api/unitService";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
interface PermanentDeleteUnitDialogProps {
unitName: string;
unitId: string;
isOpen: boolean;
onClose: () => void;
}
export const PermanentDeleteUnitDialog: React.FC<PermanentDeleteUnitDialogProps> = ({
unitName,
unitId,
isOpen,
onClose,
}) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [isDeleting, setIsDeleting] = useState(false);
const handleDelete = async () => {
setIsDeleting(true);
try {
await deleteUnit(unitId);
toast.success(t("archive.unitDeletedPermanently", "Unit permanently deleted"));
queryClient.invalidateQueries({ queryKey: ["unitList"] });
queryClient.invalidateQueries({ queryKey: ["unitChildren"] });
queryClient.invalidateQueries({ queryKey: ["archived-units"] });
onClose();
} catch (error: any) {
// Check if error is due to related entities (units or positions)
if (error?.response?.status === 400 && error?.response?.data?.message) {
const errorMessage = error?.response?.data?.message;
// Check if it's a position-related error
if (errorMessage.includes("Referenced Entity") || errorMessage.includes("referenced")) {
toast.error(
t("archive.cannotDeleteUnitWithEntities", "Cannot delete unit with related positions. Please delete or reassign all positions first."),
{
description: t("archive.deleteRelatedEntitiesFirst", "Remove all related entities before attempting deletion.")
}
);
} else if (errorMessage.includes("position")) {
toast.error(
t("archive.cannotDeleteUnitWithPositions", "Cannot delete unit that has related positions. Please delete or reassign all positions first."),
{
description: t("archive.deleteRelatedEntitiesFirst", "Remove all related entities before attempting deletion.")
}
);
} else {
toast.error(
t("archive.cannotDeleteUnit", "Cannot delete unit"),
{
description: t("archive.deleteRelatedEntitiesFirst", "Remove all related entities before attempting deletion.")
}
);
}
} else {
toast.error(t("archive.deleteUnitFailed", "Failed to delete unit"));
}
} finally {
setIsDeleting(false);
}
};
return (
<AlertDialog open={isOpen} onOpenChange={onClose}>
<AlertDialogContent className="max-w-md">
<AlertDialogHeader>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 mt-0.5">
<AlertTriangle className="h-5 w-5 text-red-600 dark:text-red-400" />
</div>
<div className="flex-1">
<AlertDialogTitle className="text-lg font-semibold text-red-900 dark:text-red-100">
{t("archive.permanentDeleteWarning", "Permanent Deletion")}
</AlertDialogTitle>
<AlertDialogDescription className="mt-2 text-sm text-gray-700 dark:text-gray-300">
{t("archive.deletePermanentlyDescription", "This will permanently delete the unit")} <strong className="font-semibold">{unitName}</strong>. {t("archive.cannotBeUndone", "This action cannot be undone.")}
</AlertDialogDescription>
</div>
</div>
</AlertDialogHeader>
<div className="bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800/50 rounded-md p-3 my-2">
<p className="text-sm text-red-800 dark:text-red-200">
{t("archive.permanentDeleteExplanation", "Once deleted, this unit cannot be recovered. Make sure all related positions have been removed first.")}
</p>
</div>
<AlertDialogFooter className="gap-2">
<AlertDialogCancel disabled={isDeleting} className="sm:w-auto">
{t("common.Cancel", "Cancel")}
</AlertDialogCancel>
<Button
variant="destructive"
onClick={handleDelete}
disabled={isDeleting}
className="gap-2"
>
{isDeleting && <Loader2 className="w-4 h-4 animate-spin" />}
{t("archive.deletePermanently", "Delete Permanently")}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -0,0 +1,34 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/common/ui/dialog";
import { ViewUsers } from "../ViewUsers";
interface ViewUsersDialogProps {
isOpen: boolean;
onClose: () => void;
unitId: string;
unitName?: string;
}
export function ViewUsersDialog({
isOpen,
onClose,
unitId,
unitName,
}: ViewUsersDialogProps) {
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-[800px] max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{unitName ? `Users in ${unitName}` : "Team Members"}
</DialogTitle>
</DialogHeader>
<ViewUsers unitId={unitId} onClose={onClose} />
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,181 @@
import { useState } from "react";
import { Button } from "@/shared/common/ui/button";
import { Input } from "@/shared/common/ui/input";
import { Label } from "@/shared/common/ui/label";
import { usePositions } from "@/user-management/hooks/usePosition";
import { PositionPayload } from "@/user-management/services/api/positionService";
import { toast } from "sonner";
import { usePositionTypes } from "@/user-management/hooks/usePositionTypes";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import { t } from "i18next";
import i18n from "@/i18n";
interface AddDepartmentFormProps {
unitId: string;
organizationId: string;
onSuccess: (departmentName: string) => void;
onCancel: () => void;
}
export function AddDepartmentForm({
unitId,
organizationId,
onSuccess,
onCancel,
}: AddDepartmentFormProps) {
const { createPosition, isCreating } = usePositions();
const { commonPositionTypes, isLoading: isLoadingTypes } = usePositionTypes({
unitId,
});
const [nameEn, setNameEn] = useState("");
const [nameAm, setNameAm] = useState("");
const [key, setKey] = useState("");
const [positionTypeId, setPositionTypeId] = useState("");
const lang = i18n.language;
const [errors, setErrors] = useState<Partial<Record<string, string>>>({});
const validateForm = () => {
const newErrors: Record<string, string> = {};
if (!nameEn.trim())
newErrors.nameEn = t("organization.englishNameRequired");
if (!nameAm.trim())
newErrors.nameAm = t("organization.amharicNameRequired");
if (!key.trim()) newErrors.key = t("contentManagement.keyRequired");
if (!positionTypeId)
newErrors.positionTypeId = t("contentManagement.selectPosType");
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
toast.error(t("contentManagement.invalidMsg"));
return;
}
const payload: PositionPayload = {
name: {
en: nameEn.trim(),
am: nameAm.trim(),
},
key: key.trim().toLowerCase().replace(/\s+/g, "-"),
unitId,
organizationId,
positionTypeId,
};
createPosition({
payload,
successCallback: () => {
toast.success(t("contentManagement.success"), {
description: `${t("userIncoming.Department")} "${
lang === "en" ? nameEn : nameAm
}" ${t("contentManagement.msg")}`,
});
onSuccess(lang === "en" ? nameEn.trim() : nameAm.trim());
},
});
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="positionType">{t("organization.positionTypes")}</Label>
<Select
onValueChange={(value) => {
setPositionTypeId(value);
setErrors((prev) => ({ ...prev, positionTypeId: "" }));
}}
value={positionTypeId}
disabled={isLoadingTypes}
>
<SelectTrigger>
<SelectValue placeholder={t("contentManagement.selectPosType")} />
</SelectTrigger>
<SelectContent className="max-h-60 overflow-y-auto">
{commonPositionTypes.map((type) => (
<SelectItem key={type.id} value={type.id}>
{lang === "en" ? type.name.en : type.name.am}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.positionTypeId && (
<p className="text-red-500 text-sm">{errors.positionTypeId}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="nameEn">{t("organization.enterEnglishName")}</Label>
<Input
id="nameEn"
placeholder={t("organization.enterEnglishName")}
value={nameEn}
onChange={(e) => {
setNameEn(e.target.value);
setErrors((prev) => ({ ...prev, nameEn: "" }));
}}
/>
{errors.nameEn && (
<p className="text-red-500 text-sm">{errors.nameEn}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="nameAm">{t("organization.enterAmharicName")}</Label>
<Input
id="nameAm"
placeholder={t("organization.enterAmharicName")}
value={nameAm}
onChange={(e) => {
setNameAm(e.target.value);
setErrors((prev) => ({ ...prev, nameAm: "" }));
}}
/>
{errors.nameAm && (
<p className="text-red-500 text-sm">{errors.nameAm}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="key">{t("contentManagement.key")}</Label>
<Input
id="key"
placeholder="e.g. finance-department"
value={key}
onChange={(e) => {
setKey(e.target.value);
setErrors((prev) => ({ ...prev, key: "" }));
}}
/>
{errors.key && <p className="text-red-500 text-sm">{errors.key}</p>}
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isCreating}
>
{t("common.Cancel")}
</Button>
<Button type="submit" disabled={isCreating}>
{isCreating
? t("organization.adding")
: t("contentManagement.addDepartment")}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,373 @@
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@/shared/common/ui/button";
import { Label } from "@/shared/common/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import { toast } from "sonner";
import { AlertCircle, Loader2 } from "lucide-react";
import { t } from "i18next";
import { useUnit } from "@/user-management/hooks/useUnit";
import { getOrganizations } from "@/shared/services/organizationsService";
import {
getUnitList,
RelateUnitToUnitPayload,
} from "@/user-management/services/api/unitService";
import { useLocalizedName } from "@/shared/common/localizedName";
import { useAuth } from "@/shared/context/AuthContext";
import { useGetOrganizationConfig } from "@/super-admin/hooks/useConfig";
import { useOrgUnitTotalCount } from "@/user-management/hooks/useOrgUnitTotalCount";
import { Name } from "../types";
interface SubCityOrg {
id: string;
name: Name;
}
interface SubCityUnit {
id: string;
name: Name;
}
interface AddFromSubCityFormProps {
parentUnitId: string;
onSuccess: () => void;
onCancel: () => void;
}
export function AddFromSubCityForm({
parentUnitId,
onSuccess,
onCancel,
}: AddFromSubCityFormProps) {
const localizedName = useLocalizedName();
const { user } = useAuth();
const organizationId =
user?.employee && user.employee.length > 0
? user.employee[0].organizationId
: "";
const [selectedSubCityId, setSelectedSubCityId] = useState("");
const [selectedUnitId, setSelectedUnitId] = useState("");
const [error, setError] = useState("");
const { relateUnit, isRelating } = useUnit();
const { data: subCitiesResp, isLoading: isLoadingSubCities } = useQuery({
queryKey: ["organizationsFilter", "subcity"],
queryFn: () =>
getOrganizations({
organizationTypeKey: "subcity",
take: 1000,
skip: 0,
}),
staleTime: 60_000,
});
const subCities: SubCityOrg[] = useMemo(() => {
const items = subCitiesResp?.data?.items ?? subCitiesResp?.data ?? [];
return Array.isArray(items) ? items : [];
}, [subCitiesResp]);
const { data: unitsResp, isLoading: isLoadingUnits } = useQuery({
queryKey: ["unitList", selectedSubCityId],
queryFn: () => getUnitList(selectedSubCityId, { take: 1000, skip: 0 }),
enabled: !!selectedSubCityId,
staleTime: 0,
});
const subCityUnits: SubCityUnit[] = useMemo(() => {
const items = unitsResp?.data?.items ?? [];
return Array.isArray(items) ? items : [];
}, [unitsResp]);
// --- Capacity check (Manage Organization Configuration) ---
const { data: orgConfigResp, isLoading: isLoadingConfig } =
useGetOrganizationConfig(organizationId);
const capacity = useMemo<number | null>(() => {
const items = orgConfigResp?.data?.items ?? [];
if (!Array.isArray(items) || items.length === 0) return null;
const match =
items.find((it: any) => it?.organizationId === organizationId) ??
items[0];
const value = Number(match?.maximumNumberOfUnits);
return Number.isFinite(value) ? value : null;
}, [orgConfigResp, organizationId]);
const { total: currentCount, isLoading: isLoadingCurrentCount } =
useOrgUnitTotalCount(organizationId);
const isAtCapacity =
capacity !== null && capacity >= 0 && currentCount >= capacity;
const remainingSlots =
capacity !== null ? Math.max(capacity - currentCount, 0) : null;
useEffect(() => {
setSelectedUnitId("");
}, [selectedSubCityId]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedSubCityId) {
setError(
t("contentManagement.subCityRequired", "Please select a sub city")
);
return;
}
if (!selectedUnitId) {
setError(t("contentManagement.unitRequired", "Please select a unit"));
return;
}
if (!parentUnitId) {
setError(
t("contentManagement.parentUnitMissing", "Parent unit is missing")
);
return;
}
if (capacity === null) {
setError(
t(
"contentManagement.noCapacityConfigured",
"No unit capacity configured for this organization. Ask the super admin to configure it in Manage Organization Configuration."
)
);
return;
}
if (isAtCapacity) {
setError(
t(
"contentManagement.capacityReached",
"Capacity limit reached. Configured maximum is {{max}}.",
{ max: capacity ?? 0 }
)
);
return;
}
const payload: RelateUnitToUnitPayload = {
unitId: selectedUnitId,
newParentUnitId: parentUnitId,
};
try {
await relateUnit({
payload,
successCallback: () => {
toast.success(
t(
"contentManagement.unitRelatedSuccess",
"Unit related successfully"
)
);
onSuccess();
},
});
} catch {
// error handled by useUnit hook
}
};
const isCheckingCapacity = isLoadingConfig || isLoadingCurrentCount;
const isCapacityMissing = !isCheckingCapacity && capacity === null;
const formLocked = isCheckingCapacity || isCapacityMissing || isAtCapacity;
const submitDisabled =
!selectedSubCityId ||
!selectedUnitId ||
isRelating ||
formLocked;
return (
<form
onSubmit={handleSubmit}
className="flex flex-col gap-4 w-full min-w-0"
>
{/* Capacity status (configured by super admin) */}
<div
className={`rounded-md border px-3 py-2 text-sm ${
isCheckingCapacity
? "border-gray-200 bg-gray-50 text-gray-600 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-300"
: capacity === null
? "border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-300"
: isAtCapacity
? "border-red-200 bg-red-50 text-red-700 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-300"
: "border-primary-200 bg-primary-50 text-primary-700 dark:border-primary-900/50 dark:bg-primary-950/30 dark:text-primary-300"
}`}
>
<div className="flex items-start gap-2">
{isCheckingCapacity ? (
<Loader2 className="h-4 w-4 mt-0.5 shrink-0 animate-spin" />
) : (
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
)}
<div className="min-w-0 break-words flex-1">
{isCheckingCapacity ? (
<span>
{t(
"contentManagement.checkingCapacity",
"Checking configured capacity…"
)}
</span>
) : capacity === null ? (
<span>
{t(
"contentManagement.noCapacityConfigured",
"No unit capacity configured for this organization. Ask the super admin to configure it in Manage Organization Configuration."
)}
</span>
) : (
<div className="space-y-1">
<div className="font-medium">
{t(
"contentManagement.configuredUnitLimit",
"Configured unit limit: {{max}}",
{ max: capacity }
)}
</div>
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs opacity-90">
<span>
{t("contentManagement.linkedUnits", "Linked")}: {currentCount}
</span>
<span>
{t("contentManagement.remainingSlots", "Remaining")}:{" "}
{remainingSlots ?? 0}
</span>
</div>
{isAtCapacity && (
<div className="text-xs font-medium">
{t(
"contentManagement.capacityReachedShort",
"Limit reached — cannot relate more units."
)}
</div>
)}
</div>
)}
</div>
</div>
</div>
<div className="flex flex-col gap-2 min-w-0">
<Label htmlFor="subCity" className="text-sm">
{t("contentManagement.selectSubCity", "Select Sub City")}
</Label>
<Select
value={selectedSubCityId}
onValueChange={(value) => {
setSelectedSubCityId(value);
setError("");
}}
disabled={isLoadingSubCities || formLocked}
>
<SelectTrigger id="subCity" className="w-full max-w-full">
<SelectValue
placeholder={
isLoadingSubCities
? t("organization.loading")
: t("contentManagement.selectSubCity", "Select Sub City")
}
/>
</SelectTrigger>
<SelectContent className="max-w-[var(--radix-select-trigger-width)]">
{isLoadingSubCities ? (
<div className="flex items-center justify-center px-2 py-3 text-sm text-gray-500">
<Loader2 className="w-4 h-4 animate-spin mr-2" />
{t("organization.loading")}
</div>
) : subCities.length === 0 ? (
<div className="px-2 py-3 text-sm text-gray-500">
{t("contentManagement.noSubCities", "No sub cities found")}
</div>
) : (
subCities.map((sc) => (
<SelectItem key={sc.id} value={sc.id}>
<span className="block truncate">
{localizedName(sc.name)}
</span>
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2 min-w-0">
<Label htmlFor="subCityUnit" className="text-sm">
{t("contentManagement.selectUnit", "Select Unit")}
</Label>
<Select
value={selectedUnitId}
onValueChange={(value) => {
setSelectedUnitId(value);
setError("");
}}
disabled={!selectedSubCityId || isLoadingUnits || formLocked}
>
<SelectTrigger id="subCityUnit" className="w-full max-w-full">
<SelectValue
placeholder={
!selectedSubCityId
? t(
"contentManagement.selectSubCityFirst",
"Select a sub city first"
)
: isLoadingUnits
? t("organization.loading")
: t("contentManagement.selectUnit", "Select Unit")
}
/>
</SelectTrigger>
<SelectContent className="max-w-[var(--radix-select-trigger-width)]">
{isLoadingUnits ? (
<div className="flex items-center justify-center px-2 py-3 text-sm text-gray-500">
<Loader2 className="w-4 h-4 animate-spin mr-2" />
{t("organization.loading")}
</div>
) : subCityUnits.length === 0 ? (
<div className="px-2 py-3 text-sm text-gray-500">
{t("contentManagement.noUnit")}
</div>
) : (
subCityUnits.map((u) => (
<SelectItem key={u.id} value={u.id}>
<span className="block truncate">{localizedName(u.name)}</span>
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
{error && (
<p className="text-red-500 text-sm break-words">{error}</p>
)}
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isRelating}
>
{t("common.Cancel")}
</Button>
<Button type="submit" disabled={submitDisabled}>
{isRelating ? (
<span className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
{t("organization.adding")}
</span>
) : (
t("contentManagement.addFromSubCity", "Add From Sub City")
)}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,202 @@
import { useState } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import { Button } from "@/shared/common/ui/button";
import { Input } from "@/shared/common/ui/input";
import { Label } from "@/shared/common/ui/label";
import { usePositions } from "@/user-management/hooks/usePosition";
import { PositionPayload } from "@/user-management/services/api/positionService";
import { toast } from "sonner";
import { usePositionTypes } from "@/user-management/hooks/usePositionTypes";
import i18n from "@/i18n";
import { t } from "i18next";
import { useLocalizedName } from "@/shared/common/localizedName";
interface AddSubDepartmentFormProps {
departmentId: string;
unitId: string;
organizationId: string;
departmentName: string;
onSuccess: (subDepartmentName: string) => void;
onCancel: () => void;
}
export function AddSubDepartmentForm({
departmentId,
departmentName,
organizationId,
unitId,
onSuccess,
onCancel,
}: AddSubDepartmentFormProps) {
const { createPosition, isCreating } = usePositions();
const { commonPositionTypes, isLoading: isLoadingTypes } = usePositionTypes({
unitId,
params: {
take: 1000, // Fetch all position types
skip: 0,
orderBy: "createdAt:Desc",
},
});
const [nameEn, setNameEn] = useState("");
const [nameAm, setNameAm] = useState("");
const [key, setKey] = useState("");
const [positionTypeId, setPositionTypeId] = useState("");
const lang = i18n.language;
const [errors, setErrors] = useState<Partial<Record<string, string>>>({});
const validateForm = () => {
const newErrors: Record<string, string> = {};
if (!nameEn.trim())
newErrors.nameEn = t("organization.englishNameRequired");
if (!nameAm.trim())
newErrors.nameAm = t("organization.amharicNameRequired");
if (!key.trim()) newErrors.key = t("contentManagement.keyRequired");
if (!positionTypeId)
newErrors.positionTypeId = t("contentManagement.selectPosType");
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
toast.error(t("contentManagement.invalidMsg"));
return;
}
const payload: PositionPayload = {
name: {
en: nameEn.trim(),
am: nameAm.trim(),
},
key: key.trim().toLowerCase().replace(/\s+/g, "-"),
unitId: unitId,
organizationId: organizationId,
parentPositionId: departmentId,
positionTypeId,
};
createPosition({
payload,
successCallback: () => {
onSuccess(nameEn.trim());
toast.success(t("contentManagement.success"), {
description: `${t("contentManagement.subDep")} "${
lang === "en" ? nameEn : nameAm
}${t("contentManagement.msg")}`,
});
},
});
};
const localizedName = useLocalizedName()
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="parentDepartment">
{t("contentManagement.parentDep")}
</Label>
<Input
id="parentDepartment"
value={departmentName}
disabled
className="bg-gray-50"
/>
</div>
<div className="space-y-2">
<Label htmlFor="positionType">{t("organization.positionTypes")}</Label>
<Select
onValueChange={(value) => {
setPositionTypeId(value);
setErrors((prev) => ({ ...prev, positionTypeId: "" }));
}}
value={positionTypeId}
disabled={isLoadingTypes}>
<SelectTrigger>
<SelectValue placeholder={t("contentManagement.selectPosType")} />
</SelectTrigger>
<SelectContent className="max-h-60 overflow-y-auto">
{commonPositionTypes.map((type) => (
<SelectItem key={type.id} value={type.id}>
{ localizedName(type.name)}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.positionTypeId && (
<p className="text-red-500 text-sm">{errors.positionTypeId}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="nameEn">{t("organization.enterEnglishName")}</Label>
<Input
id="nameEn"
placeholder={t("organization.enterEnglishName")}
value={nameEn}
onChange={(e) => {
setNameEn(e.target.value);
setErrors((prev) => ({ ...prev, nameEn: "" }));
}}
/>
{errors.nameEn && (
<p className="text-red-500 text-sm">{errors.nameEn}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="nameAm">{t("organization.enterAmharicName")}</Label>
<Input
id="nameAm"
placeholder={t("organization.enterAmharicName")}
value={nameAm}
onChange={(e) => {
setNameAm(e.target.value);
setErrors((prev) => ({ ...prev, nameAm: "" }));
}}
/>
{errors.nameAm && (
<p className="text-red-500 text-sm">{errors.nameAm}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="key">{t("contentManagement.key")}</Label>
<Input
id="key"
placeholder={t("contentManagement.key")}
value={key}
onChange={(e) => {
setKey(e.target.value);
setErrors((prev) => ({ ...prev, key: "" }));
}}
/>
{errors.key && <p className="text-red-500 text-sm">{errors.key}</p>}
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isCreating}>
{t("common.Cancel")}
</Button>
<Button type="submit" disabled={isCreating}>
{isCreating
? t("organization.adding")
: t("contentManagement.addSubdepartement")}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,251 @@
import { useMemo, useState } from "react";
import { AlertCircle, Loader2 } from "lucide-react";
import { Button } from "@/shared/common/ui/button";
import { Input } from "@/shared/common/ui/input";
import { Label } from "@/shared/common/ui/label";
import { useUnit } from "@/user-management/hooks/useUnit";
import { useGetOrganizationConfig } from "@/super-admin/hooks/useConfig";
import { useOrgUnitTotalCount } from "@/user-management/hooks/useOrgUnitTotalCount";
import { toast } from "sonner";
import i18n from "@/i18n";
import { t } from "i18next";
interface AddUnitFormProps {
organizationId: string;
onSuccess: (unitName: string) => void;
onCancel: () => void;
}
export function AddUnitForm({
organizationId,
onSuccess,
onCancel,
}: AddUnitFormProps) {
const [nameEn, setNameEn] = useState("");
const [nameAm, setNameAm] = useState("");
const [key, setKey] = useState("");
const [errors, setErrors] = useState<Partial<Record<string, string>>>({});
const lang = i18n.language;
const { createUnit, isCreating } = useUnit();
// --- Capacity check (Manage Organization Configuration) ---
const { data: orgConfigResp, isLoading: isLoadingConfig } =
useGetOrganizationConfig(organizationId || "");
const capacity = useMemo<number | null>(() => {
const items = orgConfigResp?.data?.items ?? [];
if (!Array.isArray(items) || items.length === 0) return null;
const match =
items.find((it: any) => it?.organizationId === organizationId) ??
items[0];
const value = Number(match?.maximumNumberOfUnits);
return Number.isFinite(value) ? value : null;
}, [orgConfigResp, organizationId]);
const { total: currentCount, isLoading: isLoadingCurrentCount } =
useOrgUnitTotalCount(organizationId);
const isCheckingCapacity = isLoadingConfig || isLoadingCurrentCount;
const isAtCapacity =
capacity !== null && capacity >= 0 && currentCount >= capacity;
const isCapacityMissing = !isCheckingCapacity && capacity === null;
const remainingSlots =
capacity !== null ? Math.max(capacity - currentCount, 0) : null;
const formLocked = isCheckingCapacity || isCapacityMissing || isAtCapacity;
const validateForm = () => {
const newErrors: Record<string, string> = {};
if (!nameEn.trim())
newErrors.nameEn = t("organization.englishNameRequired");
if (!nameAm.trim())
newErrors.nameAm = t("organization.amharicNameRequired");
if (!key.trim()) newErrors.key = t("contentManagement.keyRequired");
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (isCheckingCapacity) return;
if (isCapacityMissing) {
toast.error(
t(
"contentManagement.noCapacityConfigured",
"No unit capacity configured for this organization. Ask the super admin to configure it in Manage Organization Configuration."
)
);
return;
}
if (isAtCapacity) {
toast.error(
t(
"contentManagement.capacityReached",
"Capacity limit reached. Configured maximum is {{max}}.",
{ max: capacity ?? 0 }
)
);
return;
}
if (!validateForm()) {
toast.error(t("contentManagement.invalidMsg"));
return;
}
const payload = {
organizationId,
key: key.trim().toLowerCase().replace(/\s+/g, "-"),
name: {
en: nameEn.trim(),
am: nameAm.trim(),
},
};
createUnit({
payload,
successCallback: () => {
toast.success(t("contentManagement.success"), {
description: `${t("contentManagement.unit")} "${
lang === "en" ? nameEn : nameAm
}" ${t("contentManagement.msg")}`,
});
onSuccess(nameEn.trim());
},
});
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Capacity status (configured by super admin) */}
<div
className={`rounded-md border px-3 py-2 text-sm ${
isCheckingCapacity
? "border-gray-200 bg-gray-50 text-gray-600 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-300"
: isCapacityMissing
? "border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-300"
: isAtCapacity
? "border-red-200 bg-red-50 text-red-700 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-300"
: "border-primary-200 bg-primary-50 text-primary-700 dark:border-primary-900/50 dark:bg-primary-950/30 dark:text-primary-300"
}`}
>
<div className="flex items-start gap-2">
{isCheckingCapacity ? (
<Loader2 className="h-4 w-4 mt-0.5 shrink-0 animate-spin" />
) : (
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
)}
<div className="min-w-0 break-words flex-1">
{isCheckingCapacity ? (
<span>
{t(
"contentManagement.checkingCapacity",
"Checking configured capacity…"
)}
</span>
) : isCapacityMissing ? (
<span>
{t(
"contentManagement.noCapacityConfigured",
"No unit capacity configured for this organization. Ask the super admin to configure it in Manage Organization Configuration."
)}
</span>
) : (
<div className="space-y-1">
<div className="font-medium">
{t(
"contentManagement.configuredUnitLimit",
"Configured unit limit: {{max}}",
{ max: capacity }
)}
</div>
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs opacity-90">
<span>
{t("contentManagement.linkedUnits", "Used")}: {currentCount}
</span>
<span>
{t("contentManagement.remainingSlots", "Remaining")}:{" "}
{remainingSlots ?? 0}
</span>
</div>
{isAtCapacity && (
<div className="text-xs font-medium">
{t(
"contentManagement.capacityReachedShort",
"Limit reached — cannot create more units."
)}
</div>
)}
</div>
)}
</div>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="nameEn">{t("organization.enterEnglishName")}</Label>
<Input
id="nameEn"
placeholder={t("organization.enterEnglishName")}
value={nameEn}
disabled={formLocked}
onChange={(e) => {
setNameEn(e.target.value);
setErrors((prev) => ({ ...prev, nameEn: "" }));
}}
/>
{errors.nameEn && (
<p className="text-red-500 text-sm">{errors.nameEn}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="nameAm">{t("organization.enterAmharicName")}</Label>
<Input
id="nameAm"
placeholder={t("organization.enterAmharicName")}
value={nameAm}
disabled={formLocked}
onChange={(e) => {
setNameAm(e.target.value);
setErrors((prev) => ({ ...prev, nameAm: "" }));
}}
/>
{errors.nameAm && (
<p className="text-red-500 text-sm">{errors.nameAm}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="key">{t("contentManagement.key")}</Label>
<Input
id="key"
placeholder="e.g. finance-unit"
value={key}
disabled={formLocked}
onChange={(e) => {
setKey(e.target.value);
setErrors((prev) => ({ ...prev, key: "" }));
}}
/>
{errors.key && <p className="text-red-500 text-sm">{errors.key}</p>}
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isCreating}
>
{t("common.Cancel")}
</Button>
<Button type="submit" disabled={isCreating || formLocked}>
{isCreating
? t("organization.adding")
: t("contentManagement.addUnit")}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,152 @@
import { useState } from "react";
import { Button } from "@/shared/common/ui/button";
import { Input } from "@/shared/common/ui/input";
import { Label } from "@/shared/common/ui/label";
import { useToast } from "@/shared/common/ui/use-toast";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import i18n from "@/i18n";
import { t } from "i18next";
interface AddUserFormProps {
unitId: string;
onSuccess: () => void;
onCancel: () => void;
}
export function AddUserForm({ unitId, onSuccess, onCancel }: AddUserFormProps) {
const [formData, setFormData] = useState({
firstName: "",
lastName: "",
email: "",
role: "member",
});
const [isSubmitting, setIsSubmitting] = useState(false);
const { toast } = useToast();
const lang = i18n.language;
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleRoleChange = (value: string) => {
setFormData((prev) => ({ ...prev, role: value }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (
!formData.firstName.trim() ||
!formData.lastName.trim() ||
!formData.email.trim()
) {
toast({
title: "Error",
description: "All fields are required",
variant: "destructive",
});
return;
}
setIsSubmitting(true);
try {
// In a real application, this would be an API call
// For now, simulate API call with a timeout
await new Promise((resolve) => setTimeout(resolve, 500));
toast({
title: t("contentManagement.success"),
description: `${t("contentManagement.user")} ${formData.firstName} ${
formData.lastName
}${t("contentManagement.msg")}`,
});
onSuccess();
} catch (error) {
toast({
title: "Error",
description: "Failed to add user. Please try again.",
variant: "destructive",
});
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="firstName">{t("contentManagement.firstName")}</Label>
<Input
id="firstName"
name="firstName"
placeholder={t("contentManagement.firstName")}
value={formData.firstName}
onChange={handleChange}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="lastName">{t("contentManagement.lastName")}</Label>
<Input
id="lastName"
name="lastName"
placeholder={t("contentManagement.lastName")}
value={formData.lastName}
onChange={handleChange}
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="email">{t("organization.email")}</Label>
<Input
id="email"
name="email"
type="email"
placeholder={t("organization.email")}
value={formData.email}
onChange={handleChange}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Select value={formData.role} onValueChange={handleRoleChange}>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Director">Director</SelectItem>
<SelectItem value="Teamleader">Teamleader</SelectItem>
<SelectItem value="deputy">deputy</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isSubmitting}
>
{t("common.Cancel")}
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? t("organization.adding") : t("organization.addUser")}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,230 @@
import i18n from "@/i18n";
import { isValidEthiopianPhone } from "@/record-management/common/editor/Utils";
import { Button } from "@/shared/common/ui/button";
import { Input } from "@/shared/common/ui/input";
import { Label } from "@/shared/common/ui/label";
import { useEmployeePositions } from "@/user-management/hooks/useEmployeePostions";
import { t } from "i18next";
import { useState } from "react";
import { toast } from "sonner";
import { useUnitConfiguration } from "@/shared/hooks/useUnitConfiguration";
interface AddUserUnderDepartmentFormProps {
unitId: string;
departmentId: string;
departmentName: string;
onSuccess: () => void;
onCancel: () => void;
}
export function AddUserUnderDepartmentForm({
unitId,
departmentId,
departmentName,
onSuccess,
onCancel,
}: AddUserUnderDepartmentFormProps) {
const { invite, isInviting } = useEmployeePositions();
const { data: unitConfigData } = useUnitConfiguration(unitId, {
enabled: !!unitId,
});
const isApprovalRequiredForPositionChange =
unitConfigData?.data?.items?.[0]?.isApprovalRequiredForPositionChange ??
false;
const lang = i18n.language;
const [formData, setFormData] = useState({
positionId: departmentId,
username: "",
email: "",
phoneNumber: "",
name: {
am: "",
en: "",
},
});
const [errors, setErrors] = useState({
username: "",
email: "",
phoneNumber: "",
nameAm: "",
nameEn: "",
});
const handleChange = (field: string, value: string, lang?: "am" | "en") => {
if (field === "name" && lang) {
setFormData((prev) => ({
...prev,
name: {
...prev.name,
[lang]: value,
},
}));
setErrors((prev) => ({
...prev,
[`name${lang.toUpperCase()}`]: "",
}));
} else {
setFormData((prev) => ({ ...prev, [field]: value }));
setErrors((prev) => ({ ...prev, [field]: "" }));
}
};
const validate = () => {
const newErrors = {
username: "",
email: "",
phoneNumber: "",
nameAm: "",
nameEn: "",
};
let valid = true;
if (!formData.username.trim()) {
newErrors.username =t("profile.usernameRequired");
valid = false;
}
if (!formData.email.trim()) {
newErrors.email = t("profile.emailRequired");
valid = false;
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = t("profile.validEmailFormat");
valid = false;
}
if (!formData.phoneNumber.trim()) {
newErrors.phoneNumber = t("profile.phoneRequired");
valid = false;
} else if (!isValidEthiopianPhone(formData.phoneNumber)) {
newErrors.phoneNumber =
t("profile.validPhoneFormat");
valid = false;
}
if (!formData.name.am.trim()) {
newErrors.nameAm = t("profile.amharicNameRequired");
valid = false;
}
if (!formData.name.en.trim()) {
newErrors.nameEn = t("profile.englishNameRequired");
valid = false;
}
setErrors(newErrors);
return valid;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) return;
invite({
payload: formData,
successCallback: () => {
const displayName = lang === "en" ? formData.name.en : formData.name.am;
if (isApprovalRequiredForPositionChange) {
toast.success(
t(
"contentManagement.positionChangeApprovalRequiredSuccess",
"Action completed successfully. Approval is required for position changes.",
),
{
description: t(
"contentManagement.userInvitePendingApproval",
`User "${displayName}" has been invited and is pending approval for this department.`,
),
},
);
} else {
toast.success("User invited", {
description: `User "${displayName}" has been added to the department.`,
});
}
onSuccess();
},
});
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label>{t("organization.Position")}</Label>
<Input value={departmentName} disabled />
</div>
<div>
<Label>{t("organization.username")} *</Label>
<Input
value={formData.username}
onChange={(e) => handleChange("username", e.target.value)}
/>
{errors.username && (
<p className="text-red-500 text-sm">{errors.username}</p>
)}
</div>
<div>
<Label>{t("organization.email")} *</Label>
<Input
type="email"
value={formData.email}
onChange={(e) => handleChange("email", e.target.value)}
/>
{errors.email && <p className="text-red-500 text-sm">{errors.email}</p>}
</div>
<div>
<Label>{t("auth.phoneNumber")}</Label>
<Input
type="tel"
placeholder="0912345678 or +251912345678"
value={formData.phoneNumber}
onChange={(e) => handleChange("phoneNumber", e.target.value)}
/>
{errors.phoneNumber && (
<p className="text-red-500 text-sm">{errors.phoneNumber}</p>
)}
</div>
<div>
<Label>{t("organization.enterAmharicName")}*</Label>
<Input
value={formData.name.am}
onChange={(e) => handleChange("name", e.target.value, "am")}
/>
{errors.nameAm && (
<p className="text-red-500 text-sm">{errors.nameAm}</p>
)}
</div>
<div>
<Label>{t("organization.enterEnglishName")}*</Label>
<Input
value={formData.name.en}
onChange={(e) => handleChange("name", e.target.value, "en")}
/>
{errors.nameEn && (
<p className="text-red-500 text-sm">{errors.nameEn}</p>
)}
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isInviting}
>
{t("common.Cancel")}
</Button>
<Button type="submit" disabled={isInviting}>
{isInviting ? t("organization.adding") : t("organization.addUser")}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,362 @@
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/shared/common/ui/button";
import { Label } from "@/shared/common/ui/label";
import { Input } from "@/shared/common/ui/input";
import { Switch } from "@/shared/common/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import { Search } from "lucide-react";
import { useEmployees } from "@/user-management/hooks/useEmployees";
import { useEmployeePositions } from "@/user-management/hooks/useEmployeePostions";
import { useOrganizations } from "@/super-admin/hooks/useOrganizations";
import { useUnit } from "@/user-management/hooks/useUnit";
import { toast } from "sonner";
import { AssignEmployeePayload } from "@/user-management/services/api/employeePositionsService";
import { t } from "i18next";
import i18n from "@/i18n";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
import { useLocalizedName } from "@/shared/common/localizedName";
import { useUnitConfiguration } from "@/shared/hooks/useUnitConfiguration";
interface AssignUserFormProps {
unitId: string;
positionId: string;
positionName: string;
onSuccess: () => void;
onCancel: () => void;
}
export function AssignUserForm({
unitId,
positionId,
positionName,
onSuccess,
onCancel,
}: AssignUserFormProps) {
const { assignEmployee, isAssigning } = useEmployeePositions();
const { handleError } = useErrorHandler(t);
const localizedName = useLocalizedName();
const [isAdvanced, setIsAdvanced] = useState(false);
const [selectedOrg, setSelectedOrg] = useState<string>("");
const [selectedUnit, setSelectedUnit] = useState<string>("");
const [selectedUser, setSelectedUser] = useState<string>("");
const [errors, setErrors] = useState<Partial<Record<string, string>>>({});
const [page, setPage] = useState(1);
const [searchQuery, setSearchQuery] = useState("");
const pageSize = 20;
const activeUnitId = isAdvanced ? selectedUnit : unitId;
const { data: unitConfigData } = useUnitConfiguration(unitId, {
enabled: !!unitId,
});
const isApprovalRequiredForPositionChange =
unitConfigData?.data?.items?.[0]?.isApprovalRequiredForPositionChange ??
false;
const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations(
"Org",
{
take: 300,
},
);
const { data: unitsResponse, isLoading: isLoadingUnits } = useUnit().getList(
selectedOrg,
{ take: 300, skip: 0 },
);
const { employeesResponse, isLoading } = useEmployees({
unitId: activeUnitId,
});
const allUsers = employeesResponse?.items || [];
const lang = i18n.language;
useEffect(() => {
setSelectedUser("");
setSearchQuery("");
setPage(1);
setErrors({});
}, [activeUnitId]);
useEffect(() => {
setSelectedUnit("");
}, [selectedOrg]);
useEffect(() => {
if (!isAdvanced) {
setSelectedOrg("");
setSelectedUnit("");
}
}, [isAdvanced]);
// Filter users based on search query
const filteredUsers = useMemo(() => {
if (!searchQuery.trim()) return allUsers;
return allUsers.filter((emp) => {
const name = lang === "en" ? emp.name?.en : emp.name?.am;
const email = emp.email || "";
return (
name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
email.toLowerCase().includes(searchQuery.toLowerCase())
);
});
}, [allUsers, searchQuery, lang]);
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
if (scrollTop + clientHeight >= scrollHeight - 5) {
if (page * pageSize < filteredUsers.length) {
setPage((prev) => prev + 1);
}
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (isAdvanced && (!selectedOrg || !selectedUnit)) {
toast.error(t("contentManagement.requiredFields"));
return;
}
if (!selectedUser) {
setErrors({ selectedUser: t("contentManagement.userRequired") });
toast.error(t("contentManagement.userRequiredMsg"));
return;
}
const user = selectedUser;
if (!user) {
toast.error(t("contentManagement.userError"));
return;
}
const payload: AssignEmployeePayload = {
positionId,
employeeId: user,
};
try {
await assignEmployee(payload);
toast.success(
isApprovalRequiredForPositionChange
? t(
"contentManagement.positionChangeApprovalRequiredSuccess",
"User assigned successfully. Approval is required for position changes.",
)
: t("contentManagement.userSuccess"),
);
onSuccess();
} catch (error) {
handleError(error);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="flex items-center justify-between rounded-md border px-3 py-2">
<div className="space-y-0.5">
<Label htmlFor="advanced-assign">
{t("organization.advancedAssign", "Advanced")}
</Label>
<p className="text-xs text-muted-foreground">
{t(
"organization.selectOrganizationAndUnit",
"Select organization and unit before choosing a user",
)}
</p>
</div>
<Switch
id="advanced-assign"
checked={isAdvanced}
onCheckedChange={setIsAdvanced}
disabled={isAssigning}
/>
</div>
{isAdvanced && (
<div className="space-y-4 border rounded-lg p-4 bg-muted/50">
<div className="space-y-2">
<Label htmlFor="organization">
{t("organization.organizations")}
</Label>
<Select
value={selectedOrg}
onValueChange={(value) => setSelectedOrg(value)}
disabled={isLoadingOrgs}
>
<SelectTrigger id="organization" className="w-full">
<SelectValue
placeholder={t("organization.selectOrganization")}
/>
</SelectTrigger>
<SelectContent
side="bottom"
sideOffset={4}
className="w-[var(--radix-select-trigger-width)]"
>
{organizationsResponse?.items?.map((org) => (
<SelectItem key={org.id} value={org.id}>
<span className="truncate">{localizedName(org.name)}</span>
</SelectItem>
))}
{!isLoadingOrgs &&
organizationsResponse?.items?.length === 0 && (
<SelectItem value="no-organizations" disabled>
{t(
"organization.noOrganizationsFound",
"No organizations found",
)}
</SelectItem>
)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="unit">{t("organization.units")}</Label>
<Select
value={selectedUnit}
onValueChange={(value) => setSelectedUnit(value)}
disabled={!selectedOrg || isLoadingUnits}
>
<SelectTrigger id="unit" className="w-full">
<SelectValue
placeholder={
selectedOrg
? t("organization.selectUnit")
: t("organization.selectOrganizationFirst")
}
/>
</SelectTrigger>
<SelectContent
side="bottom"
sideOffset={4}
className="w-[var(--radix-select-trigger-width)]"
>
{unitsResponse?.data?.items
?.filter((unit: any) => unit.id !== unitId)
?.map((unit: any) => (
<SelectItem key={unit.id} value={unit.id}>
<span className="truncate">
{localizedName(unit.name)}
</span>
</SelectItem>
))}
{!isLoadingUnits &&
unitsResponse?.data?.items?.filter(
(unit: any) => unit.id !== unitId,
)?.length === 0 && (
<SelectItem value="no-units" disabled>
{t("organization.noUnitsFound")}
</SelectItem>
)}
</SelectContent>
</Select>
</div>
</div>
)}
{/* User Select with Integrated Search */}
<div className="space-y-2">
<Label htmlFor="user">{t("contentManagement.selectUser")}</Label>
<Select
value={selectedUser}
onValueChange={(value) => {
setSelectedUser(value);
setErrors((prev) => ({ ...prev, selectedUser: "" }));
}}
disabled={isLoading || (isAdvanced && !selectedUnit)}
>
<SelectTrigger id="user">
<SelectValue placeholder={t("contentManagement.selectUser")} />
</SelectTrigger>
<SelectContent
style={{ maxHeight: "300px", overflowY: "auto" }}
onScroll={handleScroll}
>
{/* Search Input inside Dropdown */}
<div className="sticky top-0 z-10 bg-background p-2 border-b">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<Input
type="text"
placeholder={t("organization.searchUsers")}
value={searchQuery}
onChange={(e) => {
e.stopPropagation();
setSearchQuery(e.target.value);
setPage(1); // Reset to first page when searching
}}
onClick={(e) => e.stopPropagation()}
className="pl-10 h-9 text-sm"
/>
</div>
</div>
{/* User List */}
{filteredUsers.length > 0 ? (
filteredUsers.slice(0, page * pageSize).map((emp) => (
<SelectItem key={emp.id} value={emp?.id}>
<div className="flex flex-col">
<span>
{lang === "en"
? emp.name?.en
: emp.name?.am || emp.email || "Unnamed User"}
</span>
{emp.email && (
<span className="text-xs text-gray-500">{emp.email}</span>
)}
</div>
</SelectItem>
))
) : (
<div className="text-center py-4 text-sm text-muted-foreground">
{searchQuery
? t("organization.noUsersFound")
: t("organization.noUsersAvailable")}
</div>
)}
{isLoading && (
<div className="text-center py-2 text-sm text-muted-foreground">
{t("contentManagement.loading")}
</div>
)}
</SelectContent>
</Select>
{errors.selectedUser && (
<p className="text-red-500 text-sm">{errors.selectedUser}</p>
)}
</div>
{/* Position Name */}
<div className="space-y-2">
<Label>{t("organization.Position")}</Label>
<div className="px-3 py-2 border rounded text-sm text-muted-foreground bg-muted">
{positionName || "Unknown"}
</div>
</div>
{/* Buttons */}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isAssigning}
>
{t("common.Cancel")}
</Button>
<Button type="submit" disabled={isAssigning || isLoading}>
{isAssigning ? "Assigning..." : "Assign User"}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,232 @@
import { useEffect, useState } from "react";
import { Button } from "@/shared/common/ui/button";
import { Input } from "@/shared/common/ui/input";
import { Label } from "@/shared/common/ui/label";
import { usePositions } from "@/user-management/hooks/usePosition";
import {
getPositionById,
PositionPayload,
} from "@/user-management/services/api/positionService";
import { toast } from "sonner";
import { usePositionTypes } from "@/user-management/hooks/usePositionTypes";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import { PositionDto } from "@/user-management/dto/positions/positionDto";
import i18n from "@/i18n";
import { t } from "i18next";
import { useLocalizedName } from "@/shared/common/localizedName";
interface EditDepartmentFormProps {
departmentId: string;
onSuccess: (departmentName: string) => void;
onCancel: () => void;
}
export function EditDepartmentForm({
departmentId,
onSuccess,
onCancel,
}: EditDepartmentFormProps) {
const { updatePosition, isUpdating } = usePositions();
const [nameEn, setNameEn] = useState("");
const [nameAm, setNameAm] = useState("");
const [key, setKey] = useState("");
const [unitId, setUnitId] = useState("");
const [organizationId, setOrganizationId] = useState("");
const [positionTypeId, setPositionTypeId] = useState("");
const { commonPositionTypes, isLoadingCommonTypes } = usePositionTypes({
unitId,
params: {
take: 1000, // Fetch all position types
skip: 0,
orderBy: "createdAt:Desc",
},
});
const [errors, setErrors] = useState<Partial<Record<string, string>>>({});
const [isLoading, setIsLoading] = useState(true);
const lang = i18n.language;
const localizedName = useLocalizedName();
useEffect(() => {
const fetchDepartment = async () => {
try {
const data = await getPositionById(departmentId);
const res = data.data as PositionDto;
setNameEn(res.name.en);
setNameAm(res.name.am);
setKey(res.key);
setUnitId(res.unitId);
setOrganizationId(res.organizationId);
setPositionTypeId(res.positionTypeId);
} catch {
toast.error("Failed to fetch department");
} finally {
setIsLoading(false);
}
};
fetchDepartment();
}, [departmentId]);
// Reset position type selection when unit changes
useEffect(() => {
if (unitId && commonPositionTypes.length > 0) {
// Find the current position type in the new list
const currentType = commonPositionTypes.find(
(type) => type.id === positionTypeId
);
if (!currentType) {
// If current position type is not in the new list, reset selection
setPositionTypeId("");
}
}
}, [unitId, commonPositionTypes, positionTypeId]);
const validateForm = () => {
const newErrors: Record<string, string> = {};
if (!nameEn.trim())
newErrors.nameEn = t("organization.englishNameRequired");
if (!nameAm.trim())
newErrors.nameAm = t("organization.amharicNameRequired");
if (!positionTypeId)
newErrors.positionTypeId = t("contentManagement.selectPosType");
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
toast.error(t("contentManagement.invalidMsg"));
return;
}
const payload: PositionPayload = {
name: {
en: nameEn.trim(),
am: nameAm.trim(),
},
key: key.trim().toLowerCase().replace(/\s+/g, "-"),
unitId,
organizationId,
positionTypeId,
};
updatePosition({
id: departmentId,
payload,
successCallback: () => {
toast.success(t("contentManagement.success"), {
description: `${t("contentManagement.department")} "${
lang === "en" ? nameEn : nameAm
}" ${t("contentManagement.updated")}`,
});
onSuccess(nameEn.trim());
},
});
};
if (isLoading) {
return (
<div className="text-sm text-muted-foreground">
{" "}
{t("contentManagement.loading")}
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="positionType">{t("organization.positionTypes")}</Label>
<Select
onValueChange={(value) => {
setPositionTypeId(value);
setErrors((prev) => ({ ...prev, positionTypeId: "" }));
}}
value={positionTypeId}
disabled={isLoadingCommonTypes}
>
<SelectTrigger>
<SelectValue placeholder={t("contentManagement.selectPosType")} />
</SelectTrigger>
<SelectContent className="max-h-60 overflow-y-auto">
{commonPositionTypes.map((type) => (
<SelectItem key={type.id} value={type.id}>
{localizedName(type.name)}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.positionTypeId && (
<p className="text-red-500 text-sm">{errors.positionTypeId}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="nameEn">{t("organization.enterEnglishName")}</Label>
<Input
id="nameEn"
placeholder={t("organization.enterEnglishName")}
value={nameEn}
onChange={(e) => {
setNameEn(e.target.value);
setErrors((prev) => ({ ...prev, nameEn: "" }));
}}
/>
{errors.nameEn && (
<p className="text-red-500 text-sm">{errors.nameEn}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="nameAm">{t("organization.enterAmharicName")}</Label>
<Input
id="nameAm"
placeholder={t("organization.enterAmharicName")}
value={nameAm}
onChange={(e) => {
setNameAm(e.target.value);
setErrors((prev) => ({ ...prev, nameAm: "" }));
}}
/>
{errors.nameAm && (
<p className="text-red-500 text-sm">{errors.nameAm}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="key">{t("contentManagement.key")}</Label>
<Input
id="key"
placeholder="e.g. finance-department"
value={key}
disabled
/>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isUpdating}
>
{t("common.Cancel")}
</Button>
<Button type="submit" disabled={isUpdating}>
{isUpdating ? t("profile.updating") : t("delegation.update")}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,124 @@
import { useState } from "react";
import { Button } from "@/shared/common/ui/button";
import { Input } from "@/shared/common/ui/input";
import { Label } from "@/shared/common/ui/label";
import { useUnit } from "@/user-management/hooks/useUnit";
import { toast } from "sonner";
import { UnitDto } from "@/user-management/dto/unit/unitDto";
import { t } from "i18next";
import i18n from "@/i18n";
interface EditUnitFormProps {
unit: UnitDto;
onSuccess: (unitName: string) => void;
onCancel: () => void;
}
export function EditUnitForm({ unit, onSuccess, onCancel }: EditUnitFormProps) {
const [nameEn, setNameEn] = useState(unit.name.en);
const [nameAm, setNameAm] = useState(unit.name.am);
const [errors, setErrors] = useState<Partial<Record<string, string>>>({});
const lang = i18n.language;
const { updateUnit, isUpdating } = useUnit();
const validateForm = () => {
const newErrors: Record<string, string> = {};
if (!nameEn.trim())
newErrors.nameEn = t("organization.englishNameRequired");
if (!nameAm.trim())
newErrors.nameAm = t("organization.amharicNameRequired");
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const { id, key, organizationId, parentUnitId } = unit;
if (!validateForm()) {
toast.error(t("contentManagement.invalidMsg"));
return;
}
// PUT /api/units/{id} expects { name, key, organizationId, parentUnitId }.
const payload = {
name: {
en: nameEn.trim(),
am: nameAm.trim(),
},
key: key.trim().toLowerCase().replace(/\s+/g, "-"),
organizationId,
parentUnitId: parentUnitId ?? undefined,
};
updateUnit({
id,
payload,
successCallback: () => {
toast.success("Success", {
description: `${t("contentManagement.unit")} "${
lang === "en" ? nameEn : nameAm
}" ${t("contentManagement.updated")}`,
});
onSuccess(nameEn.trim());
},
});
};
return (
<form onSubmit={handleSubmit} className="min-w-0 max-w-full space-y-4 overflow-hidden">
<div className="min-w-0 max-w-full space-y-2">
<Label htmlFor="nameEn">{t("organization.enterEnglishName")}</Label>
<Input
id="nameEn"
placeholder={t("organization.enterEnglishName")}
value={nameEn}
className="max-w-full"
onChange={(e) => {
setNameEn(e.target.value);
setErrors((prev) => ({ ...prev, nameEn: "" }));
}}
/>
{errors.nameEn && (
<p className="text-red-500 text-sm">{errors.nameEn}</p>
)}
</div>
<div className="min-w-0 max-w-full space-y-2">
<Label htmlFor="nameAm">{t("organization.enterEnglishName")}</Label>
<Input
id="nameAm"
placeholder={t("organization.enterEnglishName")}
value={nameAm}
className="max-w-full"
onChange={(e) => {
setNameAm(e.target.value);
setErrors((prev) => ({ ...prev, nameAm: "" }));
}}
/>
{errors.nameAm && (
<p className="text-red-500 text-sm">{errors.nameAm}</p>
)}
</div>
<div className="min-w-0 max-w-full space-y-2">
<Label htmlFor="key">{t("contentManagement.key")}</Label>
<Input id="key" value={unit.key} className="max-w-full" />
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isUpdating}
>
{t("common.Cancel")}
</Button>
<Button type="submit" disabled={isUpdating}>
{isUpdating ? t("profile.updating") : t("delegation.update")}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,213 @@
import { useState, useMemo } from "react";
import { Button } from "@/shared/common/ui/button";
import { Label } from "@/shared/common/ui/label";
import { Input } from "@/shared/common/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import { toast } from "sonner";
import {
useMovePosition,
usePositions,
} from "@/user-management/hooks/usePosition";
import { t } from "i18next";
import i18n from "@/i18n";
import { Search, X } from "lucide-react";
import { useUnitConfiguration } from "@/shared/hooks/useUnitConfiguration";
interface MoveDepartmentFormProps {
unitId: string;
organizationId: string;
departmentId: string;
onSuccess: (departmentId: string) => void;
onCancel: () => void;
}
export function MoveDepartmentForm({
unitId,
departmentId,
organizationId,
onSuccess,
onCancel,
}: MoveDepartmentFormProps) {
const { usePositionListByUnitId } = usePositions({
take: 1000, // Increased to get more departments for search
skip: 0,
});
const [selectedDepartmentId, setSelectedDepartmentId] = useState("");
const [searchQuery, setSearchQuery] = useState("");
const [error, setError] = useState("");
const {
data: departments,
isError,
isLoading,
} = usePositionListByUnitId(unitId, {
take: 1000,
skip: 0,
});
const lang = i18n.language;
const { mutate: moveDept, isPending } = useMovePosition();
const { data: unitConfigData } = useUnitConfiguration(unitId, {
enabled: !!unitId,
});
const isApprovalRequiredForPositionChange =
unitConfigData?.data?.items?.[0]?.isApprovalRequiredForPositionChange ??
false;
// Filter departments based on search query
const filteredDepartments = useMemo(() => {
if (!departments?.items) return [];
if (!searchQuery.trim()) return departments.items;
const query = searchQuery.toLowerCase();
return departments.items.filter((dept: any) => {
const nameEn = dept.name?.en?.toLowerCase() || "";
const nameAm = dept.name?.am?.toLowerCase() || "";
const description = dept.description?.toLowerCase() || "";
return (
nameEn.includes(query) ||
nameAm.includes(query) ||
description.includes(query)
);
});
}, [departments?.items, searchQuery]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!selectedDepartmentId) {
setError(t("addRecord.Select Department"));
toast.error(t("contentManagement.departmentRequired"));
return;
}
moveDept(
{
positionId: departmentId,
newParentId: selectedDepartmentId,
},
{
onSuccess: () => {
toast.success(
isApprovalRequiredForPositionChange
? t(
"contentManagement.positionChangeApprovalRequiredSuccess",
"Department moved successfully. Approval is required for position changes.",
)
: t("contentManagement.departmentSuccess"),
);
onSuccess(selectedDepartmentId);
},
}
);
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="department"> {t("addRecord.Select Department")}</Label>
<Select
onValueChange={(value) => {
setSelectedDepartmentId(value);
setError("");
}}
value={selectedDepartmentId}
>
<SelectTrigger>
<SelectValue placeholder={t("addRecord.Select Department")} />
</SelectTrigger>
<SelectContent className="p-0">
<div className="flex flex-col h-60">
{/* Fixed search header */}
<div className="flex-shrink-0 bg-white border-b p-2 z-10">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="Search departments..."
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setSelectedDepartmentId(""); // Clear selection when searching
}}
className="pl-10 pr-10 h-8"
/>
{searchQuery && (
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-1 top-1/2 transform -translate-y-1/2 h-6 w-6 p-0"
onClick={() => {
setSearchQuery("");
setSelectedDepartmentId("");
}}
>
<X className="h-3 w-3" />
</Button>
)}
</div>
</div>
{/* Scrollable content area */}
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="px-2 py-2">
<p className="text-gray-500">
{t("contentManagement.loading")}
</p>
</div>
) : filteredDepartments.length === 0 ? (
<div className="px-2 py-2">
<p className="text-gray-500">
{searchQuery
? "No departments found matching your search."
: "No departments available."}
</p>
</div>
) : (
filteredDepartments.map((dept: any) => (
<SelectItem key={dept.id} value={dept.id}>
<div className="flex flex-col">
<span className="font-medium">
{lang === "en" ? dept.name.en : dept.name.am}
</span>
{dept.description && (
<span className="text-xs text-gray-500">
{dept.description}
</span>
)}
</div>
</SelectItem>
))
)}
</div>
{/* Fixed footer with results count */}
{searchQuery && filteredDepartments.length > 0 && (
<div className="flex-shrink-0 bg-gray-50 border-t px-2 py-1">
<p className="text-xs text-gray-500">
Found {filteredDepartments.length} department(s)
</p>
</div>
)}
</div>
</SelectContent>
</Select>
{error && <p className="text-red-500 text-sm">{error}</p>}
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel}>
{t("common.Cancel")}
</Button>
<Button type="submit" disabled={!selectedDepartmentId || isPending}>
{isPending ? "Moving..." : t("contentManagement.moveDepartement")}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,265 @@
import { useState } from "react";
interface Entity {
id: string;
name: string;
}
interface Employee {
id: string;
name: string;
email?: string;
role?: string;
title?: string;
phone?: string;
inviteStatus?: "Pending" | "Accepted" | "Not Invited";
}
interface UseActionHandlersProps {
selectedOrgId: string;
selectedUnitId: string;
toast: (data: { title: string; description?: string }) => void;
}
export const useActionHandlers = ({
selectedOrgId,
selectedUnitId,
toast: externalToast,
}: UseActionHandlersProps) => {
const [editUnitDialogOpen, setEditUnitDialogOpen] = useState(false);
const [manageUnitAdminDialogOpen, setManageUnitAdminDialogOpen] =
useState(false);
const [deleteUnitDialogOpen, setDeleteUnitDialogOpen] = useState(false);
const [permanentDeleteUnitDialogOpen, setPermanentDeleteUnitDialogOpen] = useState(false);
const [addFromSubCityDialogOpen, setAddFromSubCityDialogOpen] = useState(false);
const [addDepartmentDialogOpen, setAddDepartmentDialogOpen] = useState(false);
const [editDepartmentDialogOpen, setEditDepartmentDialogOpen] =
useState(false);
const [deleteDepartmentDialogOpen, setDeleteDepartmentDialogOpen] =
useState(false);
const [moveDepartmentDialogOpen, setMoveDepartmentDialogOpen] =
useState(false);
const [addSubDepartmentDialogOpen, setAddSubDepartmentDialogOpen] =
useState(false);
const [addUserUnderDepartmentOpen, setAddUserUnderDepartmentOpen] =
useState(false);
const [addUserDialogOpen, setAddUserDialogOpen] = useState(false);
const [viewUsersDialogOpen, setViewUsersDialogOpen] = useState(false);
const [assignUserDialogOpen, setAssignUserDialogOpen] = useState(false);
const [deleteTeamMemberDialog, setDeleteTeamMemberDialog] = useState(false);
const [currentDepartmentId, setCurrentDepartmentId] = useState("");
const [currentDepartmentName, setCurrentDepartmentName] = useState("");
const [currentTeamMemberId, setCurrentTeamMemberId] = useState("");
const [currentTeamMemberName, setCurrentTeamMemberName] = useState("");
const[deactivateTeamMemberDialog,setDeactivateTeamMemberDialog] = useState(false)
const [currentOrganizationId, setCurrentOrganizationId] = useState("");
const [currentUnitId, setCurrentUnitId] = useState("");
const [currentUnitName, setCurrentUnitName] = useState("");
const[currentUserId, setCurrentUserId] = useState("")
const toast = (data: { title: string; description?: string }) => {
externalToast(data);
};
return {
handleSelectOrganization: (id: string) => {
setCurrentOrganizationId(id);
},
unit: {
onAddDepartment: () => {
setCurrentOrganizationId(currentOrganizationId);
setCurrentUnitId(selectedUnitId);
setAddDepartmentDialogOpen(true);
},
onManageUnitAdmin: (unitId: string) => {
setCurrentOrganizationId(selectedOrgId);
setCurrentUnitId(unitId || selectedUnitId);
setManageUnitAdminDialogOpen(true);
},
onEditUnit: (unitId: string) => {
setCurrentUnitId(unitId || selectedUnitId);
setEditUnitDialogOpen(true);
},
onArchiveUnit: (departmentId: string, departmentName: string) => {
setCurrentUnitId(departmentId);
setCurrentUnitName(departmentName);
setDeleteUnitDialogOpen(true);
},
onDeleteUnit: (departmentId: string, departmentName: string) => {
setCurrentUnitId(departmentId);
setCurrentUnitName(departmentName);
setPermanentDeleteUnitDialogOpen(true);
},
onAddFromSubCity: (unitId: string) => {
setCurrentUnitId(unitId);
setAddFromSubCityDialogOpen(true);
},
},
department: {
onAddUserUnderDepartment: (
departmentId: string,
departmentName: string
) => {
setCurrentDepartmentId(departmentId);
setCurrentDepartmentName(departmentName);
setAddUserUnderDepartmentOpen(true);
},
onEditDepartment: (departmentId: string, departmentName: string) => {
setCurrentDepartmentId(departmentId);
setCurrentDepartmentName(departmentName);
setEditDepartmentDialogOpen(true);
},
onDeleteDepartment: (departmentId: string, departmentName: string) => {
setCurrentDepartmentId(departmentId);
setCurrentDepartmentName(departmentName);
setDeleteDepartmentDialogOpen(true);
},
onMoveDepartment: (departmentId: string, departmentName: string) => {
setCurrentDepartmentId(departmentId);
setCurrentDepartmentName(departmentName);
setMoveDepartmentDialogOpen(true);
},
onViewUsers: () => toast({ title: "View Users" }),
onAddSubDepartment: (departmentId: string, departmentName: string) => {
setCurrentDepartmentId(departmentId);
setCurrentOrganizationId(selectedOrgId);
setCurrentUnitId(selectedUnitId);
setCurrentDepartmentName(departmentName);
setAddSubDepartmentDialogOpen(true);
},
onAssignUser: (departmentId: string, departmentName: string) => {
setCurrentDepartmentId(departmentId);
setCurrentDepartmentName(departmentName);
setCurrentOrganizationId(selectedOrgId);
setCurrentUnitId(selectedUnitId);
setAssignUserDialogOpen(true);
},
onDelegate: () => toast({ title: "Delegate" }),
},
teamMember: {
onDeleteTeamMember: (
departmentId: string,
departmentName: string,
teamMemberId: string,
teamMemberName: string
) => {
setCurrentDepartmentId(departmentId);
setCurrentDepartmentName(departmentName);
setDeleteTeamMemberDialog(true);
setCurrentTeamMemberId(teamMemberId);
setCurrentTeamMemberName(teamMemberName);
},
onDeactivateTeamMember:(
userId:string,
teamMemberName: string
)=>{
setCurrentUserId(userId);
setCurrentTeamMemberName(teamMemberName)
setDeactivateTeamMemberDialog(true)
}
},
dialogs: {
manageUnitAdmin: {
isOpen: manageUnitAdminDialogOpen,
onClose: () => setManageUnitAdminDialogOpen(false),
organizationId: currentOrganizationId,
unitId: currentUnitId,
},
editUnit: {
isOpen: editUnitDialogOpen,
onClose: () => setEditUnitDialogOpen(false),
unitId: currentUnitId,
},
deleteUnit: {
isOpen: deleteUnitDialogOpen,
onClose: () => setDeleteUnitDialogOpen(false),
unitId: currentUnitId,
unitName: currentUnitName,
},
permanentDeleteUnit: {
isOpen: permanentDeleteUnitDialogOpen,
onClose: () => setPermanentDeleteUnitDialogOpen(false),
unitId: currentUnitId,
unitName: currentUnitName,
},
addFromSubCity: {
isOpen: addFromSubCityDialogOpen,
onClose: () => setAddFromSubCityDialogOpen(false),
parentUnitId: currentUnitId,
},
department: {
isOpen: addDepartmentDialogOpen,
onClose: () => setAddDepartmentDialogOpen(false),
organizationId: currentOrganizationId,
unitId: currentUnitId,
},
editDepartment: {
isOpen: editDepartmentDialogOpen,
onClose: () => setEditDepartmentDialogOpen(false),
departmentId: currentDepartmentId,
departmentName: currentDepartmentName,
},
deleteDepartment: {
isOpen: deleteDepartmentDialogOpen,
onClose: () => setDeleteDepartmentDialogOpen(false),
departmentId: currentDepartmentId,
departmentName: currentDepartmentName,
},
moveDepartment: {
isOpen: moveDepartmentDialogOpen,
onClose: () => setMoveDepartmentDialogOpen(false),
departmentId: currentDepartmentId,
departmentName: currentDepartmentName,
},
userUnderDepartment: {
isOpen: addUserUnderDepartmentOpen,
onClose: () => setAddUserUnderDepartmentOpen(false),
unitId: selectedUnitId,
departmentId: currentDepartmentId,
departmentName: currentDepartmentName,
},
subDepartment: {
isOpen: addSubDepartmentDialogOpen,
onClose: () => setAddSubDepartmentDialogOpen(false),
departmentId: currentDepartmentId,
departmentName: currentDepartmentName,
organizationId: currentOrganizationId,
unitId: currentUnitId,
},
user: {
unitId: currentUnitId,
isOpen: addUserDialogOpen,
onClose: () => setAddUserDialogOpen(false),
},
viewUsers: {
unitId: currentUnitId,
isOpen: viewUsersDialogOpen,
onClose: () => setViewUsersDialogOpen(false),
},
assignUser: {
isOpen: assignUserDialogOpen,
onClose: () => setAssignUserDialogOpen(false),
departmentId: currentDepartmentId,
departmentName: currentDepartmentName,
organizationId: currentOrganizationId,
unitId: currentUnitId,
},
deleteTeamMember: {
isOpen: deleteTeamMemberDialog,
onClose: () => setDeleteTeamMemberDialog(false),
departmentId: currentDepartmentId,
departmentName: currentDepartmentName,
teamMemberId: currentTeamMemberId,
teamMemberName: currentTeamMemberName,
},
deactivateTeamMember:{
isOpen: deactivateTeamMemberDialog,
onClose: ()=> setDeactivateTeamMemberDialog(false),
userId: currentUserId,
teamMemberName: currentTeamMemberName
}
},
};
};

View File

@@ -0,0 +1,91 @@
import { useState } from "react";
import type { Entity, Name } from "../types";
import { useQueryClient } from "@tanstack/react-query";
export interface UseSelectionHandlersResult {
selectedOrgId: string;
selectedUnitId: string;
selectedDepartmentId: string;
selectedDepartmentName: string;
breadcrumb: Entity[];
handleSelectOrganization: (id: string) => void;
handleSelectUnit: (id: string, units: Entity[]) => void;
handleSelectDepartment: (id: string, departments: Entity[]) => void;
}
export const useSelectionHandlers = (
organizations: Entity[],
localizedName: (name: Name) => string
): UseSelectionHandlersResult => {
const queryClient = useQueryClient();
const [selectedOrgId, setSelectedOrgId] = useState("");
const [selectedUnitId, setSelectedUnitId] = useState("");
const [selectedDepartmentId, setSelectedDepartmentId] = useState("");
const [selectedDepartmentName, setSelectedDepartmentName] = useState("");
const [breadcrumb, setBreadcrumb] = useState<Entity[]>([]);
const handleSelectOrganization = (orgId: string) => {
queryClient.invalidateQueries({ queryKey: ["unitList"] });
setSelectedOrgId(orgId);
setSelectedUnitId("");
setSelectedDepartmentId("");
const org = organizations.find((o) => o.id === orgId);
if (org) {
setBreadcrumb([{ id: org.id, name: org.name }]);
}
};
const handleSelectUnit = (unitId: string, units: Entity[]) => {
queryClient.invalidateQueries({ queryKey: ["positionHierarchy"] });
setSelectedUnitId(unitId);
setSelectedDepartmentId("");
const unit = units.find((u) => u.id === unitId);
const org = organizations.find((o) => o.id === selectedOrgId);
if (unit && org) {
setBreadcrumb([
{ id: org.id, name: org.name },
{ id: unit.id, name: unit.name },
]);
}
};
const handleSelectDepartment = (
departmentId: string,
departments: Entity[]
) => {
queryClient.invalidateQueries({
queryKey: ["positionEmployees"],
});
setSelectedDepartmentId(departmentId);
const department = departments.find((d) => d.id === departmentId);
const unit = breadcrumb.find((b) => b.id === selectedUnitId);
const org = breadcrumb.find((b) => b.id === selectedOrgId);
department?.name &&
setSelectedDepartmentName(localizedName(department?.name) || "");
if (department && unit && org) {
setBreadcrumb([
{ id: org.id, name: org.name },
{ id: unit.id, name: unit.name },
{ id: department.id, name: department.name },
]);
}
};
return {
selectedOrgId,
selectedUnitId,
selectedDepartmentId,
selectedDepartmentName,
breadcrumb,
handleSelectOrganization,
handleSelectUnit,
handleSelectDepartment,
};
};

View File

@@ -0,0 +1,53 @@
import { useState, useEffect } from "react";
import { Name } from "../types";
import { usePositions } from "@/user-management/hooks/usePosition";
import { PositionDto } from "@/user-management/dto/positions/positionDto";
interface UseDepartmentsData {
departments: PositionEntity[];
isLoading: boolean;
setDepartments: (departments: PositionEntity[]) => void;
positions: PositionDto[];
}
export interface PositionEntity {
id: string;
name: Name;
parentId?: string | null;
children?: PositionEntity[];
}
export const mapPositionDtoToEntity = (
position: PositionDto
): PositionEntity => {
return {
id: position.id,
name: position.name, // or position.name.am for Armenian
parentId: position.parentPositionId,
...(position.subPositions.length > 0 && {
children: position.subPositions.map(mapPositionDtoToEntity),
}),
};
};
export const useDepartmentsData = (unitId: string): UseDepartmentsData => {
const [departments, setDepartments] = useState<PositionEntity[]>([]);
const [positions, setPositions] = useState<PositionDto[]>([]);
const { usePositionHierarchy } = usePositions();
const { data, isLoading } = usePositionHierarchy(unitId);
useEffect(() => {
if (data) {
const positions = data as PositionDto[];
const mappedPositions: PositionEntity[] = positions.map(
mapPositionDtoToEntity
);
setPositions(positions);
setDepartments(mappedPositions);
}
}, [data]);
return { departments, isLoading, setDepartments, positions };
};

View File

@@ -0,0 +1,31 @@
import { useState, useEffect } from "react";
import { usePositions } from "@/user-management/hooks/usePosition";
import { TeamMemberDto } from "@/user-management/dto/teamMember/teamMember";
interface UseEmployeesData {
employees: TeamMemberDto[];
isLoading: boolean;
setEmployees: (employees: TeamMemberDto[]) => void;
refetch?: () => Promise<void>;
}
export const useEmployeesData = (positionId: string): UseEmployeesData => {
const [employees, setEmployees] = useState<TeamMemberDto[]>([]);
const { useEmployeesUnderPosition } = usePositions();
const { data, isLoading, refetch } = useEmployeesUnderPosition(positionId);
useEffect(() => {
if (data) {
setEmployees(data.data as TeamMemberDto[]);
}
}, [data]);
const refetchEmployees = async () => {
if (refetch) {
await refetch();
}
};
return { employees, isLoading, setEmployees, refetch: refetchEmployees };
};

View File

@@ -0,0 +1,22 @@
import { useMyAdminOrganizations, OrgAdminOrganization } from "@/user-management/hooks/useOrgAdminOrganizations";
import { Entity } from "../types";
interface UseOrganizationsData {
organizations: Entity[];
isLoading: boolean;
}
export const useOrganizationsData = ({
currentOrgId: _currentOrgId,
}: {
currentOrgId?: string;
}): UseOrganizationsData => {
const { organizations: adminOrgs, isLoading } = useMyAdminOrganizations();
const organizations: Entity[] = adminOrgs.map((item: OrgAdminOrganization) => ({
id: item.id,
name: item.name,
}));
return { organizations, isLoading };
};

View File

@@ -0,0 +1,83 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useAuthUser } from "@/shared/hooks/useAuthUser";
import {
approvePendingEmployees,
fetchCurrentEmployees,
rejectPendingEmployees,
Params,
} from "@/record-management/services/api/employeesService";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
export enum EUserStatus {
PENDING = "pending",
ADJUSTED = "adjusted",
SUBMITTED = "submitted",
ACCEPTED = "accepted",
REJECTED = "rejected",
}
const FIVE_MIN = 5 * 60 * 1000;
export const usePendingUsers = (params: Params) => {
const { userDetails } = useAuthUser();
const unitId = userDetails?.employee?.[0]?.unitId ?? null;
const queryClient = useQueryClient();
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
// Fetch pending users
const {
data: pendingUsers = { count: 0, items: [] },
isLoading: loadingPendingUsers,
error: pendingUsersError,
} = useQuery({
queryKey: ["pendingEmployees", unitId, params],
queryFn: () =>
fetchCurrentEmployees(
{ ...params, userStatus: EUserStatus.PENDING },
unitId!
),
enabled: !!unitId,
staleTime: FIVE_MIN,
});
// Approve mutation with toast notifications
const approveMutation = useMutation({
mutationFn: async (id: string) => {
if (!id) throw new Error("Employee ID is missing");
await approvePendingEmployees(id);
},
onSuccess: () => {
toast.success("Employee approved successfully");
queryClient.invalidateQueries({ queryKey: ["pendingEmployees"] });
},
onError: (error) => {
handleError(error);
},
});
// Reject mutation with toast notifications
const rejectMutation = useMutation({
mutationFn: async (id: string) => {
if (!id) throw new Error("Employee ID is missing");
await rejectPendingEmployees(id);
},
onSuccess: () => {
toast.success("Employee rejected successfully");
queryClient.invalidateQueries({ queryKey: ["pendingEmployees"] });
},
onError: (error) => {
handleError(error);
},
});
return {
pendingUsers,
loadingPendingUsers,
pendingUsersError,
approveMutation,
rejectMutation,
};
};

View File

@@ -0,0 +1,46 @@
import { useState, useEffect } from "react";
import { Entity } from "../types";
interface Position extends Entity {
type: string;
employeeCount: number;
title: string;
}
interface UsePositionsData {
positions: Position[];
isLoading: boolean;
}
export const usePositionsData = (departmentId: string): UsePositionsData => {
const [positions, setPositions] = useState<Position[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
if (!departmentId) {
setPositions([]);
setIsLoading(false);
return;
}
await new Promise((r) => setTimeout(r, 500));
setPositions([
{
id: `${departmentId}-pos1`,
name: {
am: "Leader in Amharic",
en: "Leader",
},
type: "Leader",
employeeCount: 1,
title: "Leader",
},
]);
setIsLoading(false);
};
fetchData();
}, [departmentId]);
return { positions, isLoading };
};

View File

@@ -0,0 +1,90 @@
import { useMemo } from "react";
import { useAuth } from "@/shared/context/AuthContext";
import { useMyAdminUnits } from "@/user-management/hooks/useOrgAdminOrganizations";
import { useUnit } from "@/user-management/hooks/useUnit";
import { UnitDto } from "@/user-management/dto/unit/unitDto";
import { Entity } from "../types";
interface UseUnitsData {
units: Entity[];
fetchedUnits: UnitDto[];
isLoading: boolean;
setUnits: (units: Entity[]) => void;
}
interface UnitsParam {
take: number;
skip: number;
}
const normalizeName = (
name: string | { am?: string; en?: string },
): Entity["name"] => {
if (typeof name === "string") {
return { am: name, en: name };
}
const fallback = name.en || name.am || "";
return {
am: name.am || fallback,
en: name.en || fallback,
};
};
const dedupeById = <T extends { id: string }>(items: T[]) => {
const map = new Map<string, T>();
items.forEach((item) => {
if (!item?.id || map.has(item.id)) return;
map.set(item.id, item);
});
return Array.from(map.values());
};
export const useUnitsData = (
organizationId: string,
params: UnitsParam,
): UseUnitsData => {
const { user } = useAuth();
const isOrganizationAdmin =
user?.roles?.some((role) => role.key === "organization_admin") ?? false;
const { units: adminUnits, isLoading: isAdminUnitsLoading } =
useMyAdminUnits(organizationId, !isOrganizationAdmin);
const { data: allUnitsResponse, isLoading: isAllUnitsLoading } =
useUnit().getList(organizationId, params, isOrganizationAdmin);
const fetchedUnits = useMemo(
() => {
const sourceUnits = isOrganizationAdmin
? (allUnitsResponse?.data?.items ?? [])
: adminUnits;
return dedupeById(sourceUnits as UnitDto[]);
},
[adminUnits, allUnitsResponse, isOrganizationAdmin],
);
const units = useMemo<Entity[]>(
() =>
fetchedUnits.map((unit) => ({
id: unit.id,
name: normalizeName(unit.name),
})),
[fetchedUnits],
);
// Kept for backwards compatibility. Units are derived from query data.
const setUnits = (_: Entity[]) => {};
return {
units,
fetchedUnits,
isLoading: isOrganizationAdmin
? isAllUnitsLoading
: isAdminUnitsLoading,
setUnits,
};
};

View File

@@ -0,0 +1,248 @@
import { cn } from "@/super-admin/lib/utils";
import { DepartmentActions } from "../actions/DepartmentActions";
import { useState } from "react";
import { Entity, Name } from "../types";
import {
ChevronDown,
ChevronRight,
Dot,
Users,
UserPlus,
FolderPlus,
UserCog,
Loader2,
FolderX,
} from "lucide-react";
import { PositionDto } from "@/user-management/dto/positions/positionDto";
import { useLocalizedName } from "@/shared/common/localizedName";
import { t } from "i18next";
interface Department {
id: string;
name: Name;
positions?: Position[];
children?: Department[];
isExpanded?: boolean;
parentDepartmentId?: string;
userCount?: number;
}
interface Position {
id: string;
name: {
am: string;
en: string;
};
isExpanded?: boolean;
}
interface DepartmentListProps {
unitId: string;
selectedDepartmentId: string;
onSelectDepartment: (departmentId: string, departments: Entity[]) => void;
departments: Department[];
positions: PositionDto[];
isLoading: boolean;
searchQuery?: string;
onAddUser?: (departmentId: string, departmentName: string) => void;
onViewUsers?: (departmentId: string) => void;
onAddPosition?: (departmentId: string, positionType: string) => void;
onAddSubDepartment?: (departmentId: string, departmentName: string) => void;
onDelegate?: (departmentId: string) => void;
onAssignUser?: (departmentId: string, departmentName: string) => void;
onEditDepartment?: (departmentId: string, departmentName: string) => void;
onDeleteDepartment?: (departmentId: string, departmentName: string) => void;
onArchiveDepartment?: (departmentId: string, departmentName: string) => void;
onMoveDepartment?: (departmentId: string, departmentName: string) => void;
}
export const DepartmentList = ({
unitId,
selectedDepartmentId,
onSelectDepartment,
departments = [],
positions,
isLoading = false,
searchQuery = "",
onAddUser,
onViewUsers,
onAddPosition,
onAddSubDepartment,
onEditDepartment,
onDeleteDepartment,
onArchiveDepartment,
onDelegate,
onAssignUser,
onMoveDepartment,
}: DepartmentListProps) => {
const [hoveredId, setHoveredId] = useState<string | null>(null);
const [expandedState, setExpandedState] = useState<Record<string, boolean>>(
{}
);
const localizedName = useLocalizedName();
// Filter departments based on search query
const filterDepartments = (depts: Department[]): Department[] => {
if (!searchQuery) return depts;
return depts
.filter((dept) => {
const name = localizedName(dept.name);
const matchesName = name
.toLowerCase()
.includes(searchQuery.toLowerCase());
// Also check if any child departments match
const hasMatchingChildren =
dept.children && dept.children.length > 0
? filterDepartments(dept.children).length > 0
: false;
return matchesName || hasMatchingChildren;
})
.map((dept) => ({
...dept,
children: dept.children ? filterDepartments(dept.children) : undefined,
}));
};
const filteredDepartments = filterDepartments(departments);
const toggleExpand = (id: string) => {
setExpandedState((prev) => ({
...prev,
[id]: !prev[id],
}));
};
const renderDepartment = (dept: Department, level = 0) => {
const isSelected = dept.id === selectedDepartmentId;
const isHovered = hoveredId === dept.id;
const isExpanded = expandedState[dept.id] ?? dept.isExpanded;
const departmentName = localizedName(dept.name);
const hasChildren =
(dept.children?.length ?? 0) > 0 || (dept.positions?.length ?? 0) > 0;
return (
<div
key={dept.id}
className={cn("flex flex-col gap-1 group", level > 0 && "ml-4")}
>
<div
className={cn(
"flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700",
isSelected && "bg-gray-100 dark:bg-gray-700",
!isSelected && "text-gray-800 dark:text-gray-200"
)}
onClick={() => onSelectDepartment(dept.id, [])}
>
<div className="flex min-w-0 flex-1 items-center gap-2">
{hasChildren ? (
<button
onClick={(e) => {
e.stopPropagation();
toggleExpand(dept.id);
}}
className="w-4 h-4 flex shrink-0 items-center justify-center"
>
{isExpanded ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</button>
) : (
<Dot className="w-4 h-4 shrink-0" />
)}
<span className="min-w-0 flex-1 truncate" title={departmentName}>
{departmentName}
</span>
</div>
<DepartmentActions
department={dept}
positions={positions}
onAddUser={onAddUser}
onViewUsers={onViewUsers}
onAddPosition={onAddPosition}
onAddSubDepartment={onAddSubDepartment}
onDelegate={onDelegate}
onAssignUser={onAssignUser}
onEditDepartment={onEditDepartment}
onDeleteDepartment={onDeleteDepartment}
onArchiveDepartment={onArchiveDepartment}
onMoveDepartment={onMoveDepartment}
/>
</div>
{isExpanded && hasChildren && (
<div>
{dept.children?.map((child) => renderDepartment(child, level + 1))}
{dept.positions?.map((pos) => {
const positionName = pos.name.am || pos.name.en;
return (
<div
key={pos.id}
className={cn(
"flex items-center gap-2 text-sm py-1 pl-8 border-l-2 border-primary-100 dark:border-primary-800 ml-4 text-gray-800 dark:text-gray-200",
expandedState[pos.id] ? "bg-blue-50 dark:bg-blue-900/20" : "hover:bg-blue-50 dark:hover:bg-blue-900/20"
)}
onClick={() => toggleExpand(pos.id)}
>
<Dot className="w-4 h-4 shrink-0 text-gray-400 dark:text-gray-500" />
<span className="min-w-0 flex-1 truncate" title={positionName}>
{positionName}
</span>
{isHovered && (
<div className="flex shrink-0 gap-2 pr-2">
<UserPlus
className="w-4 h-4 text-gray-500 dark:text-gray-400 hover:text-primary-600 dark:hover:text-primary-400"
onClick={(e) => {
e.stopPropagation();
onAddUser?.(pos.id, pos.name.en);
}}
/>
<FolderPlus
className="w-4 h-4 text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400"
onClick={(e) => {
e.stopPropagation();
onAddPosition?.(pos.id, "sub");
}}
/>
<UserCog
className="w-4 h-4 text-gray-500 dark:text-gray-400 hover:text-purple-600 dark:hover:text-purple-400"
onClick={(e) => {
e.stopPropagation();
onDelegate?.(pos.id);
}}
/>
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
};
return (
<div className="flex flex-col gap-1 min-h-[400px]">
{isLoading ? (
<div className="flex justify-center items-center h-full gap-2 text-gray-500 dark:text-gray-400">
<Loader2 className="w-4 h-4 animate-spin" />
<p>{t("organization.loading")}</p>
</div>
) : filteredDepartments.length > 0 ? (
filteredDepartments.map((dept) => renderDepartment(dept))
) : (
<div className="flex flex-col items-center justify-center h-full gap-2 text-gray-400 dark:text-gray-500">
<FolderX className="w-6 h-6" />
<p className="text-sm">
{searchQuery
? t("search.noResults")
: t("userIncoming.No Department")}
</p>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,128 @@
import { cn } from "@/super-admin/lib/utils";
import { useLocalizedName } from "@/shared/common/localizedName";
import { t } from "i18next";
import { useState } from "react";
import { MoreHorizontal, Plus } from "lucide-react";
import { Button } from "@/shared/common/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/shared/common/ui/dropdown-menu";
interface Organization {
id: string;
name: { am: string; en: string };
units?: any[];
isExpanded?: boolean;
}
interface OrganizationListProps {
selectedOrgId: string;
onSelectOrganization: (orgId: string) => void;
organizations: Organization[];
isLoading: boolean;
searchQuery?: string;
onCreateUnit?: (orgId: string) => void;
canCreateUnitForOrg?: (orgId: string) => boolean;
}
export const OrganizationList = ({
selectedOrgId,
onSelectOrganization,
organizations = [],
isLoading = false,
searchQuery = "",
onCreateUnit,
canCreateUnitForOrg,
}: OrganizationListProps) => {
const localizedName = useLocalizedName();
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
const filteredOrganizations = organizations.filter((org) => {
if (!searchQuery) return true;
const name = localizedName(org.name);
return name.toLowerCase().includes(searchQuery.toLowerCase());
});
return (
<div className="flex flex-col gap-2 min-h-[400px]">
{isLoading ? (
<div className="flex justify-center items-center h-full text-gray-500 dark:text-gray-400">
<p>{t("organization.loading")}</p>
</div>
) : filteredOrganizations.length > 0 ? (
filteredOrganizations.map((org) => {
const canCreate = canCreateUnitForOrg
? canCreateUnitForOrg(org.id)
: false;
return (
<div
key={org.id}
className={cn(
"flex items-center justify-between p-2 rounded-md cursor-pointer group",
org.id === selectedOrgId
? "bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
: "hover:bg-primary-50 hover:text-primary-700 dark:hover:bg-primary-900/30 dark:hover:text-primary-300 text-gray-800 dark:text-gray-200",
)}
onClick={() => onSelectOrganization(org.id)}
>
<div className="flex-1">
<span
className={cn(
"text-sm",
org.id === selectedOrgId ? "font-medium" : "",
)}
>
{localizedName(org.name)}
</span>
</div>
{onCreateUnit && (
<DropdownMenu
open={openMenuId === org.id}
onOpenChange={(open) => setOpenMenuId(open ? org.id : null)}
>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0 opacity-0 group-hover:opacity-100 data-[state=open]:opacity-100 dark:hover:bg-gray-700"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4 dark:text-gray-400" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-[180px] dark:bg-gray-700 dark:border-gray-600"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenuLabel className="dark:text-gray-200">
Actions
</DropdownMenuLabel>
<DropdownMenuItem
disabled={!canCreate}
onClick={() => {
setOpenMenuId(null);
onCreateUnit(org.id);
}}
className="dark:text-gray-200 dark:hover:bg-gray-600"
>
<Plus className="mr-2 h-4 w-4" />
{t("contentManagement.createUnit", "Create Unit")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
})
) : (
<div className="flex flex-col items-center justify-center h-full text-gray-400 dark:text-gray-500">
<p className="text-sm">{t("contentManagement.noOrg")}</p>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,138 @@
import { cn } from "@/super-admin/lib/utils";
import { UnitActions } from "../actions/UnitActions";
import { useState, useRef } from "react";
import { Entity, Name } from "../types";
import { FolderX, Loader2 } from "lucide-react";
import { useLocalizedName } from "@/shared/common/localizedName";
import { t } from "i18next";
interface Unit {
id: string;
name: Name;
departments?: any[];
isExpanded?: boolean;
isRelated?: boolean;
}
interface UnitListProps {
organizationId?: string;
selectedUnitId: string;
onSelectUnit: (unitId: string, units: Entity[]) => void;
units: Unit[];
isLoading: boolean;
searchQuery?: string;
onEditUnit?: (unitId: string) => void;
onArchiveUnit?: (unitId: string, unitName: string) => void;
onDeleteUnit?: (unitId: string, unitName: string) => void;
onAddDepartment?: (unitId: string) => void;
onManageUnitAdmin?: (unitId: string) => void;
onAddFromSubCity?: (unitId: string) => void;
}
export const UnitList = ({
selectedUnitId,
onSelectUnit,
units = [],
isLoading = false,
searchQuery = "",
onAddDepartment,
onManageUnitAdmin,
onEditUnit,
onArchiveUnit,
onDeleteUnit,
onAddFromSubCity,
}: UnitListProps) => {
const [hoveredId, setHoveredId] = useState<string | null>(null);
const [clickTimeout, setClickTimeout] = useState<NodeJS.Timeout | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const localizedName = useLocalizedName();
// Filter units based on search query
const filteredUnits = units.filter((unit) => {
if (!searchQuery) return true;
const name = localizedName(unit.name);
return name.toLowerCase().includes(searchQuery.toLowerCase());
});
// Handle single click with delay to prevent double-click interference
const handleUnitClick = (unitId: string, units: Entity[]) => {
// If already selected, don't trigger selection again
if (unitId === selectedUnitId) {
return;
}
// Clear any existing timeout
if (clickTimeout) {
clearTimeout(clickTimeout);
}
// Set a timeout to handle the click after a short delay
const timeout = setTimeout(() => {
onSelectUnit(unitId, units);
}, 200);
setClickTimeout(timeout);
};
return (
<div
className="flex flex-col gap-2 h-[50vh] overflow-y-auto"
ref={containerRef}
>
{isLoading && filteredUnits.length === 0 ? (
<div className="flex justify-center items-center h-full gap-2 text-gray-500 dark:text-gray-400">
<Loader2 className="w-4 h-4 animate-spin" />
<p>{t("organization.loading")}</p>
</div>
) : filteredUnits.length > 0 ? (
filteredUnits.map((unit) => {
return (
<div
key={unit.id}
className={cn(
"flex items-center justify-between p-2 rounded-md cursor-pointer group",
unit.id === selectedUnitId
? "bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300"
: "hover:bg-primary-50 dark:hover:bg-primary-900/20 hover:text-primary-700 dark:hover:text-primary-300 text-gray-800 dark:text-gray-200"
)}
onMouseEnter={() => setHoveredId(unit.id)}
onMouseLeave={() => setHoveredId(null)}
onClick={() => handleUnitClick(unit.id, units)}
>
<div className="flex-1">
<span
className={cn(
"text-sm",
unit.id === selectedUnitId ? "font-medium" : ""
)}
>
{localizedName(unit.name)}
</span>
</div>
<UnitActions
unit={unit}
onAddDepartment={onAddDepartment}
onManageUnitAdmin={onManageUnitAdmin}
onEditUnit={onEditUnit}
onArchiveUnit={unit.isRelated ? undefined : onArchiveUnit}
onDeleteUnit={unit.isRelated ? undefined : onDeleteUnit}
onAddFromSubCity={onAddFromSubCity}
showAlways={hoveredId === unit.id || unit.id === selectedUnitId}
/>
</div>
);
})
) : (
<div className="flex flex-col items-center justify-center h-full gap-2 text-gray-400 dark:text-gray-500">
<FolderX className="w-6 h-6" />
<p className="text-sm">{t("contentManagement.noUnit")}</p>
</div>
)}
{isLoading && units.length > 0 && (
<div className="flex justify-center py-2 text-gray-500 dark:text-gray-400 text-sm">
<Loader2 className="w-4 h-4 animate-spin mr-2" />
{t("organization.loading")}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,19 @@
export interface Entity {
id: string;
name: Name;
}
export interface Name {
am: string;
en: string;
}
export interface Employee {
id: string;
name: { am: string; en: string };
email?: string;
role?: string;
title?: string;
phone?: string;
inviteStatus?: "Pending" | "Accepted" | "Not Invited";
}