mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 06:28:12 +00:00
fix ui
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,164 +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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,127 +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>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,73 +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>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,131 +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>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,45 +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>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,255 +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>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,146 +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>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,46 +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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,41 +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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,39 +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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,47 +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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,39 +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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,46 +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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,76 +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;
|
||||
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;
|
||||
|
||||
@@ -1,95 +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>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,74 +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>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,68 +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>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,42 +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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,62 +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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,216 +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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,251 +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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,43 +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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,82 +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>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -1,122 +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>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,34 +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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,181 +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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,373 +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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,202 +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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,251 +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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,152 +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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,230 +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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,362 +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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,232 +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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,124 +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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,213 +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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,265 +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
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
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
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,91 +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,
|
||||
};
|
||||
};
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,53 +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 };
|
||||
};
|
||||
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 };
|
||||
};
|
||||
|
||||
@@ -1,31 +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 };
|
||||
};
|
||||
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 };
|
||||
};
|
||||
|
||||
@@ -1,22 +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 };
|
||||
};
|
||||
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 };
|
||||
};
|
||||
|
||||
@@ -1,83 +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,
|
||||
};
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -1,46 +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 };
|
||||
};
|
||||
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 };
|
||||
};
|
||||
|
||||
@@ -1,90 +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,
|
||||
};
|
||||
};
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,248 +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>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,128 +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>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,138 +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>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,19 +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";
|
||||
}
|
||||
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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user