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

@@ -2039,6 +2039,7 @@
"setting": "ቅንብሮች", "setting": "ቅንብሮች",
"loadingAdmins": "አስተዳዳሪዎችን በመጫን ላይ...", "loadingAdmins": "አስተዳዳሪዎችን በመጫን ላይ...",
"errorLoadingAdmins": "የአስተዳዳሪ መረጃን ማጫን ላይ ስህተት ተፈጥሯል", "errorLoadingAdmins": "የአስተዳዳሪ መረጃን ማጫን ላይ ስህተት ተፈጥሯል",
"errorLoadingUnits": "ክፍሎችን ማጫን ላይ ስህተት ተፈጥሯል",
"retry": "ደግመው ይሞክሩ", "retry": "ደግመው ይሞክሩ",
"assignAdmin": "አስተዳዳሪ መመደብ", "assignAdmin": "አስተዳዳሪ መመደብ",
"addAdmin": "አስተዳዳሪ ያክሉ", "addAdmin": "አስተዳዳሪ ያክሉ",
@@ -2651,6 +2652,18 @@
"selectApplicationToLoadPermissions": "ፍቃዶቹን ለማስገንዘብ አፕሊኬሽኑን ይምረጡ", "selectApplicationToLoadPermissions": "ፍቃዶቹን ለማስገንዘብ አፕሊኬሽኑን ይምረጡ",
"copyPermissionsHint": "የነበረ የቦታ አይነት ይምረጡ፤ ፍቃዶቹ አስቀድመው ይሞላሉ፣ ከታች ማስተካከል ይችላሉ።", "copyPermissionsHint": "የነበረ የቦታ አይነት ይምረጡ፤ ፍቃዶቹ አስቀድመው ይሞላሉ፣ ከታች ማስተካከል ይችላሉ።",
"copyPermissionsFailed": "ፍቃዶችን መቅዳት አልተቻለም", "copyPermissionsFailed": "ፍቃዶችን መቅዳት አልተቻለም",
"selectOrganizationToCopy": "መቅዳት የሚችሏቸውን የቦታ ዓይነቶች ለማየት መጀመሪያ ድርጅት ይምረጡ",
"cannotClearAllPermissions": "ተቀምጧል። ፍቃዶቹ አልተቀየሩም — ይህ የቦታ ዓይነት ቢያንስ አንድ ፍቃድ ሊኖረው ይገባል።",
"permissionsSelected": "{{count}} ተመርጠዋል",
"positionTypeCreated": "የቦታ ዓይነት ተፈጥሯል",
"positionTypeUpdated": "የቦታ ዓይነት ተሻሽሏል",
"positionTypeDeleted": "የቦታ ዓይነት ተሰርዟል",
"positionTypeMigrated": "የቦታ ዓይነት ዝውውር ተሻሽሏል",
"positionTypeNotFound": "የቦታ ዓይነት አልተገኘም",
"permissionsAssignFailed": "የቦታ ዓይነቱ ተቀምጧል፣ ነገር ግን ፍቃዶቹን መመደብ አልተቻለም። እንደገና ለመሞከር ደግመው ይክፈቱት።",
"failedToLoadPermissions": "ፍቃዶችን መጫን አልተቻለም",
"failedToLoadPositionTypes": "የቦታ ዓይነቶችን መጫን አልተቻለም",
"exportFailed": "የቦታ ዓይነት ቁልፎችን መላክ አልተቻለም",
"perFailed": "ፍቃድ መፍጠር አልተቻለም", "perFailed": "ፍቃድ መፍጠር አልተቻለም",
"perSuccess": "የፍቃድ አይነት ተፈጠረና ፍቃዶች ተመደቡ", "perSuccess": "የፍቃድ አይነት ተፈጠረና ፍቃዶች ተመደቡ",
"updatePerSuccess": "ፍቃድ በትክክል ተዘምኗል", "updatePerSuccess": "ፍቃድ በትክክል ተዘምኗል",

View File

@@ -2057,6 +2057,7 @@
"setting": "Setting", "setting": "Setting",
"loadingAdmins": "Loading admins...", "loadingAdmins": "Loading admins...",
"errorLoadingAdmins": "Error loading admin data", "errorLoadingAdmins": "Error loading admin data",
"errorLoadingUnits": "Error loading units",
"retry": "Retry", "retry": "Retry",
"assignAdmin": "Assign Admin", "assignAdmin": "Assign Admin",
"addAdmin": "Add Admin", "addAdmin": "Add Admin",
@@ -2760,6 +2761,18 @@
"selectApplicationToLoadPermissions": "Select an application to load its permissions", "selectApplicationToLoadPermissions": "Select an application to load its permissions",
"copyPermissionsHint": "Pick an existing position type to pre-fill its permissions, then edit below.", "copyPermissionsHint": "Pick an existing position type to pre-fill its permissions, then edit below.",
"copyPermissionsFailed": "Failed to copy permissions", "copyPermissionsFailed": "Failed to copy permissions",
"selectOrganizationToCopy": "Select an organization to see the position types you can copy from",
"cannotClearAllPermissions": "Saved. Permissions were left unchanged — this position type must keep at least one permission.",
"permissionsSelected": "{{count}} selected",
"positionTypeCreated": "Position type created",
"positionTypeUpdated": "Position type updated",
"positionTypeDeleted": "Position type deleted",
"positionTypeMigrated": "Position type migration updated",
"positionTypeNotFound": "Position type not found",
"permissionsAssignFailed": "Position type saved, but assigning its permissions failed. Reopen it to try again.",
"failedToLoadPermissions": "Failed to load permissions",
"failedToLoadPositionTypes": "Failed to load position types",
"exportFailed": "Failed to export position type keys",
"perFailed": "Failed To Create Permission", "perFailed": "Failed To Create Permission",
"perSuccess": "Permission type created and permissions assigned", "perSuccess": "Permission type created and permissions assigned",
"updatePerSuccess": "Permission updated successfully", "updatePerSuccess": "Permission updated successfully",

View File

@@ -1525,6 +1525,7 @@
"setting": "Paramètre", "setting": "Paramètre",
"loadingAdmins": "Chargement des administrateurs...", "loadingAdmins": "Chargement des administrateurs...",
"errorLoadingAdmins": "Erreur lors du chargement des données administrateur", "errorLoadingAdmins": "Erreur lors du chargement des données administrateur",
"errorLoadingUnits": "Erreur lors du chargement des unités",
"retry": "Réessayer", "retry": "Réessayer",
"assignAdmin": "Assigner un administrateur", "assignAdmin": "Assigner un administrateur",
"addAdmin": "Ajouter un administrateur", "addAdmin": "Ajouter un administrateur",
@@ -1886,6 +1887,18 @@
"selectApplicationToLoadPermissions": "Sélectionner une application pour charger ses autorisations", "selectApplicationToLoadPermissions": "Sélectionner une application pour charger ses autorisations",
"copyPermissionsHint": "Choisissez un type de poste existant pour préremplir ses autorisations, puis modifiez ci-dessous.", "copyPermissionsHint": "Choisissez un type de poste existant pour préremplir ses autorisations, puis modifiez ci-dessous.",
"copyPermissionsFailed": "Échec de la copie des autorisations", "copyPermissionsFailed": "Échec de la copie des autorisations",
"selectOrganizationToCopy": "Sélectionnez une organisation pour voir les types de poste que vous pouvez copier",
"cannotClearAllPermissions": "Enregistré. Les autorisations n'ont pas été modifiées — ce type de poste doit conserver au moins une autorisation.",
"permissionsSelected": "{{count}} sélectionné(s)",
"positionTypeCreated": "Type de poste créé",
"positionTypeUpdated": "Type de poste mis à jour",
"positionTypeDeleted": "Type de poste supprimé",
"positionTypeMigrated": "Migration du type de poste mise à jour",
"positionTypeNotFound": "Type de poste introuvable",
"permissionsAssignFailed": "Type de poste enregistré, mais l'attribution de ses autorisations a échoué. Rouvrez-le pour réessayer.",
"failedToLoadPermissions": "Échec du chargement des autorisations",
"failedToLoadPositionTypes": "Échec du chargement des types de poste",
"exportFailed": "Échec de l'exportation des clés de type de poste",
"perFailed": "Échec de la création de lautorisation", "perFailed": "Échec de la création de lautorisation",
"perSuccess": "Type dautorisation créé et autorisations assignées", "perSuccess": "Type dautorisation créé et autorisations assignées",
"updatePerSuccess": "Autorisation mise à jour avec succès", "updatePerSuccess": "Autorisation mise à jour avec succès",

View File

