fix(backoffice): scope permission copying to the selected organization

"Copy permissions from" listed every position type in every
organization, because the dropdown read an unfiltered GET
/position-types. IAM exposes no organization-scoped route and carries no
organizationId on a position type, so the list is now narrowed
client-side to the built-in (isSystem) types plus those whose unit
belongs to the selected organization, with the type being edited
excluded.

Also in position management:

- Invalidate every position-type cache key root after a mutation. React
  Query matches prefixes element by element, so ["position-type"] never
  reached ["position-types-common", ...] and the department pickers kept
  serving a stale list. invalidatePositionTypeQueries() covers all three
  roots and is shared by the hook and the form.
- Drop getByOrganizationId and getCommonTypesByOrganizationId. Both
  issued the same requests as their unit counterparts and had no callers.
- Surface errors that were being swallowed. Three mutations had empty
  onError handlers, hiding IAM's 403 for built-in position types, and
  CreatePositionForm's bare catch discarded the reason for every failure.
- Move organization and unit into the zod schema so they validate with
  translated messages and inline errors instead of an ad-hoc toast, and
  keep submit disabled through the permission-assignment call that
  follows the save.
- Report the two outcomes the form used to hide: a save that succeeded
  while permission assignment failed, and clearing every permission,
  which assign-seconds-for-first cannot express.
- Fix the list page's loading and error states, which rendered the
  "Add User" string as a spinner, ignored the unit-scoped query, and
  left the export button stuck after a failed download.
- Halve PermissionSearch's requests. It fetched 50 rows, read the total
  off the response and immediately refetched, and it re-filtered results
  on the undebounced term, blanking the list while typing.

Remove the three record toggles. They never worked: IAM's
PositionTypeConfiguration holds only { id, organizationId,
positionTypeId, timeframe } in every published build, canAssignRecord
and canCreateBankRecord exist nowhere in the package, and the global
ValidationPipe runs with forbidNonWhitelisted, so every write was a 400.
The reads were broken too, passing a positionTypeId to a route that
filters on organizationId. A TODO records where the real flag lives:
PositionConfiguration.canReceiveRecord, keyed by positionId.

Delete ActionsColumn.tsx, which had no references.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Nathnael
2026-07-23 13:42:08 +00:00
parent 063a8799f5
commit 346d4718bf
13 changed files with 1444 additions and 1628 deletions

View File

