This commit is contained in:
natib21
2026-07-10 11:25:59 +00:00
parent 2696ca7498
commit e6e44e773b
1146 changed files with 200266 additions and 90527 deletions

View File

@@ -0,0 +1,201 @@
import { useState } from "react";
import { Input } from "@/shared/common/ui/input";
import { Search } from "lucide-react";
import { OrganizationList } from "./lists/OrganizationList";
import { UnitList } from "./lists/UnitList";
import { DepartmentList } from "./lists/DepartmentList";
import { PositionServicesList } from "./lists/PositionServicesList";
import { useOrganizationsData } from "@/user-management/userManagement/hooks/useOrganizationsData";
import { useUnitsData } from "@/user-management/userManagement/hooks/useUnitsData";
import { useDepartmentsData } from "@/user-management/userManagement/hooks/useDepartmentsData";
import { useSelectionHandlers } from "@/user-management/userManagement/handlers/useSelectionHandlers";
import { Breadcrumb } from "@/user-management/userManagement/components/Breadcrumb";
import { useAuth } from "@/shared/context/AuthContext";
import { useLocalizedName } from "@/shared/common/localizedName";
import { t } from "i18next";
import { AssignServiceDialog } from "./dialogs/AssignServiceDialog";
const ServiceAssignmentPage = () => {
const [searchQuery, setSearchQuery] = useState("");
const [unitSearchQuery, setUnitSearchQuery] = useState("");
const [departmentSearchQuery, setDepartmentSearchQuery] = useState("");
const { user } = useAuth();
const localizedName = useLocalizedName();
// Dialog state for service assignment
const [isAssignDialogOpen, setIsAssignDialogOpen] = useState(false);
const [selectedPositionId, setSelectedPositionId] = useState<string>("");
const [selectedPositionName, setSelectedPositionName] = useState<string>("");
// Get organization ID from user
const organizationId =
user?.employee && user.employee.length > 0
? user.employee[0].organizationId
: undefined;
// Hooks for data loading
const { organizations, isLoading: orgsLoading } = useOrganizationsData({
currentOrgId: organizationId,
});
const {
selectedOrgId,
selectedUnitId,
selectedDepartmentId,
breadcrumb,
handleSelectOrganization,
handleSelectUnit,
handleSelectDepartment,
} = useSelectionHandlers(organizations, localizedName);
const [take] = useState(100);
const [skip] = useState(0);
const {
units,
isLoading: unitsLoading,
setUnits,
} = useUnitsData(selectedOrgId, { take, skip });
const {
departments,
isLoading: deptsLoading,
setDepartments,
positions,
} = useDepartmentsData(selectedUnitId);
// Handle assign service action
const handleAssignService = (positionId: string, positionName: string) => {
setSelectedPositionId(positionId);
setSelectedPositionName(positionName);
setIsAssignDialogOpen(true);
};
return (
<div className="h-full flex flex-col">
<div className="p-4 border-b">
<div className="flex justify-between items-center mb-4">
<h1 className="text-xl font-semibold text-gray-800">
{t("organization.serviceAssignment", "Service Assignment")}
</h1>
</div>
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-gray-500" />
<Input
placeholder="Search organizations, units, departments..."
className="pl-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
<div className="mt-4">
<Breadcrumb breadcrumb={breadcrumb} hasTeamMembers={false} />
</div>
</div>
{/* Position Services List */}
<div className="px-4 pb-4">
<PositionServicesList positionId={selectedDepartmentId} />
</div>
{/* Desktop view - grid layout */}
<div className="flex-1 p-4 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="border rounded-md p-3 bg-white shadow-sm">
<h3 className="font-medium text-gray-700 mb-3 border-b pb-2">
{t("organization.organizations")}
</h3>
<OrganizationList
selectedOrgId={selectedOrgId}
organizations={organizations}
isLoading={orgsLoading}
searchQuery={searchQuery}
onSelectOrganization={(id: string) => {
setUnits([]);
setDepartments([]);
handleSelectOrganization(id);
}}
/>
</div>
<div className="border rounded-md p-3 bg-white shadow-sm">
<h3 className="font-medium text-gray-700 mb-3 border-b pb-2">
{t("contentManagement.Units")}
</h3>
<div className="mb-3">
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-gray-500" />
<Input
placeholder={t("search.searchUnits", "Search units...")}
className="pl-8 text-sm"
value={unitSearchQuery}
onChange={(e) => setUnitSearchQuery(e.target.value)}
/>
</div>
</div>
<UnitList
organizationId={selectedOrgId}
selectedUnitId={selectedUnitId}
onSelectUnit={(id: string) => {
setDepartments([]);
handleSelectUnit(id, units);
}}
units={units}
isLoading={unitsLoading}
searchQuery={unitSearchQuery}
/>
</div>
<div className="border rounded-md p-3 bg-white shadow-sm">
<h3 className="font-medium text-gray-700 mb-3 border-b pb-2">
{t("userIncoming.Departments")}
</h3>
<div className="mb-3">
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-gray-500" />
<Input
placeholder={t(
"search.searchDepartments",
"Search departments..."
)}
className="pl-8 text-sm"
value={departmentSearchQuery}
onChange={(e) => setDepartmentSearchQuery(e.target.value)}
/>
</div>
</div>
<DepartmentList
unitId={selectedUnitId}
selectedDepartmentId={selectedDepartmentId}
onSelectDepartment={(id: string) => {
handleSelectDepartment(id, departments);
}}
departments={departments}
positions={positions}
isLoading={deptsLoading}
searchQuery={departmentSearchQuery}
onAssignService={handleAssignService}
/>
</div>
</div>
{/* Assign Service Dialog */}
<AssignServiceDialog
isOpen={isAssignDialogOpen}
onClose={() => {
setIsAssignDialogOpen(false);
setSelectedPositionId("");
setSelectedPositionName("");
}}
positionId={selectedPositionId}
positionName={selectedPositionName}
organizationId={selectedOrgId}
/>
</div>
);
};
export default ServiceAssignmentPage;

