This commit is contained in:
Michael Abebe
2026-06-02 13:43:44 +03:00
parent 2a489ca18d
commit 88a280eea2
8 changed files with 851 additions and 49 deletions

View File

@@ -5,6 +5,7 @@ import {
Param,
ParseUUIDPipe,
Post,
Query,
Put,
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
@@ -27,6 +28,19 @@ export class BackofficeController {
return this.backofficeService.createOrganizationUser(organizationId, dto);
}
@Get("organizations/:orgId/employees")
@ApiOperation({ summary: "Get deduplicated organization employees for backoffice" })
getOrganizationEmployees(
@Param("orgId", ParseUUIDPipe) organizationId: string,
@Query("skip") skip?: string,
@Query("take") take?: string,
) {
return this.backofficeService.getOrganizationEmployees(organizationId, {
skip,
take,
});
}
@Get("organizations/:orgId/employee-users/:userId/roles")
@ApiOperation({ summary: "Get org-scoped roles assigned to an employee user" })
getEmployeeUserRoles(

View File

@@ -27,6 +27,8 @@ const EDR_ORG_MANAGER_ROLE_KEY = "edr_org_manager";
@Injectable()
export class BackofficeService {
constructor(
@InjectRepository(Employee)
private readonly employeeRepository: Repository<Employee>,
@InjectRepository(Organization)
private readonly organizationRepository: Repository<Organization>,
@InjectRepository(Role)
@@ -214,6 +216,45 @@ export class BackofficeService {
}));
}
async getOrganizationEmployees(
organizationId: string,
query: { skip?: string; take?: string },
) {
const organizationExists = await this.organizationRepository.exists({
where: { id: organizationId },
});
if (!organizationExists) {
throw new NotFoundException("organization_not_found");
}
const take = Number.parseInt(query.take ?? "1000", 10);
const skip = Number.parseInt(query.skip ?? "0", 10);
const employees = await this.employeeRepository.find({
where: {
organizationId,
isCurrent: true,
},
relations: {
user: true,
employeePositions: {
position: true,
},
},
order: {
createdAt: "DESC",
},
});
const deduplicated = this.mergeEmployeesByUser(employees);
return {
count: deduplicated.length,
items: deduplicated.slice(skip, skip + take),
};
}
async replaceEmployeeUserRoles(
organizationId: string,
userId: string,
@@ -282,6 +323,53 @@ export class BackofficeService {
}
}
private mergeEmployeesByUser(employees: Employee[]) {
const employeesByUserId = new Map<string, Employee>();
for (const employee of employees) {
const userId = employee.userId;
const employeeId = employee.id;
if (!userId) {
if (employeeId) {
employeesByUserId.set(employeeId, employee);
}
continue;
}
const existing = employeesByUserId.get(userId);
if (!existing) {
employeesByUserId.set(userId, employee);
continue;
}
const existingPositions = existing.employeePositions ?? [];
const nextPositions = employee.employeePositions ?? [];
const mergedEmployeePositions = Array.from(
new Map(
[...existingPositions, ...nextPositions].map((employeePosition) => [
employeePosition.id,
employeePosition,
]),
).values(),
);
employeesByUserId.set(userId, {
...existing,
...employee,
id: existing.id,
user: existing.user ?? employee.user,
userId,
name: existing.name ?? employee.name,
status: existing.status ?? employee.status,
employeePositions: mergedEmployeePositions,
});
}
return [...employeesByUserId.values()];
}
private async ensureOrganizationAdminAccess(
manager: EntityManager,
organizationId: string,

View File

@@ -15,6 +15,7 @@ const IAM_PERMISSION_KEYS = {
deletePositionPermission: "can:delete:position_permission",
deleteUnit: "can:delete:unit",
deleteUserRole: "can:delete:user_role",
findAllOrganization: "can:find_all:organization",
manageOrganizationAdmin: "manage:organizationAdmin",
manageUnitAdmin: "manage:unitAdmin",
updateUnit: "can:update:unit",
@@ -217,6 +218,7 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [
IAM_PERMISSION_KEYS.createPositionPermission,
IAM_PERMISSION_KEYS.deletePositionPermission,
IAM_PERMISSION_KEYS.viewPositionPermission,
IAM_PERMISSION_KEYS.findAllOrganization,
],
},
{

View File

@@ -167,7 +167,7 @@ const App = () => {
<Route path="user-management" element={<UserManagementPage />} />
<Route path="user-management/users" element={<UsersPage />} />
<Route path="user-management/position-types" element={<PositionTypesPage />} />
<Route path="user-management/employees" element={<EmployeesPage />} />
{/* <Route path="user-management/employees" element={<EmployeesPage />} /> */}
<Route path="user-management/permissions" element={<PermissionsPage />} />
<Route path="user-management/roles" element={<RolesPage />} />

View File

@@ -100,7 +100,7 @@ const EmployeesPage = () => {
const responses = await Promise.all(
organizationIds.map((organizationId) =>
api.get<ListResponse<EmployeeRecord>>(
`/employees/${organizationId}/by-organization`,
`/backoffice/organizations/${organizationId}/employees`,
{
params: {
skip: 0,

View File

@@ -12,6 +12,7 @@ import {
SelectTrigger,
SelectValue,
} from "@edr/ui-common";
import { CopyPlus, Plus, RefreshCw } from "lucide-react";
import { api } from "@/auth/http";
import { useAuth } from "@/auth/useAuth";
@@ -47,6 +48,7 @@ interface PermissionRecord {
id: string;
key: string;
name?: LocaleText;
applicationId?: string | null;
}
interface ListResponse<T> {
@@ -57,6 +59,42 @@ interface ListResponse<T> {
const PAGE_SIZE = 1000;
const inputClassName =
"w-full rounded-xl border border-border bg-background px-3 py-2.5 text-sm text-foreground outline-none transition focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100 dark:focus:ring-emerald-950";
const buttonClassName =
"inline-flex items-center justify-center gap-2 rounded-xl px-3 py-2 text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-60";
const emptyCreateForm = {
copyPermissionFromId: "",
key: "",
nameAm: "",
nameEn: "",
};
type PositionTypeEditFormState = {
key: string;
nameAm: string;
nameEn: string;
};
const emptyEditForm: PositionTypeEditFormState = {
key: "",
nameAm: "",
nameEn: "",
};
const toggleSelection = (
currentIds: string[],
targetIds: string[],
checked: boolean,
) => {
if (checked) {
return [...new Set([...currentIds, ...targetIds])];
}
return currentIds.filter((id) => !targetIds.includes(id));
};
const getLocaleLabel = (value?: LocaleText | null, fallback = "Unnamed") =>
value?.en ?? value?.am ?? fallback;
@@ -102,11 +140,22 @@ const PositionTypesPage = () => {
const [loadingOrganizations, setLoadingOrganizations] = useState(true);
const [loadingUnits, setLoadingUnits] = useState(false);
const [loadingPositionTypes, setLoadingPositionTypes] = useState(false);
const [loadingPermissionsCatalog, setLoadingPermissionsCatalog] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [selectedPositionType, setSelectedPositionType] = useState<PositionTypeRecord | null>(null);
const [positionTypePermissions, setPositionTypePermissions] = useState<PermissionRecord[]>([]);
const [allPermissions, setAllPermissions] = useState<PermissionRecord[]>([]);
const [permissionsLoading, setPermissionsLoading] = useState(false);
const [permissionsError, setPermissionsError] = useState<string | null>(null);
const [permissionSearch, setPermissionSearch] = useState("");
const [selectedPermissionIds, setSelectedPermissionIds] = useState<string[]>([]);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [createForm, setCreateForm] = useState(emptyCreateForm);
const [createPermissionSearch, setCreatePermissionSearch] = useState("");
const [createPermissionIds, setCreatePermissionIds] = useState<string[]>([]);
const [createError, setCreateError] = useState<string | null>(null);
const [editForm, setEditForm] = useState<PositionTypeEditFormState>(emptyEditForm);
const isSuperAdmin = Boolean(user?.roles?.some((role) => role.key === "super_admin"));
const allowedOrgIds = useMemo(
@@ -130,6 +179,65 @@ const PositionTypesPage = () => {
const selectedOrganization =
visibleOrganizations.find((organization) => organization.id === selectedOrgId) ?? null;
const selectedUnit = units.find((unit) => unit.id === selectedUnitId) ?? null;
const availableCopySources = useMemo(
() => positionTypes.filter((positionType) => positionType.id !== selectedPositionType?.id),
[positionTypes, selectedPositionType?.id],
);
const filteredPermissions = useMemo(() => {
const query = permissionSearch.trim().toLowerCase();
return allPermissions.filter((permission) => {
if (!query) {
return true;
}
const label = getLocaleLabel(permission.name, permission.key).toLowerCase();
return label.includes(query) || permission.key.toLowerCase().includes(query);
});
}, [allPermissions, permissionSearch]);
const filteredCreatePermissions = useMemo(() => {
const query = createPermissionSearch.trim().toLowerCase();
return allPermissions.filter((permission) => {
if (!query) {
return true;
}
const label = getLocaleLabel(permission.name, permission.key).toLowerCase();
return label.includes(query) || permission.key.toLowerCase().includes(query);
});
}, [allPermissions, createPermissionSearch]);
const allFilteredPermissionIds = filteredPermissions.map((permission) => permission.id);
const allFilteredCreatePermissionIds = filteredCreatePermissions.map((permission) => permission.id);
const areAllFilteredPermissionsSelected =
allFilteredPermissionIds.length > 0 &&
allFilteredPermissionIds.every((id) => selectedPermissionIds.includes(id));
const areAllFilteredCreatePermissionsSelected =
allFilteredCreatePermissionIds.length > 0 &&
allFilteredCreatePermissionIds.every((id) => createPermissionIds.includes(id));
const loadPositionTypes = async (unitId: string) => {
const response = await api.get<ListResponse<PositionTypeRecord>>(
`/position-types/list-with-commons/${unitId}`,
{
params: {
skip: 0,
take: PAGE_SIZE,
orderBy: "createdAt:Desc",
},
},
);
return getItems(response.data);
};
const loadPermissionsForPositionType = async (positionTypeId: string) => {
const response = await api.get<ListResponse<PermissionRecord>>(
`/position-type-permissions/given-first/${positionTypeId}`,
);
return getItems(response.data);
};
useEffect(() => {
let isMounted = true;
@@ -170,6 +278,45 @@ const PositionTypesPage = () => {
};
}, []);
useEffect(() => {
let isMounted = true;
const loadPermissionsCatalog = async () => {
setLoadingPermissionsCatalog(true);
try {
const response = await api.get<ListResponse<PermissionRecord>>("/permissions", {
params: {
skip: 0,
take: 2000,
},
});
if (!isMounted) {
return;
}
setAllPermissions(getItems(response.data));
} catch {
if (!isMounted) {
return;
}
setAllPermissions([]);
} finally {
if (isMounted) {
setLoadingPermissionsCatalog(false);
}
}
};
void loadPermissionsCatalog();
return () => {
isMounted = false;
};
}, []);
useEffect(() => {
if (!visibleOrganizations.length) {
setSelectedOrgId("");
@@ -245,27 +392,18 @@ const PositionTypesPage = () => {
let isMounted = true;
const loadPositionTypes = async () => {
const loadItems = async () => {
setLoadingPositionTypes(true);
setErrorMessage(null);
try {
const response = await api.get<ListResponse<PositionTypeRecord>>(
`/position-types/list-with-commons/${selectedUnitId}`,
{
params: {
skip: 0,
take: PAGE_SIZE,
orderBy: "createdAt:Desc",
},
},
);
const items = await loadPositionTypes(selectedUnitId);
if (!isMounted) {
return;
}
setPositionTypes(getItems(response.data));
setPositionTypes(items);
} catch (error) {
if (!isMounted) {
return;
@@ -284,7 +422,7 @@ const PositionTypesPage = () => {
}
};
void loadPositionTypes();
void loadItems();
return () => {
isMounted = false;
@@ -294,11 +432,20 @@ const PositionTypesPage = () => {
useEffect(() => {
if (!selectedPositionType) {
setPositionTypePermissions([]);
setSelectedPermissionIds([]);
setEditForm(emptyEditForm);
setPermissionsError(null);
setPermissionsLoading(false);
setPermissionSearch("");
return;
}
setEditForm({
key: selectedPositionType.key,
nameAm: selectedPositionType.name?.am ?? "",
nameEn: selectedPositionType.name?.en ?? "",
});
let isMounted = true;
const loadPermissions = async () => {
@@ -306,15 +453,14 @@ const PositionTypesPage = () => {
setPermissionsError(null);
try {
const response = await api.get<ListResponse<PermissionRecord>>(
`/position-type-permissions/given-first/${selectedPositionType.id}`,
);
const items = await loadPermissionsForPositionType(selectedPositionType.id);
if (!isMounted) {
return;
}
setPositionTypePermissions(getItems(response.data));
setPositionTypePermissions(items);
setSelectedPermissionIds(items.map((permission) => permission.id));
} catch (error) {
if (!isMounted) {
return;
@@ -340,6 +486,181 @@ const PositionTypesPage = () => {
};
}, [selectedPositionType]);
const handleRefresh = async () => {
if (!selectedUnitId) {
return;
}
setLoadingPositionTypes(true);
setErrorMessage(null);
try {
const items = await loadPositionTypes(selectedUnitId);
setPositionTypes(items);
if (selectedPositionType) {
const nextSelected = items.find((item) => item.id === selectedPositionType.id) ?? selectedPositionType;
setSelectedPositionType(nextSelected);
}
} catch (error) {
setErrorMessage(
isAxiosError(error)
? error.response?.data?.message ?? "Unable to refresh position types."
: "Unable to refresh position types.",
);
} finally {
setLoadingPositionTypes(false);
}
};
const openCreateDialog = () => {
setCreateForm(emptyCreateForm);
setCreatePermissionIds([]);
setCreatePermissionSearch("");
setCreateError(null);
setIsCreateOpen(true);
};
const handleSelectCopySource = async (positionTypeId: string) => {
setCreateForm((current) => ({ ...current, copyPermissionFromId: positionTypeId }));
if (!positionTypeId) {
setCreatePermissionIds([]);
return;
}
try {
const copiedPermissions = await loadPermissionsForPositionType(positionTypeId);
setCreatePermissionIds(copiedPermissions.map((permission) => permission.id));
} catch (error) {
setCreateError(
isAxiosError(error)
? error.response?.data?.message ?? "Unable to copy permissions."
: "Unable to copy permissions.",
);
}
};
const handleCreatePositionType = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!selectedUnitId) {
setCreateError("Select a unit before creating a position type.");
return;
}
setSubmitting(true);
setCreateError(null);
try {
const response = await api.post<PositionTypeRecord>("/position-types", {
key: createForm.key.trim(),
name: {
am: createForm.nameAm.trim(),
en: createForm.nameEn.trim(),
},
unitId: selectedUnitId,
});
const createdPositionType = response.data;
await api.post("/position-type-permissions/assign-seconds-for-first", {
firstId: createdPositionType.id,
secondIds: createPermissionIds,
});
setIsCreateOpen(false);
setCreateForm(emptyCreateForm);
setCreatePermissionIds([]);
await handleRefresh();
} catch (error) {
setCreateError(
isAxiosError(error)
? error.response?.data?.message ?? "Unable to create position type."
: "Unable to create position type.",
);
} finally {
setSubmitting(false);
}
};
const handleSavePositionTypeChanges = async () => {
if (!selectedPositionType) {
return;
}
setSubmitting(true);
setPermissionsError(null);
try {
if (!selectedPositionType.isSystem) {
await api.put(`/position-types/${selectedPositionType.id}`, {
key: editForm.key.trim(),
name: {
am: editForm.nameAm.trim(),
en: editForm.nameEn.trim(),
},
unitId: selectedPositionType.unitId,
});
}
await api.post("/position-type-permissions/assign-seconds-for-first", {
firstId: selectedPositionType.id,
secondIds: selectedPermissionIds,
});
const [refreshedPermissions, refreshedPositionTypes] = await Promise.all([
loadPermissionsForPositionType(selectedPositionType.id),
selectedUnitId ? loadPositionTypes(selectedUnitId) : Promise.resolve(positionTypes),
]);
setPositionTypePermissions(refreshedPermissions);
setSelectedPermissionIds(refreshedPermissions.map((permission) => permission.id));
setPositionTypes(refreshedPositionTypes);
const refreshedSelected = refreshedPositionTypes.find((item) => item.id === selectedPositionType.id);
if (refreshedSelected) {
setSelectedPositionType(refreshedSelected);
}
} catch (error) {
setPermissionsError(
isAxiosError(error)
? error.response?.data?.message ?? "Unable to update position type."
: "Unable to update position type.",
);
} finally {
setSubmitting(false);
}
};
const handleSavePermissions = async () => {
if (!selectedPositionType) {
return;
}
setSubmitting(true);
setPermissionsError(null);
try {
await api.post("/position-type-permissions/assign-seconds-for-first", {
firstId: selectedPositionType.id,
secondIds: selectedPermissionIds,
});
const items = await loadPermissionsForPositionType(selectedPositionType.id);
setPositionTypePermissions(items);
setSelectedPermissionIds(items.map((permission) => permission.id));
} catch (error) {
setPermissionsError(
isAxiosError(error)
? error.response?.data?.message ?? "Unable to update position type permissions."
: "Unable to update position type permissions.",
);
} finally {
setSubmitting(false);
}
};
return (
<section className="p-6">
<div className="space-y-6 rounded-2xl border border-border bg-card p-8 shadow-sm">
@@ -351,11 +672,31 @@ const PositionTypesPage = () => {
<div>
<h1 className="text-3xl font-semibold text-foreground">Position Type</h1>
<p className="mt-3 max-w-2xl text-sm text-muted-foreground">
Browse position types for a selected organization unit, including shared system entries.
Browse position types for a selected organization unit, add new ones, and manage their permissions.
</p>
</div>
<div className="rounded-full border border-border bg-muted px-3 py-1 text-sm font-medium text-muted-foreground">
{positionTypes.length} position types
<div className="flex items-center gap-2">
<div className="rounded-full border border-border bg-muted px-3 py-1 text-sm font-medium text-muted-foreground">
{positionTypes.length} position types
</div>
<button
type="button"
onClick={() => void handleRefresh()}
disabled={!selectedUnitId || loadingPositionTypes}
className={`${buttonClassName} border border-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground`}
>
<RefreshCw className="h-4 w-4" />
Refresh
</button>
<button
type="button"
onClick={openCreateDialog}
disabled={!selectedUnitId || submitting}
className={`${buttonClassName} bg-emerald-600 text-white hover:bg-emerald-700`}
>
<Plus className="h-4 w-4" />
New position type
</button>
</div>
</div>
</div>
@@ -497,7 +838,7 @@ const PositionTypesPage = () => {
</DialogTitle>
<DialogDescription>
{selectedPositionType
? `Review the permission set assigned to ${getLocaleLabel(selectedPositionType.name, selectedPositionType.key)}.`
? `Review and update the permission set assigned to ${getLocaleLabel(selectedPositionType.name, selectedPositionType.key)}.`
: undefined}
</DialogDescription>
</DialogHeader>
@@ -540,10 +881,51 @@ const PositionTypesPage = () => {
</div>
<div className="space-y-3">
<div className="grid gap-4 rounded-2xl border border-border bg-muted/40 p-4 sm:grid-cols-2">
<label className="flex flex-col gap-2 text-sm">
<span className="font-medium text-foreground">English name</span>
<input
className={inputClassName}
value={editForm.nameEn}
onChange={(event) =>
setEditForm((current) => ({ ...current, nameEn: event.target.value }))
}
disabled={selectedPositionType.isSystem}
/>
</label>
<label className="flex flex-col gap-2 text-sm">
<span className="font-medium text-foreground">Amharic name</span>
<input
className={inputClassName}
value={editForm.nameAm}
onChange={(event) =>
setEditForm((current) => ({ ...current, nameAm: event.target.value }))
}
disabled={selectedPositionType.isSystem}
/>
</label>
<label className="flex flex-col gap-2 text-sm sm:col-span-2">
<span className="font-medium text-foreground">Key</span>
<input
className={inputClassName}
value={editForm.key}
onChange={(event) =>
setEditForm((current) => ({ ...current, key: event.target.value }))
}
disabled={selectedPositionType.isSystem}
/>
</label>
{selectedPositionType.isSystem ? (
<p className="text-xs text-muted-foreground sm:col-span-2">
System position types keep their name and key, but you can still manage permissions here.
</p>
) : null}
</div>
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm font-semibold text-foreground">Permissions</h2>
<div className="rounded-full border border-border bg-muted px-3 py-1 text-xs font-medium text-muted-foreground">
{positionTypePermissions.length} permissions
{selectedPermissionIds.length} permissions selected
</div>
</div>
@@ -555,27 +937,86 @@ const PositionTypesPage = () => {
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-8 text-center text-sm text-red-700 dark:border-red-950 dark:bg-red-950/30 dark:text-red-300">
{permissionsError}
</div>
) : positionTypePermissions.length ? (
<div className="grid gap-3 md:grid-cols-2">
{positionTypePermissions.map((permission) => (
<article
key={permission.id}
className="rounded-2xl border border-border/70 bg-background/80 p-4 shadow-sm"
>
<div className="min-w-0 space-y-1">
<p className="truncate text-sm font-semibold leading-5 text-foreground">
{getLocaleLabel(permission.name, permission.key)}
</p>
<p className="truncate font-mono text-xs text-muted-foreground/90">
{permission.key}
</p>
</div>
</article>
))}
</div>
) : (
<div className="rounded-2xl border border-dashed border-border bg-muted px-4 py-8 text-center text-sm text-muted-foreground">
No permissions are assigned to this position type.
<div className="space-y-4">
<input
className={inputClassName}
value={permissionSearch}
onChange={(event) => setPermissionSearch(event.target.value)}
placeholder="Search permissions by name or key"
/>
<label className="flex items-center gap-2 rounded-xl border border-border bg-background px-3 py-2 text-sm">
<input
type="checkbox"
checked={areAllFilteredPermissionsSelected}
onChange={(event) => {
setSelectedPermissionIds((current) =>
toggleSelection(
current,
allFilteredPermissionIds,
event.target.checked,
),
);
}}
/>
<span className="font-medium text-foreground">Select all</span>
</label>
{loadingPermissionsCatalog ? (
<div className="rounded-2xl border border-dashed border-border bg-muted px-4 py-8 text-center text-sm text-muted-foreground">
Loading permissions catalog...
</div>
) : filteredPermissions.length ? (
<div className="grid max-h-[420px] gap-2 overflow-y-auto pr-1 md:grid-cols-2 xl:grid-cols-3">
{filteredPermissions.map((permission) => (
<label
key={permission.id}
className="flex items-start gap-2 rounded-xl border border-border bg-background px-3 py-2 text-xs"
>
<input
type="checkbox"
checked={selectedPermissionIds.includes(permission.id)}
onChange={(event) => {
setSelectedPermissionIds((current) =>
event.target.checked
? [...current, permission.id]
: current.filter((item) => item !== permission.id),
);
}}
/>
<div className="min-w-0 leading-4">
<div className="truncate font-medium text-foreground">
{getLocaleLabel(permission.name, permission.key)}
</div>
<div className="truncate text-xs text-muted-foreground">{permission.key}</div>
</div>
</label>
))}
</div>
) : (
<div className="rounded-2xl border border-dashed border-border bg-muted px-4 py-8 text-center text-sm text-muted-foreground">
No permissions match the current search.
</div>
)}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setSelectedPositionType(null)}
className={`${buttonClassName} border border-border bg-card text-card-foreground hover:bg-accent hover:text-accent-foreground`}
>
Close
</button>
<button
type="button"
disabled={submitting || loadingPermissionsCatalog}
onClick={() => void handleSavePositionTypeChanges()}
className={`${buttonClassName} bg-emerald-600 text-white hover:bg-emerald-700`}
>
Save changes
</button>
</div>
</div>
)}
</div>
@@ -583,6 +1024,175 @@ const PositionTypesPage = () => {
) : null}
</DialogContent>
</Dialog>
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Create position type</DialogTitle>
<DialogDescription>
Add a new position type for the selected unit and optionally copy permissions from an existing one.
</DialogDescription>
</DialogHeader>
<form className="space-y-4" onSubmit={(event) => void handleCreatePositionType(event)}>
<div className="grid gap-4 md:grid-cols-2">
<label className="flex flex-col gap-2 text-sm">
<span className="font-medium text-foreground">English name</span>
<input
className={inputClassName}
value={createForm.nameEn}
onChange={(event) =>
setCreateForm((current) => ({ ...current, nameEn: event.target.value }))
}
/>
</label>
<label className="flex flex-col gap-2 text-sm">
<span className="font-medium text-foreground">Amharic name</span>
<input
className={inputClassName}
value={createForm.nameAm}
onChange={(event) =>
setCreateForm((current) => ({ ...current, nameAm: event.target.value }))
}
/>
</label>
<label className="flex flex-col gap-2 text-sm">
<span className="font-medium text-foreground">Key</span>
<input
className={inputClassName}
value={createForm.key}
onChange={(event) =>
setCreateForm((current) => ({ ...current, key: event.target.value }))
}
/>
</label>
<label className="flex flex-col gap-2 text-sm">
<span className="font-medium text-foreground">Copy permissions from</span>
<Select
value={createForm.copyPermissionFromId || undefined}
onValueChange={(value) => void handleSelectCopySource(value)}
>
<SelectTrigger className="w-full rounded-xl bg-background">
<SelectValue placeholder="Optional" />
</SelectTrigger>
<SelectContent>
{availableCopySources.map((positionType) => (
<SelectItem key={positionType.id} value={positionType.id}>
{getLocaleLabel(positionType.name, positionType.key)}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
</div>
<div className="space-y-3 rounded-2xl border border-border bg-background/60 p-4">
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm font-semibold text-foreground">Permissions</h2>
<div className="rounded-full border border-border bg-muted px-3 py-1 text-xs font-medium text-muted-foreground">
{createPermissionIds.length} selected
</div>
</div>
<input
className={inputClassName}
value={createPermissionSearch}
onChange={(event) => setCreatePermissionSearch(event.target.value)}
placeholder="Search permissions by name or key"
/>
<label className="flex items-center gap-2 rounded-xl border border-border bg-background px-3 py-2 text-sm">
<input
type="checkbox"
checked={areAllFilteredCreatePermissionsSelected}
onChange={(event) => {
setCreatePermissionIds((current) =>
toggleSelection(
current,
allFilteredCreatePermissionIds,
event.target.checked,
),
);
}}
/>
<span className="font-medium text-foreground">Select all</span>
</label>
{loadingPermissionsCatalog ? (
<div className="rounded-2xl border border-dashed border-border bg-muted px-4 py-8 text-center text-sm text-muted-foreground">
Loading permissions catalog...
</div>
) : filteredCreatePermissions.length ? (
<div className="grid max-h-[320px] gap-2 overflow-y-auto pr-1 md:grid-cols-2 xl:grid-cols-3">
{filteredCreatePermissions.map((permission) => (
<label
key={permission.id}
className="flex items-start gap-2 rounded-xl border border-border bg-background px-3 py-2 text-xs"
>
<input
type="checkbox"
checked={createPermissionIds.includes(permission.id)}
onChange={(event) => {
setCreatePermissionIds((current) =>
event.target.checked
? [...current, permission.id]
: current.filter((item) => item !== permission.id),
);
}}
/>
<div className="min-w-0 leading-4">
<div className="truncate font-medium text-foreground">
{getLocaleLabel(permission.name, permission.key)}
</div>
<div className="truncate text-xs text-muted-foreground">{permission.key}</div>
</div>
</label>
))}
</div>
) : (
<div className="rounded-2xl border border-dashed border-border bg-muted px-4 py-8 text-center text-sm text-muted-foreground">
No permissions match the current search.
</div>
)}
</div>
{createForm.copyPermissionFromId ? (
<div className="rounded-2xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-700 dark:border-sky-950 dark:bg-sky-950/30 dark:text-sky-300">
<div className="flex items-center gap-2">
<CopyPlus className="h-4 w-4" />
The new position type will inherit permissions from the selected source.
</div>
</div>
) : null}
{createError ? (
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-950 dark:bg-red-950/30 dark:text-red-300">
{createError}
</div>
) : null}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setIsCreateOpen(false)}
className={`${buttonClassName} border border-border bg-card text-card-foreground hover:bg-accent hover:text-accent-foreground`}
>
Cancel
</button>
<button
type="submit"
disabled={submitting || !selectedUnitId}
className={`${buttonClassName} bg-emerald-600 text-white hover:bg-emerald-700`}
>
Create position type
</button>
</div>
</form>
</DialogContent>
</Dialog>
</section>
);
};

View File

@@ -188,6 +188,46 @@ const getItems = <T,>(payload: ListResponse<T> | T[] | undefined | null) => {
return payload.items ?? payload.data ?? [];
};
const mergeEmployeesByUser = (employees: EmployeeRecord[]) => {
const employeesByUserId = new Map<string, EmployeeRecord>();
for (const employee of employees) {
const userId = employee.user?.id;
if (!userId) {
employeesByUserId.set(employee.id, employee);
continue;
}
const existing = employeesByUserId.get(userId);
if (!existing) {
employeesByUserId.set(userId, employee);
continue;
}
const existingPositions = existing.employeePositions ?? [];
const nextPositions = employee.employeePositions ?? [];
const mergedPositions = Array.from(
new Map(
[...existingPositions, ...nextPositions].map((position) => [position.id, position]),
).values(),
);
employeesByUserId.set(userId, {
...existing,
...employee,
id: existing.id,
name: existing.name ?? employee.name,
status: existing.status ?? employee.status,
user: existing.user ?? employee.user,
employeePositions: mergedPositions,
});
}
return [...employeesByUserId.values()];
};
const toInternalKey = (value: string) =>
value
.trim()
@@ -611,9 +651,9 @@ const UserManagementPage = () => {
try {
const response = await api.get<ListResponse<EmployeeRecord>>(
`/employees/${organizationId}/by-organization`,
`/backoffice/organizations/${organizationId}/employees`,
);
setOrgEmployees(getItems(response.data));
setOrgEmployees(mergeEmployeesByUser(getItems(response.data)));
} catch {
setOrgEmployees([]);
} finally {
@@ -772,7 +812,7 @@ const UserManagementPage = () => {
return;
}
setSelectedOrgId(null);
setSelectedOrgId(visibleOrganizations[0]?.id ?? null);
setSelectedOrgConfiguration(null);
setUnits([]);
setPositions([]);
@@ -780,6 +820,14 @@ const UserManagementPage = () => {
setOrgEmployees([]);
}, [selectedOrgId, visibleOrganizations]);
useEffect(() => {
if (!selectedOrgId) {
return;
}
void refreshSelectedOrg();
}, [refreshSelectedOrg, selectedOrgId]);
useEffect(() => {
if (!selectedOrgId) {
setUnits([]);

View File

@@ -104,6 +104,46 @@ const getItems = <T,>(payload: ListResponse<T> | T[] | undefined | null) => {
return payload.items ?? payload.data ?? [];
};
const mergeEmployeesByUser = (employees: EmployeeRecord[]) => {
const employeesByUserId = new Map<string, EmployeeRecord>();
for (const employee of employees) {
const userId = employee.user?.id;
if (!userId) {
employeesByUserId.set(employee.id, employee);
continue;
}
const existing = employeesByUserId.get(userId);
if (!existing) {
employeesByUserId.set(userId, employee);
continue;
}
const existingPositions = existing.employeePositions ?? [];
const nextPositions = employee.employeePositions ?? [];
const mergedPositions = Array.from(
new Map(
[...existingPositions, ...nextPositions].map((position) => [position.id, position]),
).values(),
);
employeesByUserId.set(userId, {
...existing,
...employee,
id: existing.id,
name: existing.name ?? employee.name,
status: existing.status ?? employee.status,
user: existing.user ?? employee.user,
employeePositions: mergedPositions,
});
}
return [...employeesByUserId.values()];
};
const getErrorMessage = (error: unknown, fallback: string) => {
if (isAxiosError(error)) {
const message = error.response?.data?.message;
@@ -231,9 +271,9 @@ const UsersPage = () => {
try {
const response = await api.get<ListResponse<EmployeeRecord>>(
`/employees/${organizationId}/by-organization`,
`/backoffice/organizations/${organizationId}/employees`,
);
setOrgEmployees(getItems(response.data));
setOrgEmployees(mergeEmployeesByUser(getItems(response.data)));
} catch {
setOrgEmployees([]);
} finally {