@@ -1,4 +1,9 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
QueryClient,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
CreatePositionTypePayload,
PositionRequest,
@@ -23,10 +28,22 @@ interface positionParams {
interface UsePositionTypeManagerProps {
id?: string;
unitId?: string;
organizationId?: string;
params?: positionParams; // 👈 we expected query params to be passed like this
}
/**
* Every cache key this hook writes under. React Query matches key prefixes
* element by element, so `["position-type"]` does NOT reach
* `["position-types-common", ...]` — each root has to be listed. Anything that
* mutates a position type should call this rather than hand-picking keys, or
* the department pickers (which read the "-common" queries) go stale.
*/
export const invalidatePositionTypeQueries = (queryClient: QueryClient) => {
["position-types", "position-type", "position-types-common"].forEach(
(root) => queryClient.invalidateQueries({ queryKey: [root] }),
);
};
export const usePositionTypes = ({
id,
params = {
@@ -35,11 +52,11 @@ export const usePositionTypes = ({
orderBy: "createdAt:Desc",
},
unitId,
organizationId,
}: UsePositionTypeManagerProps = {}) => {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
const invalidateAll = () => invalidatePositionTypeQueries(queryClient);
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ["position-types", params],
queryFn: () => positionTypeService.getAll(params).then((res) => res.data),
@@ -70,38 +87,6 @@ export const usePositionTypes = ({
enabled: !!unitId,
});
// Position types by organization ID
const {
data: positionTypeByOrgId,
isLoading: isLoadingOrgPosition,
isError: isErrorOrgPosition,
refetch: refetchOrgPosition,
} = useQuery<PositionTypesListResponse | undefined>({
queryKey: ["position-type-org", organizationId, params],
queryFn: async () => {
if (!organizationId) return undefined;
const res = await positionTypeService.getByOrganizationId(organizationId, params);
return res.data as PositionTypesListResponse | undefined;
},
enabled: !!organizationId,
});
// Common types with organization ID (includes both org-specific and common types)
const {
data: commonPositionTypesByOrgId,
isLoading: isLoadingCommonOrgTypes,
isError: isErrorCommonOrgTypes,
refetch: refetchCommonOrgTypes,
} = useQuery<PositionTypesListResponse | undefined>({
queryKey: ["position-types-common-org", organizationId, params],
queryFn: async () => {
if (!organizationId) return undefined;
const res = await positionTypeService.getCommonTypesByOrganizationId(organizationId, params);
return res.data as PositionTypesListResponse | undefined;
},
enabled: !!organizationId,
});
// Common types with unit ID (includes both unit-specific and common types)
const {
data: commonPositionTypes,
@@ -123,15 +108,16 @@ export const usePositionTypes = ({
mutationFn: (payload: CreatePositionTypePayload) =>
positionTypeService.create(payload),
onSuccess: () => {
toast.success("Position type created");
queryClient.invalidateQueries({ queryKey: ["position-types"] });
toast.success(t("contentManagement.positionTypeCreated"));
invalidateAll();
},
onError: (error) => {
handleError(error);
},
});
// Update
// Update. IAM answers 403 `position_type_not_allowed_to_update` for built-in
// (isSystem) types, so the error has to reach the user.
const updatePositionType = useMutation({
mutationFn: ({
id,
@@ -141,11 +127,12 @@ export const usePositionTypes = ({
data: UpdatePositionTypePayload;
}) => positionTypeService.update(id, data),
onSuccess: () => {
toast.success("Position type updated");
queryClient.invalidateQueries({ queryKey: ["position-types"] });
queryClient.invalidateQueries({ queryKey: ["position-type", id] });
toast.success(t("contentManagement.positionTypeUpdated"));
invalidateAll();
},
onError: (error) => {
handleError(error);
},
onError: () => {},
});
//update positon from to
@@ -153,30 +140,32 @@ export const usePositionTypes = ({
mutationFn: ({ toId, fromId }: { toId: string; fromId: string }) =>
positionTypeService.updateFromto(toId, fromId),
onSuccess: () => {
toast.success("Position type migration updated");
queryClient.invalidateQueries({ queryKey: ["position-types-to"] });
queryClient.invalidateQueries({ queryKey: ["position-type", id] });
toast.success(t("contentManagement.positionTypeMigrated"));
invalidateAll();
},
onError: (error) => {
handleError(error);
},
onError: () => {},
});
//update all postions
const migratePositionsByPositions = useMutation({
mutationFn: ({ id, data }: { id: string; data: PositionRequest }) =>
positionTypeService.updateByPostion(id, data),
onSuccess: () => {
toast.success("Position type migration updated");
queryClient.invalidateQueries({ queryKey: ["position-types-migration"] });
queryClient.invalidateQueries({ queryKey: ["position-type", id] });
toast.success(t("contentManagement.positionTypeMigrated"));
invalidateAll();
},
onError: (error) => {
handleError(error);
},
onError: () => {},
});
// Delete
// Delete. Also 403s for built-in types.
const deletePositionType = useMutation({
mutationFn: (id: string) => positionTypeService.delete(id),
onSuccess: () => {
toast.success("Position type deleted");
queryClient.invalidateQueries({ queryKey: ["position-types"] });
toast.success(t("contentManagement.positionTypeDeleted"));
invalidateAll();
},
onError: (error) => {
handleError(error);
@@ -205,16 +194,6 @@ export const usePositionTypes = ({
refetchPosition,
isErrorPosition,
isLoadingPosition,
// organization-based position types
positionTypeByOrgId,
refetchOrgPosition,
isErrorOrgPosition,
isLoadingOrgPosition,
// common types with organization ID
commonPositionTypesByOrgId,
refetchCommonOrgTypes,
isErrorCommonOrgTypes,
isLoadingCommonOrgTypes,
// common types with unit ID
commonPositionTypes: commonPositionTypes?.items ?? [],
isLoadingCommonTypes,