mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +00:00
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:
@@ -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": "ፍቃድ በትክክል ተዘምኗል",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 l’autorisation",
|
"perFailed": "Échec de la création de l’autorisation",
|
||||||
"perSuccess": "Type d’autorisation créé et autorisations assignées",
|
"perSuccess": "Type d’autorisation créé et autorisations assignées",
|
||||||
"updatePerSuccess": "Autorisation mise à jour avec succès",
|
"updatePerSuccess": "Autorisation mise à jour avec succès",
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -12,10 +12,13 @@ import {
|
|||||||
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 {
|
||||||
|
invalidatePositionTypeQueries,
|
||||||
|
usePositionTypes,
|
||||||
|
} from "@/user-management/hooks/usePositionTypes";
|
||||||
import { positionTypePermissionService } from "@/user-management/services/api/positionTypePermissionService";
|
import { positionTypePermissionService } from "@/user-management/services/api/positionTypePermissionService";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { t } from "i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAuth } from "@/shared/context/AuthContext";
|
import { useAuth } from "@/shared/context/AuthContext";
|
||||||
import { useUnit } from "@/user-management/hooks/useUnit";
|
import { useUnit } from "@/user-management/hooks/useUnit";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
@@ -32,19 +35,11 @@ import { PositionTypeDto } from "@/user-management/dto/positions/positionType";
|
|||||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||||
import { useOrganizations } from "@/super-admin/hooks/useOrganizations";
|
import { useOrganizations } from "@/super-admin/hooks/useOrganizations";
|
||||||
import { OrganizationDto } from "@/shared/dto/organization/organizationDto";
|
import { OrganizationDto } from "@/shared/dto/organization/organizationDto";
|
||||||
import i18n from "@/i18n";
|
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||||
import { PermissionSearch } from "./PermissionSearch";
|
import { PermissionSearch } from "./PermissionSearch";
|
||||||
import { useApplications } from "@/user-management/hooks/useApplications";
|
import { useApplications } from "@/user-management/hooks/useApplications";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
const formSchema = z.object({
|
|
||||||
nameAm: z.string().min(2),
|
|
||||||
nameEn: z.string().min(2),
|
|
||||||
permissions: z.array(z.string()),
|
|
||||||
});
|
|
||||||
|
|
||||||
type FormValues = z.infer<typeof formSchema>;
|
|
||||||
|
|
||||||
export interface CreatePositionFormProps {
|
export interface CreatePositionFormProps {
|
||||||
mode?: "create" | "edit";
|
mode?: "create" | "edit";
|
||||||
positionTypeId?: string;
|
positionTypeId?: string;
|
||||||
@@ -66,11 +61,14 @@ export const CreatePositionForm = ({
|
|||||||
onCancel,
|
onCancel,
|
||||||
}: CreatePositionFormProps = {}) => {
|
}: CreatePositionFormProps = {}) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { handleError } = useErrorHandler(t);
|
||||||
const {
|
const {
|
||||||
createPositionType,
|
createPositionType,
|
||||||
updatePositionType,
|
updatePositionType,
|
||||||
positionTypes,
|
positionTypes,
|
||||||
isLoading: isLoadingPositionTypes,
|
isLoading: isLoadingPositionTypes,
|
||||||
|
isError: isErrorPositionTypes,
|
||||||
} = usePositionTypes();
|
} = usePositionTypes();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { getList, getById } = useUnit();
|
const { getList, getById } = useUnit();
|
||||||
@@ -79,22 +77,45 @@ export const CreatePositionForm = ({
|
|||||||
user?.employee && user.employee.length > 0
|
user?.employee && user.employee.length > 0
|
||||||
? user.employee[0].organizationId
|
? user.employee[0].organizationId
|
||||||
: undefined;
|
: undefined;
|
||||||
const [selectedOrganizationId, setSelectedOrganizationId] = useState<string>(
|
|
||||||
userOrganizationId ?? "",
|
|
||||||
);
|
|
||||||
const [selectedUnitId, setSelectedUnitId] = useState<string>(
|
|
||||||
initialValues?.unitId ?? "",
|
|
||||||
);
|
|
||||||
const [selectedApplicationId, setSelectedApplicationId] =
|
const [selectedApplicationId, setSelectedApplicationId] =
|
||||||
useState<string>("");
|
useState<string>("");
|
||||||
const [copyFromPositionId, setCopyFromPositionId] = useState<string>("");
|
const [copyFromPositionId, setCopyFromPositionId] = useState<string>("");
|
||||||
const [isCopying, setIsCopying] = useState(false);
|
const [isCopying, setIsCopying] = useState(false);
|
||||||
const [isLoadingEditData, setIsLoadingEditData] = useState(mode === "edit");
|
const [isLoadingEditData, setIsLoadingEditData] = useState(mode === "edit");
|
||||||
const hasLoadedEditData = useRef(false);
|
const hasLoadedEditData = useRef(false);
|
||||||
const lang = i18n.language;
|
// Permissions the position type had when the form opened. Needed because the
|
||||||
|
// API cannot represent "no permissions" (see onSubmit).
|
||||||
|
const loadedPermissionCount = useRef(0);
|
||||||
const { applications, isLoading: isLoadingApplications } = useApplications();
|
const { applications, isLoading: isLoadingApplications } = useApplications();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const formSchema = useMemo(
|
||||||
|
() =>
|
||||||
|
z.object({
|
||||||
|
nameEn: z.string().trim().min(2, t("organization.englishNameRequired")),
|
||||||
|
nameAm: z.string().trim().min(2, t("organization.amharicNameRequired")),
|
||||||
|
organizationId: z.string().min(1, t("organization.organizationRequired")),
|
||||||
|
unitId: z.string().min(1, t("contentManagement.unitRequired")),
|
||||||
|
permissions: z.array(z.string()),
|
||||||
|
}),
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
nameAm: initialValues?.nameAm ?? "",
|
||||||
|
nameEn: initialValues?.nameEn ?? "",
|
||||||
|
organizationId: userOrganizationId ?? "",
|
||||||
|
unitId: initialValues?.unitId ?? "",
|
||||||
|
permissions: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedOrganizationId = form.watch("organizationId");
|
||||||
|
|
||||||
const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations(
|
const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations(
|
||||||
"Org",
|
"Org",
|
||||||
{ take: 3000 },
|
{ take: 3000 },
|
||||||
@@ -141,21 +162,36 @@ export const CreatePositionForm = ({
|
|||||||
),
|
),
|
||||||
enabled: mode === "edit" && !!positionTypeId,
|
enabled: mode === "edit" && !!positionTypeId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A position type belongs to a unit, and a unit to an organization — IAM has
|
||||||
|
// no organizationId on the type itself and no organization-scoped route, so
|
||||||
|
// the picked org narrows the list through its units. isSystem types are the
|
||||||
|
// shared "commons" and stay available to every organization.
|
||||||
|
const orgUnitIds = useMemo(
|
||||||
|
() =>
|
||||||
|
new Set(
|
||||||
|
(unitsResponse?.data?.items ?? []).map((unit: UnitDto) => unit.id),
|
||||||
|
),
|
||||||
|
[unitsResponse],
|
||||||
|
);
|
||||||
|
|
||||||
|
const copyFromOptions = useMemo(() => {
|
||||||
|
if (!selectedOrganizationId) return [];
|
||||||
|
return positionTypes.filter(
|
||||||
|
(type: PositionTypeDto) =>
|
||||||
|
type.id !== positionTypeId &&
|
||||||
|
(type.isSystem || (!!type.unitId && orgUnitIds.has(type.unitId))),
|
||||||
|
);
|
||||||
|
}, [positionTypes, orgUnitIds, selectedOrganizationId, positionTypeId]);
|
||||||
|
|
||||||
// Reset the selected unit when the organization changes so a unit from a
|
// Reset the selected unit when the organization changes so a unit from a
|
||||||
// different org can't be submitted by mistake.
|
// different org can't be submitted by mistake. The copy source is cleared
|
||||||
|
// too — it is scoped to the old organization.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode === "edit") return;
|
if (mode === "edit") return;
|
||||||
setSelectedUnitId("");
|
form.setValue("unitId", "");
|
||||||
}, [selectedOrganizationId, mode]);
|
setCopyFromPositionId("");
|
||||||
|
}, [selectedOrganizationId, mode, form]);
|
||||||
const form = useForm<FormValues>({
|
|
||||||
resolver: zodResolver(formSchema),
|
|
||||||
defaultValues: {
|
|
||||||
nameAm: initialValues?.nameAm ?? "",
|
|
||||||
nameEn: initialValues?.nameEn ?? "",
|
|
||||||
permissions: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode !== "edit" || !initialValues || !positionTypeId) return;
|
if (mode !== "edit" || !initialValues || !positionTypeId) return;
|
||||||
@@ -168,17 +204,14 @@ export const CreatePositionForm = ({
|
|||||||
hasLoadedEditData.current = true;
|
hasLoadedEditData.current = true;
|
||||||
|
|
||||||
const unit = editUnitResponse?.data;
|
const unit = editUnitResponse?.data;
|
||||||
if (unit) {
|
|
||||||
setSelectedOrganizationId(unit.organizationId);
|
|
||||||
setSelectedUnitId(unit.id);
|
|
||||||
} else if (initialValues.unitId) {
|
|
||||||
setSelectedUnitId(initialValues.unitId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ids = permissionsResponse?.data?.items?.map((p) => p.id) ?? [];
|
const ids = permissionsResponse?.data?.items?.map((p) => p.id) ?? [];
|
||||||
|
loadedPermissionCount.current = ids.length;
|
||||||
|
|
||||||
form.reset({
|
form.reset({
|
||||||
nameAm: initialValues.nameAm,
|
nameAm: initialValues.nameAm,
|
||||||
nameEn: initialValues.nameEn,
|
nameEn: initialValues.nameEn,
|
||||||
|
organizationId: unit?.organizationId ?? userOrganizationId ?? "",
|
||||||
|
unitId: unit?.id ?? initialValues.unitId ?? "",
|
||||||
permissions: ids,
|
permissions: ids,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -194,36 +227,32 @@ export const CreatePositionForm = ({
|
|||||||
isPermissionsError,
|
isPermissionsError,
|
||||||
editUnitResponse,
|
editUnitResponse,
|
||||||
permissionsResponse,
|
permissionsResponse,
|
||||||
|
userOrganizationId,
|
||||||
form,
|
form,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handlePermissionChange = (permissionId: string, checked: boolean) => {
|
const handlePermissionChange = (permissionId: string, checked: boolean) => {
|
||||||
const currentPermissions = form.getValues("permissions");
|
const currentPermissions = form.getValues("permissions");
|
||||||
if (checked) {
|
form.setValue(
|
||||||
form.setValue("permissions", [...currentPermissions, permissionId]);
|
"permissions",
|
||||||
} else {
|
checked
|
||||||
form.setValue(
|
? [...currentPermissions, permissionId]
|
||||||
"permissions",
|
: currentPermissions.filter((id) => id !== permissionId),
|
||||||
currentPermissions.filter((id) => id !== permissionId),
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopyFrom = async (positionTypeId: string) => {
|
const handleCopyFrom = async (sourcePositionTypeId: string) => {
|
||||||
setCopyFromPositionId(positionTypeId);
|
setCopyFromPositionId(sourcePositionTypeId);
|
||||||
if (!positionTypeId) {
|
|
||||||
form.setValue("permissions", []);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsCopying(true);
|
setIsCopying(true);
|
||||||
try {
|
try {
|
||||||
const response =
|
const response =
|
||||||
await positionTypePermissionService.getPermissionsByPositionTypeId(
|
await positionTypePermissionService.getPermissionsByPositionTypeId(
|
||||||
positionTypeId,
|
sourcePositionTypeId,
|
||||||
);
|
);
|
||||||
const ids = response.data.items?.map((p) => p.id) ?? [];
|
const ids = response.data.items?.map((p) => p.id) ?? [];
|
||||||
form.setValue("permissions", ids);
|
form.setValue("permissions", ids);
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
toast.error(t("contentManagement.copyPermissionsFailed"));
|
toast.error(t("contentManagement.copyPermissionsFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
setIsCopying(false);
|
setIsCopying(false);
|
||||||
@@ -231,23 +260,17 @@ export const CreatePositionForm = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = async (values: FormValues) => {
|
const onSubmit = async (values: FormValues) => {
|
||||||
|
const payload = {
|
||||||
|
name: { am: values.nameAm, en: values.nameEn },
|
||||||
|
key: values.nameEn.toLowerCase().replace(/\s+/g, "-"),
|
||||||
|
unitId: values.unitId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
// conflict on the globally-unique key, ...).
|
||||||
|
let targetId = positionTypeId;
|
||||||
try {
|
try {
|
||||||
if (!selectedUnitId) {
|
|
||||||
toast.error(t("organization.selectUnit"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
name: {
|
|
||||||
am: values.nameAm,
|
|
||||||
en: values.nameEn,
|
|
||||||
},
|
|
||||||
key: values.nameEn.toLowerCase().replace(/\s+/g, "-"),
|
|
||||||
unitId: selectedUnitId,
|
|
||||||
};
|
|
||||||
|
|
||||||
let targetId = positionTypeId;
|
|
||||||
|
|
||||||
if (mode === "edit" && positionTypeId) {
|
if (mode === "edit" && positionTypeId) {
|
||||||
await updatePositionType.mutateAsync({
|
await updatePositionType.mutateAsync({
|
||||||
id: positionTypeId,
|
id: positionTypeId,
|
||||||
@@ -257,30 +280,43 @@ export const CreatePositionForm = ({
|
|||||||
const response = await createPositionType.mutateAsync(payload);
|
const response = await createPositionType.mutateAsync(payload);
|
||||||
targetId = response.data.id;
|
targetId = response.data.id;
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
return; // already reported by the mutation's onError
|
||||||
|
}
|
||||||
|
|
||||||
if (targetId && values.permissions.length > 0) {
|
// assign-seconds-for-first replaces the whole set, but an empty secondIds
|
||||||
|
// fails server-side — so "unassign everything" is not expressible. Keep the
|
||||||
|
// save and tell the user their permissions were left alone.
|
||||||
|
const mustClearAll =
|
||||||
|
values.permissions.length === 0 && loadedPermissionCount.current > 0;
|
||||||
|
|
||||||
|
if (targetId && values.permissions.length > 0) {
|
||||||
|
try {
|
||||||
await positionTypePermissionService.assignPermissionsToPositionType({
|
await positionTypePermissionService.assignPermissionsToPositionType({
|
||||||
firstId: targetId,
|
firstId: targetId,
|
||||||
secondIds: values.permissions,
|
secondIds: values.permissions,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
invalidatePositionTypeQueries(queryClient);
|
||||||
|
toast.error(t("contentManagement.permissionsAssignFailed"));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
queryClient.invalidateQueries({
|
invalidatePositionTypeQueries(queryClient);
|
||||||
queryKey: ["position-type"],
|
queryClient.invalidateQueries({ queryKey: ["position-type-permissions"] });
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["position-types"] });
|
if (mustClearAll) {
|
||||||
queryClient.invalidateQueries({
|
toast.warning(t("contentManagement.cannotClearAllPermissions"));
|
||||||
queryKey: ["position-type-permissions"],
|
} else {
|
||||||
});
|
|
||||||
toast.success(t("contentManagement.permissionSuccess"));
|
toast.success(t("contentManagement.permissionSuccess"));
|
||||||
|
}
|
||||||
|
|
||||||
if (onSuccess) {
|
if (onSuccess) {
|
||||||
onSuccess();
|
onSuccess();
|
||||||
} else {
|
} else {
|
||||||
navigate("/user-management/position-management");
|
navigate("/user-management/position-management");
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.error(t("contentManagement.permissionFailed"));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -292,6 +328,19 @@ export const CreatePositionForm = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectedPermissionCount = form.watch("permissions").length;
|
||||||
|
// form.formState.isSubmitting stays true for the whole async handler, so it
|
||||||
|
// also covers the permission-assignment call that follows the save.
|
||||||
|
const isBusy = form.formState.isSubmitting || isCopying;
|
||||||
|
|
||||||
|
const copyFromPlaceholder = !selectedOrganizationId
|
||||||
|
? t("contentManagement.selectOrganizationToCopy")
|
||||||
|
: isCopying || isLoadingPositionTypes || isLoadingUnits
|
||||||
|
? t("common.loading")
|
||||||
|
: isErrorPositionTypes
|
||||||
|
? t("contentManagement.failedToLoadPositionTypes")
|
||||||
|
: t("contentManagement.selectPositionToCopy");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
@@ -323,43 +372,51 @@ export const CreatePositionForm = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ✅ Organization (searchable, all orgs) */}
|
{/* Organization (searchable, all orgs) */}
|
||||||
<div className="mb-4 w-full">
|
<FormField
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
control={form.control}
|
||||||
{t("organization.organization") || "Organization"}
|
name="organizationId"
|
||||||
</label>
|
render={({ field }) => (
|
||||||
<SingleSelect
|
<FormItem>
|
||||||
options={organizationOptions}
|
<FormLabel>{t("organization.organization")}</FormLabel>
|
||||||
value={selectedOrganizationId}
|
<SingleSelect
|
||||||
onValueChange={setSelectedOrganizationId}
|
options={organizationOptions}
|
||||||
placeholder={
|
value={field.value}
|
||||||
isLoadingOrgs
|
onValueChange={field.onChange}
|
||||||
? t("common.loading")
|
placeholder={
|
||||||
: t("organization.selectOrganization") ||
|
isLoadingOrgs
|
||||||
"Select an organization"
|
? t("common.loading")
|
||||||
}
|
: t("organization.selectOrganization")
|
||||||
/>
|
}
|
||||||
</div>
|
/>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* ✅ Unit Selector — searchable, scoped to picked org */}
|
{/* Unit Selector — searchable, scoped to picked org */}
|
||||||
<div className="mb-4 w-full">
|
<FormField
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
control={form.control}
|
||||||
{t("organization.selectUnit")}
|
name="unitId"
|
||||||
</label>
|
render={({ field }) => (
|
||||||
<SingleSelect
|
<FormItem>
|
||||||
options={unitOptions}
|
<FormLabel>{t("organization.selectUnit")}</FormLabel>
|
||||||
value={selectedUnitId}
|
<SingleSelect
|
||||||
onValueChange={setSelectedUnitId}
|
options={unitOptions}
|
||||||
placeholder={
|
value={field.value}
|
||||||
!selectedOrganizationId
|
onValueChange={field.onChange}
|
||||||
? t("organization.selectOrganizationFirst") ||
|
placeholder={
|
||||||
"Select an organization first"
|
!selectedOrganizationId
|
||||||
: isLoadingUnits
|
? t("organization.selectOrganizationFirst")
|
||||||
? t("common.loading")
|
: isLoadingUnits
|
||||||
: t("organization.selectUnit")
|
? t("common.loading")
|
||||||
}
|
: t("organization.selectUnit")
|
||||||
/>
|
}
|
||||||
</div>
|
/>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="mb-4 w-1/2">
|
<div className="mb-4 w-1/2">
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
@@ -367,15 +424,21 @@ export const CreatePositionForm = ({
|
|||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={selectedApplicationId}
|
value={selectedApplicationId}
|
||||||
onValueChange={(value) => setSelectedApplicationId(value)}
|
onValueChange={setSelectedApplicationId}
|
||||||
disabled={isLoadingApplications}>
|
disabled={isLoadingApplications}>
|
||||||
<SelectTrigger className="mt-1 block w-full border-gray-300 rounded-md shadow-sm">
|
<SelectTrigger className="mt-1 block w-full border-gray-300 rounded-md shadow-sm">
|
||||||
<SelectValue placeholder="Select an Application" />
|
<SelectValue
|
||||||
|
placeholder={
|
||||||
|
isLoadingApplications
|
||||||
|
? t("common.loading")
|
||||||
|
: t("contentManagement.selectApplication")
|
||||||
|
}
|
||||||
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="max-h-60 overflow-y-auto">
|
<SelectContent className="max-h-60 overflow-y-auto">
|
||||||
{applications?.map((app) => (
|
{applications?.map((app) => (
|
||||||
<SelectItem key={app.id} value={app.id}>
|
<SelectItem key={app.id} value={app.id}>
|
||||||
{lang === "en" ? app.name.en : app.name.am}
|
{localizedName(app.name)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -389,18 +452,17 @@ export const CreatePositionForm = ({
|
|||||||
<Select
|
<Select
|
||||||
value={copyFromPositionId}
|
value={copyFromPositionId}
|
||||||
onValueChange={handleCopyFrom}
|
onValueChange={handleCopyFrom}
|
||||||
disabled={isLoadingPositionTypes || isCopying}>
|
disabled={
|
||||||
|
!selectedOrganizationId ||
|
||||||
|
isLoadingPositionTypes ||
|
||||||
|
isLoadingUnits ||
|
||||||
|
isCopying
|
||||||
|
}>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue
|
<SelectValue placeholder={copyFromPlaceholder} />
|
||||||
placeholder={
|
|
||||||
isCopying
|
|
||||||
? t("common.loading")
|
|
||||||
: t("contentManagement.selectPositionToCopy")
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="max-h-60 overflow-y-auto">
|
<SelectContent className="max-h-60 overflow-y-auto">
|
||||||
{positionTypes.map((p: PositionTypeDto) => (
|
{copyFromOptions.map((p: PositionTypeDto) => (
|
||||||
<SelectItem key={p.id} value={p.id}>
|
<SelectItem key={p.id} value={p.id}>
|
||||||
{localizedName(p.name)}
|
{localizedName(p.name)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -417,7 +479,18 @@ export const CreatePositionForm = ({
|
|||||||
name="permissions"
|
name="permissions"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t("contentManagement.permission")}</FormLabel>
|
<FormLabel>
|
||||||
|
{t("contentManagement.permission")}
|
||||||
|
{selectedPermissionCount > 0 && (
|
||||||
|
<span className="ml-2 font-normal text-muted-foreground">
|
||||||
|
(
|
||||||
|
{t("contentManagement.permissionsSelected", {
|
||||||
|
count: selectedPermissionCount,
|
||||||
|
})}
|
||||||
|
)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
<PermissionSearch
|
<PermissionSearch
|
||||||
selectedPermissions={field.value}
|
selectedPermissions={field.value}
|
||||||
onPermissionChange={handlePermissionChange}
|
onPermissionChange={handlePermissionChange}
|
||||||
@@ -432,6 +505,7 @@ export const CreatePositionForm = ({
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
disabled={isBusy}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (onCancel) {
|
if (onCancel) {
|
||||||
onCancel();
|
onCancel();
|
||||||
@@ -441,12 +515,12 @@ export const CreatePositionForm = ({
|
|||||||
}}>
|
}}>
|
||||||
{t("common.Cancel")}
|
{t("common.Cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button type="submit" disabled={isBusy}>
|
||||||
type="submit"
|
{form.formState.isSubmitting
|
||||||
disabled={
|
? t("common.saving")
|
||||||
createPositionType.isPending || updatePositionType.isPending
|
: mode === "edit"
|
||||||
}>
|
? t("delegation.update")
|
||||||
{mode === "edit" ? t("delegation.update") : t("delegation.save")}
|
: t("delegation.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
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";
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
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 { useUnit } from "@/user-management/hooks/useUnit";
|
||||||
import { useAuth } from "@/shared/context/AuthContext";
|
import { useAuth } from "@/shared/context/AuthContext";
|
||||||
@@ -17,7 +18,6 @@ import { positionTypePermissionService } from "@/user-management/services/api/po
|
|||||||
import { usePositionTypes } from "@/user-management/hooks/usePositionTypes";
|
import { usePositionTypes } from "@/user-management/hooks/usePositionTypes";
|
||||||
import { useApplications } from "@/user-management/hooks/useApplications";
|
import { useApplications } from "@/user-management/hooks/useApplications";
|
||||||
import { PermissionSearch } from "./PermissionSearch";
|
import { PermissionSearch } from "./PermissionSearch";
|
||||||
import { PermissionDto } from "@/user-management/dto/permissions/permissonDto";
|
|
||||||
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";
|
||||||
@@ -28,7 +28,9 @@ export const EditPositionForm = ({ id }: { id: string }) => {
|
|||||||
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 =
|
const organizationId =
|
||||||
user?.employee && user.employee.length > 0
|
user?.employee && user.employee.length > 0
|
||||||
@@ -44,30 +46,47 @@ export const EditPositionForm = ({ id }: { id: string }) => {
|
|||||||
|
|
||||||
const [selectedApplicationId, setSelectedApplicationId] =
|
const [selectedApplicationId, setSelectedApplicationId] =
|
||||||
useState<string>("");
|
useState<string>("");
|
||||||
const [assignedPermissions, setAssignedPermissions] = useState<
|
|
||||||
PermissionDto[]
|
|
||||||
>([]);
|
|
||||||
const [isLoadingPermissions, setIsLoadingPermissions] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
// Shares the cache key CreatePositionForm writes under, so editing a position
|
||||||
const load = async () => {
|
// type's permissions refreshes this view too.
|
||||||
if (!positionType) return;
|
const {
|
||||||
setIsLoadingPermissions(true);
|
data: assignedResponse,
|
||||||
try {
|
isLoading: isLoadingPermissions,
|
||||||
const assigned =
|
isError: isErrorPermissions,
|
||||||
await positionTypePermissionService.getPermissionsByPositionTypeId(
|
} = useQuery({
|
||||||
positionType.id,
|
queryKey: ["position-type-permissions", id],
|
||||||
);
|
queryFn: () =>
|
||||||
setAssignedPermissions(assigned.data.items ?? []);
|
positionTypePermissionService.getPermissionsByPositionTypeId(id),
|
||||||
} finally {
|
enabled: !!id,
|
||||||
setIsLoadingPermissions(false);
|
});
|
||||||
}
|
|
||||||
};
|
|
||||||
load();
|
|
||||||
}, [positionType]);
|
|
||||||
|
|
||||||
if (isLoadingSingle) return <p>Loading...</p>;
|
const assignedPermissions = assignedResponse?.data?.items ?? [];
|
||||||
if (!positionType) return null;
|
|
||||||
|
if (isLoadingSingle) {
|
||||||
|
return (
|
||||||
|
<p className="py-8 text-center text-muted-foreground">
|
||||||
|
{t("common.loading")}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isErrorSingle || !positionType) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="py-8 text-center text-red-500">
|
||||||
|
{t("contentManagement.positionTypeNotFound")}
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => navigate("/user-management/position-management")}>
|
||||||
|
{t("common.Back")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const unit = unitsResponse?.data?.items?.find(
|
const unit = unitsResponse?.data?.items?.find(
|
||||||
(u: UnitDto) => u.id === positionType.unitId,
|
(u: UnitDto) => u.id === positionType.unitId,
|
||||||
@@ -87,22 +106,32 @@ export const EditPositionForm = ({ id }: { id: string }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Key</Label>
|
<Label>{t("contentManagement.key")}</Label>
|
||||||
<Input value={positionType.key} disabled readOnly />
|
<Input value={positionType.key} disabled readOnly />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("organization.selectUnit")}</Label>
|
||||||
|
<Input value={unitName ?? ""} disabled readOnly />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t("contentManagement.selectApplication")}</Label>
|
<Label>{t("contentManagement.selectApplication")}</Label>
|
||||||
<Select
|
<Select
|
||||||
value={selectedApplicationId}
|
value={selectedApplicationId}
|
||||||
onValueChange={(value) => setSelectedApplicationId(value)}
|
onValueChange={setSelectedApplicationId}
|
||||||
disabled={isLoadingApplications}
|
disabled={isLoadingApplications}>
|
||||||
>
|
|
||||||
<SelectTrigger className="mt-1 block w-full border-gray-300 rounded-md shadow-sm">
|
<SelectTrigger className="mt-1 block w-full border-gray-300 rounded-md shadow-sm">
|
||||||
<SelectValue placeholder="Select an Application" />
|
<SelectValue
|
||||||
|
placeholder={
|
||||||
|
isLoadingApplications
|
||||||
|
? t("common.loading")
|
||||||
|
: t("contentManagement.selectApplication")
|
||||||
|
}
|
||||||
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="max-h-60 overflow-y-auto">
|
<SelectContent className="max-h-60 overflow-y-auto">
|
||||||
{applications?.map((app: any) => (
|
{applications?.map((app) => (
|
||||||
<SelectItem key={app.id} value={app.id}>
|
<SelectItem key={app.id} value={app.id}>
|
||||||
{localizedName(app.name)}
|
{localizedName(app.name)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -125,7 +154,13 @@ export const EditPositionForm = ({ id }: { id: string }) => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="border rounded-md p-4 bg-background max-h-96 overflow-y-auto">
|
<div className="border rounded-md p-4 bg-background max-h-96 overflow-y-auto">
|
||||||
{isLoadingPermissions ? (
|
{isLoadingPermissions ? (
|
||||||
<div className="text-center py-4 text-gray-500">Loading...</div>
|
<div className="text-center py-4 text-gray-500">
|
||||||
|
{t("common.loading")}
|
||||||
|
</div>
|
||||||
|
) : isErrorPermissions ? (
|
||||||
|
<div className="text-center py-4 text-red-500">
|
||||||
|
{t("contentManagement.failedToLoadPermissions")}
|
||||||
|
</div>
|
||||||
) : assignedPermissions.length === 0 ? (
|
) : assignedPermissions.length === 0 ? (
|
||||||
<div className="text-center py-4 text-gray-500">
|
<div className="text-center py-4 text-gray-500">
|
||||||
{t("contentManagement.noPermissionsAvailable")}
|
{t("contentManagement.noPermissionsAvailable")}
|
||||||
@@ -135,8 +170,7 @@ export const EditPositionForm = ({ id }: { id: string }) => {
|
|||||||
{assignedPermissions.map((perm) => (
|
{assignedPermissions.map((perm) => (
|
||||||
<li
|
<li
|
||||||
key={perm.id}
|
key={perm.id}
|
||||||
className="capitalize text-sm py-1 px-2 rounded bg-muted/40"
|
className="capitalize text-sm py-1 px-2 rounded bg-muted/40">
|
||||||
>
|
|
||||||
{localizedName(perm.name)}
|
{localizedName(perm.name)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -150,8 +184,7 @@ export const EditPositionForm = ({ id }: { id: string }) => {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => navigate("/user-management/position-management")}
|
onClick={() => navigate("/user-management/position-management")}>
|
||||||
>
|
|
||||||
{t("common.Back")}
|
{t("common.Back")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
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";
|
||||||
@@ -14,7 +14,10 @@ interface PermissionSearchProps {
|
|||||||
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
|
||||||
|
// mount for the same list.
|
||||||
|
const TAKE = 1000;
|
||||||
|
|
||||||
export const PermissionSearch: React.FC<PermissionSearchProps> = ({
|
export const PermissionSearch: React.FC<PermissionSearchProps> = ({
|
||||||
selectedPermissions,
|
selectedPermissions,
|
||||||
@@ -24,60 +27,30 @@ export const PermissionSearch: React.FC<PermissionSearchProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
|
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
|
||||||
const [take, setTake] = useState(INITIAL_TAKE); // Start with 50
|
|
||||||
const hasSetTotalCount = useRef(false); // Track if we've set the total count
|
|
||||||
|
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const localizedName = useLocalizedName();
|
const localizedName = useLocalizedName();
|
||||||
|
|
||||||
/** ------------------ 1. Debounce Search ------------------ */
|
/** ------------------ 1. Debounce Search ------------------ */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => setDebouncedSearchTerm(searchTerm), 300);
|
||||||
setDebouncedSearchTerm(searchTerm);
|
|
||||||
setTake(INITIAL_TAKE); // Reset to 50
|
|
||||||
hasSetTotalCount.current = false; // Reset the flag
|
|
||||||
}, 300);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [searchTerm]);
|
}, [searchTerm]);
|
||||||
|
|
||||||
/** ------------------ 2. Fetch Permissions ------------------ */
|
/** ------------------ 2. Fetch Permissions ------------------ */
|
||||||
|
// The API does the filtering. Filtering the result again on the *undebounced*
|
||||||
|
// term used to blank the list for 300ms on every keystroke.
|
||||||
const { permissions, isPermissionsLoading } = usePermissionManager({
|
const { permissions, isPermissionsLoading } = usePermissionManager({
|
||||||
params: applicationId
|
params: applicationId
|
||||||
? {
|
? {
|
||||||
take,
|
take: TAKE,
|
||||||
skip: 0, // Always skip 0, we fetch everything at once
|
skip: 0,
|
||||||
search: debouncedSearchTerm || undefined,
|
search: debouncedSearchTerm || undefined,
|
||||||
applicationId,
|
applicationId,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
/** ------------------ 3. Update take to total count after first fetch ------------------ */
|
const items = permissions?.items ?? [];
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
permissions?.count &&
|
|
||||||
!hasSetTotalCount.current &&
|
|
||||||
take !== permissions.count
|
|
||||||
) {
|
|
||||||
hasSetTotalCount.current = true;
|
|
||||||
setTake(permissions.count); // Fetch all items
|
|
||||||
}
|
|
||||||
}, [permissions?.count, take]);
|
|
||||||
|
|
||||||
/** ------------------ 4. Client-side Filtering (Optional) ------------------ */
|
|
||||||
const filteredPermissions = useMemo(() => {
|
|
||||||
if (!permissions?.items?.length) return [];
|
|
||||||
if (!searchTerm.trim()) return permissions.items;
|
|
||||||
|
|
||||||
return permissions.items.filter((perm: PermissionDto) => {
|
|
||||||
const name = localizedName(perm.name).toLowerCase();
|
|
||||||
const key = perm.key.toLowerCase();
|
|
||||||
const search = searchTerm.toLowerCase();
|
|
||||||
return name.includes(search) || key.includes(search);
|
|
||||||
});
|
|
||||||
}, [permissions?.items, searchTerm, localizedName]);
|
|
||||||
|
|
||||||
/** ------------------ Render ------------------ */
|
/** ------------------ Render ------------------ */
|
||||||
return (
|
return (
|
||||||
@@ -96,31 +69,26 @@ export const PermissionSearch: React.FC<PermissionSearchProps> = ({
|
|||||||
{/* Permission List Container */}
|
{/* Permission List Container */}
|
||||||
{!applicationId ? (
|
{!applicationId ? (
|
||||||
<div className="max-h-96 overflow-y-auto border rounded-md p-4 bg-background text-center text-gray-500">
|
<div className="max-h-96 overflow-y-auto border rounded-md p-4 bg-background text-center text-gray-500">
|
||||||
{t("contentManagement.selectApplicationToLoadPermissions") ||
|
{t("contentManagement.selectApplicationToLoadPermissions")}
|
||||||
"Select an application to load permissions."}
|
|
||||||
</div>
|
</div>
|
||||||
) : isPermissionsLoading ? (
|
) : isPermissionsLoading ? (
|
||||||
<div className="flex justify-center py-10">
|
<div className="flex justify-center py-10">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
|
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div className="max-h-96 overflow-y-auto border rounded-md p-4 bg-background">
|
||||||
ref={scrollContainerRef}
|
{items.length === 0 ? (
|
||||||
className="max-h-96 overflow-y-auto border rounded-md p-4 bg-background"
|
|
||||||
>
|
|
||||||
{filteredPermissions.length === 0 ? (
|
|
||||||
<div className="text-center py-4 text-gray-500">
|
<div className="text-center py-4 text-gray-500">
|
||||||
{searchTerm
|
{debouncedSearchTerm
|
||||||
? t("contentManagement.noPermissionsFound")
|
? t("contentManagement.noPermissionsFound")
|
||||||
: t("contentManagement.noPermissionsAvailable")}
|
: t("contentManagement.noPermissionsAvailable")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{filteredPermissions.map((perm: PermissionDto) => (
|
{items.map((perm: PermissionDto) => (
|
||||||
<div
|
<div
|
||||||
key={perm.id}
|
key={perm.id}
|
||||||
className="flex flex-row items-start space-x-3 space-y-0"
|
className="flex flex-row items-start space-x-3 space-y-0">
|
||||||
>
|
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedPermissions.includes(perm.id)}
|
checked={selectedPermissions.includes(perm.id)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@@ -139,10 +107,10 @@ export const PermissionSearch: React.FC<PermissionSearchProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Footer Info */}
|
{/* Footer Info */}
|
||||||
{filteredPermissions.length > 0 && (
|
{items.length > 0 && (
|
||||||
<div className="text-xs text-muted-foreground px-1">
|
<div className="text-xs text-muted-foreground px-1">
|
||||||
{t("contentManagement.showingPermissions", {
|
{t("contentManagement.showingPermissions", {
|
||||||
count: filteredPermissions.length,
|
count: items.length,
|
||||||
total: permissions?.count || 0,
|
total: permissions?.count || 0,
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
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";
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@ 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 { toast } from "sonner";
|
||||||
import { useUnit } from "@/user-management/hooks/useUnit";
|
import { useUnit } from "@/user-management/hooks/useUnit";
|
||||||
import { useAuth } from "@/shared/context/AuthContext";
|
import { useAuth } from "@/shared/context/AuthContext";
|
||||||
import {
|
import {
|
||||||
@@ -24,14 +25,13 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/shared/common/ui/select";
|
} from "@/shared/common/ui/select";
|
||||||
import { UnitDto } from "@/user-management/dto/unit/unitDto";
|
import { UnitDto } from "@/user-management/dto/unit/unitDto";
|
||||||
import { usePositionTypeConfiguration } from "@/user-management/hooks/usePostionType";
|
import { PositionTypeDto } from "@/user-management/dto/positions/positionType";
|
||||||
|
|
||||||
export default function PositionManagement() {
|
export default function PositionManagement() {
|
||||||
const [pageIndex, setPageIndex] = useState(0);
|
const [pageIndex, setPageIndex] = useState(0);
|
||||||
const pageSize = 10;
|
const pageSize = 10;
|
||||||
const [isExporting, setIsExporting] = useState<boolean>(false);
|
const [isExporting, setIsExporting] = useState<boolean>(false);
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const { createConfiguration } = usePositionTypeConfiguration();
|
|
||||||
|
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
@@ -39,10 +39,13 @@ export default function PositionManagement() {
|
|||||||
|
|
||||||
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
|
// Add state for selected unitId
|
||||||
// Default: if super_admin => "All", otherwise wait for units
|
// Default: if super_admin => "All", otherwise wait for units
|
||||||
@@ -68,10 +71,16 @@ export default function PositionManagement() {
|
|||||||
const handlePageChange = (newPage: number) => {
|
const handlePageChange = (newPage: number) => {
|
||||||
setPageIndex(newPage);
|
setPageIndex(newPage);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const showingAllUnits = selectedUnitId === "All";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
positionTypeResponse,
|
positionTypeResponse,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
isError,
|
||||||
positionTypeByUnitId,
|
positionTypeByUnitId,
|
||||||
|
isLoadingPosition,
|
||||||
|
isErrorPosition,
|
||||||
refetch,
|
refetch,
|
||||||
refetchPosition,
|
refetchPosition,
|
||||||
} = usePositionTypes({
|
} = usePositionTypes({
|
||||||
@@ -80,78 +89,39 @@ export default function PositionManagement() {
|
|||||||
skip: 0,
|
skip: 0,
|
||||||
orderBy: "updatedAt:DESC",
|
orderBy: "updatedAt:DESC",
|
||||||
},
|
},
|
||||||
unitId: selectedUnitId === "All" ? undefined : selectedUnitId,
|
unitId: showingAllUnits ? undefined : selectedUnitId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch position types without unitId for migration options
|
// Refresh whichever list is on screen. `positionTypeResponse` is the
|
||||||
const {
|
// unscoped fetch, so it doubles as the migration-target source — no second
|
||||||
positionTypeResponse: globalPositionTypes,
|
// usePositionTypes() call needed (its cache key ignores unitId, so a second
|
||||||
refetch: refetchGlobalPositionTypes,
|
// call returned the very same query).
|
||||||
} = usePositionTypes({
|
const handlePositionTypeChanged = useCallback(async () => {
|
||||||
params: {
|
await (showingAllUnits ? refetch() : refetchPosition());
|
||||||
take: 1000, // Get all global position types
|
}, [showingAllUnits, refetch, refetchPosition]);
|
||||||
skip: 0,
|
|
||||||
orderBy: "updatedAt:DESC",
|
|
||||||
},
|
|
||||||
unitId: undefined, // Explicitly fetch position types without unitId
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create a combined refetch function for the onDelete callback
|
|
||||||
const handlePositionTypeDeleted = async () => {
|
|
||||||
await Promise.all([
|
|
||||||
selectedUnitId === "All" ? refetch() : refetchPosition(),
|
|
||||||
refetchGlobalPositionTypes(),
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
const handleToggle = async (
|
|
||||||
positionTypeId: string,
|
|
||||||
checked: boolean,
|
|
||||||
field: "canReceiveRecord" | "canAssignRecord" | "canCreateBankRecord",
|
|
||||||
) => {
|
|
||||||
if (!selectedUnitId || selectedUnitId === "All") return;
|
|
||||||
|
|
||||||
await createConfiguration({
|
|
||||||
positionTypeId,
|
|
||||||
timeframe: "yearly",
|
|
||||||
organizationId: organizationId!,
|
|
||||||
canReceiveRecord: field === "canReceiveRecord" ? checked : false,
|
|
||||||
canAssignRecord: field === "canAssignRecord" ? checked : false,
|
|
||||||
canCreateBankRecord: field === "canCreateBankRecord" ? checked : false,
|
|
||||||
});
|
|
||||||
|
|
||||||
await handlePositionTypeDeleted();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create columns with positionTypeResponse
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() =>
|
() =>
|
||||||
createPositionTypeColumns(
|
createPositionTypeColumns(
|
||||||
selectedUnitId === "All" ? positionTypeResponse : positionTypeByUnitId,
|
positionTypeResponse,
|
||||||
globalPositionTypes,
|
handlePositionTypeChanged,
|
||||||
handlePositionTypeDeleted,
|
handlePositionTypeChanged,
|
||||||
handlePositionTypeDeleted,
|
|
||||||
handleToggle, // ← pass toggle handler
|
|
||||||
selectedUnitId === "All", // ← isGlobal: hide toggle when "All"
|
|
||||||
),
|
),
|
||||||
[
|
[positionTypeResponse, handlePositionTypeChanged],
|
||||||
selectedUnitId,
|
|
||||||
positionTypeResponse,
|
|
||||||
positionTypeByUnitId,
|
|
||||||
globalPositionTypes,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const allItems = useMemo(
|
const allItems = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(selectedUnitId === "All"
|
(showingAllUnits
|
||||||
? positionTypeResponse?.items
|
? positionTypeResponse?.items
|
||||||
: positionTypeByUnitId?.items) || [],
|
: positionTypeByUnitId?.items) || [],
|
||||||
[selectedUnitId, positionTypeResponse?.items, positionTypeByUnitId?.items],
|
[showingAllUnits, positionTypeResponse?.items, positionTypeByUnitId?.items],
|
||||||
);
|
);
|
||||||
|
|
||||||
const filteredItems = useMemo(() => {
|
const filteredItems = useMemo(() => {
|
||||||
const trimmed = searchTerm.trim().toLowerCase();
|
const trimmed = searchTerm.trim().toLowerCase();
|
||||||
if (!trimmed) return allItems;
|
if (!trimmed) return allItems;
|
||||||
return allItems.filter((item: any) => {
|
return allItems.filter((item: PositionTypeDto) => {
|
||||||
const en = (item?.name?.en || "").toLowerCase();
|
const en = (item?.name?.en || "").toLowerCase();
|
||||||
const am = (item?.name?.am || "").toLowerCase();
|
const am = (item?.name?.am || "").toLowerCase();
|
||||||
const key = (item?.key || "").toLowerCase();
|
const key = (item?.key || "").toLowerCase();
|
||||||
@@ -166,44 +136,37 @@ export default function PositionManagement() {
|
|||||||
return filteredItems.slice(start, start + pageSize);
|
return filteredItems.slice(start, start + pageSize);
|
||||||
}, [filteredItems, pageIndex, pageSize]);
|
}, [filteredItems, pageIndex, pageSize]);
|
||||||
|
|
||||||
if (isLoading) {
|
// Track whichever query is actually feeding the table — picking a unit used
|
||||||
return <div>{t("contentManagement.addUser")}</div>;
|
// to leave the previous unit's rows on screen with no loading state.
|
||||||
}
|
const isLoadingList = showingAllUnits ? isLoading : isLoadingPosition;
|
||||||
|
const isErrorList = showingAllUnits ? isError : isErrorPosition;
|
||||||
|
|
||||||
const exportTypes = () => {
|
const exportTypes = () => {
|
||||||
setIsExporting(true);
|
setIsExporting(true);
|
||||||
positionTypeService
|
positionTypeService
|
||||||
.getAll({
|
.getAll({ take: 3000 })
|
||||||
take: 3000,
|
|
||||||
})
|
|
||||||
.then((allPositionKeys) => {
|
.then((allPositionKeys) => {
|
||||||
// Get the position type keys
|
|
||||||
const positionTypeKeys = allPositionKeys.data?.items?.map((p) => p.key);
|
const positionTypeKeys = allPositionKeys.data?.items?.map((p) => p.key);
|
||||||
|
|
||||||
if (positionTypeKeys && positionTypeKeys.length > 0) {
|
if (!positionTypeKeys?.length) {
|
||||||
// Convert the array of keys into a string, with each key on a new line
|
toast.error(t("contentManagement.exportFailed"));
|
||||||
const fileContent = positionTypeKeys.join("\n");
|
return;
|
||||||
|
|
||||||
// Create a Blob from the string content
|
|
||||||
const blob = new Blob([fileContent], { type: "text/plain" });
|
|
||||||
|
|
||||||
// Create a link element to trigger the download
|
|
||||||
const link = document.createElement("a");
|
|
||||||
|
|
||||||
// Create an object URL for the Blob
|
|
||||||
link.href = URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
// Set the download attribute with a file name
|
|
||||||
link.download = "position_keys.txt";
|
|
||||||
|
|
||||||
// Programmatically trigger a click on the link to start the download
|
|
||||||
link.click();
|
|
||||||
|
|
||||||
// Clean up by revoking the object URL
|
|
||||||
URL.revokeObjectURL(link.href);
|
|
||||||
} else {
|
|
||||||
console.error("No position type keys found.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One key per line, downloaded as a plain text file.
|
||||||
|
const blob = new Blob([positionTypeKeys.join("\n")], {
|
||||||
|
type: "text/plain",
|
||||||
|
});
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = URL.createObjectURL(blob);
|
||||||
|
link.download = "position_keys.txt";
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(link.href);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error(t("contentManagement.exportFailed"));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
setIsExporting(false);
|
setIsExporting(false);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -215,29 +178,28 @@ export default function PositionManagement() {
|
|||||||
<CardTitle className="text-xl font-semibold ">
|
<CardTitle className="text-xl font-semibold ">
|
||||||
{t("contentManagement.permissionType")}
|
{t("contentManagement.permissionType")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Button onClick={exportTypes}>
|
<Button onClick={exportTypes} disabled={isExporting}>
|
||||||
{isExporting
|
{isExporting
|
||||||
? t("contentManagement.exporting")
|
? t("contentManagement.exporting")
|
||||||
: t("contentManagement.exportTypes")}
|
: t("contentManagement.exportTypes")}
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
{unitsResponse?.data?.items?.length > 0 && (
|
{!!unitsResponse?.data?.items?.length && (
|
||||||
<div className="mb-4 w-1/2">
|
<div className="mb-4 w-1/2">
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
Select Unit
|
{t("organization.selectUnit")}
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={selectedUnitId}
|
value={selectedUnitId}
|
||||||
onValueChange={(value) => setSelectedUnitId(value)}
|
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">
|
<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">
|
||||||
<SelectValue placeholder="Select a Unit" />
|
<SelectValue placeholder={t("organization.selectUnit")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem key="all" value="All">
|
<SelectItem key="all" value="All">
|
||||||
All
|
{t("common.all")}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
{unitsResponse?.data.items.map((unit: UnitDto) => (
|
{unitsResponse.data.items.map((unit: UnitDto) => (
|
||||||
<SelectItem key={unit.id} value={unit.id}>
|
<SelectItem key={unit.id} value={unit.id}>
|
||||||
<span className="block truncate max-w-70">
|
<span className="block truncate max-w-70">
|
||||||
{unit.name.en || unit.name.am}
|
{unit.name.en || unit.name.am}
|
||||||
@@ -248,27 +210,42 @@ export default function PositionManagement() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{isUnitsError && (
|
||||||
|
<p className="mb-4 text-sm text-red-500">
|
||||||
|
{t("organization.errorLoadingUnits")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<CardContent className="px-0">
|
<CardContent className="px-0">
|
||||||
<AdvancedTable
|
{isLoadingList ? (
|
||||||
columns={columns}
|
<div className="py-10 text-center text-muted-foreground">
|
||||||
data={paginatedItems}
|
{t("common.loading")}
|
||||||
tableName="Positions"
|
</div>
|
||||||
toolBarPosition="right"
|
) : isErrorList ? (
|
||||||
itemCount={filteredItems.length}
|
<div className="py-10 text-center text-red-500">
|
||||||
onGlobalFilterChange={setSearchTerm}
|
{t("contentManagement.failedToLoadPositionTypes")}
|
||||||
extraToolbar={
|
</div>
|
||||||
<Link to="/user-management/position-management/new">
|
) : (
|
||||||
<Button className="px-5 py-2 rounded-md text-sm font-medium shadow-md">
|
<AdvancedTable
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
columns={columns}
|
||||||
{t("contentManagement.newPermission")}
|
data={paginatedItems}
|
||||||
</Button>
|
tableName="Positions"
|
||||||
</Link>
|
toolBarPosition="right"
|
||||||
}
|
itemCount={filteredItems.length}
|
||||||
pageIndex={pageIndex}
|
onGlobalFilterChange={setSearchTerm}
|
||||||
onPageChange={handlePageChange}
|
extraToolbar={
|
||||||
nextFunction={() => handlePageChange(pageIndex + 1)}
|
<Link to="/user-management/position-management/new">
|
||||||
prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))}
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,14 +26,8 @@ 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 { positionTypeService } from "@/user-management/services/api/positionTypesService";
|
import { usePositionTypes } from "@/user-management/hooks/usePositionTypes";
|
||||||
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
|
||||||
import { usePositionTypeConfiguration } from "@/user-management/hooks/usePostionType";
|
|
||||||
import { Switch } from "@/shared/common/ui/switch";
|
|
||||||
import { PositionTypeConfigurationDto } from "@/user-management/services/api/positionTypeConfigurationService";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -47,59 +41,42 @@ interface PositionTypeResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ActionsCellProps = {
|
type ActionsCellProps = {
|
||||||
row: PositionTypeDto | PositionTypeConfigurationDto;
|
row: PositionTypeDto;
|
||||||
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; // true when viewing "All" units — hide toggle
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* TODO(record-toggles): this menu used to carry CanReceiveRecord /
|
||||||
|
* CanAssignRecord / CanCreateBankRecord switches. They never worked. IAM's
|
||||||
|
* PositionTypeConfiguration entity only has { id, organizationId,
|
||||||
|
* positionTypeId, timeframe } — verified against every local build (0.7.4
|
||||||
|
* through 0.7.12) and the live swagger. canAssignRecord and
|
||||||
|
* canCreateBankRecord do not exist anywhere in the IAM package, and the global
|
||||||
|
* ValidationPipe runs with forbidNonWhitelisted, so every write 400'd. The
|
||||||
|
* reads were broken too: the list route filters on organizationId (the repo is
|
||||||
|
* built as TExtraCrudRepository(repo, "organizationId")) while the UI passed a
|
||||||
|
* positionTypeId, so it always came back empty.
|
||||||
|
*
|
||||||
|
* The flag that does exist is PositionConfiguration.canReceiveRecord, keyed by
|
||||||
|
* positionId — a per-position setting served by /api/position-configurations,
|
||||||
|
* not a per-position-type one. Restoring this needs either that endpoint and a
|
||||||
|
* position-level UI, or new columns on PositionTypeConfiguration in IAM.
|
||||||
|
*/
|
||||||
const PositionTypeActionsCell: React.FC<ActionsCellProps> = ({
|
const PositionTypeActionsCell: React.FC<ActionsCellProps> = ({
|
||||||
row,
|
row,
|
||||||
globalPositionTypes,
|
globalPositionTypes,
|
||||||
onDelete,
|
onDelete,
|
||||||
onEdit,
|
onEdit,
|
||||||
onToggle,
|
|
||||||
isGlobal = false,
|
|
||||||
}) => {
|
}) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
const [showMigrateDialog, setShowMigrateDialog] = useState(false);
|
const [showMigrateDialog, setShowMigrateDialog] = useState(false);
|
||||||
const [showEditDialog, setShowEditDialog] = useState(false);
|
const [showEditDialog, setShowEditDialog] = useState(false);
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
|
||||||
const localizedName = useLocalizedName();
|
const localizedName = useLocalizedName();
|
||||||
const { handleError } = useErrorHandler(t);
|
const { deletePositionType } = usePositionTypes();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
// Use row.id as the positionTypeId for the configuration lookup
|
|
||||||
|
|
||||||
const {
|
|
||||||
configurations,
|
|
||||||
isLoadingConfigurations,
|
|
||||||
updateConfiguration,
|
|
||||||
isUpdatingConfiguration,
|
|
||||||
} = usePositionTypeConfiguration(
|
|
||||||
row?.id ?? null, // 👈 pass row.id as unitId
|
|
||||||
);
|
|
||||||
|
|
||||||
const configItem = configurations[0];
|
|
||||||
const isCanReceiveRecord = configItem?.canReceiveRecord ?? false;
|
|
||||||
const isCanAssignRecord = configItem?.canAssignRecord ?? false;
|
|
||||||
const isCanCreateBankRecord = configItem?.canCreateBankRecord ?? false;
|
|
||||||
|
|
||||||
const invalidateConfig = () => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["positionTypeConfigurations", row.id],
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["positionTypeConfiguration", row.id],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create position type options from globalPositionTypes - only those WITHOUT unitId
|
// Create position type options from globalPositionTypes - only those WITHOUT unitId
|
||||||
const positionTypeOptions =
|
const positionTypeOptions =
|
||||||
@@ -129,97 +106,18 @@ const PositionTypeActionsCell: React.FC<ActionsCellProps> = ({
|
|||||||
setShowEditDialog(true);
|
setShowEditDialog(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Goes through the mutation rather than the service directly, so the cache is
|
||||||
|
// invalidated and IAM's 403 for built-in types reaches the user.
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
try {
|
try {
|
||||||
setIsDeleting(true);
|
await deletePositionType.mutateAsync(row.id);
|
||||||
await positionTypeService.delete(row.id);
|
|
||||||
toast.success(t("common.DeletedSuccessfully"));
|
|
||||||
setShowDeleteDialog(false);
|
setShowDeleteDialog(false);
|
||||||
if (onDelete) {
|
await onDelete?.();
|
||||||
await onDelete();
|
} catch {
|
||||||
}
|
// reported by the mutation's onError
|
||||||
} catch (error) {
|
|
||||||
handleError(error);
|
|
||||||
toast.error(t("common.FailedToDelete"));
|
|
||||||
} finally {
|
|
||||||
setIsDeleting(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleChange = async (checked: boolean) => {
|
|
||||||
if (isGlobal) return;
|
|
||||||
try {
|
|
||||||
if (configItem?.id) {
|
|
||||||
await updateConfiguration({
|
|
||||||
id: configItem.id,
|
|
||||||
payload: {
|
|
||||||
organizationId: configItem.organizationId,
|
|
||||||
positionTypeId: configItem.positionTypeId,
|
|
||||||
timeframe: configItem.timeframe,
|
|
||||||
canReceiveRecord: checked,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await onToggle?.(row.id, checked, "canReceiveRecord");
|
|
||||||
}
|
|
||||||
toast.success(t("incomingRecord.UpdatedSuccessfully"));
|
|
||||||
invalidateConfig();
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error);
|
|
||||||
toast.error(t("incomingRecord.FailedToUpdate"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAssignToggleChange = async (checked: boolean) => {
|
|
||||||
if (isGlobal) return;
|
|
||||||
try {
|
|
||||||
if (configItem?.id) {
|
|
||||||
await updateConfiguration({
|
|
||||||
id: configItem.id,
|
|
||||||
payload: {
|
|
||||||
organizationId: configItem.organizationId,
|
|
||||||
positionTypeId: configItem.positionTypeId,
|
|
||||||
timeframe: configItem.timeframe,
|
|
||||||
canAssignRecord: checked,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await onToggle?.(row.id, checked, "canAssignRecord");
|
|
||||||
}
|
|
||||||
toast.success(t("incomingRecord.UpdatedSuccessfully"));
|
|
||||||
invalidateConfig();
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error);
|
|
||||||
toast.error(t("incomingRecord.FailedToUpdate"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateBankRecordToggleChange = async (checked: boolean) => {
|
|
||||||
if (isGlobal) return;
|
|
||||||
try {
|
|
||||||
if (configItem?.id) {
|
|
||||||
await updateConfiguration({
|
|
||||||
id: configItem.id,
|
|
||||||
payload: {
|
|
||||||
organizationId: configItem.organizationId,
|
|
||||||
positionTypeId: configItem.positionTypeId,
|
|
||||||
timeframe: configItem.timeframe,
|
|
||||||
canCreateBankRecord: checked,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await onToggle?.(row.id, checked, "canCreateBankRecord");
|
|
||||||
}
|
|
||||||
toast.success(t("incomingRecord.UpdatedSuccessfully"));
|
|
||||||
invalidateConfig();
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error);
|
|
||||||
toast.error(t("incomingRecord.FailedToUpdate"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const rowName = "name" in row ? row.name : { am: "", en: "" };
|
|
||||||
const isPositionType = "name" in row && "key" in row;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||||
@@ -238,7 +136,7 @@ const PositionTypeActionsCell: React.FC<ActionsCellProps> = ({
|
|||||||
setDropdownOpen(false);
|
setDropdownOpen(false);
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
<DropdownMenuLabel>{t("userRecord.Actions")}</DropdownMenuLabel>
|
||||||
|
|
||||||
{canBeModified && (
|
{canBeModified && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -249,7 +147,7 @@ const PositionTypeActionsCell: React.FC<ActionsCellProps> = ({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canBeModified && isPositionType && (
|
{canBeModified && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={handleEdit}
|
onSelect={handleEdit}
|
||||||
className="cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200">
|
className="cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200">
|
||||||
@@ -276,50 +174,6 @@ const PositionTypeActionsCell: React.FC<ActionsCellProps> = ({
|
|||||||
<span>{t("common.Delete")}</span>
|
<span>{t("common.Delete")}</span>
|
||||||
</DropdownMenuItem>
|
</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>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
@@ -330,7 +184,7 @@ const PositionTypeActionsCell: React.FC<ActionsCellProps> = ({
|
|||||||
setShowMigrateDialog(false);
|
setShowMigrateDialog(false);
|
||||||
}}
|
}}
|
||||||
toId={row.id}
|
toId={row.id}
|
||||||
toName={localizedName(rowName)}
|
toName={localizedName(row.name)}
|
||||||
positionTypeOptions={positionTypeOptions}
|
positionTypeOptions={positionTypeOptions}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -341,7 +195,7 @@ const PositionTypeActionsCell: React.FC<ActionsCellProps> = ({
|
|||||||
<DialogTitle>{t("common.Edit")}</DialogTitle>
|
<DialogTitle>{t("common.Edit")}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex-1 overflow-y-auto pr-2 min-h-0">
|
<div className="flex-1 overflow-y-auto pr-2 min-h-0">
|
||||||
{isPositionType && showEditDialog && (
|
{showEditDialog && (
|
||||||
<CreatePositionForm
|
<CreatePositionForm
|
||||||
key={row.id + "-edit"}
|
key={row.id + "-edit"}
|
||||||
mode="edit"
|
mode="edit"
|
||||||
@@ -349,7 +203,7 @@ const PositionTypeActionsCell: React.FC<ActionsCellProps> = ({
|
|||||||
initialValues={{
|
initialValues={{
|
||||||
nameAm: row.name.am,
|
nameAm: row.name.am,
|
||||||
nameEn: row.name.en,
|
nameEn: row.name.en,
|
||||||
unitId: row.unitId,
|
unitId: row.unitId ?? "",
|
||||||
key: row.key,
|
key: row.key,
|
||||||
}}
|
}}
|
||||||
onSuccess={async () => {
|
onSuccess={async () => {
|
||||||
@@ -371,17 +225,21 @@ const PositionTypeActionsCell: React.FC<ActionsCellProps> = ({
|
|||||||
<AlertDialogTitle>{t("common.ConfirmDelete")}</AlertDialogTitle>
|
<AlertDialogTitle>{t("common.ConfirmDelete")}</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
{t("common.DeleteConfirmationMessage", {
|
{t("common.DeleteConfirmationMessage", {
|
||||||
defaultValue: `Are you sure you want to delete "${localizedName(rowName)}"? This action cannot be undone.`,
|
name: localizedName(row.name),
|
||||||
})}
|
})}
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>{t("common.Cancel")}</AlertDialogCancel>
|
<AlertDialogCancel disabled={deletePositionType.isPending}>
|
||||||
|
{t("common.Cancel")}
|
||||||
|
</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
disabled={isDeleting}
|
disabled={deletePositionType.isPending}
|
||||||
className="bg-red-500 hover:bg-red-600">
|
className="bg-red-500 hover:bg-red-600">
|
||||||
{isDeleting ? t("common.Deleting") : t("common.Delete")}
|
{deletePositionType.isPending
|
||||||
|
? t("common.Deleting")
|
||||||
|
: t("common.Delete")}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|||||||
@@ -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}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user