mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 06:28:12 +00:00
user management ui
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
|
||||
interface BreadcrumbItem {
|
||||
id: string;
|
||||
name: { am: string; en: string };
|
||||
}
|
||||
|
||||
interface BreadcrumbProps {
|
||||
breadcrumb: BreadcrumbItem[];
|
||||
hasTeamMembers: boolean;
|
||||
}
|
||||
|
||||
export const Breadcrumb: FC<BreadcrumbProps> = ({
|
||||
breadcrumb,
|
||||
hasTeamMembers,
|
||||
}) => {
|
||||
const localizedName = useLocalizedName();
|
||||
return (
|
||||
<div className="flex items-center text-sm text-gray-500 dark:text-gray-400 mb-4 overflow-x-auto">
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300">User Management</span>
|
||||
|
||||
{breadcrumb.map((item, index) => (
|
||||
<div key={item.id} className="flex items-center">
|
||||
<ChevronRight className="h-4 w-4 mx-2 flex-shrink-0 text-gray-400 dark:text-gray-500" />
|
||||
<span
|
||||
className={
|
||||
index === breadcrumb.length - 1 ? "font-medium text-gray-800 dark:text-gray-200" : ""
|
||||
}
|
||||
>
|
||||
{localizedName(item.name)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{hasTeamMembers && (
|
||||
<div className="flex items-center">
|
||||
<ChevronRight className="h-4 w-4 mx-2 flex-shrink-0 text-gray-400 dark:text-gray-500" />
|
||||
<span className="font-medium text-gray-800 dark:text-gray-200">Team Members</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,255 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/common/ui/dialog";
|
||||
import { Search, UserCircle, AlertCircle, X } from "lucide-react";
|
||||
import {
|
||||
useOrganizationEmployeeSearch,
|
||||
OrganizationEmployee,
|
||||
} from "../../hooks/useOrganizationEmployeeSearch";
|
||||
import { t } from "i18next";
|
||||
import i18n from "@/i18n";
|
||||
import { toast } from "sonner";
|
||||
import { TeamMembers } from "../TeamMembers";
|
||||
import { TeamMemberDto } from "../../dto/teamMember/teamMember";
|
||||
import { resendVerificationCode } from "@/shared/services/authService";
|
||||
|
||||
interface OrganizationEmployeeSearchProps {
|
||||
organizationId?: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Convert EmployeeWithUnitDto to TeamMemberDto format
|
||||
const convertToTeamMember = (
|
||||
employee: OrganizationEmployee,
|
||||
organizationId: string
|
||||
): TeamMemberDto => {
|
||||
const primaryPosition = employee.employeePositions?.[0];
|
||||
const userName = employee.user?.name ?? { en: "", am: "" };
|
||||
const positionName = primaryPosition?.position?.name ?? { en: "", am: "" };
|
||||
|
||||
return {
|
||||
organizationId,
|
||||
status: "Active",
|
||||
id: employee.id,
|
||||
isCurrent: true,
|
||||
name: primaryPosition
|
||||
? `${userName.en || userName.am} - ${positionName.en || positionName.am}`
|
||||
: (userName.en || userName.am || null),
|
||||
employeePositions: employee.employeePositions || [],
|
||||
user: {
|
||||
id: employee.user.id,
|
||||
name: userName,
|
||||
username: employee.user.email.split("@")[0],
|
||||
email: employee.user.email,
|
||||
userType: "employee",
|
||||
sharepointId: null,
|
||||
status: "pending",
|
||||
phoneNumber: "",
|
||||
hasSetPassword: false,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const OrganizationEmployeeSearch: React.FC<
|
||||
OrganizationEmployeeSearchProps
|
||||
> = ({ organizationId, isOpen, onClose }) => {
|
||||
const {
|
||||
filteredEmployees,
|
||||
totalCount,
|
||||
filteredCount,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
isLoading,
|
||||
error,
|
||||
} = useOrganizationEmployeeSearch(organizationId);
|
||||
// Convert filtered employees to TeamMemberDto format
|
||||
const convertedEmployees = useMemo(() => {
|
||||
if (!organizationId) return [];
|
||||
return filteredEmployees.map((employee) =>
|
||||
convertToTeamMember(employee, organizationId)
|
||||
);
|
||||
}, [filteredEmployees, organizationId]);
|
||||
|
||||
// Action handlers for TeamMembers
|
||||
const handleInviteEmployee = async (employee: TeamMemberDto) => {
|
||||
const lang = i18n.language;
|
||||
try {
|
||||
// Show immediate feedback that action is being processed
|
||||
if (employee.user.status === "pending") {
|
||||
toast.loading(t("search.resendingVerification"));
|
||||
} else {
|
||||
toast.loading(t("search.sendingInvitation"));
|
||||
}
|
||||
|
||||
await resendVerificationCode({
|
||||
email: employee.user.email,
|
||||
phoneNumber: employee.user.phoneNumber,
|
||||
});
|
||||
|
||||
// Show success notification with more specific message for resend
|
||||
const userName =
|
||||
lang === "en" ? employee.user.name.en : employee.user.name.am;
|
||||
|
||||
if (employee.user.status === "pending") {
|
||||
toast.success(t("search.verificationCodeResent"), {
|
||||
description: t("search.verificationCodeSentTo", { name: userName }),
|
||||
duration: 4000,
|
||||
});
|
||||
} else {
|
||||
toast.success(t("search.invitationSent"), {
|
||||
description: t("search.invitationSentTo", { name: userName }),
|
||||
duration: 4000,
|
||||
});
|
||||
}
|
||||
|
||||
// Refresh the employee list to update statuses
|
||||
// Note: We could add a refetch function here if needed
|
||||
} catch (error: unknown) {
|
||||
// Show error notification
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
const failureMessage =
|
||||
employee.user.status === "pending"
|
||||
? t("search.failedToResend")
|
||||
: t("search.failedToSendInvitation");
|
||||
|
||||
toast.error("Error", {
|
||||
description: `${failureMessage}: ${errorMessage}`,
|
||||
duration: 4000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteEmployee = (
|
||||
departmentId: string,
|
||||
departmentName: string,
|
||||
teamMemberId: string,
|
||||
teamMemberName: string
|
||||
) => {
|
||||
// Simple confirmation for remove action
|
||||
const confirmed = window.confirm(
|
||||
t("search.confirmRemoveEmployee", { name: teamMemberName })
|
||||
);
|
||||
|
||||
if (confirmed) {
|
||||
|
||||
toast.info(t("search.removeFunctionalityNotImplemented"));
|
||||
}
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-8 h-8 border-4 border-t-blue-500 border-blue-200 rounded-full animate-spin mb-2"></div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("search.searchingEmployees")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-12 text-red-500 dark:text-red-400">
|
||||
<AlertCircle className="h-12 w-12 mx-auto mb-2" />
|
||||
<p className="text-sm font-medium">
|
||||
{t("search.errorLoadingEmployees")}
|
||||
</p>
|
||||
<p className="text-xs mt-1 dark:text-gray-400">
|
||||
{error instanceof Error ? error.message : "Unknown error"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (filteredEmployees.length === 0 && searchQuery.trim()) {
|
||||
return (
|
||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
<Search className="h-12 w-12 mx-auto mb-2 text-gray-300 dark:text-gray-600" />
|
||||
<p className="text-sm font-medium">{t("search.noEmployeesFound")}</p>
|
||||
<p className="text-xs mt-1 text-gray-400 dark:text-gray-500">
|
||||
{t("search.tryDifferentKeywords")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (filteredEmployees.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
<UserCircle className="h-12 w-12 mx-auto mb-2 text-gray-300 dark:text-gray-600" />
|
||||
<p className="text-sm font-medium">
|
||||
{t("search.startTypingToSearch")}
|
||||
</p>
|
||||
<p className="text-xs mt-1 text-gray-400 dark:text-gray-500">
|
||||
{t("search.searchByNameEmailPhone")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Use TeamMembers component for consistent UI and actions
|
||||
return (
|
||||
<TeamMembers
|
||||
departmentId="" // Not applicable for organization-wide search
|
||||
departmentName={t("search.allEmployees")}
|
||||
employees={convertedEmployees}
|
||||
isLoading={false}
|
||||
searchQuery="" // We handle search at this level
|
||||
onInviteEmployee={handleInviteEmployee}
|
||||
onDeleteEmployee={handleDeleteEmployee}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden dark:bg-gray-800">
|
||||
<DialogHeader className="flex flex-row items-center justify-between">
|
||||
<DialogTitle className="flex items-center gap-2 dark:text-white">
|
||||
<Search className="h-5 w-5 dark:text-gray-400" />
|
||||
{t("search.searchAllEmployees")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Search Input */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-3 h-4 w-4 text-gray-400 dark:text-gray-500" />
|
||||
<Input
|
||||
placeholder={t("search.searchByNameEmailPhonePosition")}
|
||||
className="pl-10 dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Results Count */}
|
||||
{searchQuery.trim() && (
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t("search.showingResults", {
|
||||
count: filteredCount,
|
||||
total: totalCount,
|
||||
query: searchQuery,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
<div className="max-h-96 overflow-y-auto">{renderContent()}</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { X, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { t } from "i18next";
|
||||
|
||||
interface UnitSelectionModalProps {
|
||||
units: unknown[];
|
||||
isLoading: boolean;
|
||||
onSelectUnit: (unitId: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const UNITS_PER_PAGE = 10;
|
||||
|
||||
export const UnitSelectionModal: React.FC<UnitSelectionModalProps> = ({
|
||||
units,
|
||||
isLoading,
|
||||
onSelectUnit,
|
||||
onClose,
|
||||
}) => {
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
|
||||
const typedUnits = units
|
||||
.filter(
|
||||
(unit): unit is { id: string; name: any; description?: string } =>
|
||||
typeof unit === "object" &&
|
||||
unit !== null &&
|
||||
"id" in unit &&
|
||||
"name" in unit
|
||||
);
|
||||
|
||||
const totalPages = Math.ceil(typedUnits.length / UNITS_PER_PAGE);
|
||||
const startIndex = currentPage * UNITS_PER_PAGE;
|
||||
const endIndex = startIndex + UNITS_PER_PAGE;
|
||||
const currentUnits = typedUnits.slice(startIndex, endIndex);
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentPage < totalPages - 1) {
|
||||
setCurrentPage(currentPage + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
if (currentPage > 0) {
|
||||
setCurrentPage(currentPage - 1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-2xl w-full mx-4 shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold dark:text-white">
|
||||
{t("selectUnit")}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 focus:outline-none dark:hover:bg-gray-700 rounded"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-2 dark:text-gray-400">{t("loadingUnits")}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Units List */}
|
||||
<div className="space-y-2 mb-4">
|
||||
{currentUnits.length > 0 ? (
|
||||
currentUnits.map((unit) => (
|
||||
<button
|
||||
key={unit.id}
|
||||
onClick={() => onSelectUnit(unit.id)}
|
||||
className="w-full text-left p-4 rounded border border-gray-200 hover:bg-gray-100 hover:shadow-sm transition-all focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer dark:border-gray-600 dark:hover:bg-gray-700 dark:focus:ring-blue-400"
|
||||
>
|
||||
<div className="font-medium dark:text-gray-200 truncate">
|
||||
{unit.name?.en || unit.name}
|
||||
</div>
|
||||
{unit.description && (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 truncate mt-1">
|
||||
{unit.description}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<p>{t("noUnitsFound")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination Info */}
|
||||
{totalPages > 1 && (
|
||||
<div className="text-center text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
{t("page", {
|
||||
current: currentPage + 1,
|
||||
total: totalPages,
|
||||
defaultValue: `Page ${currentPage + 1} of ${totalPages}`,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer with Pagination */}
|
||||
<div className="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePrevious}
|
||||
disabled={currentPage === 0}
|
||||
className="dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
{t("previous")}
|
||||
</Button>
|
||||
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{typedUnits.length > 0
|
||||
? `${startIndex + 1} - ${Math.min(endIndex, typedUnits.length)} of ${typedUnits.length}`
|
||||
: "0"}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNext}
|
||||
disabled={currentPage >= totalPages - 1}
|
||||
className="dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
>
|
||||
{t("next")}
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user