@@ -1,94 +0,0 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "@/shared/common/ui/button";
import {
AlertDialog,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogCancel,
AlertDialogAction,
} from "@/shared/common/ui/alert-dialog";
import { usePositionTypes } from "@/user-management/hooks/usePositionTypes";
import { PositionTypeDto } from "@/user-management/dto/positions/positionType";
import { t } from "i18next";
type ActionsColumnProps = {
row: PositionTypeDto;
};
const ActionsColumn: React.FC<ActionsColumnProps> = ({ row }) => {
const navigate = useNavigate();
const [openDialog, setOpenDialog] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const { deletePositionType } = usePositionTypes({ id: "" });
const handleDeleteClick = (id: string) => {
setDeletingId(id);
setOpenDialog(true);
};
const handleDeleteConfirm = async () => {
if (!deletingId) return;
await deletePositionType.mutateAsync(deletingId);
setOpenDialog(false);
setDeletingId(null);
};
return (
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() =>
navigate(`/user-management/position-management/edit/${row.id}`)
}
>
{t("common.Edit")}
</Button>
<AlertDialog open={openDialog} onOpenChange={setOpenDialog}>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
onClick={() => handleDeleteClick(row.id)}
>
{t("userRecord.Delete")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("contentManagement.delMsg")}</AlertDialogTitle>
<AlertDialogDescription>
{t("contentManagement.delMsg2")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
onClick={() => {
setOpenDialog(false);
setDeletingId(null);
}}
>
{t("common.Cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteConfirm}
disabled={deletePositionType.isPending}
>
{deletePositionType.isPending
? t("organization.deleting")
: t("organization.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
export default ActionsColumn;

View File

@@ -1,455 +1,529 @@
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@/shared/common/ui/input"; import { Input } from "@/shared/common/ui/input";
import { Button } from "@/shared/common/ui/button"; import { Button } from "@/shared/common/ui/button";
import { import {
Form, Form,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
FormControl, FormControl,
FormMessage, FormMessage,
} from "@/shared/common/ui/form"; } from "@/shared/common/ui/form";
import { toast } from "sonner"; import { toast } from "sonner";
import { usePositionTypes } from "@/user-management/hooks/usePositionTypes"; import {
import { positionTypePermissionService } from "@/user-management/services/api/positionTypePermissionService"; invalidatePositionTypeQueries,
import { useNavigate } from "react-router-dom"; usePositionTypes,
import { t } from "i18next"; } from "@/user-management/hooks/usePositionTypes";
import { useAuth } from "@/shared/context/AuthContext"; import { positionTypePermissionService } from "@/user-management/services/api/positionTypePermissionService";
import { useUnit } from "@/user-management/hooks/useUnit"; import { useNavigate } from "react-router-dom";
import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next";
import { import { useAuth } from "@/shared/context/AuthContext";
Select, import { useUnit } from "@/user-management/hooks/useUnit";
SelectContent, import { useEffect, useMemo, useRef, useState } from "react";
SelectItem, import {
SelectTrigger, Select,
SelectValue, SelectContent,
} from "@/shared/common/ui/select"; SelectItem,
import { SingleSelect } from "@/shared/common/ui/single-select"; SelectTrigger,
import { UnitDto } from "@/user-management/dto/unit/unitDto"; SelectValue,
import { PositionTypeDto } from "@/user-management/dto/positions/positionType"; } from "@/shared/common/ui/select";
import { useLocalizedName } from "@/shared/common/localizedName"; import { SingleSelect } from "@/shared/common/ui/single-select";
import { useOrganizations } from "@/super-admin/hooks/useOrganizations"; import { UnitDto } from "@/user-management/dto/unit/unitDto";
import { OrganizationDto } from "@/shared/dto/organization/organizationDto"; import { PositionTypeDto } from "@/user-management/dto/positions/positionType";
import i18n from "@/i18n"; import { useLocalizedName } from "@/shared/common/localizedName";
import { PermissionSearch } from "./PermissionSearch"; import { useOrganizations } from "@/super-admin/hooks/useOrganizations";
import { useApplications } from "@/user-management/hooks/useApplications"; import { OrganizationDto } from "@/shared/dto/organization/organizationDto";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
import { PermissionSearch } from "./PermissionSearch";
const formSchema = z.object({ import { useApplications } from "@/user-management/hooks/useApplications";
nameAm: z.string().min(2), import { useQuery, useQueryClient } from "@tanstack/react-query";
nameEn: z.string().min(2),
permissions: z.array(z.string()), export interface CreatePositionFormProps {
}); mode?: "create" | "edit";
positionTypeId?: string;
type FormValues = z.infer<typeof formSchema>; initialValues?: {
nameAm: string;
export interface CreatePositionFormProps { nameEn: string;
mode?: "create" | "edit"; unitId: string;
positionTypeId?: string; key?: string;
initialValues?: { };
nameAm: string; onSuccess?: () => void;
nameEn: string; onCancel?: () => void;
unitId: string; }
key?: string;
}; export const CreatePositionForm = ({
onSuccess?: () => void; mode = "create",
onCancel?: () => void; positionTypeId,
} initialValues,
onSuccess,
export const CreatePositionForm = ({ onCancel,
mode = "create", }: CreatePositionFormProps = {}) => {
positionTypeId, const navigate = useNavigate();
initialValues, const { t } = useTranslation();
onSuccess, const { handleError } = useErrorHandler(t);
onCancel, const {
}: CreatePositionFormProps = {}) => { createPositionType,
const navigate = useNavigate(); updatePositionType,
const { positionTypes,
createPositionType, isLoading: isLoadingPositionTypes,
updatePositionType, isError: isErrorPositionTypes,
positionTypes, } = usePositionTypes();
isLoading: isLoadingPositionTypes, const { user } = useAuth();
} = usePositionTypes(); const { getList, getById } = useUnit();
const { user } = useAuth(); const localizedName = useLocalizedName();
const { getList, getById } = useUnit(); const userOrganizationId =
const localizedName = useLocalizedName(); user?.employee && user.employee.length > 0
const userOrganizationId = ? user.employee[0].organizationId
user?.employee && user.employee.length > 0 : undefined;
? user.employee[0].organizationId const [selectedApplicationId, setSelectedApplicationId] =
: undefined; useState<string>("");
const [selectedOrganizationId, setSelectedOrganizationId] = useState<string>( const [copyFromPositionId, setCopyFromPositionId] = useState<string>("");
userOrganizationId ?? "", const [isCopying, setIsCopying] = useState(false);
); const [isLoadingEditData, setIsLoadingEditData] = useState(mode === "edit");
const [selectedUnitId, setSelectedUnitId] = useState<string>( const hasLoadedEditData = useRef(false);
initialValues?.unitId ?? "", // Permissions the position type had when the form opened. Needed because the
); // API cannot represent "no permissions" (see onSubmit).
const [selectedApplicationId, setSelectedApplicationId] = const loadedPermissionCount = useRef(0);
useState<string>(""); const { applications, isLoading: isLoadingApplications } = useApplications();
const [copyFromPositionId, setCopyFromPositionId] = useState<string>(""); const queryClient = useQueryClient();
const [isCopying, setIsCopying] = useState(false);
const [isLoadingEditData, setIsLoadingEditData] = useState(mode === "edit"); const formSchema = useMemo(
const hasLoadedEditData = useRef(false); () =>
const lang = i18n.language; z.object({
const { applications, isLoading: isLoadingApplications } = useApplications(); nameEn: z.string().trim().min(2, t("organization.englishNameRequired")),
const queryClient = useQueryClient(); nameAm: z.string().trim().min(2, t("organization.amharicNameRequired")),
organizationId: z.string().min(1, t("organization.organizationRequired")),
const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations( unitId: z.string().min(1, t("contentManagement.unitRequired")),
"Org", permissions: z.array(z.string()),
{ take: 3000 }, }),
); [t],
);
const { data: unitsResponse, isLoading: isLoadingUnits } = getList(
selectedOrganizationId, type FormValues = z.infer<typeof formSchema>;
{ take: 3000, skip: 0 },
); const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
const organizationOptions = useMemo( defaultValues: {
() => nameAm: initialValues?.nameAm ?? "",
(organizationsResponse?.items ?? []).map((org: OrganizationDto) => ({ nameEn: initialValues?.nameEn ?? "",
value: org.id, organizationId: userOrganizationId ?? "",
label: localizedName(org.name) || org.id, unitId: initialValues?.unitId ?? "",
})), permissions: [],
[organizationsResponse, localizedName], },
); });
const unitOptions = useMemo( const selectedOrganizationId = form.watch("organizationId");
() =>
(unitsResponse?.data?.items ?? []).map((unit: UnitDto) => ({ const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations(
value: unit.id, "Org",
label: localizedName(unit.name) || unit.id, { take: 3000 },
})), );
[unitsResponse, localizedName],
); const { data: unitsResponse, isLoading: isLoadingUnits } = getList(
selectedOrganizationId,
const { { take: 3000, skip: 0 },
data: editUnitResponse, );
isSuccess: isUnitSuccess,
isError: isUnitError, const organizationOptions = useMemo(
} = getById(initialValues?.unitId ?? ""); () =>
(organizationsResponse?.items ?? []).map((org: OrganizationDto) => ({
const { value: org.id,
data: permissionsResponse, label: localizedName(org.name) || org.id,
isSuccess: isPermissionsSuccess, })),
isError: isPermissionsError, [organizationsResponse, localizedName],
} = useQuery({ );
queryKey: ["position-type-permissions", positionTypeId],
queryFn: () => const unitOptions = useMemo(
positionTypePermissionService.getPermissionsByPositionTypeId( () =>
positionTypeId!, (unitsResponse?.data?.items ?? []).map((unit: UnitDto) => ({
), value: unit.id,
enabled: mode === "edit" && !!positionTypeId, label: localizedName(unit.name) || unit.id,
}); })),
// Reset the selected unit when the organization changes so a unit from a [unitsResponse, localizedName],
// different org can't be submitted by mistake. );
useEffect(() => {
if (mode === "edit") return; const {
setSelectedUnitId(""); data: editUnitResponse,
}, [selectedOrganizationId, mode]); isSuccess: isUnitSuccess,
isError: isUnitError,
const form = useForm<FormValues>({ } = getById(initialValues?.unitId ?? "");
resolver: zodResolver(formSchema),
defaultValues: { const {
nameAm: initialValues?.nameAm ?? "", data: permissionsResponse,
nameEn: initialValues?.nameEn ?? "", isSuccess: isPermissionsSuccess,
permissions: [], isError: isPermissionsError,
}, } = useQuery({
}); queryKey: ["position-type-permissions", positionTypeId],
queryFn: () =>
useEffect(() => { positionTypePermissionService.getPermissionsByPositionTypeId(
if (mode !== "edit" || !initialValues || !positionTypeId) return; positionTypeId!,
if (hasLoadedEditData.current) return; ),
enabled: mode === "edit" && !!positionTypeId,
const isUnitDone = !initialValues.unitId || isUnitSuccess || isUnitError; });
const isPermissionsDone = isPermissionsSuccess || isPermissionsError;
// A position type belongs to a unit, and a unit to an organization — IAM has
if (isUnitDone && isPermissionsDone) { // no organizationId on the type itself and no organization-scoped route, so
hasLoadedEditData.current = true; // the picked org narrows the list through its units. isSystem types are the
// shared "commons" and stay available to every organization.
const unit = editUnitResponse?.data; const orgUnitIds = useMemo(
if (unit) { () =>
setSelectedOrganizationId(unit.organizationId); new Set(
setSelectedUnitId(unit.id); (unitsResponse?.data?.items ?? []).map((unit: UnitDto) => unit.id),
} else if (initialValues.unitId) { ),
setSelectedUnitId(initialValues.unitId); [unitsResponse],
} );
const ids = permissionsResponse?.data?.items?.map((p) => p.id) ?? []; const copyFromOptions = useMemo(() => {
form.reset({ if (!selectedOrganizationId) return [];
nameAm: initialValues.nameAm, return positionTypes.filter(
nameEn: initialValues.nameEn, (type: PositionTypeDto) =>
permissions: ids, type.id !== positionTypeId &&
}); (type.isSystem || (!!type.unitId && orgUnitIds.has(type.unitId))),
);
setIsLoadingEditData(false); }, [positionTypes, orgUnitIds, selectedOrganizationId, positionTypeId]);
}
}, [ // Reset the selected unit when the organization changes so a unit from a
mode, // different org can't be submitted by mistake. The copy source is cleared
initialValues, // too — it is scoped to the old organization.
positionTypeId, useEffect(() => {
isUnitSuccess, if (mode === "edit") return;
isUnitError, form.setValue("unitId", "");
isPermissionsSuccess, setCopyFromPositionId("");
isPermissionsError, }, [selectedOrganizationId, mode, form]);
editUnitResponse,
permissionsResponse, useEffect(() => {
form, if (mode !== "edit" || !initialValues || !positionTypeId) return;
]); if (hasLoadedEditData.current) return;
const handlePermissionChange = (permissionId: string, checked: boolean) => { const isUnitDone = !initialValues.unitId || isUnitSuccess || isUnitError;
const currentPermissions = form.getValues("permissions"); const isPermissionsDone = isPermissionsSuccess || isPermissionsError;
if (checked) {
form.setValue("permissions", [...currentPermissions, permissionId]); if (isUnitDone && isPermissionsDone) {
} else { hasLoadedEditData.current = true;
form.setValue(
"permissions", const unit = editUnitResponse?.data;
currentPermissions.filter((id) => id !== permissionId), const ids = permissionsResponse?.data?.items?.map((p) => p.id) ?? [];
); loadedPermissionCount.current = ids.length;
}
}; form.reset({
nameAm: initialValues.nameAm,
const handleCopyFrom = async (positionTypeId: string) => { nameEn: initialValues.nameEn,
setCopyFromPositionId(positionTypeId); organizationId: unit?.organizationId ?? userOrganizationId ?? "",
if (!positionTypeId) { unitId: unit?.id ?? initialValues.unitId ?? "",
form.setValue("permissions", []); permissions: ids,
return; });
}
setIsCopying(true); setIsLoadingEditData(false);
try { }
const response = }, [
await positionTypePermissionService.getPermissionsByPositionTypeId( mode,
positionTypeId, initialValues,
); positionTypeId,
const ids = response.data.items?.map((p) => p.id) ?? []; isUnitSuccess,
form.setValue("permissions", ids); isUnitError,
} catch { isPermissionsSuccess,
toast.error(t("contentManagement.copyPermissionsFailed")); isPermissionsError,
} finally { editUnitResponse,
setIsCopying(false); permissionsResponse,
} userOrganizationId,
}; form,
]);
const onSubmit = async (values: FormValues) => {
try { const handlePermissionChange = (permissionId: string, checked: boolean) => {
if (!selectedUnitId) { const currentPermissions = form.getValues("permissions");
toast.error(t("organization.selectUnit")); form.setValue(
return; "permissions",
} checked
? [...currentPermissions, permissionId]
const payload = { : currentPermissions.filter((id) => id !== permissionId),
name: { );
am: values.nameAm, };
en: values.nameEn,
}, const handleCopyFrom = async (sourcePositionTypeId: string) => {
key: values.nameEn.toLowerCase().replace(/\s+/g, "-"), setCopyFromPositionId(sourcePositionTypeId);
unitId: selectedUnitId, setIsCopying(true);
}; try {
const response =
let targetId = positionTypeId; await positionTypePermissionService.getPermissionsByPositionTypeId(
sourcePositionTypeId,
if (mode === "edit" && positionTypeId) { );
await updatePositionType.mutateAsync({ const ids = response.data.items?.map((p) => p.id) ?? [];
id: positionTypeId, form.setValue("permissions", ids);
data: payload, } catch (error) {
}); handleError(error);
} else { toast.error(t("contentManagement.copyPermissionsFailed"));
const response = await createPositionType.mutateAsync(payload); } finally {
targetId = response.data.id; setIsCopying(false);
} }
};
if (targetId && values.permissions.length > 0) {
await positionTypePermissionService.assignPermissionsToPositionType({ const onSubmit = async (values: FormValues) => {
firstId: targetId, const payload = {
secondIds: values.permissions, name: { am: values.nameAm, en: values.nameEn },
}); key: values.nameEn.toLowerCase().replace(/\s+/g, "-"),
} unitId: values.unitId,
};
queryClient.invalidateQueries({
queryKey: ["position-type"], // Save the position type first. If this fails nothing else runs, and the
}); // mutation's own onError surfaces the reason (403 for built-in types,
queryClient.invalidateQueries({ queryKey: ["position-types"] }); // conflict on the globally-unique key, ...).
queryClient.invalidateQueries({ let targetId = positionTypeId;
queryKey: ["position-type-permissions"], try {
}); if (mode === "edit" && positionTypeId) {
toast.success(t("contentManagement.permissionSuccess")); await updatePositionType.mutateAsync({
id: positionTypeId,
if (onSuccess) { data: payload,
onSuccess(); });
} else { } else {
navigate("/user-management/position-management"); const response = await createPositionType.mutateAsync(payload);
} targetId = response.data.id;
} catch { }
toast.error(t("contentManagement.permissionFailed")); } catch {
} return; // already reported by the mutation's onError
}; }
if (isLoadingEditData) { // assign-seconds-for-first replaces the whole set, but an empty secondIds
return ( // fails server-side — so "unassign everything" is not expressible. Keep the
<div className="py-8 text-center text-muted-foreground"> // save and tell the user their permissions were left alone.
{t("common.loading")} const mustClearAll =
</div> values.permissions.length === 0 && loadedPermissionCount.current > 0;
);
} if (targetId && values.permissions.length > 0) {
try {
return ( await positionTypePermissionService.assignPermissionsToPositionType({
<Form {...form}> firstId: targetId,
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> secondIds: values.permissions,
<FormField });
control={form.control} } catch (error) {
name="nameEn" handleError(error);
render={({ field }) => ( invalidatePositionTypeQueries(queryClient);
<FormItem> toast.error(t("contentManagement.permissionsAssignFailed"));
<FormLabel>{t("contentManagement.englishName")}</FormLabel> return;
<FormControl> }
<Input {...field} placeholder="e.g. HR Coordinator" /> }
</FormControl>
<FormMessage /> invalidatePositionTypeQueries(queryClient);
</FormItem> queryClient.invalidateQueries({ queryKey: ["position-type-permissions"] });
)}
/> if (mustClearAll) {
toast.warning(t("contentManagement.cannotClearAllPermissions"));
<FormField } else {
control={form.control} toast.success(t("contentManagement.permissionSuccess"));
name="nameAm" }
render={({ field }) => (
<FormItem> if (onSuccess) {
<FormLabel>{t("contentManagement.amharicName")}</FormLabel> onSuccess();
<FormControl> } else {
<Input {...field} placeholder="e.g. ሰብል አያት" /> navigate("/user-management/position-management");
</FormControl> }
<FormMessage /> };
</FormItem>
)} if (isLoadingEditData) {
/> return (
<div className="py-8 text-center text-muted-foreground">
{/* ✅ Organization (searchable, all orgs) */} {t("common.loading")}
<div className="mb-4 w-full"> </div>
<label className="block text-sm font-medium text-gray-700 mb-1"> );
{t("organization.organization") || "Organization"} }
</label>
<SingleSelect const selectedPermissionCount = form.watch("permissions").length;
options={organizationOptions} // form.formState.isSubmitting stays true for the whole async handler, so it
value={selectedOrganizationId} // also covers the permission-assignment call that follows the save.
onValueChange={setSelectedOrganizationId} const isBusy = form.formState.isSubmitting || isCopying;
placeholder={
isLoadingOrgs const copyFromPlaceholder = !selectedOrganizationId
? t("common.loading") ? t("contentManagement.selectOrganizationToCopy")
: t("organization.selectOrganization") || : isCopying || isLoadingPositionTypes || isLoadingUnits
"Select an organization" ? t("common.loading")
} : isErrorPositionTypes
/> ? t("contentManagement.failedToLoadPositionTypes")
</div> : t("contentManagement.selectPositionToCopy");
{/* ✅ Unit Selector — searchable, scoped to picked org */} return (
<div className="mb-4 w-full"> <Form {...form}>
<label className="block text-sm font-medium text-gray-700 mb-1"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{t("organization.selectUnit")} <FormField
</label> control={form.control}
<SingleSelect name="nameEn"
options={unitOptions} render={({ field }) => (
value={selectedUnitId} <FormItem>
onValueChange={setSelectedUnitId} <FormLabel>{t("contentManagement.englishName")}</FormLabel>
placeholder={ <FormControl>
!selectedOrganizationId <Input {...field} placeholder="e.g. HR Coordinator" />
? t("organization.selectOrganizationFirst") || </FormControl>
"Select an organization first" <FormMessage />
: isLoadingUnits </FormItem>
? t("common.loading") )}
: t("organization.selectUnit") />
}
/> <FormField
</div> control={form.control}
name="nameAm"
<div className="mb-4 w-1/2"> render={({ field }) => (
<label className="block text-sm font-medium text-gray-700"> <FormItem>
{t("contentManagement.selectApplication")} <FormLabel>{t("contentManagement.amharicName")}</FormLabel>
</label> <FormControl>
<Select <Input {...field} placeholder="e.g. ሰብል አያት" />
value={selectedApplicationId} </FormControl>
onValueChange={(value) => setSelectedApplicationId(value)} <FormMessage />
disabled={isLoadingApplications}> </FormItem>
<SelectTrigger className="mt-1 block w-full border-gray-300 rounded-md shadow-sm"> )}
<SelectValue placeholder="Select an Application" /> />
</SelectTrigger>
<SelectContent className="max-h-60 overflow-y-auto"> {/* Organization (searchable, all orgs) */}
{applications?.map((app) => ( <FormField
<SelectItem key={app.id} value={app.id}> control={form.control}
{lang === "en" ? app.name.en : app.name.am} name="organizationId"
</SelectItem> render={({ field }) => (
))} <FormItem>
</SelectContent> <FormLabel>{t("organization.organization")}</FormLabel>
</Select> <SingleSelect
</div> options={organizationOptions}
value={field.value}
<div className="space-y-2"> onValueChange={field.onChange}
<label className="block text-sm font-medium text-gray-700"> placeholder={
{t("contentManagement.copyPermissionsFrom")} isLoadingOrgs
</label> ? t("common.loading")
<Select : t("organization.selectOrganization")
value={copyFromPositionId} }
onValueChange={handleCopyFrom} />
disabled={isLoadingPositionTypes || isCopying}> <FormMessage />
<SelectTrigger className="w-full"> </FormItem>
<SelectValue )}
placeholder={ />
isCopying
? t("common.loading") {/* Unit Selector — searchable, scoped to picked org */}
: t("contentManagement.selectPositionToCopy") <FormField
} control={form.control}
/> name="unitId"
</SelectTrigger> render={({ field }) => (
<SelectContent className="max-h-60 overflow-y-auto"> <FormItem>
{positionTypes.map((p: PositionTypeDto) => ( <FormLabel>{t("organization.selectUnit")}</FormLabel>
<SelectItem key={p.id} value={p.id}> <SingleSelect
{localizedName(p.name)} options={unitOptions}
</SelectItem> value={field.value}
))} onValueChange={field.onChange}
</SelectContent> placeholder={
</Select> !selectedOrganizationId
<p className="text-xs text-muted-foreground"> ? t("organization.selectOrganizationFirst")
{t("contentManagement.copyPermissionsHint")} : isLoadingUnits
</p> ? t("common.loading")
</div> : t("organization.selectUnit")
}
<FormField />
control={form.control} <FormMessage />
name="permissions" </FormItem>
render={({ field }) => ( )}
<FormItem> />
<FormLabel>{t("contentManagement.permission")}</FormLabel>
<PermissionSearch <div className="mb-4 w-1/2">
selectedPermissions={field.value} <label className="block text-sm font-medium text-gray-700">
onPermissionChange={handlePermissionChange} {t("contentManagement.selectApplication")}
applicationId={selectedApplicationId} </label>
/> <Select
<FormMessage /> value={selectedApplicationId}
</FormItem> onValueChange={setSelectedApplicationId}
)} disabled={isLoadingApplications}>
/> <SelectTrigger className="mt-1 block w-full border-gray-300 rounded-md shadow-sm">
<SelectValue
<div className="flex justify-end gap-4"> placeholder={
<Button isLoadingApplications
type="button" ? t("common.loading")
variant="outline" : t("contentManagement.selectApplication")
onClick={() => { }
if (onCancel) { />
onCancel(); </SelectTrigger>
} else { <SelectContent className="max-h-60 overflow-y-auto">
window.history.back(); {applications?.map((app) => (
} <SelectItem key={app.id} value={app.id}>
}}> {localizedName(app.name)}
{t("common.Cancel")} </SelectItem>
</Button> ))}
<Button </SelectContent>
type="submit" </Select>
disabled={ </div>
createPositionType.isPending || updatePositionType.isPending
}> <div className="space-y-2">
{mode === "edit" ? t("delegation.update") : t("delegation.save")} <label className="block text-sm font-medium text-gray-700">
</Button> {t("contentManagement.copyPermissionsFrom")}
</div> </label>
</form> <Select
</Form> value={copyFromPositionId}
); onValueChange={handleCopyFrom}
}; disabled={
!selectedOrganizationId ||
isLoadingPositionTypes ||
isLoadingUnits ||
isCopying
}>
<SelectTrigger className="w-full">
<SelectValue placeholder={copyFromPlaceholder} />
</SelectTrigger>
<SelectContent className="max-h-60 overflow-y-auto">
{copyFromOptions.map((p: PositionTypeDto) => (
<SelectItem key={p.id} value={p.id}>
{localizedName(p.name)}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("contentManagement.copyPermissionsHint")}
</p>
</div>
<FormField
control={form.control}
name="permissions"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("contentManagement.permission")}
{selectedPermissionCount > 0 && (
<span className="ml-2 font-normal text-muted-foreground">
(
{t("contentManagement.permissionsSelected", {
count: selectedPermissionCount,
})}
)
</span>
)}
</FormLabel>
<PermissionSearch
selectedPermissions={field.value}
onPermissionChange={handlePermissionChange}
applicationId={selectedApplicationId}
/>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-4">
<Button
type="button"
variant="outline"
disabled={isBusy}
onClick={() => {
if (onCancel) {
onCancel();
} else {
window.history.back();
}
}}>
{t("common.Cancel")}
</Button>
<Button type="submit" disabled={isBusy}>
{form.formState.isSubmitting
? t("common.saving")
: mode === "edit"
? t("delegation.update")
: t("delegation.save")}
</Button>
</div>
</form>
</Form>
);
};

View File

@@ -1,160 +1,193 @@
import { useEffect, useState } from "react"; import { useState } from "react";
import { Button } from "@/shared/common/ui/button"; import { Button } from "@/shared/common/ui/button";
import { Input } from "@/shared/common/ui/input"; import { Input } from "@/shared/common/ui/input";
import { Label } from "@/shared/common/ui/label"; import { Label } from "@/shared/common/ui/label";
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/shared/common/ui/select"; } from "@/shared/common/ui/select";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useUnit } from "@/user-management/hooks/useUnit";
import { useAuth } from "@/shared/context/AuthContext"; import { useUnit } from "@/user-management/hooks/useUnit";
import { positionTypePermissionService } from "@/user-management/services/api/positionTypePermissionService"; import { useAuth } from "@/shared/context/AuthContext";
import { usePositionTypes } from "@/user-management/hooks/usePositionTypes"; import { positionTypePermissionService } from "@/user-management/services/api/positionTypePermissionService";
import { useApplications } from "@/user-management/hooks/useApplications"; import { usePositionTypes } from "@/user-management/hooks/usePositionTypes";
import { PermissionSearch } from "./PermissionSearch"; import { useApplications } from "@/user-management/hooks/useApplications";
import { PermissionDto } from "@/user-management/dto/permissions/permissonDto"; import { PermissionSearch } from "./PermissionSearch";
import { useLocalizedName } from "@/shared/common/localizedName"; import { useLocalizedName } from "@/shared/common/localizedName";
import { UnitDto } from "@/user-management/dto/unit/unitDto"; import { UnitDto } from "@/user-management/dto/unit/unitDto";
import { t } from "i18next"; import { t } from "i18next";
export const EditPositionForm = ({ id }: { id: string }) => { export const EditPositionForm = ({ id }: { id: string }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useAuth(); const { user } = useAuth();
const { getList } = useUnit(); const { getList } = useUnit();
const localizedName = useLocalizedName(); const localizedName = useLocalizedName();
const { positionType, isLoadingSingle } = usePositionTypes({ id }); const { positionType, isLoadingSingle, isErrorSingle } = usePositionTypes({
id,
const organizationId = });
user?.employee && user.employee.length > 0
? user.employee[0].organizationId const organizationId =
: undefined; user?.employee && user.employee.length > 0
? user.employee[0].organizationId
const { applications, isLoading: isLoadingApplications } = useApplications(); : undefined;
const { data: unitsResponse } = getList(organizationId || "", { const { applications, isLoading: isLoadingApplications } = useApplications();
take: 300,
skip: 0, const { data: unitsResponse } = getList(organizationId || "", {
}); take: 300,
skip: 0,
const [selectedApplicationId, setSelectedApplicationId] = });
useState<string>("");
const [assignedPermissions, setAssignedPermissions] = useState< const [selectedApplicationId, setSelectedApplicationId] =
PermissionDto[] useState<string>("");
>([]);
const [isLoadingPermissions, setIsLoadingPermissions] = useState(false); // Shares the cache key CreatePositionForm writes under, so editing a position
// type's permissions refreshes this view too.
useEffect(() => { const {
const load = async () => { data: assignedResponse,
if (!positionType) return; isLoading: isLoadingPermissions,
setIsLoadingPermissions(true); isError: isErrorPermissions,
try { } = useQuery({
const assigned = queryKey: ["position-type-permissions", id],
await positionTypePermissionService.getPermissionsByPositionTypeId( queryFn: () =>
positionType.id, positionTypePermissionService.getPermissionsByPositionTypeId(id),
); enabled: !!id,
setAssignedPermissions(assigned.data.items ?? []); });
} finally {
setIsLoadingPermissions(false); const assignedPermissions = assignedResponse?.data?.items ?? [];
}
}; if (isLoadingSingle) {
load(); return (
}, [positionType]); <p className="py-8 text-center text-muted-foreground">
{t("common.loading")}
if (isLoadingSingle) return <p>Loading...</p>; </p>
if (!positionType) return null; );
}
const unit = unitsResponse?.data?.items?.find(
(u: UnitDto) => u.id === positionType.unitId, if (isErrorSingle || !positionType) {
); return (
const unitName = unit ? unit.name.en || unit.name.am : positionType.unitId; <div className="space-y-4">
<p className="py-8 text-center text-red-500">
return ( {t("contentManagement.positionTypeNotFound")}
<div className="space-y-6"> </p>
<div className="space-y-2"> <div className="flex justify-end">
<Label>{t("contentManagement.englishName")}</Label> <Button
<Input value={positionType.name.en} disabled readOnly /> type="button"
</div> variant="outline"
onClick={() => navigate("/user-management/position-management")}>
<div className="space-y-2"> {t("common.Back")}
<Label>{t("contentManagement.amharicName")}</Label> </Button>
<Input value={positionType.name.am} disabled readOnly /> </div>
</div> </div>
);
<div className="space-y-2"> }
<Label>Key</Label>
<Input value={positionType.key} disabled readOnly /> const unit = unitsResponse?.data?.items?.find(
</div> (u: UnitDto) => u.id === positionType.unitId,
);
<div className="space-y-2"> const unitName = unit ? unit.name.en || unit.name.am : positionType.unitId;
<Label>{t("contentManagement.selectApplication")}</Label>
<Select return (
value={selectedApplicationId} <div className="space-y-6">
onValueChange={(value) => setSelectedApplicationId(value)} <div className="space-y-2">
disabled={isLoadingApplications} <Label>{t("contentManagement.englishName")}</Label>
> <Input value={positionType.name.en} disabled readOnly />
<SelectTrigger className="mt-1 block w-full border-gray-300 rounded-md shadow-sm"> </div>
<SelectValue placeholder="Select an Application" />
</SelectTrigger> <div className="space-y-2">
<SelectContent className="max-h-60 overflow-y-auto"> <Label>{t("contentManagement.amharicName")}</Label>
{applications?.map((app: any) => ( <Input value={positionType.name.am} disabled readOnly />
<SelectItem key={app.id} value={app.id}> </div>
{localizedName(app.name)}
</SelectItem> <div className="space-y-2">
))} <Label>{t("contentManagement.key")}</Label>
</SelectContent> <Input value={positionType.key} disabled readOnly />
</Select> </div>
</div>
<div className="space-y-2">
<div className="space-y-2"> <Label>{t("organization.selectUnit")}</Label>
<Label>{t("contentManagement.permission")}</Label> <Input value={unitName ?? ""} disabled readOnly />
{selectedApplicationId ? ( </div>
<PermissionSearch
selectedPermissions={assignedPermissions.map((perm) => perm.id)} <div className="space-y-2">
onPermissionChange={() => { <Label>{t("contentManagement.selectApplication")}</Label>
// view-only mode in edit form <Select
}} value={selectedApplicationId}
applicationId={selectedApplicationId} onValueChange={setSelectedApplicationId}
disabled disabled={isLoadingApplications}>
/> <SelectTrigger className="mt-1 block w-full border-gray-300 rounded-md shadow-sm">
) : ( <SelectValue
<div className="border rounded-md p-4 bg-background max-h-96 overflow-y-auto"> placeholder={
{isLoadingPermissions ? ( isLoadingApplications
<div className="text-center py-4 text-gray-500">Loading...</div> ? t("common.loading")
) : assignedPermissions.length === 0 ? ( : t("contentManagement.selectApplication")
<div className="text-center py-4 text-gray-500"> }
{t("contentManagement.noPermissionsAvailable")} />
</div> </SelectTrigger>
) : ( <SelectContent className="max-h-60 overflow-y-auto">
<ul className="grid grid-cols-1 md:grid-cols-2 gap-2"> {applications?.map((app) => (
{assignedPermissions.map((perm) => ( <SelectItem key={app.id} value={app.id}>
<li {localizedName(app.name)}
key={perm.id} </SelectItem>
className="capitalize text-sm py-1 px-2 rounded bg-muted/40" ))}
> </SelectContent>
{localizedName(perm.name)} </Select>
</li> </div>
))}
</ul> <div className="space-y-2">
)} <Label>{t("contentManagement.permission")}</Label>
</div> {selectedApplicationId ? (
)} <PermissionSearch
</div> selectedPermissions={assignedPermissions.map((perm) => perm.id)}
onPermissionChange={() => {
<div className="flex justify-end"> // view-only mode in edit form
<Button }}
type="button" applicationId={selectedApplicationId}
variant="outline" disabled
onClick={() => navigate("/user-management/position-management")} />
> ) : (
{t("common.Back")} <div className="border rounded-md p-4 bg-background max-h-96 overflow-y-auto">
</Button> {isLoadingPermissions ? (
</div> <div className="text-center py-4 text-gray-500">
</div> {t("common.loading")}
); </div>
}; ) : isErrorPermissions ? (
<div className="text-center py-4 text-red-500">
{t("contentManagement.failedToLoadPermissions")}
</div>
) : assignedPermissions.length === 0 ? (
<div className="text-center py-4 text-gray-500">
{t("contentManagement.noPermissionsAvailable")}
</div>
) : (
<ul className="grid grid-cols-1 md:grid-cols-2 gap-2">
{assignedPermissions.map((perm) => (
<li
key={perm.id}
className="capitalize text-sm py-1 px-2 rounded bg-muted/40">
{localizedName(perm.name)}
</li>
))}
</ul>
)}
</div>
)}
</div>
<div className="flex justify-end">
<Button
type="button"
variant="outline"
onClick={() => navigate("/user-management/position-management")}>
{t("common.Back")}
</Button>
</div>
</div>
);
};

View File

@@ -1,152 +1,120 @@
import React, { useState, useEffect, useMemo, useRef } from "react"; import React, { useState, useEffect } from "react";
import { Input } from "@/shared/common/ui/input"; import { Input } from "@/shared/common/ui/input";
import { Checkbox } from "@/shared/common/ui/checkbox"; import { Checkbox } from "@/shared/common/ui/checkbox";
import { usePermissionManager } from "@/user-management/hooks/usePermissionManager"; import { usePermissionManager } from "@/user-management/hooks/usePermissionManager";
import { PermissionDto } from "@/user-management/dto/permissions/permissonDto"; import { PermissionDto } from "@/user-management/dto/permissions/permissonDto";
import { useLocalizedName } from "@/shared/common/localizedName"; import { useLocalizedName } from "@/shared/common/localizedName";
import { t } from "i18next"; import { t } from "i18next";
import { Search, Loader2 } from "lucide-react"; import { Search, Loader2 } from "lucide-react";
interface PermissionSearchProps { interface PermissionSearchProps {
selectedPermissions: string[]; selectedPermissions: string[];
onPermissionChange: (permissionId: string, checked: boolean) => void; onPermissionChange: (permissionId: string, checked: boolean) => void;
applicationId?: string; applicationId?: string;
disabled?: boolean; disabled?: boolean;
} }
const INITIAL_TAKE = 50; // Initial number of items to fetch // One request per application. This used to fetch 50, read `count` off the
// response and immediately refetch with take = count — two round trips on every
export const PermissionSearch: React.FC<PermissionSearchProps> = ({ // mount for the same list.
selectedPermissions, const TAKE = 1000;
onPermissionChange,
applicationId, export const PermissionSearch: React.FC<PermissionSearchProps> = ({
disabled = false, selectedPermissions,
}) => { onPermissionChange,
const [searchTerm, setSearchTerm] = useState(""); applicationId,
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); disabled = false,
const [take, setTake] = useState(INITIAL_TAKE); // Start with 50 }) => {
const hasSetTotalCount = useRef(false); // Track if we've set the total count const [searchTerm, setSearchTerm] = useState("");
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
const scrollContainerRef = useRef<HTMLDivElement>(null);
const localizedName = useLocalizedName();
const localizedName = useLocalizedName();
/** ------------------ 1. Debounce Search ------------------ */
/** ------------------ 1. Debounce Search ------------------ */ useEffect(() => {
useEffect(() => { const timer = setTimeout(() => setDebouncedSearchTerm(searchTerm), 300);
const timer = setTimeout(() => { return () => clearTimeout(timer);
setDebouncedSearchTerm(searchTerm); }, [searchTerm]);
setTake(INITIAL_TAKE); // Reset to 50
hasSetTotalCount.current = false; // Reset the flag /** ------------------ 2. Fetch Permissions ------------------ */
}, 300); // The API does the filtering. Filtering the result again on the *undebounced*
// term used to blank the list for 300ms on every keystroke.
return () => clearTimeout(timer); const { permissions, isPermissionsLoading } = usePermissionManager({
}, [searchTerm]); params: applicationId
? {
/** ------------------ 2. Fetch Permissions ------------------ */ take: TAKE,
const { permissions, isPermissionsLoading } = usePermissionManager({ skip: 0,
params: applicationId search: debouncedSearchTerm || undefined,
? { applicationId,
take, }
skip: 0, // Always skip 0, we fetch everything at once : undefined,
search: debouncedSearchTerm || undefined, });
applicationId,
} const items = permissions?.items ?? [];
: undefined,
}); /** ------------------ Render ------------------ */
return (
/** ------------------ 3. Update take to total count after first fetch ------------------ */ <div className="space-y-4">
useEffect(() => { {/* Search Input */}
if ( <div className="relative">
permissions?.count && <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
!hasSetTotalCount.current && <Input
take !== permissions.count placeholder={t("contentManagement.searchPermissions")}
) { value={searchTerm}
hasSetTotalCount.current = true; onChange={(e) => setSearchTerm(e.target.value)}
setTake(permissions.count); // Fetch all items className="pl-10"
} />
}, [permissions?.count, take]); </div>
/** ------------------ 4. Client-side Filtering (Optional) ------------------ */ {/* Permission List Container */}
const filteredPermissions = useMemo(() => { {!applicationId ? (
if (!permissions?.items?.length) return []; <div className="max-h-96 overflow-y-auto border rounded-md p-4 bg-background text-center text-gray-500">
if (!searchTerm.trim()) return permissions.items; {t("contentManagement.selectApplicationToLoadPermissions")}
</div>
return permissions.items.filter((perm: PermissionDto) => { ) : isPermissionsLoading ? (
const name = localizedName(perm.name).toLowerCase(); <div className="flex justify-center py-10">
const key = perm.key.toLowerCase(); <Loader2 className="h-8 w-8 animate-spin text-gray-400" />
const search = searchTerm.toLowerCase(); </div>
return name.includes(search) || key.includes(search); ) : (
}); <div className="max-h-96 overflow-y-auto border rounded-md p-4 bg-background">
}, [permissions?.items, searchTerm, localizedName]); {items.length === 0 ? (
<div className="text-center py-4 text-gray-500">
/** ------------------ Render ------------------ */ {debouncedSearchTerm
return ( ? t("contentManagement.noPermissionsFound")
<div className="space-y-4"> : t("contentManagement.noPermissionsAvailable")}
{/* Search Input */} </div>
<div className="relative"> ) : (
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" /> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input {items.map((perm: PermissionDto) => (
placeholder={t("contentManagement.searchPermissions")} <div
value={searchTerm} key={perm.id}
onChange={(e) => setSearchTerm(e.target.value)} className="flex flex-row items-start space-x-3 space-y-0">
className="pl-10" <Checkbox
/> checked={selectedPermissions.includes(perm.id)}
</div> disabled={disabled}
onCheckedChange={(checked) => {
{/* Permission List Container */} if (!disabled) onPermissionChange(perm.id, !!checked);
{!applicationId ? ( }}
<div className="max-h-96 overflow-y-auto border rounded-md p-4 bg-background text-center text-gray-500"> />
{t("contentManagement.selectApplicationToLoadPermissions") || <label className="capitalize cursor-pointer font-normal text-sm">
"Select an application to load permissions."} {localizedName(perm.name)}
</div> </label>
) : isPermissionsLoading ? ( </div>
<div className="flex justify-center py-10"> ))}
<Loader2 className="h-8 w-8 animate-spin text-gray-400" /> </div>
</div> )}
) : ( </div>
<div )}
ref={scrollContainerRef}
className="max-h-96 overflow-y-auto border rounded-md p-4 bg-background" {/* Footer Info */}
> {items.length > 0 && (
{filteredPermissions.length === 0 ? ( <div className="text-xs text-muted-foreground px-1">
<div className="text-center py-4 text-gray-500"> {t("contentManagement.showingPermissions", {
{searchTerm count: items.length,
? t("contentManagement.noPermissionsFound") total: permissions?.count || 0,
: t("contentManagement.noPermissionsAvailable")} })}
</div> </div>
) : ( )}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> </div>
{filteredPermissions.map((perm: PermissionDto) => ( );
<div };
key={perm.id}
className="flex flex-row items-start space-x-3 space-y-0"
>
<Checkbox
checked={selectedPermissions.includes(perm.id)}
disabled={disabled}
onCheckedChange={(checked) => {
if (!disabled) onPermissionChange(perm.id, !!checked);
}}
/>
<label className="capitalize cursor-pointer font-normal text-sm">
{localizedName(perm.name)}
</label>
</div>
))}
</div>
)}
</div>
)}
{/* Footer Info */}
{filteredPermissions.length > 0 && (
<div className="text-xs text-muted-foreground px-1">
{t("contentManagement.showingPermissions", {
count: filteredPermissions.length,
total: permissions?.count || 0,
})}
</div>
)}
</div>
);
};

View File

@@ -1,276 +1,253 @@
import { useEffect, useState, useMemo } from "react"; import { useEffect, useState, useMemo, useCallback } from "react";
import { Button } from "@/shared/common/ui/button"; import { Button } from "@/shared/common/ui/button";
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable"; import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { import {
Card, Card,
CardContent, CardContent,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/shared/common/ui/card"; } from "@/shared/common/ui/card";
import { usePositionTypes } from "@/user-management/hooks/usePositionTypes"; import { usePositionTypes } from "@/user-management/hooks/usePositionTypes";
import { createPositionTypeColumns } from "./PositionTypeColumnDefn"; import { createPositionTypeColumns } from "./PositionTypeColumnDefn";
import { positionTypeService } from "@/user-management/services/api/positionTypesService"; import { positionTypeService } from "@/user-management/services/api/positionTypesService";
import { t } from "i18next"; import { t } from "i18next";
import { useUnit } from "@/user-management/hooks/useUnit"; import { toast } from "sonner";
import { useAuth } from "@/shared/context/AuthContext"; import { useUnit } from "@/user-management/hooks/useUnit";
import { import { useAuth } from "@/shared/context/AuthContext";
Select, import {
SelectContent, Select,
SelectItem, SelectContent,
SelectTrigger, SelectItem,
SelectValue, SelectTrigger,
} from "@/shared/common/ui/select"; SelectValue,
import { UnitDto } from "@/user-management/dto/unit/unitDto"; } from "@/shared/common/ui/select";
import { usePositionTypeConfiguration } from "@/user-management/hooks/usePostionType"; import { UnitDto } from "@/user-management/dto/unit/unitDto";
import { PositionTypeDto } from "@/user-management/dto/positions/positionType";
export default function PositionManagement() {
const [pageIndex, setPageIndex] = useState(0); export default function PositionManagement() {
const pageSize = 10; const [pageIndex, setPageIndex] = useState(0);
const [isExporting, setIsExporting] = useState<boolean>(false); const pageSize = 10;
const [searchTerm, setSearchTerm] = useState(""); const [isExporting, setIsExporting] = useState<boolean>(false);
const { createConfiguration } = usePositionTypeConfiguration(); const [searchTerm, setSearchTerm] = useState("");
const { user } = useAuth(); const { user } = useAuth();
const { getAccessibleList } = useUnit(); const { getAccessibleList } = useUnit();
const organizationId = user?.employee?.[0]?.organizationId; const organizationId = user?.employee?.[0]?.organizationId;
const { data: unitsResponse } = getAccessibleList(organizationId ?? "", { const { data: unitsResponse, isError: isUnitsError } = getAccessibleList(
take: 300, organizationId ?? "",
skip: 0, {
}); take: 300,
skip: 0,
// Add state for selected unitId },
// Default: if super_admin => "All", otherwise wait for units );
const [selectedUnitId, setSelectedUnitId] = useState<string>("All");
// Add state for selected unitId
useEffect(() => { // Default: if super_admin => "All", otherwise wait for units
// If theres no selectedUnitId yet, default to first unit (if any), otherwise keep "All" const [selectedUnitId, setSelectedUnitId] = useState<string>("All");
if (!selectedUnitId) {
if (unitsResponse?.data?.items?.length) { useEffect(() => {
setSelectedUnitId(unitsResponse.data.items[0].id); // If theres no selectedUnitId yet, default to first unit (if any), otherwise keep "All"
} else { if (!selectedUnitId) {
setSelectedUnitId("All"); if (unitsResponse?.data?.items?.length) {
} setSelectedUnitId(unitsResponse.data.items[0].id);
} } else {
}, [unitsResponse, selectedUnitId]); setSelectedUnitId("All");
}
// Reset to first page whenever the search term or unit changes so users }
// land on the first page of matches instead of an empty later page. }, [unitsResponse, selectedUnitId]);
useEffect(() => {
setPageIndex(0); // Reset to first page whenever the search term or unit changes so users
}, [searchTerm, selectedUnitId]); // land on the first page of matches instead of an empty later page.
useEffect(() => {
const handlePageChange = (newPage: number) => { setPageIndex(0);
setPageIndex(newPage); }, [searchTerm, selectedUnitId]);
};
const { const handlePageChange = (newPage: number) => {
positionTypeResponse, setPageIndex(newPage);
isLoading, };
positionTypeByUnitId,
refetch, const showingAllUnits = selectedUnitId === "All";
refetchPosition,
} = usePositionTypes({ const {
params: { positionTypeResponse,
take: 1000, isLoading,
skip: 0, isError,
orderBy: "updatedAt:DESC", positionTypeByUnitId,
}, isLoadingPosition,
unitId: selectedUnitId === "All" ? undefined : selectedUnitId, isErrorPosition,
}); refetch,
refetchPosition,
// Fetch position types without unitId for migration options } = usePositionTypes({
const { params: {
positionTypeResponse: globalPositionTypes, take: 1000,
refetch: refetchGlobalPositionTypes, skip: 0,
} = usePositionTypes({ orderBy: "updatedAt:DESC",
params: { },
take: 1000, // Get all global position types unitId: showingAllUnits ? undefined : selectedUnitId,
skip: 0, });
orderBy: "updatedAt:DESC",
}, // Refresh whichever list is on screen. `positionTypeResponse` is the
unitId: undefined, // Explicitly fetch position types without unitId // unscoped fetch, so it doubles as the migration-target source — no second
}); // usePositionTypes() call needed (its cache key ignores unitId, so a second
// call returned the very same query).
// Create a combined refetch function for the onDelete callback const handlePositionTypeChanged = useCallback(async () => {
const handlePositionTypeDeleted = async () => { await (showingAllUnits ? refetch() : refetchPosition());
await Promise.all([ }, [showingAllUnits, refetch, refetchPosition]);
selectedUnitId === "All" ? refetch() : refetchPosition(),
refetchGlobalPositionTypes(), const columns = useMemo(
]); () =>
}; createPositionTypeColumns(
const handleToggle = async ( positionTypeResponse,
positionTypeId: string, handlePositionTypeChanged,
checked: boolean, handlePositionTypeChanged,
field: "canReceiveRecord" | "canAssignRecord" | "canCreateBankRecord", ),
) => { [positionTypeResponse, handlePositionTypeChanged],
if (!selectedUnitId || selectedUnitId === "All") return; );
await createConfiguration({ const allItems = useMemo(
positionTypeId, () =>
timeframe: "yearly", (showingAllUnits
organizationId: organizationId!, ? positionTypeResponse?.items
canReceiveRecord: field === "canReceiveRecord" ? checked : false, : positionTypeByUnitId?.items) || [],
canAssignRecord: field === "canAssignRecord" ? checked : false, [showingAllUnits, positionTypeResponse?.items, positionTypeByUnitId?.items],
canCreateBankRecord: field === "canCreateBankRecord" ? checked : false, );
});
const filteredItems = useMemo(() => {
await handlePositionTypeDeleted(); const trimmed = searchTerm.trim().toLowerCase();
}; if (!trimmed) return allItems;
return allItems.filter((item: PositionTypeDto) => {
// Create columns with positionTypeResponse const en = (item?.name?.en || "").toLowerCase();
const columns = useMemo( const am = (item?.name?.am || "").toLowerCase();
() => const key = (item?.key || "").toLowerCase();
createPositionTypeColumns( return (
selectedUnitId === "All" ? positionTypeResponse : positionTypeByUnitId, en.includes(trimmed) || am.includes(trimmed) || key.includes(trimmed)
globalPositionTypes, );
handlePositionTypeDeleted, });
handlePositionTypeDeleted, }, [allItems, searchTerm]);
handleToggle, // ← pass toggle handler
selectedUnitId === "All", // ← isGlobal: hide toggle when "All" const paginatedItems = useMemo(() => {
), const start = pageIndex * pageSize;
[ return filteredItems.slice(start, start + pageSize);
selectedUnitId, }, [filteredItems, pageIndex, pageSize]);
positionTypeResponse,
positionTypeByUnitId, // Track whichever query is actually feeding the table — picking a unit used
globalPositionTypes, // to leave the previous unit's rows on screen with no loading state.
], const isLoadingList = showingAllUnits ? isLoading : isLoadingPosition;
); const isErrorList = showingAllUnits ? isError : isErrorPosition;
const allItems = useMemo(
() => const exportTypes = () => {
(selectedUnitId === "All" setIsExporting(true);
? positionTypeResponse?.items positionTypeService
: positionTypeByUnitId?.items) || [], .getAll({ take: 3000 })
[selectedUnitId, positionTypeResponse?.items, positionTypeByUnitId?.items], .then((allPositionKeys) => {
); const positionTypeKeys = allPositionKeys.data?.items?.map((p) => p.key);
const filteredItems = useMemo(() => { if (!positionTypeKeys?.length) {
const trimmed = searchTerm.trim().toLowerCase(); toast.error(t("contentManagement.exportFailed"));
if (!trimmed) return allItems; return;
return allItems.filter((item: any) => { }
const en = (item?.name?.en || "").toLowerCase();
const am = (item?.name?.am || "").toLowerCase(); // One key per line, downloaded as a plain text file.
const key = (item?.key || "").toLowerCase(); const blob = new Blob([positionTypeKeys.join("\n")], {
return ( type: "text/plain",
en.includes(trimmed) || am.includes(trimmed) || key.includes(trimmed) });
); const link = document.createElement("a");
}); link.href = URL.createObjectURL(blob);
}, [allItems, searchTerm]); link.download = "position_keys.txt";
link.click();
const paginatedItems = useMemo(() => { URL.revokeObjectURL(link.href);
const start = pageIndex * pageSize; })
return filteredItems.slice(start, start + pageSize); .catch(() => {
}, [filteredItems, pageIndex, pageSize]); toast.error(t("contentManagement.exportFailed"));
})
if (isLoading) { .finally(() => {
return <div>{t("contentManagement.addUser")}</div>; setIsExporting(false);
} });
};
const exportTypes = () => {
setIsExporting(true); return (
positionTypeService <div className="p-6 space-y-6">
.getAll({ <Card className="py-0 col-span-2 shadow-none border-none bg-transparent px-0">
take: 3000, <CardHeader className="flex flex-row justify-between items-center px-0">
}) <CardTitle className="text-xl font-semibold ">
.then((allPositionKeys) => { {t("contentManagement.permissionType")}
// Get the position type keys </CardTitle>
const positionTypeKeys = allPositionKeys.data?.items?.map((p) => p.key); <Button onClick={exportTypes} disabled={isExporting}>
{isExporting
if (positionTypeKeys && positionTypeKeys.length > 0) { ? t("contentManagement.exporting")
// Convert the array of keys into a string, with each key on a new line : t("contentManagement.exportTypes")}
const fileContent = positionTypeKeys.join("\n"); </Button>
</CardHeader>
// Create a Blob from the string content {!!unitsResponse?.data?.items?.length && (
const blob = new Blob([fileContent], { type: "text/plain" }); <div className="mb-4 w-1/2">
<label className="block text-sm font-medium text-gray-700">
// Create a link element to trigger the download {t("organization.selectUnit")}
const link = document.createElement("a"); </label>
<Select
// Create an object URL for the Blob value={selectedUnitId}
link.href = URL.createObjectURL(blob); onValueChange={(value) => setSelectedUnitId(value)}>
<SelectTrigger className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm [&>span]:truncate">
// Set the download attribute with a file name <SelectValue placeholder={t("organization.selectUnit")} />
link.download = "position_keys.txt"; </SelectTrigger>
<SelectContent>
// Programmatically trigger a click on the link to start the download <SelectItem key="all" value="All">
link.click(); {t("common.all")}
</SelectItem>
// Clean up by revoking the object URL {unitsResponse.data.items.map((unit: UnitDto) => (
URL.revokeObjectURL(link.href); <SelectItem key={unit.id} value={unit.id}>
} else { <span className="block truncate max-w-70">
console.error("No position type keys found."); {unit.name.en || unit.name.am}
} </span>
setIsExporting(false); </SelectItem>
}); ))}
}; </SelectContent>
</Select>
return ( </div>
<div className="p-6 space-y-6"> )}
<Card className="py-0 col-span-2 shadow-none border-none bg-transparent px-0"> {isUnitsError && (
<CardHeader className="flex flex-row justify-between items-center px-0"> <p className="mb-4 text-sm text-red-500">
<CardTitle className="text-xl font-semibold "> {t("organization.errorLoadingUnits")}
{t("contentManagement.permissionType")} </p>
</CardTitle> )}
<Button onClick={exportTypes}> <CardContent className="px-0">
{isExporting {isLoadingList ? (
? t("contentManagement.exporting") <div className="py-10 text-center text-muted-foreground">
: t("contentManagement.exportTypes")} {t("common.loading")}
</Button> </div>
</CardHeader> ) : isErrorList ? (
{unitsResponse?.data?.items?.length > 0 && ( <div className="py-10 text-center text-red-500">
<div className="mb-4 w-1/2"> {t("contentManagement.failedToLoadPositionTypes")}
<label className="block text-sm font-medium text-gray-700"> </div>
Select Unit ) : (
</label> <AdvancedTable
<Select columns={columns}
value={selectedUnitId} data={paginatedItems}
onValueChange={(value) => setSelectedUnitId(value)} tableName="Positions"
> toolBarPosition="right"
<SelectTrigger className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm [&>span]:truncate"> itemCount={filteredItems.length}
<SelectValue placeholder="Select a Unit" /> onGlobalFilterChange={setSearchTerm}
</SelectTrigger> extraToolbar={
<SelectContent> <Link to="/user-management/position-management/new">
<SelectItem key="all" value="All"> <Button className="px-5 py-2 rounded-md text-sm font-medium shadow-md">
All <Plus className="w-4 h-4 mr-2" />
</SelectItem> {t("contentManagement.newPermission")}
{unitsResponse?.data.items.map((unit: UnitDto) => ( </Button>
<SelectItem key={unit.id} value={unit.id}> </Link>
<span className="block truncate max-w-70"> }
{unit.name.en || unit.name.am} pageIndex={pageIndex}
</span> onPageChange={handlePageChange}
</SelectItem> nextFunction={() => handlePageChange(pageIndex + 1)}
))} prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))}
</SelectContent> />
</Select> )}
</div> </CardContent>
)} </Card>
<CardContent className="px-0"> </div>
<AdvancedTable );
columns={columns} }
data={paginatedItems}
tableName="Positions"
toolBarPosition="right"
itemCount={filteredItems.length}
onGlobalFilterChange={setSearchTerm}
extraToolbar={
<Link to="/user-management/position-management/new">
<Button className="px-5 py-2 rounded-md text-sm font-medium shadow-md">
<Plus className="w-4 h-4 mr-2" />
{t("contentManagement.newPermission")}
</Button>
</Link>
}
pageIndex={pageIndex}
onPageChange={handlePageChange}
nextFunction={() => handlePageChange(pageIndex + 1)}
prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))}
/>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,393 +1,251 @@
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { PositionTypeDto } from "@/user-management/dto/positions/positionType"; import { PositionTypeDto } from "@/user-management/dto/positions/positionType";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuTrigger, DropdownMenuTrigger,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuItem, DropdownMenuItem,
} from "@/shared/common/ui/dropdown-menu"; } from "@/shared/common/ui/dropdown-menu";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
AlertDialogCancel, AlertDialogCancel,
AlertDialogContent, AlertDialogContent,
AlertDialogDescription, AlertDialogDescription,
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "@/shared/common/ui/alert-dialog"; } from "@/shared/common/ui/alert-dialog";
import { Button } from "@/shared/common/ui/button"; import { Button } from "@/shared/common/ui/button";
import { MoreVertical, Edit, Eye, Trash2, Pencil } from "lucide-react"; import { MoreVertical, Edit, Eye, Trash2, Pencil } from "lucide-react";
import { t } from "i18next"; import { t } from "i18next";
import PositionTypeMigrationModal from "./PostionTypeMigration"; import PositionTypeMigrationModal from "./PostionTypeMigration";
import { CreatePositionForm } from "./CreatePositionForm"; import { CreatePositionForm } from "./CreatePositionForm";
import { toast } from "sonner"; import { useLocalizedName } from "@/shared/common/localizedName";
import { useLocalizedName } from "@/shared/common/localizedName"; import { usePositionTypes } from "@/user-management/hooks/usePositionTypes";
import { positionTypeService } from "@/user-management/services/api/positionTypesService"; import {
import { useErrorHandler } from "@/shared/hooks/useErrorHandler"; Dialog,
import { usePositionTypeConfiguration } from "@/user-management/hooks/usePostionType"; DialogContent,
import { Switch } from "@/shared/common/ui/switch"; DialogHeader,
import { PositionTypeConfigurationDto } from "@/user-management/services/api/positionTypeConfigurationService"; DialogTitle,
import { useQueryClient } from "@tanstack/react-query"; } from "@/shared/common/ui/dialog";
import {
Dialog, interface PositionTypeResponse {
DialogContent, items: PositionTypeDto[];
DialogHeader, count: number;
DialogTitle, }
} from "@/shared/common/ui/dialog";
type ActionsCellProps = {
interface PositionTypeResponse { row: PositionTypeDto;
items: PositionTypeDto[]; globalPositionTypes?: PositionTypeResponse;
count: number; onDelete?: () => void | Promise<void>;
} onEdit?: () => void | Promise<void>;
};
type ActionsCellProps = {
row: PositionTypeDto | PositionTypeConfigurationDto; /*
globalPositionTypes?: PositionTypeResponse; * TODO(record-toggles): this menu used to carry CanReceiveRecord /
onDelete?: () => void | Promise<void>; * CanAssignRecord / CanCreateBankRecord switches. They never worked. IAM's
onEdit?: () => void | Promise<void>; * PositionTypeConfiguration entity only has { id, organizationId,
onToggle?: ( * positionTypeId, timeframe } — verified against every local build (0.7.4
positionTypeId: string, * through 0.7.12) and the live swagger. canAssignRecord and
checked: boolean, * canCreateBankRecord do not exist anywhere in the IAM package, and the global
field: "canReceiveRecord" | "canAssignRecord" | "canCreateBankRecord", * ValidationPipe runs with forbidNonWhitelisted, so every write 400'd. The
) => void | Promise<void>; * reads were broken too: the list route filters on organizationId (the repo is
isGlobal?: boolean; // true when viewing "All" units — hide toggle * built as TExtraCrudRepository(repo, "organizationId")) while the UI passed a
}; * positionTypeId, so it always came back empty.
*
const PositionTypeActionsCell: React.FC<ActionsCellProps> = ({ * The flag that does exist is PositionConfiguration.canReceiveRecord, keyed by
row, * positionId — a per-position setting served by /api/position-configurations,
globalPositionTypes, * not a per-position-type one. Restoring this needs either that endpoint and a
onDelete, * position-level UI, or new columns on PositionTypeConfiguration in IAM.
onEdit, */
onToggle, const PositionTypeActionsCell: React.FC<ActionsCellProps> = ({
isGlobal = false, row,
}) => { globalPositionTypes,
const navigate = useNavigate(); onDelete,
const [dropdownOpen, setDropdownOpen] = useState(false); onEdit,
const [showMigrateDialog, setShowMigrateDialog] = useState(false); }) => {
const [showEditDialog, setShowEditDialog] = useState(false); const navigate = useNavigate();
const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false); const [showMigrateDialog, setShowMigrateDialog] = useState(false);
const localizedName = useLocalizedName(); const [showEditDialog, setShowEditDialog] = useState(false);
const { handleError } = useErrorHandler(t); const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const queryClient = useQueryClient(); const localizedName = useLocalizedName();
// Use row.id as the positionTypeId for the configuration lookup const { deletePositionType } = usePositionTypes();
const { // Create position type options from globalPositionTypes - only those WITHOUT unitId
configurations, const positionTypeOptions =
isLoadingConfigurations, globalPositionTypes?.items
updateConfiguration, .filter((item) => !item.unitId)
isUpdatingConfiguration, .map((item) => ({
} = usePositionTypeConfiguration( label: localizedName(item.name),
row?.id ?? null, // 👈 pass row.id as unitId value: item.id,
); })) || [];
const configItem = configurations[0]; // Only show migrate/delete actions if current row has a unitId
const isCanReceiveRecord = configItem?.canReceiveRecord ?? false; const canBeModified = !!row.unitId;
const isCanAssignRecord = configItem?.canAssignRecord ?? false;
const isCanCreateBankRecord = configItem?.canCreateBankRecord ?? false; const handleView = () => {
navigate(`/user-management/position-management/edit/${row.id}`);
const invalidateConfig = () => { };
queryClient.invalidateQueries({
queryKey: ["positionTypeConfigurations", row.id], const handleMigrate = (e: Event) => {
}); e.preventDefault();
queryClient.invalidateQueries({ setDropdownOpen(false);
queryKey: ["positionTypeConfiguration", row.id], setShowMigrateDialog(true);
}); };
};
const handleEdit = (e: Event) => {
// Create position type options from globalPositionTypes - only those WITHOUT unitId e.preventDefault();
const positionTypeOptions = setDropdownOpen(false);
globalPositionTypes?.items setShowEditDialog(true);
.filter((item) => !item.unitId) };
.map((item) => ({
label: localizedName(item.name), // Goes through the mutation rather than the service directly, so the cache is
value: item.id, // invalidated and IAM's 403 for built-in types reaches the user.
})) || []; const handleDelete = async () => {
try {
// Only show migrate/delete actions if current row has a unitId await deletePositionType.mutateAsync(row.id);
const canBeModified = !!row.unitId; setShowDeleteDialog(false);
await onDelete?.();
const handleView = () => { } catch {
navigate(`/user-management/position-management/edit/${row.id}`); // reported by the mutation's onError
}; }
};
const handleMigrate = (e: Event) => {
e.preventDefault(); return (
setDropdownOpen(false); <>
setShowMigrateDialog(true); <DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
}; <DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
const handleEdit = (e: Event) => { <MoreVertical className="h-4 w-4" />
e.preventDefault(); <span className="sr-only">Open actions menu</span>
setDropdownOpen(false); </Button>
setShowEditDialog(true); </DropdownMenuTrigger>
};
<DropdownMenuContent
const handleDelete = async () => { align="end"
try { onInteractOutside={(e) => {
setIsDeleting(true); const target = e.target as HTMLElement;
await positionTypeService.delete(row.id); if (!target.closest('[role="dialog"]')) {
toast.success(t("common.DeletedSuccessfully")); setDropdownOpen(false);
setShowDeleteDialog(false); }
if (onDelete) { }}>
await onDelete(); <DropdownMenuLabel>{t("userRecord.Actions")}</DropdownMenuLabel>
}
} catch (error) { {canBeModified && (
handleError(error); <DropdownMenuItem
toast.error(t("common.FailedToDelete")); onSelect={handleMigrate}
} finally { className="cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200">
setIsDeleting(false); <Edit className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" />
} <span> {t("common.Migrate")}</span>
}; </DropdownMenuItem>
)}
const handleToggleChange = async (checked: boolean) => {
if (isGlobal) return; {canBeModified && (
try { <DropdownMenuItem
if (configItem?.id) { onSelect={handleEdit}
await updateConfiguration({ className="cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200">
id: configItem.id, <Pencil className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" />
payload: { <span>{t("common.Edit")}</span>
organizationId: configItem.organizationId, </DropdownMenuItem>
positionTypeId: configItem.positionTypeId, )}
timeframe: configItem.timeframe,
canReceiveRecord: checked, <DropdownMenuItem
}, onSelect={handleView}
}); className="cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200">
} else { <Eye className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" />
await onToggle?.(row.id, checked, "canReceiveRecord"); <span>{t("common.View")}</span>
} </DropdownMenuItem>
toast.success(t("incomingRecord.UpdatedSuccessfully"));
invalidateConfig(); {canBeModified && (
} catch (error) { <DropdownMenuItem
handleError(error); onSelect={() => {
toast.error(t("incomingRecord.FailedToUpdate")); setDropdownOpen(false);
} setShowDeleteDialog(true);
}; }}
className="cursor-pointer hover:!text-red-500 !bg-transparent !transition-colors duration-200">
const handleAssignToggleChange = async (checked: boolean) => { <Trash2 className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" />
if (isGlobal) return; <span>{t("common.Delete")}</span>
try { </DropdownMenuItem>
if (configItem?.id) { )}
await updateConfiguration({ </DropdownMenuContent>
id: configItem.id, </DropdownMenu>
payload: {
organizationId: configItem.organizationId, {showMigrateDialog && (
positionTypeId: configItem.positionTypeId, <PositionTypeMigrationModal
timeframe: configItem.timeframe, isOpen={showMigrateDialog}
canAssignRecord: checked, onClose={() => {
}, setShowMigrateDialog(false);
}); }}
} else { toId={row.id}
await onToggle?.(row.id, checked, "canAssignRecord"); toName={localizedName(row.name)}
} positionTypeOptions={positionTypeOptions}
toast.success(t("incomingRecord.UpdatedSuccessfully")); />
invalidateConfig(); )}
} catch (error) {
handleError(error); <Dialog open={showEditDialog} onOpenChange={setShowEditDialog}>
toast.error(t("incomingRecord.FailedToUpdate")); <DialogContent className="sm:max-w-3xl max-h-[90vh] flex flex-col p-6">
} <DialogHeader className="pb-4">
}; <DialogTitle>{t("common.Edit")}</DialogTitle>
</DialogHeader>
const handleCreateBankRecordToggleChange = async (checked: boolean) => { <div className="flex-1 overflow-y-auto pr-2 min-h-0">
if (isGlobal) return; {showEditDialog && (
try { <CreatePositionForm
if (configItem?.id) { key={row.id + "-edit"}
await updateConfiguration({ mode="edit"
id: configItem.id, positionTypeId={row.id}
payload: { initialValues={{
organizationId: configItem.organizationId, nameAm: row.name.am,
positionTypeId: configItem.positionTypeId, nameEn: row.name.en,
timeframe: configItem.timeframe, unitId: row.unitId ?? "",
canCreateBankRecord: checked, key: row.key,
}, }}
}); onSuccess={async () => {
} else { setShowEditDialog(false);
await onToggle?.(row.id, checked, "canCreateBankRecord"); if (onEdit) {
} await onEdit();
toast.success(t("incomingRecord.UpdatedSuccessfully")); }
invalidateConfig(); }}
} catch (error) { onCancel={() => setShowEditDialog(false)}
handleError(error); />
toast.error(t("incomingRecord.FailedToUpdate")); )}
} </div>
}; </DialogContent>
const rowName = "name" in row ? row.name : { am: "", en: "" }; </Dialog>
const isPositionType = "name" in row && "key" in row;
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
return ( <AlertDialogContent>
<> <AlertDialogHeader>
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}> <AlertDialogTitle>{t("common.ConfirmDelete")}</AlertDialogTitle>
<DropdownMenuTrigger asChild> <AlertDialogDescription>
<Button variant="ghost" className="h-8 w-8 p-0"> {t("common.DeleteConfirmationMessage", {
<MoreVertical className="h-4 w-4" /> name: localizedName(row.name),
<span className="sr-only">Open actions menu</span> })}
</Button> </AlertDialogDescription>
</DropdownMenuTrigger> </AlertDialogHeader>
<AlertDialogFooter>
<DropdownMenuContent <AlertDialogCancel disabled={deletePositionType.isPending}>
align="end" {t("common.Cancel")}
onInteractOutside={(e) => { </AlertDialogCancel>
const target = e.target as HTMLElement; <AlertDialogAction
if (!target.closest('[role="dialog"]')) { onClick={handleDelete}
setDropdownOpen(false); disabled={deletePositionType.isPending}
} className="bg-red-500 hover:bg-red-600">
}}> {deletePositionType.isPending
<DropdownMenuLabel>Actions</DropdownMenuLabel> ? t("common.Deleting")
: t("common.Delete")}
{canBeModified && ( </AlertDialogAction>
<DropdownMenuItem </AlertDialogFooter>
onSelect={handleMigrate} </AlertDialogContent>
className="cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200"> </AlertDialog>
<Edit className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" /> </>
<span> {t("common.Migrate")}</span> );
</DropdownMenuItem> };
)}
export default PositionTypeActionsCell;
{canBeModified && isPositionType && (
<DropdownMenuItem
onSelect={handleEdit}
className="cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200">
<Pencil className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" />
<span>{t("common.Edit")}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
onSelect={handleView}
className="cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200">
<Eye className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" />
<span>{t("common.View")}</span>
</DropdownMenuItem>
{canBeModified && (
<DropdownMenuItem
onSelect={() => {
setDropdownOpen(false);
setShowDeleteDialog(true);
}}
className="cursor-pointer hover:!text-red-500 !bg-transparent !transition-colors duration-200">
<Trash2 className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" />
<span>{t("common.Delete")}</span>
</DropdownMenuItem>
)}
{/* Toggle moved here from ToggleCell */}
{!isGlobal && (
<div className="px-2 py-2 border-t mt-1">
<div className="flex items-center justify-between">
<span className="text-sm">
{t("contentManagement.CanReceiveRecord")}
</span>
<Switch
checked={isCanReceiveRecord}
disabled={isLoadingConfigurations || isUpdatingConfiguration}
onCheckedChange={handleToggleChange}
/>
</div>
</div>
)}
{!isGlobal && (
<div className="px-2 py-2 border-t mt-1">
<div className="flex items-center justify-between">
<span className="text-sm">
{t("contentManagement.CanAssignRecord")}
</span>
<Switch
checked={isCanAssignRecord}
disabled={isLoadingConfigurations || isUpdatingConfiguration}
onCheckedChange={handleAssignToggleChange}
/>
</div>
</div>
)}
{!isGlobal && (
<div className="px-2 py-2 border-t mt-1">
<div className="flex items-center justify-between gap-4">
<span className="text-sm">
{t("contentManagement.CanCreateBankRecord")}
</span>
<Switch
checked={isCanCreateBankRecord}
disabled={isLoadingConfigurations || isUpdatingConfiguration}
onCheckedChange={handleCreateBankRecordToggleChange}
/>
</div>
</div>
)}
</DropdownMenuContent>
</DropdownMenu>
{showMigrateDialog && (
<PositionTypeMigrationModal
isOpen={showMigrateDialog}
onClose={() => {
setShowMigrateDialog(false);
}}
toId={row.id}
toName={localizedName(rowName)}
positionTypeOptions={positionTypeOptions}
/>
)}
<Dialog open={showEditDialog} onOpenChange={setShowEditDialog}>
<DialogContent className="sm:max-w-3xl max-h-[90vh] flex flex-col p-6">
<DialogHeader className="pb-4">
<DialogTitle>{t("common.Edit")}</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto pr-2 min-h-0">
{isPositionType && showEditDialog && (
<CreatePositionForm
key={row.id + "-edit"}
mode="edit"
positionTypeId={row.id}
initialValues={{
nameAm: row.name.am,
nameEn: row.name.en,
unitId: row.unitId,
key: row.key,
}}
onSuccess={async () => {
setShowEditDialog(false);
if (onEdit) {
await onEdit();
}
}}
onCancel={() => setShowEditDialog(false)}
/>
)}
</div>
</DialogContent>
</Dialog>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("common.ConfirmDelete")}</AlertDialogTitle>
<AlertDialogDescription>
{t("common.DeleteConfirmationMessage", {
defaultValue: `Are you sure you want to delete "${localizedName(rowName)}"? This action cannot be undone.`,
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.Cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
className="bg-red-500 hover:bg-red-600">
{isDeleting ? t("common.Deleting") : t("common.Delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
};
export default PositionTypeActionsCell;

View File

@@ -16,16 +16,9 @@ const NameCell = ({ name }: { name: PositionTypeDto["name"] }) => {
}; };
export const createPositionTypeColumns = ( export const createPositionTypeColumns = (
_positionTypeResponse?: PositionTypeResponse,
globalPositionTypes?: PositionTypeResponse, globalPositionTypes?: PositionTypeResponse,
onDelete?: () => void | Promise<void>, onDelete?: () => void | Promise<void>,
onEdit?: () => void | Promise<void>, onEdit?: () => void | Promise<void>,
onToggle?: (
positionTypeId: string,
checked: boolean,
field: "canReceiveRecord" | "canAssignRecord" | "canCreateBankRecord",
) => void | Promise<void>,
isGlobal?: boolean,
): ColumnDef<PositionTypeDto>[] => [ ): ColumnDef<PositionTypeDto>[] => [
{ {
accessorKey: "name", accessorKey: "name",
@@ -64,8 +57,6 @@ export const createPositionTypeColumns = (
globalPositionTypes={globalPositionTypes} globalPositionTypes={globalPositionTypes}
onDelete={onDelete} onDelete={onDelete}
onEdit={onEdit} onEdit={onEdit}
onToggle={onToggle}
isGlobal={isGlobal}
/> />
), ),
}, },

View File

@@ -5,10 +5,14 @@ export interface PositionTypeDto {
en: string; en: string;
}; };
key: string; key: string;
unitId: string; /**
canReceiveRecord: boolean; * Null for the built-in ("common") types, which `isSystem` marks and which
canCreateBankRecord?: boolean; * every unit can use. IAM has no organizationId on a position type — the
canAssignRecord: boolean; * owning organization is only reachable via unit -> organizationId.
*/
unitId: string | null;
/** Built-in type. IAM rejects update/delete on these with a 403. */
isSystem?: boolean;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }

View File

@@ -1,4 +1,9 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import {
QueryClient,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { import {
CreatePositionTypePayload, CreatePositionTypePayload,
PositionRequest, PositionRequest,
@@ -23,10 +28,22 @@ interface positionParams {
interface UsePositionTypeManagerProps { interface UsePositionTypeManagerProps {
id?: string; id?: string;
unitId?: string; unitId?: string;
organizationId?: string;
params?: positionParams; // 👈 we expected query params to be passed like this 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 = ({ export const usePositionTypes = ({
id, id,
params = { params = {
@@ -35,11 +52,11 @@ export const usePositionTypes = ({
orderBy: "createdAt:Desc", orderBy: "createdAt:Desc",
}, },
unitId, unitId,
organizationId,
}: UsePositionTypeManagerProps = {}) => { }: UsePositionTypeManagerProps = {}) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { t } = useTranslation(); const { t } = useTranslation();
const { handleError } = useErrorHandler(t); const { handleError } = useErrorHandler(t);
const invalidateAll = () => invalidatePositionTypeQueries(queryClient);
const { data, isLoading, isError, refetch } = useQuery({ const { data, isLoading, isError, refetch } = useQuery({
queryKey: ["position-types", params], queryKey: ["position-types", params],
queryFn: () => positionTypeService.getAll(params).then((res) => res.data), queryFn: () => positionTypeService.getAll(params).then((res) => res.data),
@@ -70,38 +87,6 @@ export const usePositionTypes = ({
enabled: !!unitId, 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) // Common types with unit ID (includes both unit-specific and common types)
const { const {
data: commonPositionTypes, data: commonPositionTypes,
@@ -123,15 +108,16 @@ export const usePositionTypes = ({
mutationFn: (payload: CreatePositionTypePayload) => mutationFn: (payload: CreatePositionTypePayload) =>
positionTypeService.create(payload), positionTypeService.create(payload),
onSuccess: () => { onSuccess: () => {
toast.success("Position type created"); toast.success(t("contentManagement.positionTypeCreated"));
queryClient.invalidateQueries({ queryKey: ["position-types"] }); invalidateAll();
}, },
onError: (error) => { onError: (error) => {
handleError(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({ const updatePositionType = useMutation({
mutationFn: ({ mutationFn: ({
id, id,
@@ -141,11 +127,12 @@ export const usePositionTypes = ({
data: UpdatePositionTypePayload; data: UpdatePositionTypePayload;
}) => positionTypeService.update(id, data), }) => positionTypeService.update(id, data),
onSuccess: () => { onSuccess: () => {
toast.success("Position type updated"); toast.success(t("contentManagement.positionTypeUpdated"));
queryClient.invalidateQueries({ queryKey: ["position-types"] }); invalidateAll();
queryClient.invalidateQueries({ queryKey: ["position-type", id] }); },
onError: (error) => {
handleError(error);
}, },
onError: () => {},
}); });
//update positon from to //update positon from to
@@ -153,30 +140,32 @@ export const usePositionTypes = ({
mutationFn: ({ toId, fromId }: { toId: string; fromId: string }) => mutationFn: ({ toId, fromId }: { toId: string; fromId: string }) =>
positionTypeService.updateFromto(toId, fromId), positionTypeService.updateFromto(toId, fromId),
onSuccess: () => { onSuccess: () => {
toast.success("Position type migration updated"); toast.success(t("contentManagement.positionTypeMigrated"));
queryClient.invalidateQueries({ queryKey: ["position-types-to"] }); invalidateAll();
queryClient.invalidateQueries({ queryKey: ["position-type", id] }); },
onError: (error) => {
handleError(error);
}, },
onError: () => {},
}); });
//update all postions //update all postions
const migratePositionsByPositions = useMutation({ const migratePositionsByPositions = useMutation({
mutationFn: ({ id, data }: { id: string; data: PositionRequest }) => mutationFn: ({ id, data }: { id: string; data: PositionRequest }) =>
positionTypeService.updateByPostion(id, data), positionTypeService.updateByPostion(id, data),
onSuccess: () => { onSuccess: () => {
toast.success("Position type migration updated"); toast.success(t("contentManagement.positionTypeMigrated"));
queryClient.invalidateQueries({ queryKey: ["position-types-migration"] }); invalidateAll();
queryClient.invalidateQueries({ queryKey: ["position-type", id] }); },
onError: (error) => {
handleError(error);
}, },
onError: () => {},
}); });
// Delete // Delete. Also 403s for built-in types.
const deletePositionType = useMutation({ const deletePositionType = useMutation({
mutationFn: (id: string) => positionTypeService.delete(id), mutationFn: (id: string) => positionTypeService.delete(id),
onSuccess: () => { onSuccess: () => {
toast.success("Position type deleted"); toast.success(t("contentManagement.positionTypeDeleted"));
queryClient.invalidateQueries({ queryKey: ["position-types"] }); invalidateAll();
}, },
onError: (error) => { onError: (error) => {
handleError(error); handleError(error);
@@ -205,16 +194,6 @@ export const usePositionTypes = ({
refetchPosition, refetchPosition,
isErrorPosition, isErrorPosition,
isLoadingPosition, isLoadingPosition,
// organization-based position types
positionTypeByOrgId,
refetchOrgPosition,
isErrorOrgPosition,
isLoadingOrgPosition,
// common types with organization ID
commonPositionTypesByOrgId,
refetchCommonOrgTypes,
isErrorCommonOrgTypes,
isLoadingCommonOrgTypes,
// common types with unit ID // common types with unit ID
commonPositionTypes: commonPositionTypes?.items ?? [], commonPositionTypes: commonPositionTypes?.items ?? [],
isLoadingCommonTypes, isLoadingCommonTypes,

View File

@@ -40,38 +40,25 @@ export const positionTypeService = {
getById: (id: string): Promise<AxiosResponse<PositionTypeDto>> => getById: (id: string): Promise<AxiosResponse<PositionTypeDto>> =>
axiosInstance.get(`/position-types/${id}`, { headers: withHeaders() }), axiosInstance.get(`/position-types/${id}`, { headers: withHeaders() }),
// Types owned by one unit. IAM has no organization-scoped route — position
// types carry a unitId only, so scoping to an org means filtering by that
// org's units client-side.
getByUnitId: ( getByUnitId: (
id: string, unitId: string,
params?: Params, params?: Params,
): Promise<AxiosResponse<PositionTypesListResponse>> => ): Promise<AxiosResponse<PositionTypesListResponse>> =>
axiosInstance.get(`/position-types/list/${id}`, { axiosInstance.get(`/position-types/list/${unitId}`, {
headers: withHeaders(),
params,
}),
getByOrganizationId: (
id: string,
params?: Params,
): Promise<AxiosResponse<PositionTypesListResponse>> =>
axiosInstance.get(`/position-types/list/${id}`, {
headers: withHeaders(),
params,
}),
getCommonTypesByOrganizationId: (
id: string,
params?: Params,
): Promise<AxiosResponse<PositionTypesListResponse>> =>
axiosInstance.get(`/position-types/list-with-commons/${id}`, {
headers: withHeaders(), headers: withHeaders(),
params, params,
}), }),
// WHERE isSystem = true OR unitId = :unitId — "commons" means the built-in
// types, not the ones with a null unitId.
getCommonTypesById: ( getCommonTypesById: (
id: string, unitId: string,
params: Params, params: Params,
): Promise<AxiosResponse<PositionTypesListResponse>> => ): Promise<AxiosResponse<PositionTypesListResponse>> =>
axiosInstance.get(`/position-types/list-with-commons/${id}`, { axiosInstance.get(`/position-types/list-with-commons/${unitId}`, {
headers: withHeaders(), headers: withHeaders(),
params, params,
}), }),