Files
edr-platform/apps/edr-freight-web/backoffice/src/user-management/userManagement/TeamMembers.tsx
natib21 0d98ccb04b fix
2026-07-17 13:10:27 +00:00

749 lines
29 KiB
TypeScript

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