mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
WIP
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user