View File

@@ -0,0 +1,595 @@
import { useState, useEffect, useCallback } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/common/ui/dialog";
import { Button } from "@/shared/common/ui/button";
import { Checkbox } from "@/shared/common/ui/checkbox";
import { Label } from "@/shared/common/ui/label";
import { Input } from "@/shared/common/ui/input";
import {
Search,
Loader2,
ChevronRight,
ChevronDown,
Folder,
FileText,
} from "lucide-react";
import { useToast } from "@/shared/common/ui/use-toast";
import { t } from "i18next";
import { ScrollArea } from "@/shared/common/ui/scroll-area";
import {
assignServicesToPosition,
getAssignedServices,
} from "@/performance-management/services/api/serviceAssignmentService";
import { useLocalizedName } from "@/shared/common/localizedName";
import { useServiceCategoryList } from "@/performance-management/hooks/useServiceCategory";
import { useServiceList } from "@/performance-management/hooks/useServiceHook";
interface AssignServiceDialogProps {
isOpen: boolean;
onClose: () => void;
positionId: string;
positionName: string;
organizationId: string;
}
interface ServiceCategory {
id: string;
name: Record<string, string>;
description?: Record<string, string>;
}
interface Service {
id: string;
name: Record<string, string>;
description?: Record<string, string>;
serviceCategoryId?: string;
parentServiceId?: string;
childServices?: Service[];
}
export const AssignServiceDialog = ({
isOpen,
onClose,
positionId,
positionName,
organizationId,
}: AssignServiceDialogProps) => {
const { toast } = useToast();
const localizedName = useLocalizedName();
const [selectedServiceIds, setSelectedServiceIds] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
new Set()
);
const [expandedServices, setExpandedServices] = useState<Set<string>>(
new Set()
);
const [alreadyAssignedServiceIds, setAlreadyAssignedServiceIds] = useState<
string[]
>([]);
// Fetch service categories
const { data: serviceCategoriesResponse, isLoading: categoriesLoading } =
useServiceCategoryList(organizationId);
// Fetch all services (without category filter for search)
const { data: allServicesResponse } = useServiceList(organizationId);
// Helper function to safely render text that might be localized
const renderText = (
text: Record<string, string> | string | undefined
): string => {
if (!text) return "";
if (typeof text === "string") return text;
if (typeof text === "object") {
const textObj = text as Record<string, string>;
if (textObj.en || textObj.am) {
return localizedName({ am: textObj.am || "", en: textObj.en || "" });
}
}
return String(text);
};
// Extract service categories
let serviceCategories: ServiceCategory[] = [];
if (serviceCategoriesResponse?.data) {
if (Array.isArray(serviceCategoriesResponse.data)) {
serviceCategories = serviceCategoriesResponse.data;
} else if (serviceCategoriesResponse.data.data) {
serviceCategories = Array.isArray(serviceCategoriesResponse.data.data)
? serviceCategoriesResponse.data.data
: [];
} else if (serviceCategoriesResponse.data.items) {
serviceCategories = Array.isArray(serviceCategoriesResponse.data.items)
? serviceCategoriesResponse.data.items
: [];
}
}
// Extract all services
let allServices: Service[] = [];
if (allServicesResponse?.data) {
if (Array.isArray(allServicesResponse.data)) {
allServices = allServicesResponse.data;
} else if (allServicesResponse.data.data) {
allServices = Array.isArray(allServicesResponse.data.data)
? allServicesResponse.data.data
: [];
} else if (allServicesResponse.data.items) {
allServices = Array.isArray(allServicesResponse.data.items)
? allServicesResponse.data.items
: [];
}
}
// Build parent-child relationships
const servicesWithChildren = allServices.map((service) => {
const childServices = allServices.filter(
(s) => s.parentServiceId === service.id
);
return {
...service,
childServices: childServices.length > 0 ? childServices : undefined,
};
});
// Fetch assigned service when dialog opens
const fetchAssignedService = useCallback(async () => {
try {
const response = await getAssignedServices(positionId);
let assignedServices = response.data?.data || response.data || [];
if (!Array.isArray(assignedServices)) {
assignedServices = assignedServices.items || [];
}
if (Array.isArray(assignedServices) && assignedServices.length > 0) {
const serviceIds = assignedServices
.map(
(service: { id?: string; serviceId?: string; firstId?: string }) =>
service.id || service.serviceId || service.firstId
)
.filter((id: string | undefined): id is string => !!id);
setSelectedServiceIds(serviceIds);
setAlreadyAssignedServiceIds(serviceIds);
// Auto-expand categories containing the selected services
serviceIds.forEach((serviceId: string) => {
const service = allServices.find((s) => s.id === serviceId);
if (service?.serviceCategoryId) {
setExpandedCategories((prev) =>
new Set(prev).add(service.serviceCategoryId!)
);
}
// Auto-expand parent services if this is a child service
if (service?.parentServiceId) {
setExpandedServices((prev) =>
new Set(prev).add(service.parentServiceId!)
);
}
});
} else {
setSelectedServiceIds([]);
setAlreadyAssignedServiceIds([]);
}
} catch (error) {
console.error("Failed to fetch assigned service:", error);
setSelectedServiceIds([]);
setAlreadyAssignedServiceIds([]);
}
}, [positionId, allServices]);
useEffect(() => {
if (isOpen && positionId) {
fetchAssignedService();
}
}, [isOpen, positionId, fetchAssignedService]);
const handleToggleService = (serviceId: string) => {
// Check if service is already assigned
if (alreadyAssignedServiceIds.includes(serviceId)) {
toast({
title: "Warning",
description: "Service already assigned to this position",
variant: "destructive",
});
return;
}
setSelectedServiceIds((prev) => {
if (prev.includes(serviceId)) {
return prev.filter((id) => id !== serviceId);
} else {
return [...prev, serviceId];
}
});
};
const handleSave = async () => {
// Filter out already assigned services
const newServiceIds = selectedServiceIds.filter(
(id) => !alreadyAssignedServiceIds.includes(id)
);
if (newServiceIds.length === 0) {
toast({
title: "Warning",
description: "Please select at least one new service to assign",
variant: "destructive",
});
return;
}
setIsSaving(true);
try {
await assignServicesToPosition(positionId, newServiceIds);
toast({
title: "Success",
description: `${newServiceIds.length} service(s) assigned to ${positionName} successfully`,
variant: "default",
});
onClose();
} catch (error: unknown) {
console.error("Failed to assign service:", error);
const errorMessage =
(
error as {
response?: { data?: { message?: string } };
message?: string;
}
).response?.data?.message ||
(error as { message?: string }).message ||
"Failed to assign service";
toast({
title: "Error",
description: errorMessage,
variant: "destructive",
});
} finally {
setIsSaving(false);
}
};
const handleClearSelection = () => {
setSelectedServiceIds([]);
};
const toggleCategory = (categoryId: string) => {
setExpandedCategories((prev) => {
const newSet = new Set(prev);
if (newSet.has(categoryId)) {
newSet.delete(categoryId);
} else {
newSet.add(categoryId);
}
return newSet;
});
};
const toggleService = (serviceId: string) => {
setExpandedServices((prev) => {
const newSet = new Set(prev);
if (newSet.has(serviceId)) {
newSet.delete(serviceId);
} else {
newSet.add(serviceId);
}
return newSet;
});
};
// Group services by category (only parent services)
const parentServicesByCategory = servicesWithChildren
.filter((service) => !service.parentServiceId)
.reduce((acc, service) => {
const categoryId = service.serviceCategoryId || "uncategorized";
if (!acc[categoryId]) {
acc[categoryId] = [];
}
acc[categoryId].push(service);
return acc;
}, {} as Record<string, Service[]>);
// Filter categories and services based on search
const filteredData = searchQuery
? {
categories: serviceCategories.filter((cat) =>
renderText(cat.name).toLowerCase().includes(searchQuery.toLowerCase())
),
services: servicesWithChildren.filter((service) => {
const searchLower = searchQuery.toLowerCase();
const serviceName = renderText(service.name).toLowerCase();
const serviceDesc = renderText(service.description).toLowerCase();
return (
serviceName.includes(searchLower) ||
serviceDesc.includes(searchLower)
);
}),
}
: { categories: serviceCategories, services: servicesWithChildren };
// Auto-expand categories when searching
useEffect(() => {
if (searchQuery && filteredData.services.length > 0) {
const categoriesToExpand = new Set<string>();
const servicesToExpand = new Set<string>();
filteredData.services.forEach((service) => {
if (service.serviceCategoryId) {
categoriesToExpand.add(service.serviceCategoryId);
}
// If it's a child service, expand its parent
if (service.parentServiceId) {
servicesToExpand.add(service.parentServiceId);
}
});
setExpandedCategories(categoriesToExpand);
setExpandedServices(servicesToExpand);
}
}, [searchQuery, filteredData.services]);
const renderService = (service: Service, isChild: boolean = false) => {
const isExpanded = expandedServices.has(service.id);
const hasChildren =
service.childServices && service.childServices.length > 0;
const isAlreadyAssigned = alreadyAssignedServiceIds.includes(service.id);
return (
<div key={service.id} className="space-y-1">
<div
className={`flex items-start gap-3 p-3 rounded-lg border transition-colors ${
isChild ? "ml-6" : ""
} ${
selectedServiceIds.includes(service.id)
? "border-purple-500 bg-purple-50"
: isAlreadyAssigned
? "border-gray-300 bg-gray-50 opacity-60"
: "border-gray-200 hover:bg-gray-50"
} ${hasChildren ? "cursor-pointer" : ""}`}
>
{hasChildren && (
<div
onClick={() => toggleService(service.id)}
className="cursor-pointer pt-1"
>
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-gray-500" />
) : (
<ChevronRight className="h-4 w-4 text-gray-500" />
)}
</div>
)}
<Checkbox
checked={selectedServiceIds.includes(service.id)}
onCheckedChange={() => handleToggleService(service.id)}
id={`service-${service.id}`}
className="mt-1"
disabled={isAlreadyAssigned}
/>
<FileText className="h-4 w-4 text-blue-500 mt-1" />
<div
className="flex-1"
onClick={() =>
!isAlreadyAssigned && handleToggleService(service.id)
}
>
<Label
htmlFor={`service-${service.id}`}
className={`cursor-pointer block ${
isAlreadyAssigned ? "cursor-not-allowed" : ""
}`}
>
<div className="font-medium text-gray-900">
{renderText(service.name)}
{isAlreadyAssigned && (
<span className="ml-2 text-xs text-gray-500">
(Already Assigned)
</span>
)}
</div>
{service.description && (
<div className="text-sm text-gray-600 mt-1 line-clamp-2">
{renderText(service.description)}
</div>
)}
</Label>
</div>
</div>
{/* Render child services */}
{hasChildren && isExpanded && (
<div className="ml-6 space-y-2 mt-1">
{service.childServices!.map((childService) =>
renderService(childService, true)
)}
</div>
)}
</div>
);
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>{t("assignServices", "Assign Services")}</DialogTitle>
<DialogDescription>
{t(
"assignServicesDescription",
`Select one or more services to assign to position: ${positionName}`
)}
</DialogDescription>
</DialogHeader>
<div className="flex-1 flex flex-col min-h-0 gap-4">
{/* Search */}
<div className="relative shrink-0">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-gray-500" />
<Input
placeholder={t(
"searchServices",
"Search services or categories..."
)}
className="pl-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{/* Tree View */}
<ScrollArea className="flex-1 border rounded-md p-2 min-h-0">
<div className="max-h-[400px]">
{categoriesLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-purple-600" />
</div>
) : filteredData.categories.length === 0 &&
filteredData.services.length === 0 ? (
<div className="text-center py-8 text-gray-500">
{t("noServicesFound", "No services found")}
</div>
) : (
<div>
<div className="space-y-1 pr-3">
{filteredData.categories.map((category) => {
const categoryServices = searchQuery
? filteredData.services.filter(
(s) =>
s.serviceCategoryId === category.id &&
!s.parentServiceId
)
: parentServicesByCategory[category.id] || [];
const isExpanded = expandedCategories.has(category.id);
if (searchQuery && categoryServices.length === 0) {
return null;
}
return (
<div key={category.id} className="space-y-1">
{/* Category Header */}
<div
className="flex items-center gap-2 p-2 rounded-md hover:bg-gray-100 cursor-pointer"
onClick={() => toggleCategory(category.id)}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-gray-500" />
) : (
<ChevronRight className="h-4 w-4 text-gray-500" />
)}
<Folder className="h-4 w-4 text-purple-500" />
<span className="font-medium text-gray-900">
{renderText(category.name)}
</span>
<span className="text-xs text-gray-500 ml-auto">
({categoryServices.length})
</span>
</div>
{/* Services under this category */}
{isExpanded && categoryServices.length > 0 && (
<div className="ml-6 space-y-2 mt-1">
{categoryServices.map((service) =>
renderService(service)
)}
</div>
)}
</div>
);
})}
{/* Uncategorized services */}
{parentServicesByCategory["uncategorized"] &&
parentServicesByCategory["uncategorized"].length > 0 && (
<div className="space-y-1">
<div
className="flex items-center gap-2 p-2 rounded-md hover:bg-gray-100 cursor-pointer"
onClick={() => toggleCategory("uncategorized")}
>
{expandedCategories.has("uncategorized") ? (
<ChevronDown className="h-4 w-4 text-gray-500" />
) : (
<ChevronRight className="h-4 w-4 text-gray-500" />
)}
<Folder className="h-4 w-4 text-gray-400" />
<span className="font-medium text-gray-700">
Uncategorized
</span>
<span className="text-xs text-gray-500 ml-auto">
(
{parentServicesByCategory["uncategorized"].length}
)
</span>
</div>
{expandedCategories.has("uncategorized") && (
<div className="ml-6 space-y-2 mt-1">
{parentServicesByCategory["uncategorized"].map(
(service) => renderService(service)
)}
</div>
)}
</div>
)}
</div>
</div>
)}
</div>
</ScrollArea>
{/* Selected service info */}
<div className="flex items-center justify-between text-sm shrink-0">
<div className="text-gray-600">
{selectedServiceIds.length > 0
? t(
"servicesSelected",
`${selectedServiceIds.length} service(s) selected`
)
: t("noServiceSelected", "No services selected")}
</div>
{selectedServiceIds.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={handleClearSelection}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
{t("clearSelection", "Clear Selection")}
</Button>
)}
</div>
</div>
<DialogFooter className="shrink-0">
<Button variant="outline" onClick={onClose} disabled={isSaving}>
{t("common.cancel", "Cancel")}
</Button>
<Button
onClick={handleSave}
disabled={isSaving || selectedServiceIds.length === 0}
className="bg-purple-600 hover:bg-purple-700"
>
{isSaving ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{t("saving", "Saving...")}
</>
) : (
t("assignServices", "Assign Services")
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,192 @@
import {
ChevronDown,
ChevronRight,
Briefcase,
MoreVertical,
} from "lucide-react";
import { Skeleton } from "@/shared/common/ui/skeleton";
import { useLocalizedName } from "@/shared/common/localizedName";
import { useState } from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/common/ui/dropdown-menu";
import { Button } from "@/shared/common/ui/button";
import { t } from "i18next";
import { PositionDto } from "@/user-management/dto/positions/positionDto";
interface DepartmentListProps {
unitId: string;
selectedDepartmentId: string;
onSelectDepartment: (id: string) => void;
departments: any[];
positions: PositionDto[];
isLoading: boolean;
searchQuery: string;
onAssignService?: (positionId: string, positionName: string) => void;
}
export const DepartmentList = ({
unitId,
selectedDepartmentId,
onSelectDepartment,
departments,
positions,
isLoading,
searchQuery,
onAssignService,
}: DepartmentListProps) => {
const localizedName = useLocalizedName();
const [expandedPositions, setExpandedPositions] = useState<Set<string>>(
new Set()
);
const togglePosition = (positionId: string) => {
setExpandedPositions((prev) => {
const newSet = new Set(prev);
if (newSet.has(positionId)) {
newSet.delete(positionId);
} else {
newSet.add(positionId);
}
return newSet;
});
};
// Recursive function to render position hierarchy
const renderPosition = (position: PositionDto, level: number = 0) => {
const hasSubPositions =
position.subPositions && position.subPositions.length > 0;
const isExpanded = expandedPositions.has(position.id);
return (
<div key={position.id} className="space-y-1">
<div
className={`flex items-center gap-2 p-2 rounded-lg border transition-colors ${
selectedDepartmentId === position.id
? "bg-purple-50 border-purple-500"
: "bg-white border-gray-200 hover:bg-gray-50"
}`}
style={{ marginLeft: `${level * 16}px` }}
>
{hasSubPositions ? (
<button
onClick={() => togglePosition(position.id)}
className="p-1 hover:bg-gray-200 rounded flex-shrink-0"
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
) : (
<div className="w-6 flex-shrink-0" />
)}
<button
onClick={() => onSelectDepartment(position.id)}
className="flex-1 flex items-center gap-2 text-left min-w-0"
>
<Briefcase className="h-4 w-4 flex-shrink-0" />
<span className="font-medium truncate">
{localizedName(position.name)}
</span>
</button>
{hasSubPositions && (
<span className="text-xs text-gray-500 px-2 flex-shrink-0">
{position.subPositions.length}
</span>
)}
{onAssignService && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 flex-shrink-0"
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
onAssignService(position.id, localizedName(position.name))
}
>
{t("assignService", "Assign Service")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{/* Render sub-positions recursively */}
{isExpanded && hasSubPositions && (
<div className="space-y-1">
{position.subPositions.map((subPosition) =>
renderPosition(subPosition, level + 1)
)}
</div>
)}
</div>
);
};
if (!unitId) {
return (
<div className="text-center py-8 text-gray-500">
{t("selectUnitFirst", "Select a unit first")}
</div>
);
}
if (isLoading) {
return (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
);
}
// Filter positions based on search query
const filterPositions = (positions: PositionDto[]): PositionDto[] => {
if (!searchQuery) return positions;
return positions.filter((position) => {
const matchesSearch = localizedName(position.name)
.toLowerCase()
.includes(searchQuery.toLowerCase());
// Also check sub-positions
const hasMatchingSubPosition =
position.subPositions &&
filterPositions(position.subPositions).length > 0;
return matchesSearch || hasMatchingSubPosition;
});
};
const filteredPositions = filterPositions(positions);
if (filteredPositions.length === 0) {
return (
<div className="text-center py-8 text-gray-500">
{t("noPositionsFound", "No positions found")}
</div>
);
}
return (
<div className="space-y-2">
{filteredPositions.map((position) => renderPosition(position))}
</div>
);
};

View File

@@ -0,0 +1,72 @@
import { Building2 } from "lucide-react";
import { Skeleton } from "@/shared/common/ui/skeleton";
import { useLocalizedName } from "@/shared/common/localizedName";
interface Organization {
id: string;
name: {
en: string;
am: string;
};
}
interface OrganizationListProps {
selectedOrgId: string;
organizations: Organization[];
isLoading: boolean;
searchQuery: string;
onSelectOrganization: (id: string) => void;
}
export const OrganizationList = ({
selectedOrgId,
organizations,
isLoading,
searchQuery,
onSelectOrganization,
}: OrganizationListProps) => {
const localizedName = useLocalizedName();
const filteredOrganizations = organizations.filter((org) =>
localizedName(org.name).toLowerCase().includes(searchQuery.toLowerCase())
);
if (isLoading) {
return (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
);
}
if (filteredOrganizations.length === 0) {
return (
<div className="text-center py-8 text-gray-500">
No organizations found
</div>
);
}
return (
<div className="space-y-2">
{filteredOrganizations.map((org) => (
<button
key={org.id}
onClick={() => onSelectOrganization(org.id)}
className={`w-full text-left p-3 rounded-lg border transition-colors ${
selectedOrgId === org.id
? "bg-purple-50 border-purple-500 text-purple-700"
: "bg-white border-gray-200 hover:bg-gray-50"
}`}
>
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4" />
<span className="font-medium">{localizedName(org.name)}</span>
</div>
</button>
))}
</div>
);
};

View File

@@ -0,0 +1,360 @@
import React, { useEffect, useState } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/common/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/common/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/common/ui/alert-dialog";
import { Button } from "@/shared/common/ui/button";
import {
MoreVertical,
Clock,
ChevronDown,
ChevronRight,
Trash2,
} from "lucide-react";
import {
getPositionServices,
deletePositionService,
} from "@/performance-management/services/api/serviceAssignmentService";
import { useLocalizedName } from "@/shared/common/localizedName";
import { Skeleton } from "@/shared/common/ui/skeleton";
import { useToast } from "@/shared/common/ui/use-toast";
import { CreateWorkingHoursModal } from "@/performance-management/components/WorkingHours/CreateWorkingHoursModal";
import { EditWorkingHoursModal } from "@/performance-management/components/WorkingHours/EditWorkingHoursModal";
import { WorkingDaysTable } from "@/performance-management/components/WorkingHours/WorkingDaysTable";
import { useWorkingHoursMutations } from "@/performance-management/hooks/useWorkingHoursHook";
import { WorkingHoursResponse } from "@/performance-management/types/workingHoursTypes";
import { t } from "i18next";
interface PositionServicesListProps {
positionId: string;
}
export const PositionServicesList = ({
positionId,
}: PositionServicesListProps) => {
const [services, setServices] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isWorkingHoursModalOpen, setIsWorkingHoursModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [selectedPositionServiceId, setSelectedPositionServiceId] =
useState<string>("");
const [editingWorkingDay, setEditingWorkingDay] =
useState<WorkingHoursResponse | null>(null);
const [expandedServiceId, setExpandedServiceId] = useState<string | null>(
null
);
const [refreshTrigger, setRefreshTrigger] = useState(0);
const [deletingServiceId, setDeletingServiceId] = useState<string | null>(
null
);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isDeletingService, setIsDeletingService] = useState(false);
const localizedName = useLocalizedName();
const { toast } = useToast();
const { delete: deleteWorkingHours } = useWorkingHoursMutations();
const handleCreateWorkingHours = (positionServiceId: string) => {
setSelectedPositionServiceId(positionServiceId);
setIsWorkingHoursModalOpen(true);
};
const handleWorkingHoursSuccess = () => {
setIsWorkingHoursModalOpen(false);
setSelectedPositionServiceId("");
setRefreshTrigger((prev) => prev + 1);
};
const handleEditWorkingDay = (workingDay: WorkingHoursResponse) => {
setEditingWorkingDay(workingDay);
setIsEditModalOpen(true);
};
const handleEditSuccess = () => {
setIsEditModalOpen(false);
setEditingWorkingDay(null);
setRefreshTrigger((prev) => prev + 1);
};
const handleDeleteWorkingDay = async (id: string) => {
deleteWorkingHours.mutate(id, {
onSuccess: () => {
toast({
title: "Success",
description: "Working hours deleted successfully",
});
setRefreshTrigger((prev) => prev + 1);
},
onError: (error: any) => {
toast({
title: "Error",
description:
error?.response?.data?.message || "Failed to delete working hours",
variant: "destructive",
});
},
});
};
const toggleExpand = (serviceId: string) => {
setExpandedServiceId(expandedServiceId === serviceId ? null : serviceId);
};
const handleDeleteService = (positionServiceId: string) => {
setDeletingServiceId(positionServiceId);
setIsDeleteDialogOpen(true);
};
const confirmDeleteService = async () => {
if (!deletingServiceId) return;
setIsDeletingService(true);
try {
await deletePositionService(deletingServiceId);
toast({
title: "Success",
description: "Service removed from position successfully",
});
// Refresh the services list
const response = await getPositionServices(positionId);
const data = response.data;
if (data && Array.isArray(data.items)) {
setServices(data.items);
} else if (Array.isArray(data)) {
setServices(data);
} else {
setServices([]);
}
setIsDeleteDialogOpen(false);
setDeletingServiceId(null);
} catch (error: any) {
toast({
title: "Error",
description:
error?.response?.data?.message || "Failed to remove service",
variant: "destructive",
});
} finally {
setIsDeletingService(false);
}
};
useEffect(() => {
const fetchServices = async () => {
if (!positionId) {
setServices([]);
return;
}
setIsLoading(true);
try {
const response = await getPositionServices(positionId);
// Handle both array and single object response
const data = response.data;
if (data && Array.isArray(data.items)) {
setServices(data.items);
} else if (Array.isArray(data)) {
setServices(data);
} else if (data) {
setServices([data]);
} else {
setServices([]);
}
} catch (error) {
console.error("Failed to fetch position services", error);
setServices([]);
} finally {
setIsLoading(false);
}
};
fetchServices();
}, [positionId]);
if (!positionId) {
return null;
}
if (isLoading) {
return (
<div className="space-y-2 border rounded-md p-4 bg-white mt-4">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
);
}
if (services.length === 0) {
return (
<div className="text-center py-8 text-gray-500 border rounded-md bg-white mt-4">
{t("noServicesFound", "No services found for this position")}
</div>
);
}
return (
<div className="border rounded-md bg-white overflow-hidden mt-4 shadow-sm">
<div className="p-3 border-b bg-gray-50">
<h3 className="font-medium text-gray-700">
{t("associatedServices", "Associated Services")}
</h3>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12"></TableHead>
<TableHead>{t("name", "Name")}</TableHead>
<TableHead>{t("slug", "Slug")}</TableHead>
<TableHead>{t("description", "Description")}</TableHead>
<TableHead className="text-right">
{t("actions", "Actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{services.map((positionService) => (
<React.Fragment key={positionService.id}>
<TableRow className="cursor-pointer hover:bg-gray-50">
<TableCell onClick={() => toggleExpand(positionService.id)}>
<Button variant="ghost" size="sm" className="p-0 h-6 w-6">
{expandedServiceId === positionService.id ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
</TableCell>
<TableCell
className="font-medium"
onClick={() => toggleExpand(positionService.id)}
>
{localizedName(positionService.service?.name)}
</TableCell>
<TableCell onClick={() => toggleExpand(positionService.id)}>
{positionService.service?.slug}
</TableCell>
<TableCell onClick={() => toggleExpand(positionService.id)}>
{localizedName(positionService.service?.description)}
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
handleCreateWorkingHours(positionService.id)
}
>
<Clock className="h-4 w-4 mr-2" />
{t("createWorkingHours", "Create Working Hours")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDeleteService(positionService.id)}
className="text-red-600 focus:text-red-600"
>
<Trash2 className="h-4 w-4 mr-2" />
{t("removeService", "Remove Service")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
{expandedServiceId === positionService.id && (
<TableRow>
<TableCell colSpan={5} className="bg-gray-50 p-4">
<div className="space-y-2">
<h4 className="font-semibold text-sm text-gray-700">
{t("workingDays", "Working Days")}
</h4>
<WorkingDaysTable
positionServiceId={positionService.id}
onEdit={handleEditWorkingDay}
onDelete={handleDeleteWorkingDay}
refreshTrigger={refreshTrigger}
/>
</div>
</TableCell>
</TableRow>
)}
</React.Fragment>
))}
</TableBody>
</Table>
{/* Create Working Hours Modal */}
<CreateWorkingHoursModal
open={isWorkingHoursModalOpen}
onClose={() => {
setIsWorkingHoursModalOpen(false);
setSelectedPositionServiceId("");
}}
onSuccess={handleWorkingHoursSuccess}
positionServiceId={selectedPositionServiceId}
/>
{/* Edit Working Hours Modal */}
<EditWorkingHoursModal
open={isEditModalOpen}
onClose={() => {
setIsEditModalOpen(false);
setEditingWorkingDay(null);
}}
onSuccess={handleEditSuccess}
workingDay={editingWorkingDay}
/>
{/* Delete Service Confirmation Dialog */}
<AlertDialog
open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove Service from Position</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to remove this service from the position?
This will also delete all associated working hours. This action
cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeletingService}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDeleteService}
disabled={isDeletingService}
className="bg-red-600 hover:bg-red-700"
>
{isDeletingService ? "Removing..." : "Remove"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};

View File

@@ -0,0 +1,78 @@
import { Building } from "lucide-react";
import { Skeleton } from "@/shared/common/ui/skeleton";
import { useLocalizedName } from "@/shared/common/localizedName";
interface Unit {
id: string;
name: {
en: string;
am: string;
};
}
interface UnitListProps {
organizationId: string;
selectedUnitId: string;
onSelectUnit: (id: string) => void;
units: Unit[];
isLoading: boolean;
searchQuery: string;
}
export const UnitList = ({
organizationId,
selectedUnitId,
onSelectUnit,
units,
isLoading,
searchQuery,
}: UnitListProps) => {
const localizedName = useLocalizedName();
const filteredUnits = units.filter((unit) =>
localizedName(unit.name).toLowerCase().includes(searchQuery.toLowerCase())
);
if (!organizationId) {
return (
<div className="text-center py-8 text-gray-500">
Select an organization first
</div>
);
}
if (isLoading) {
return (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
);
}
if (filteredUnits.length === 0) {
return <div className="text-center py-8 text-gray-500">No units found</div>;
}
return (
<div className="space-y-2">
{filteredUnits.map((unit) => (
<button
key={unit.id}
onClick={() => onSelectUnit(unit.id)}
className={`w-full text-left p-3 rounded-lg border transition-colors ${
selectedUnitId === unit.id
? "bg-purple-50 border-purple-500 text-purple-700"
: "bg-white border-gray-200 hover:bg-gray-50"
}`}
>
<div className="flex items-center gap-2">
<Building className="h-4 w-4" />
<span className="font-medium">{localizedName(unit.name)}</span>
</div>
</button>
))}
</div>
);
};