mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 22:25:42 +00:00
WIP
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
Post,
|
Post,
|
||||||
|
Query,
|
||||||
Put,
|
Put,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
@@ -27,6 +28,19 @@ export class BackofficeController {
|
|||||||
return this.backofficeService.createOrganizationUser(organizationId, dto);
|
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")
|
@Get("organizations/:orgId/employee-users/:userId/roles")
|
||||||
@ApiOperation({ summary: "Get org-scoped roles assigned to an employee user" })
|
@ApiOperation({ summary: "Get org-scoped roles assigned to an employee user" })
|
||||||
getEmployeeUserRoles(
|
getEmployeeUserRoles(
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ const EDR_ORG_MANAGER_ROLE_KEY = "edr_org_manager";
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class BackofficeService {
|
export class BackofficeService {
|
||||||
constructor(
|
constructor(
|
||||||
|
@InjectRepository(Employee)
|
||||||
|
private readonly employeeRepository: Repository<Employee>,
|
||||||
@InjectRepository(Organization)
|
@InjectRepository(Organization)
|
||||||
private readonly organizationRepository: Repository<Organization>,
|
private readonly organizationRepository: Repository<Organization>,
|
||||||
@InjectRepository(Role)
|
@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(
|
async replaceEmployeeUserRoles(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
userId: 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(
|
private async ensureOrganizationAdminAccess(
|
||||||
manager: EntityManager,
|
manager: EntityManager,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const IAM_PERMISSION_KEYS = {
|
|||||||
deletePositionPermission: "can:delete:position_permission",
|
deletePositionPermission: "can:delete:position_permission",
|
||||||
deleteUnit: "can:delete:unit",
|
deleteUnit: "can:delete:unit",
|
||||||
deleteUserRole: "can:delete:user_role",
|
deleteUserRole: "can:delete:user_role",
|
||||||
|
findAllOrganization: "can:find_all:organization",
|
||||||
manageOrganizationAdmin: "manage:organizationAdmin",
|
manageOrganizationAdmin: "manage:organizationAdmin",
|
||||||
manageUnitAdmin: "manage:unitAdmin",
|
manageUnitAdmin: "manage:unitAdmin",
|
||||||
updateUnit: "can:update:unit",
|
updateUnit: "can:update:unit",
|
||||||
@@ -217,6 +218,7 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [
|
|||||||
IAM_PERMISSION_KEYS.createPositionPermission,
|
IAM_PERMISSION_KEYS.createPositionPermission,
|
||||||
IAM_PERMISSION_KEYS.deletePositionPermission,
|
IAM_PERMISSION_KEYS.deletePositionPermission,
|
||||||
IAM_PERMISSION_KEYS.viewPositionPermission,
|
IAM_PERMISSION_KEYS.viewPositionPermission,
|
||||||
|
IAM_PERMISSION_KEYS.findAllOrganization,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ const App = () => {
|
|||||||
<Route path="user-management" element={<UserManagementPage />} />
|
<Route path="user-management" element={<UserManagementPage />} />
|
||||||
<Route path="user-management/users" element={<UsersPage />} />
|
<Route path="user-management/users" element={<UsersPage />} />
|
||||||
<Route path="user-management/position-types" element={<PositionTypesPage />} />
|
<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/permissions" element={<PermissionsPage />} />
|
||||||
<Route path="user-management/roles" element={<RolesPage />} />
|
<Route path="user-management/roles" element={<RolesPage />} />
|
||||||
|
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ const EmployeesPage = () => {
|
|||||||
const responses = await Promise.all(
|
const responses = await Promise.all(
|
||||||
organizationIds.map((organizationId) =>
|
organizationIds.map((organizationId) =>
|
||||||
api.get<ListResponse<EmployeeRecord>>(
|
api.get<ListResponse<EmployeeRecord>>(
|
||||||
`/employees/${organizationId}/by-organization`,
|
`/backoffice/organizations/${organizationId}/employees`,
|
||||||
{
|
{
|
||||||
params: {
|
params: {
|
||||||
skip: 0,
|
skip: 0,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@edr/ui-common";
|
} from "@edr/ui-common";
|
||||||
|
import { CopyPlus, Plus, RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
import { api } from "@/auth/http";
|
import { api } from "@/auth/http";
|
||||||
import { useAuth } from "@/auth/useAuth";
|
import { useAuth } from "@/auth/useAuth";
|
||||||
@@ -47,6 +48,7 @@ interface PermissionRecord {
|
|||||||
id: string;
|
id: string;
|
||||||
key: string;
|
key: string;
|
||||||
name?: LocaleText;
|
name?: LocaleText;
|
||||||
|
applicationId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ListResponse<T> {
|
interface ListResponse<T> {
|
||||||
@@ -57,6 +59,42 @@ interface ListResponse<T> {
|
|||||||
|
|
||||||
const PAGE_SIZE = 1000;
|
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") =>
|
const getLocaleLabel = (value?: LocaleText | null, fallback = "Unnamed") =>
|
||||||
value?.en ?? value?.am ?? fallback;
|
value?.en ?? value?.am ?? fallback;
|
||||||
|
|
||||||
@@ -102,11 +140,22 @@ const PositionTypesPage = () => {
|
|||||||
const [loadingOrganizations, setLoadingOrganizations] = useState(true);
|
const [loadingOrganizations, setLoadingOrganizations] = useState(true);
|
||||||
const [loadingUnits, setLoadingUnits] = useState(false);
|
const [loadingUnits, setLoadingUnits] = useState(false);
|
||||||
const [loadingPositionTypes, setLoadingPositionTypes] = 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 [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
const [selectedPositionType, setSelectedPositionType] = useState<PositionTypeRecord | null>(null);
|
const [selectedPositionType, setSelectedPositionType] = useState<PositionTypeRecord | null>(null);
|
||||||
const [positionTypePermissions, setPositionTypePermissions] = useState<PermissionRecord[]>([]);
|
const [positionTypePermissions, setPositionTypePermissions] = useState<PermissionRecord[]>([]);
|
||||||
|
const [allPermissions, setAllPermissions] = useState<PermissionRecord[]>([]);
|
||||||
const [permissionsLoading, setPermissionsLoading] = useState(false);
|
const [permissionsLoading, setPermissionsLoading] = useState(false);
|
||||||
const [permissionsError, setPermissionsError] = useState<string | null>(null);
|
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 isSuperAdmin = Boolean(user?.roles?.some((role) => role.key === "super_admin"));
|
||||||
const allowedOrgIds = useMemo(
|
const allowedOrgIds = useMemo(
|
||||||
@@ -130,6 +179,65 @@ const PositionTypesPage = () => {
|
|||||||
const selectedOrganization =
|
const selectedOrganization =
|
||||||
visibleOrganizations.find((organization) => organization.id === selectedOrgId) ?? null;
|
visibleOrganizations.find((organization) => organization.id === selectedOrgId) ?? null;
|
||||||
const selectedUnit = units.find((unit) => unit.id === selectedUnitId) ?? 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(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
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(() => {
|
useEffect(() => {
|
||||||
if (!visibleOrganizations.length) {
|
if (!visibleOrganizations.length) {
|
||||||
setSelectedOrgId("");
|
setSelectedOrgId("");
|
||||||
@@ -245,27 +392,18 @@ const PositionTypesPage = () => {
|
|||||||
|
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
|
|
||||||
const loadPositionTypes = async () => {
|
const loadItems = async () => {
|
||||||
setLoadingPositionTypes(true);
|
setLoadingPositionTypes(true);
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.get<ListResponse<PositionTypeRecord>>(
|
const items = await loadPositionTypes(selectedUnitId);
|
||||||
`/position-types/list-with-commons/${selectedUnitId}`,
|
|
||||||
{
|
|
||||||
params: {
|
|
||||||
skip: 0,
|
|
||||||
take: PAGE_SIZE,
|
|
||||||
orderBy: "createdAt:Desc",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isMounted) {
|
if (!isMounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setPositionTypes(getItems(response.data));
|
setPositionTypes(items);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isMounted) {
|
if (!isMounted) {
|
||||||
return;
|
return;
|
||||||
@@ -284,7 +422,7 @@ const PositionTypesPage = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
void loadPositionTypes();
|
void loadItems();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
isMounted = false;
|
isMounted = false;
|
||||||
@@ -294,11 +432,20 @@ const PositionTypesPage = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedPositionType) {
|
if (!selectedPositionType) {
|
||||||
setPositionTypePermissions([]);
|
setPositionTypePermissions([]);
|
||||||
|
setSelectedPermissionIds([]);
|
||||||
|
setEditForm(emptyEditForm);
|
||||||
setPermissionsError(null);
|
setPermissionsError(null);
|
||||||
setPermissionsLoading(false);
|
setPermissionsLoading(false);
|
||||||
|
setPermissionSearch("");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setEditForm({
|
||||||
|
key: selectedPositionType.key,
|
||||||
|
nameAm: selectedPositionType.name?.am ?? "",
|
||||||
|
nameEn: selectedPositionType.name?.en ?? "",
|
||||||
|
});
|
||||||
|
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
|
|
||||||
const loadPermissions = async () => {
|
const loadPermissions = async () => {
|
||||||
@@ -306,15 +453,14 @@ const PositionTypesPage = () => {
|
|||||||
setPermissionsError(null);
|
setPermissionsError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.get<ListResponse<PermissionRecord>>(
|
const items = await loadPermissionsForPositionType(selectedPositionType.id);
|
||||||
`/position-type-permissions/given-first/${selectedPositionType.id}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isMounted) {
|
if (!isMounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setPositionTypePermissions(getItems(response.data));
|
setPositionTypePermissions(items);
|
||||||
|
setSelectedPermissionIds(items.map((permission) => permission.id));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isMounted) {
|
if (!isMounted) {
|
||||||
return;
|
return;
|
||||||
@@ -340,6 +486,181 @@ const PositionTypesPage = () => {
|
|||||||
};
|
};
|
||||||
}, [selectedPositionType]);
|
}, [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 (
|
return (
|
||||||
<section className="p-6">
|
<section className="p-6">
|
||||||
<div className="space-y-6 rounded-2xl border border-border bg-card p-8 shadow-sm">
|
<div className="space-y-6 rounded-2xl border border-border bg-card p-8 shadow-sm">
|
||||||
@@ -351,11 +672,31 @@ const PositionTypesPage = () => {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-semibold text-foreground">Position Type</h1>
|
<h1 className="text-3xl font-semibold text-foreground">Position Type</h1>
|
||||||
<p className="mt-3 max-w-2xl text-sm text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-full border border-border bg-muted px-3 py-1 text-sm font-medium text-muted-foreground">
|
<div className="flex items-center gap-2">
|
||||||
{positionTypes.length} position types
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -497,7 +838,7 @@ const PositionTypesPage = () => {
|
|||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{selectedPositionType
|
{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}
|
: undefined}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -540,10 +881,51 @@ const PositionTypesPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<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">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<h2 className="text-sm font-semibold text-foreground">Permissions</h2>
|
<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">
|
<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>
|
||||||
</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">
|
<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}
|
{permissionsError}
|
||||||
</div>
|
</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">
|
<div className="space-y-4">
|
||||||
No permissions are assigned to this position type.
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -583,6 +1024,175 @@ const PositionTypesPage = () => {
|
|||||||
) : null}
|
) : null}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -188,6 +188,46 @@ const getItems = <T,>(payload: ListResponse<T> | T[] | undefined | null) => {
|
|||||||
return payload.items ?? payload.data ?? [];
|
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) =>
|
const toInternalKey = (value: string) =>
|
||||||
value
|
value
|
||||||
.trim()
|
.trim()
|
||||||
@@ -611,9 +651,9 @@ const UserManagementPage = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.get<ListResponse<EmployeeRecord>>(
|
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 {
|
} catch {
|
||||||
setOrgEmployees([]);
|
setOrgEmployees([]);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -772,7 +812,7 @@ const UserManagementPage = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelectedOrgId(null);
|
setSelectedOrgId(visibleOrganizations[0]?.id ?? null);
|
||||||
setSelectedOrgConfiguration(null);
|
setSelectedOrgConfiguration(null);
|
||||||
setUnits([]);
|
setUnits([]);
|
||||||
setPositions([]);
|
setPositions([]);
|
||||||
@@ -780,6 +820,14 @@ const UserManagementPage = () => {
|
|||||||
setOrgEmployees([]);
|
setOrgEmployees([]);
|
||||||
}, [selectedOrgId, visibleOrganizations]);
|
}, [selectedOrgId, visibleOrganizations]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedOrgId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void refreshSelectedOrg();
|
||||||
|
}, [refreshSelectedOrg, selectedOrgId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedOrgId) {
|
if (!selectedOrgId) {
|
||||||
setUnits([]);
|
setUnits([]);
|
||||||
|
|||||||
@@ -104,6 +104,46 @@ const getItems = <T,>(payload: ListResponse<T> | T[] | undefined | null) => {
|
|||||||
return payload.items ?? payload.data ?? [];
|
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) => {
|
const getErrorMessage = (error: unknown, fallback: string) => {
|
||||||
if (isAxiosError(error)) {
|
if (isAxiosError(error)) {
|
||||||
const message = error.response?.data?.message;
|
const message = error.response?.data?.message;
|
||||||
@@ -231,9 +271,9 @@ const UsersPage = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.get<ListResponse<EmployeeRecord>>(
|
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 {
|
} catch {
|
||||||
setOrgEmployees([]);
|
setOrgEmployees([]);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user