mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 08:48:11 +00:00
fix ui
This commit is contained in:
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user