user management ui

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

View File

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

View File

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

View File

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