diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index c93c61f13..c292a0395 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -252,8 +252,11 @@ export class BookingsController { return this.bookingsService.findAll(filter, companyId); } + // Powers the customer-detail bookings tab, so `customers:view` reaches it too + // — otherwise a staffer granted only the customer permission gets a page whose + // tabs 403 individually. @Get("by-company/:companyId/customer-view") - @BookingView() + @BookingStaff([FREIGHT_PERMS.customers.view, FREIGHT_PERMS.bookings.view]) @ApiOperation({ summary: "List bookings for a company (customer-view shape, backoffice)", }) diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 43d70ce19..8e3be4d1a 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -11,13 +11,22 @@ import { HttpCode, HttpStatus, UseInterceptors, + UseGuards, UploadedFiles, BadRequestException, + NotFoundException, } from "@nestjs/common"; import { AnyFilesInterceptor } from "@nestjs/platform-express"; import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger"; import { CurrentUser } from "@edr/api-common"; -import { FreightAdmin } from "../../common/booking-guards"; +import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; +import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard"; +import { BookingStaff } from "../../common/booking-guards"; +import { + assertFreightPermission, + hasFreightPermission, +} from "../../common/freight-permission.util"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { FilesService } from "../files/files.service"; import { CompaniesService } from "./companies.service"; import { CreateCompanyDto } from "./dto/create-company.dto"; @@ -59,6 +68,23 @@ interface CurrentIamUser { phoneNumber?: string; } +/** + * Which permission a status write needs. Approving/reactivating is a different + * authority from suspending, but both arrive on the same route with the target + * in the BODY — a route-level guard can't tell them apart, so the handlers + * assert against this map instead. + * + * Keyed by string so it serves both `CompanyStatus` and `ProfileStatus` + * (a superset: it adds `rejected`). + */ +const STATUS_PERM: Record = { + active: FREIGHT_PERMS.customers.verify, + pending: FREIGHT_PERMS.customers.verify, + rejected: FREIGHT_PERMS.customers.verify, + suspended: FREIGHT_PERMS.customers.deactivate, + blacklisted: FREIGHT_PERMS.customers.deactivate, +}; + @ApiTags("Companies") @Controller("companies") export class CompaniesController { @@ -410,7 +436,7 @@ export class CompaniesController { // Used by backoffice @Post() - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.create) @ApiOperation({ summary: "Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)", @@ -421,12 +447,14 @@ export class CompaniesController { } @Get("stats") + @BookingStaff(FREIGHT_PERMS.customers.view) @ApiOperation({ summary: "Company counts by status (KPI strip)" }) async getStats(): Promise { return this.companiesService.getCompanyStats(); } @Get() + @BookingStaff(FREIGHT_PERMS.customers.view) @ApiOperation({ summary: "List companies (paginated, filterable)" }) async findAll( @Query() query: ListCompaniesQueryDto, @@ -436,6 +464,7 @@ export class CompaniesController { } @Get(":id") + @BookingStaff(FREIGHT_PERMS.customers.view) @ApiOperation({ summary: "Get company by ID" }) async findById( @Param("id", ParseUUIDPipe) id: string, @@ -446,30 +475,77 @@ export class CompaniesController { return dto; } + /** + * Edits fields AND carries `status`, so it spans two authorities. The route + * guard is one-of (a status-only caller must get in); the asserts below are + * what actually authorize: touching `status` needs the permission + * {@link STATUS_PERM} maps it to, touching anything else needs + * `customers:update`. Both checks are required — without the second, a + * caller holding only `customers:deactivate` could rename the company. + */ @Patch(":id") - @FreightAdmin() + @BookingStaff([ + FREIGHT_PERMS.customers.update, + FREIGHT_PERMS.customers.verify, + FREIGHT_PERMS.customers.deactivate, + ]) @ApiOperation({ summary: "Update a company" }) async update( @Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateCompanyDto, + @CurrentUser() user: TCurrentUser, ): Promise { + const { status, ...fields } = dto; + if (status) assertFreightPermission(user, STATUS_PERM[status]); + if (Object.keys(fields).length > 0) { + assertFreightPermission(user, FREIGHT_PERMS.customers.update); + } const company = await this.companiesService.updateCompany(id, dto); return new ResponseCompanyDto(company); } @Delete(":id") - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.deactivate) @ApiOperation({ summary: "Soft-delete a company" }) @HttpCode(HttpStatus.NO_CONTENT) async remove(@Param("id", ParseUUIDPipe) id: string): Promise { await this.companiesService.deleteCompany(id); } + /** + * Dual-audience: staff read any customer's documents, and the portal reads + * its OWN during onboarding (`companiesService.getDocuments`). So the route + * is authenticated-only and the split happens here — same shape as + * `GET /contracts/:id`. Gating it on a staff permission alone would 403 every + * customer on their own documents. + * + * The staff arm is one-of because two pages consume it: the customer detail + * page (`customers:view`) and the contract-request detail page, whose route + * is gated on `contracts:view` — a contract reviewer without the customer + * permission still needs the applicant's documents. + */ @Get(":companyId/documents") + @UseGuards(JwtGuard) @ApiOperation({ summary: "List documents uploaded for a company" }) async listDocuments( @Param("companyId", ParseUUIDPipe) companyId: string, + @CurrentUser() user: TCurrentUser, ) { + const isStaff = [ + FREIGHT_PERMS.customers.view, + FREIGHT_PERMS.contracts.view, + FREIGHT_PERMS.bookings.view, + ].some((p) => hasFreightPermission(user, p)); + + if (!isStaff) { + const { company } = await this.companiesService.getCompanyInfoByUserId( + user.id, + ); + // Hidden as NotFound rather than Forbidden so company ids can't be probed. + if (company.id !== companyId) { + throw new NotFoundException(`Company ${companyId} not found`); + } + } const files = await this.filesService.findByResource(companyId, "companies"); return Promise.all( files.map(async (f) => ({ @@ -490,7 +566,7 @@ export class CompaniesController { } @Post("documents/:fileId/request-change") - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.verify) @ApiOperation({ summary: "Ask the customer to correct one uploaded document", description: @@ -532,14 +608,23 @@ export class CompaniesController { return this.companiesService.uploadCompanyDocuments(companyId, files, user.id); } + /** + * Approve / reject / suspend / blacklist all arrive here with the target in + * the body, so authorization is per-status via {@link STATUS_PERM} rather + * than on the route (the guard is only the one-of gate). + */ @Patch("company-profiles/:profileId/status") - @FreightAdmin() + @BookingStaff([ + FREIGHT_PERMS.customers.verify, + FREIGHT_PERMS.customers.deactivate, + ]) @ApiOperation({ summary: "Update a company profile's approval status" }) async updateCompanyProfileStatus( - @CurrentUser() user: CurrentIamUser, + @CurrentUser() user: TCurrentUser, @Param("profileId", ParseUUIDPipe) profileId: string, @Body() dto: UpdateCompanyProfileStatusDto, ): Promise { + assertFreightPermission(user, STATUS_PERM[dto.status]); const profile = await this.companiesService.setCompanyProfileStatus( profileId, dto.status, @@ -550,7 +635,7 @@ export class CompaniesController { } @Get(":companyId/change-requests") - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.view) @ApiOperation({ summary: "List a company's profile change requests" }) async listChangeRequests( @Param("companyId", ParseUUIDPipe) companyId: string, @@ -560,7 +645,7 @@ export class CompaniesController { } @Post("change-requests/:id/approve") - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.verify) @ApiOperation({ summary: "Approve a pending profile change request (applies the changes)", }) @@ -576,7 +661,7 @@ export class CompaniesController { } @Post("change-requests/:id/reject") - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.verify) @ApiOperation({ summary: "Reject a pending profile change request with a note", }) @@ -594,7 +679,7 @@ export class CompaniesController { } @Post(":companyId/profiles") - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.update) @ApiOperation({ summary: "Add a profile (employee) to a company" }) async createProfile( @Param("companyId", ParseUUIDPipe) companyId: string, @@ -608,6 +693,7 @@ export class CompaniesController { } @Get(":companyId/profiles") + @BookingStaff(FREIGHT_PERMS.customers.view) @ApiOperation({ summary: "List profiles for a company" }) async listProfiles( @Param("companyId", ParseUUIDPipe) companyId: string, @@ -618,6 +704,7 @@ export class CompaniesController { } @Get("profile/user/:userId") + @BookingStaff(FREIGHT_PERMS.customers.view) @ApiOperation({ summary: "Get profile by IAM user ID" }) async findProfileByUser( @Param("userId", ParseUUIDPipe) userId: string, diff --git a/apps/edr-freight-api/src/modules/payment/payment.controller.ts b/apps/edr-freight-api/src/modules/payment/payment.controller.ts index 50856c3d7..2ae62d4f3 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.controller.ts @@ -16,7 +16,8 @@ import { } from "@nestjs/swagger"; import { Response } from "express"; import { Public } from "@edr/api-common"; -import { BookingView } from "../../common/booking-guards"; +import { BookingStaff, BookingView } from "../../common/booking-guards"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { PaymentService } from "./payment.service"; import { IntentStatusDto } from "./payments.dto"; @@ -25,7 +26,9 @@ import { IntentStatusDto } from "./payments.dto"; export class PaymentController { constructor(private readonly paymentService: PaymentService) { } + // Customer-detail payments tab — same one-of rule as the bookings tab. @Get("by-company/:companyId/customer-view") + @BookingStaff([FREIGHT_PERMS.customers.view, FREIGHT_PERMS.payments.view]) @ApiOperation({ summary: "List payments for a company (customer-view shape, backoffice)" }) findByCompanyCustomerView( @Param("companyId", ParseUUIDPipe) companyId: string, diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 04a26e3fe..bb4d024c8 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -802,8 +802,22 @@ const App = () => { } /> } /> - } /> - } /> + + + + } + /> + + + + } + /> )} - - - - + {/* Reviewing the diff is `customers:view`; deciding on it is + `customers:verify`. Without it the request stays readable but + un-actionable. */} + {canReview && ( + + + + + )} )} diff --git a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx index 04267cc7b..112026ca6 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx @@ -11,6 +11,8 @@ import { } from "@mantine/core"; import { useMutation } from "@tanstack/react-query"; import { useState } from "react"; +import { useAuth } from "@/auth/useAuth"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { api } from "@/services/api"; import type { @@ -286,6 +288,19 @@ export function InvoiceStatusBadge({ * regardless (setCompanyProfileStatus). Suspend/blacklist/reinstate stay live so * an already-active profile is still managable. */ +/** + * Which permission each status write needs. Mirrors `STATUS_PERM` in the API's + * `companies.controller.ts` — approving is a different authority from + * suspending, and both go through the same endpoint. Keep the two in step. + */ +const STATUS_PERM: Record = { + active: FREIGHT_PERMS.customers.verify, + pending: FREIGHT_PERMS.customers.verify, + rejected: FREIGHT_PERMS.customers.verify, + suspended: FREIGHT_PERMS.customers.deactivate, + blacklisted: FREIGHT_PERMS.customers.deactivate, +}; + export function ProfileApprovalActions({ profileId, status, @@ -295,6 +310,10 @@ export function ProfileApprovalActions({ status: ProfileStatus; locked?: boolean; }) { + const { user } = useAuth(); + /** The API rejects these anyway — hide rather than offer a button that 403s. */ + const canSet = (next: ProfileStatus) => + hasPermission(user, STATUS_PERM[next]); const { mutate, isPending } = useMutation( api.customers.setProfileStatus.mutationOptions(), ); @@ -414,35 +433,41 @@ export function ProfileApprovalActions({ } if (status === "pending") { + if (!canSet("active") && !canSet("rejected")) return null; return ( <> {decisionModal} - - + {canSet("active") && ( + + )} + {canSet("rejected") && ( + + )} ); } if (status === "rejected") { + if (!canSet("active")) return null; return ( - + {canSet("active") && ( + + )} + {canSet("blacklisted") && ( + + )} ); } if (status === "blacklisted") { + if (!canSet("pending")) return null; return ( - - - - - - - - - {t("contentManagement.delMsg")} - - {t("contentManagement.delMsg2")} - - - - { - setOpenDialog(false); - setDeletingId(null); - }} - > - {t("common.Cancel")} - - - {deletePositionType.isPending - ? t("organization.deleting") - : t("organization.delete")} - - - - - - ); -}; - -export default ActionsColumn; diff --git a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/CreatePositionForm.tsx b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/CreatePositionForm.tsx index d525aa4f8..f8fc0cfdd 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/CreatePositionForm.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/CreatePositionForm.tsx @@ -1,455 +1,529 @@ -import { useForm } from "react-hook-form"; -import { z } from "zod"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { Input } from "@/shared/common/ui/input"; -import { Button } from "@/shared/common/ui/button"; -import { - Form, - FormField, - FormItem, - FormLabel, - FormControl, - FormMessage, -} from "@/shared/common/ui/form"; -import { toast } from "sonner"; -import { usePositionTypes } from "@/user-management/hooks/usePositionTypes"; -import { positionTypePermissionService } from "@/user-management/services/api/positionTypePermissionService"; -import { useNavigate } from "react-router-dom"; -import { t } from "i18next"; -import { useAuth } from "@/shared/context/AuthContext"; -import { useUnit } from "@/user-management/hooks/useUnit"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/shared/common/ui/select"; -import { SingleSelect } from "@/shared/common/ui/single-select"; -import { UnitDto } from "@/user-management/dto/unit/unitDto"; -import { PositionTypeDto } from "@/user-management/dto/positions/positionType"; -import { useLocalizedName } from "@/shared/common/localizedName"; -import { useOrganizations } from "@/super-admin/hooks/useOrganizations"; -import { OrganizationDto } from "@/shared/dto/organization/organizationDto"; -import i18n from "@/i18n"; -import { PermissionSearch } from "./PermissionSearch"; -import { useApplications } from "@/user-management/hooks/useApplications"; -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; - -export interface CreatePositionFormProps { - mode?: "create" | "edit"; - positionTypeId?: string; - initialValues?: { - nameAm: string; - nameEn: string; - unitId: string; - key?: string; - }; - onSuccess?: () => void; - onCancel?: () => void; -} - -export const CreatePositionForm = ({ - mode = "create", - positionTypeId, - initialValues, - onSuccess, - onCancel, -}: CreatePositionFormProps = {}) => { - const navigate = useNavigate(); - const { - createPositionType, - updatePositionType, - positionTypes, - isLoading: isLoadingPositionTypes, - } = usePositionTypes(); - const { user } = useAuth(); - const { getList, getById } = useUnit(); - const localizedName = useLocalizedName(); - const userOrganizationId = - user?.employee && user.employee.length > 0 - ? user.employee[0].organizationId - : undefined; - const [selectedOrganizationId, setSelectedOrganizationId] = useState( - userOrganizationId ?? "", - ); - const [selectedUnitId, setSelectedUnitId] = useState( - initialValues?.unitId ?? "", - ); - const [selectedApplicationId, setSelectedApplicationId] = - useState(""); - const [copyFromPositionId, setCopyFromPositionId] = useState(""); - const [isCopying, setIsCopying] = useState(false); - const [isLoadingEditData, setIsLoadingEditData] = useState(mode === "edit"); - const hasLoadedEditData = useRef(false); - const lang = i18n.language; - const { applications, isLoading: isLoadingApplications } = useApplications(); - const queryClient = useQueryClient(); - - const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations( - "Org", - { take: 3000 }, - ); - - const { data: unitsResponse, isLoading: isLoadingUnits } = getList( - selectedOrganizationId, - { take: 3000, skip: 0 }, - ); - - const organizationOptions = useMemo( - () => - (organizationsResponse?.items ?? []).map((org: OrganizationDto) => ({ - value: org.id, - label: localizedName(org.name) || org.id, - })), - [organizationsResponse, localizedName], - ); - - const unitOptions = useMemo( - () => - (unitsResponse?.data?.items ?? []).map((unit: UnitDto) => ({ - value: unit.id, - label: localizedName(unit.name) || unit.id, - })), - [unitsResponse, localizedName], - ); - - const { - data: editUnitResponse, - isSuccess: isUnitSuccess, - isError: isUnitError, - } = getById(initialValues?.unitId ?? ""); - - const { - data: permissionsResponse, - isSuccess: isPermissionsSuccess, - isError: isPermissionsError, - } = useQuery({ - queryKey: ["position-type-permissions", positionTypeId], - queryFn: () => - positionTypePermissionService.getPermissionsByPositionTypeId( - positionTypeId!, - ), - enabled: mode === "edit" && !!positionTypeId, - }); - // Reset the selected unit when the organization changes so a unit from a - // different org can't be submitted by mistake. - useEffect(() => { - if (mode === "edit") return; - setSelectedUnitId(""); - }, [selectedOrganizationId, mode]); - - const form = useForm({ - resolver: zodResolver(formSchema), - defaultValues: { - nameAm: initialValues?.nameAm ?? "", - nameEn: initialValues?.nameEn ?? "", - permissions: [], - }, - }); - - useEffect(() => { - if (mode !== "edit" || !initialValues || !positionTypeId) return; - if (hasLoadedEditData.current) return; - - const isUnitDone = !initialValues.unitId || isUnitSuccess || isUnitError; - const isPermissionsDone = isPermissionsSuccess || isPermissionsError; - - if (isUnitDone && isPermissionsDone) { - hasLoadedEditData.current = true; - - 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) ?? []; - form.reset({ - nameAm: initialValues.nameAm, - nameEn: initialValues.nameEn, - permissions: ids, - }); - - setIsLoadingEditData(false); - } - }, [ - mode, - initialValues, - positionTypeId, - isUnitSuccess, - isUnitError, - isPermissionsSuccess, - isPermissionsError, - editUnitResponse, - permissionsResponse, - form, - ]); - - const handlePermissionChange = (permissionId: string, checked: boolean) => { - const currentPermissions = form.getValues("permissions"); - if (checked) { - form.setValue("permissions", [...currentPermissions, permissionId]); - } else { - form.setValue( - "permissions", - currentPermissions.filter((id) => id !== permissionId), - ); - } - }; - - const handleCopyFrom = async (positionTypeId: string) => { - setCopyFromPositionId(positionTypeId); - if (!positionTypeId) { - form.setValue("permissions", []); - return; - } - setIsCopying(true); - try { - const response = - await positionTypePermissionService.getPermissionsByPositionTypeId( - positionTypeId, - ); - const ids = response.data.items?.map((p) => p.id) ?? []; - form.setValue("permissions", ids); - } catch { - toast.error(t("contentManagement.copyPermissionsFailed")); - } finally { - setIsCopying(false); - } - }; - - const onSubmit = async (values: FormValues) => { - 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) { - await updatePositionType.mutateAsync({ - id: positionTypeId, - data: payload, - }); - } else { - const response = await createPositionType.mutateAsync(payload); - targetId = response.data.id; - } - - if (targetId && values.permissions.length > 0) { - await positionTypePermissionService.assignPermissionsToPositionType({ - firstId: targetId, - secondIds: values.permissions, - }); - } - - queryClient.invalidateQueries({ - queryKey: ["position-type"], - }); - queryClient.invalidateQueries({ queryKey: ["position-types"] }); - queryClient.invalidateQueries({ - queryKey: ["position-type-permissions"], - }); - toast.success(t("contentManagement.permissionSuccess")); - - if (onSuccess) { - onSuccess(); - } else { - navigate("/user-management/position-management"); - } - } catch { - toast.error(t("contentManagement.permissionFailed")); - } - }; - - if (isLoadingEditData) { - return ( -
- {t("common.loading")} -
- ); - } - - return ( -
- - ( - - {t("contentManagement.englishName")} - - - - - - )} - /> - - ( - - {t("contentManagement.amharicName")} - - - - - - )} - /> - - {/* ✅ Organization (searchable, all orgs) */} -
- - -
- - {/* ✅ Unit Selector — searchable, scoped to picked org */} -
- - -
- -
- - -
- -
- - -

- {t("contentManagement.copyPermissionsHint")} -

-
- - ( - - {t("contentManagement.permission")} - - - - )} - /> - -
- - -
- - - ); -}; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Input } from "@/shared/common/ui/input"; +import { Button } from "@/shared/common/ui/button"; +import { + Form, + FormField, + FormItem, + FormLabel, + FormControl, + FormMessage, +} from "@/shared/common/ui/form"; +import { toast } from "sonner"; +import { + invalidatePositionTypeQueries, + usePositionTypes, +} from "@/user-management/hooks/usePositionTypes"; +import { positionTypePermissionService } from "@/user-management/services/api/positionTypePermissionService"; +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { useAuth } from "@/shared/context/AuthContext"; +import { useUnit } from "@/user-management/hooks/useUnit"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/common/ui/select"; +import { SingleSelect } from "@/shared/common/ui/single-select"; +import { UnitDto } from "@/user-management/dto/unit/unitDto"; +import { PositionTypeDto } from "@/user-management/dto/positions/positionType"; +import { useLocalizedName } from "@/shared/common/localizedName"; +import { useOrganizations } from "@/super-admin/hooks/useOrganizations"; +import { OrganizationDto } from "@/shared/dto/organization/organizationDto"; +import { useErrorHandler } from "@/shared/hooks/useErrorHandler"; +import { PermissionSearch } from "./PermissionSearch"; +import { useApplications } from "@/user-management/hooks/useApplications"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; + +export interface CreatePositionFormProps { + mode?: "create" | "edit"; + positionTypeId?: string; + initialValues?: { + nameAm: string; + nameEn: string; + unitId: string; + key?: string; + }; + onSuccess?: () => void; + onCancel?: () => void; +} + +export const CreatePositionForm = ({ + mode = "create", + positionTypeId, + initialValues, + onSuccess, + onCancel, +}: CreatePositionFormProps = {}) => { + const navigate = useNavigate(); + const { t } = useTranslation(); + const { handleError } = useErrorHandler(t); + const { + createPositionType, + updatePositionType, + positionTypes, + isLoading: isLoadingPositionTypes, + isError: isErrorPositionTypes, + } = usePositionTypes(); + const { user } = useAuth(); + const { getList, getById } = useUnit(); + const localizedName = useLocalizedName(); + const userOrganizationId = + user?.employee && user.employee.length > 0 + ? user.employee[0].organizationId + : undefined; + const [selectedApplicationId, setSelectedApplicationId] = + useState(""); + const [copyFromPositionId, setCopyFromPositionId] = useState(""); + const [isCopying, setIsCopying] = useState(false); + const [isLoadingEditData, setIsLoadingEditData] = useState(mode === "edit"); + const hasLoadedEditData = useRef(false); + // 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 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; + + const form = useForm({ + 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( + "Org", + { take: 3000 }, + ); + + const { data: unitsResponse, isLoading: isLoadingUnits } = getList( + selectedOrganizationId, + { take: 3000, skip: 0 }, + ); + + const organizationOptions = useMemo( + () => + (organizationsResponse?.items ?? []).map((org: OrganizationDto) => ({ + value: org.id, + label: localizedName(org.name) || org.id, + })), + [organizationsResponse, localizedName], + ); + + const unitOptions = useMemo( + () => + (unitsResponse?.data?.items ?? []).map((unit: UnitDto) => ({ + value: unit.id, + label: localizedName(unit.name) || unit.id, + })), + [unitsResponse, localizedName], + ); + + const { + data: editUnitResponse, + isSuccess: isUnitSuccess, + isError: isUnitError, + } = getById(initialValues?.unitId ?? ""); + + const { + data: permissionsResponse, + isSuccess: isPermissionsSuccess, + isError: isPermissionsError, + } = useQuery({ + queryKey: ["position-type-permissions", positionTypeId], + queryFn: () => + positionTypePermissionService.getPermissionsByPositionTypeId( + 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 + // different org can't be submitted by mistake. The copy source is cleared + // too — it is scoped to the old organization. + useEffect(() => { + if (mode === "edit") return; + form.setValue("unitId", ""); + setCopyFromPositionId(""); + }, [selectedOrganizationId, mode, form]); + + useEffect(() => { + if (mode !== "edit" || !initialValues || !positionTypeId) return; + if (hasLoadedEditData.current) return; + + const isUnitDone = !initialValues.unitId || isUnitSuccess || isUnitError; + const isPermissionsDone = isPermissionsSuccess || isPermissionsError; + + if (isUnitDone && isPermissionsDone) { + hasLoadedEditData.current = true; + + const unit = editUnitResponse?.data; + const ids = permissionsResponse?.data?.items?.map((p) => p.id) ?? []; + loadedPermissionCount.current = ids.length; + + form.reset({ + nameAm: initialValues.nameAm, + nameEn: initialValues.nameEn, + organizationId: unit?.organizationId ?? userOrganizationId ?? "", + unitId: unit?.id ?? initialValues.unitId ?? "", + permissions: ids, + }); + + setIsLoadingEditData(false); + } + }, [ + mode, + initialValues, + positionTypeId, + isUnitSuccess, + isUnitError, + isPermissionsSuccess, + isPermissionsError, + editUnitResponse, + permissionsResponse, + userOrganizationId, + form, + ]); + + const handlePermissionChange = (permissionId: string, checked: boolean) => { + const currentPermissions = form.getValues("permissions"); + form.setValue( + "permissions", + checked + ? [...currentPermissions, permissionId] + : currentPermissions.filter((id) => id !== permissionId), + ); + }; + + const handleCopyFrom = async (sourcePositionTypeId: string) => { + setCopyFromPositionId(sourcePositionTypeId); + setIsCopying(true); + try { + const response = + await positionTypePermissionService.getPermissionsByPositionTypeId( + sourcePositionTypeId, + ); + const ids = response.data.items?.map((p) => p.id) ?? []; + form.setValue("permissions", ids); + } catch (error) { + handleError(error); + toast.error(t("contentManagement.copyPermissionsFailed")); + } finally { + setIsCopying(false); + } + }; + + 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 { + if (mode === "edit" && positionTypeId) { + await updatePositionType.mutateAsync({ + id: positionTypeId, + data: payload, + }); + } else { + const response = await createPositionType.mutateAsync(payload); + targetId = response.data.id; + } + } catch { + return; // already reported by the mutation's onError + } + + // 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({ + firstId: targetId, + secondIds: values.permissions, + }); + } catch (error) { + handleError(error); + invalidatePositionTypeQueries(queryClient); + toast.error(t("contentManagement.permissionsAssignFailed")); + return; + } + } + + invalidatePositionTypeQueries(queryClient); + queryClient.invalidateQueries({ queryKey: ["position-type-permissions"] }); + + if (mustClearAll) { + toast.warning(t("contentManagement.cannotClearAllPermissions")); + } else { + toast.success(t("contentManagement.permissionSuccess")); + } + + if (onSuccess) { + onSuccess(); + } else { + navigate("/user-management/position-management"); + } + }; + + if (isLoadingEditData) { + return ( +
+ {t("common.loading")} +
+ ); + } + + 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 ( +
+ + ( + + {t("contentManagement.englishName")} + + + + + + )} + /> + + ( + + {t("contentManagement.amharicName")} + + + + + + )} + /> + + {/* Organization (searchable, all orgs) */} + ( + + {t("organization.organization")} + + + + )} + /> + + {/* Unit Selector — searchable, scoped to picked org */} + ( + + {t("organization.selectUnit")} + + + + )} + /> + +
+ + +
+ +
+ + +

+ {t("contentManagement.copyPermissionsHint")} +

+
+ + ( + + + {t("contentManagement.permission")} + {selectedPermissionCount > 0 && ( + + ( + {t("contentManagement.permissionsSelected", { + count: selectedPermissionCount, + })} + ) + + )} + + + + + )} + /> + +
+ + +
+ + + ); +}; diff --git a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/EditPositionForm.tsx b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/EditPositionForm.tsx index e69a6eedb..befe2f7d8 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/EditPositionForm.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/EditPositionForm.tsx @@ -1,160 +1,193 @@ -import { useEffect, useState } from "react"; -import { Button } from "@/shared/common/ui/button"; -import { Input } from "@/shared/common/ui/input"; -import { Label } from "@/shared/common/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/shared/common/ui/select"; -import { useNavigate } from "react-router-dom"; - -import { useUnit } from "@/user-management/hooks/useUnit"; -import { useAuth } from "@/shared/context/AuthContext"; -import { positionTypePermissionService } from "@/user-management/services/api/positionTypePermissionService"; -import { usePositionTypes } from "@/user-management/hooks/usePositionTypes"; -import { useApplications } from "@/user-management/hooks/useApplications"; -import { PermissionSearch } from "./PermissionSearch"; -import { PermissionDto } from "@/user-management/dto/permissions/permissonDto"; -import { useLocalizedName } from "@/shared/common/localizedName"; -import { UnitDto } from "@/user-management/dto/unit/unitDto"; -import { t } from "i18next"; - -export const EditPositionForm = ({ id }: { id: string }) => { - const navigate = useNavigate(); - const { user } = useAuth(); - const { getList } = useUnit(); - const localizedName = useLocalizedName(); - - const { positionType, isLoadingSingle } = usePositionTypes({ id }); - - const organizationId = - user?.employee && user.employee.length > 0 - ? user.employee[0].organizationId - : undefined; - - const { applications, isLoading: isLoadingApplications } = useApplications(); - - const { data: unitsResponse } = getList(organizationId || "", { - take: 300, - skip: 0, - }); - - const [selectedApplicationId, setSelectedApplicationId] = - useState(""); - const [assignedPermissions, setAssignedPermissions] = useState< - PermissionDto[] - >([]); - const [isLoadingPermissions, setIsLoadingPermissions] = useState(false); - - useEffect(() => { - const load = async () => { - if (!positionType) return; - setIsLoadingPermissions(true); - try { - const assigned = - await positionTypePermissionService.getPermissionsByPositionTypeId( - positionType.id, - ); - setAssignedPermissions(assigned.data.items ?? []); - } finally { - setIsLoadingPermissions(false); - } - }; - load(); - }, [positionType]); - - if (isLoadingSingle) return

Loading...

; - if (!positionType) return null; - - const unit = unitsResponse?.data?.items?.find( - (u: UnitDto) => u.id === positionType.unitId, - ); - const unitName = unit ? unit.name.en || unit.name.am : positionType.unitId; - - return ( -
-
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - {selectedApplicationId ? ( - perm.id)} - onPermissionChange={() => { - // view-only mode in edit form - }} - applicationId={selectedApplicationId} - disabled - /> - ) : ( -
- {isLoadingPermissions ? ( -
Loading...
- ) : assignedPermissions.length === 0 ? ( -
- {t("contentManagement.noPermissionsAvailable")} -
- ) : ( -
    - {assignedPermissions.map((perm) => ( -
  • - {localizedName(perm.name)} -
  • - ))} -
- )} -
- )} -
- -
- -
-
- ); -}; +import { useState } from "react"; +import { Button } from "@/shared/common/ui/button"; +import { Input } from "@/shared/common/ui/input"; +import { Label } from "@/shared/common/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/common/ui/select"; +import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; + +import { useUnit } from "@/user-management/hooks/useUnit"; +import { useAuth } from "@/shared/context/AuthContext"; +import { positionTypePermissionService } from "@/user-management/services/api/positionTypePermissionService"; +import { usePositionTypes } from "@/user-management/hooks/usePositionTypes"; +import { useApplications } from "@/user-management/hooks/useApplications"; +import { PermissionSearch } from "./PermissionSearch"; +import { useLocalizedName } from "@/shared/common/localizedName"; +import { UnitDto } from "@/user-management/dto/unit/unitDto"; +import { t } from "i18next"; + +export const EditPositionForm = ({ id }: { id: string }) => { + const navigate = useNavigate(); + const { user } = useAuth(); + const { getList } = useUnit(); + const localizedName = useLocalizedName(); + + const { positionType, isLoadingSingle, isErrorSingle } = usePositionTypes({ + id, + }); + + const organizationId = + user?.employee && user.employee.length > 0 + ? user.employee[0].organizationId + : undefined; + + const { applications, isLoading: isLoadingApplications } = useApplications(); + + const { data: unitsResponse } = getList(organizationId || "", { + take: 300, + skip: 0, + }); + + const [selectedApplicationId, setSelectedApplicationId] = + useState(""); + + // Shares the cache key CreatePositionForm writes under, so editing a position + // type's permissions refreshes this view too. + const { + data: assignedResponse, + isLoading: isLoadingPermissions, + isError: isErrorPermissions, + } = useQuery({ + queryKey: ["position-type-permissions", id], + queryFn: () => + positionTypePermissionService.getPermissionsByPositionTypeId(id), + enabled: !!id, + }); + + const assignedPermissions = assignedResponse?.data?.items ?? []; + + if (isLoadingSingle) { + return ( +

+ {t("common.loading")} +

+ ); + } + + if (isErrorSingle || !positionType) { + return ( +
+

+ {t("contentManagement.positionTypeNotFound")} +

+
+ +
+
+ ); + } + + const unit = unitsResponse?.data?.items?.find( + (u: UnitDto) => u.id === positionType.unitId, + ); + const unitName = unit ? unit.name.en || unit.name.am : positionType.unitId; + + return ( +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + {selectedApplicationId ? ( + perm.id)} + onPermissionChange={() => { + // view-only mode in edit form + }} + applicationId={selectedApplicationId} + disabled + /> + ) : ( +
+ {isLoadingPermissions ? ( +
+ {t("common.loading")} +
+ ) : isErrorPermissions ? ( +
+ {t("contentManagement.failedToLoadPermissions")} +
+ ) : assignedPermissions.length === 0 ? ( +
+ {t("contentManagement.noPermissionsAvailable")} +
+ ) : ( +
    + {assignedPermissions.map((perm) => ( +
  • + {localizedName(perm.name)} +
  • + ))} +
+ )} +
+ )} +
+ +
+ +
+
+ ); +}; diff --git a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PermissionSearch.tsx b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PermissionSearch.tsx index bfc645fc7..8d853a3b0 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PermissionSearch.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PermissionSearch.tsx @@ -1,152 +1,120 @@ -import React, { useState, useEffect, useMemo, useRef } from "react"; -import { Input } from "@/shared/common/ui/input"; -import { Checkbox } from "@/shared/common/ui/checkbox"; -import { usePermissionManager } from "@/user-management/hooks/usePermissionManager"; -import { PermissionDto } from "@/user-management/dto/permissions/permissonDto"; -import { useLocalizedName } from "@/shared/common/localizedName"; -import { t } from "i18next"; -import { Search, Loader2 } from "lucide-react"; - -interface PermissionSearchProps { - selectedPermissions: string[]; - onPermissionChange: (permissionId: string, checked: boolean) => void; - applicationId?: string; - disabled?: boolean; -} - -const INITIAL_TAKE = 50; // Initial number of items to fetch - -export const PermissionSearch: React.FC = ({ - selectedPermissions, - onPermissionChange, - applicationId, - disabled = false, -}) => { - const [searchTerm, setSearchTerm] = 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(null); - - const localizedName = useLocalizedName(); - - /** ------------------ 1. Debounce Search ------------------ */ - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearchTerm(searchTerm); - setTake(INITIAL_TAKE); // Reset to 50 - hasSetTotalCount.current = false; // Reset the flag - }, 300); - - return () => clearTimeout(timer); - }, [searchTerm]); - - /** ------------------ 2. Fetch Permissions ------------------ */ - const { permissions, isPermissionsLoading } = usePermissionManager({ - params: applicationId - ? { - take, - skip: 0, // Always skip 0, we fetch everything at once - search: debouncedSearchTerm || undefined, - applicationId, - } - : undefined, - }); - - /** ------------------ 3. Update take to total count after first fetch ------------------ */ - 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 ------------------ */ - return ( -
- {/* Search Input */} -
- - setSearchTerm(e.target.value)} - className="pl-10" - /> -
- - {/* Permission List Container */} - {!applicationId ? ( -
- {t("contentManagement.selectApplicationToLoadPermissions") || - "Select an application to load permissions."} -
- ) : isPermissionsLoading ? ( -
- -
- ) : ( -
- {filteredPermissions.length === 0 ? ( -
- {searchTerm - ? t("contentManagement.noPermissionsFound") - : t("contentManagement.noPermissionsAvailable")} -
- ) : ( -
- {filteredPermissions.map((perm: PermissionDto) => ( -
- { - if (!disabled) onPermissionChange(perm.id, !!checked); - }} - /> - -
- ))} -
- )} -
- )} - - {/* Footer Info */} - {filteredPermissions.length > 0 && ( -
- {t("contentManagement.showingPermissions", { - count: filteredPermissions.length, - total: permissions?.count || 0, - })} -
- )} -
- ); -}; +import React, { useState, useEffect } from "react"; +import { Input } from "@/shared/common/ui/input"; +import { Checkbox } from "@/shared/common/ui/checkbox"; +import { usePermissionManager } from "@/user-management/hooks/usePermissionManager"; +import { PermissionDto } from "@/user-management/dto/permissions/permissonDto"; +import { useLocalizedName } from "@/shared/common/localizedName"; +import { t } from "i18next"; +import { Search, Loader2 } from "lucide-react"; + +interface PermissionSearchProps { + selectedPermissions: string[]; + onPermissionChange: (permissionId: string, checked: boolean) => void; + applicationId?: string; + disabled?: boolean; +} + +// 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 = ({ + selectedPermissions, + onPermissionChange, + applicationId, + disabled = false, +}) => { + const [searchTerm, setSearchTerm] = useState(""); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); + + const localizedName = useLocalizedName(); + + /** ------------------ 1. Debounce Search ------------------ */ + useEffect(() => { + const timer = setTimeout(() => setDebouncedSearchTerm(searchTerm), 300); + return () => clearTimeout(timer); + }, [searchTerm]); + + /** ------------------ 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({ + params: applicationId + ? { + take: TAKE, + skip: 0, + search: debouncedSearchTerm || undefined, + applicationId, + } + : undefined, + }); + + const items = permissions?.items ?? []; + + /** ------------------ Render ------------------ */ + return ( +
+ {/* Search Input */} +
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> +
+ + {/* Permission List Container */} + {!applicationId ? ( +
+ {t("contentManagement.selectApplicationToLoadPermissions")} +
+ ) : isPermissionsLoading ? ( +
+ +
+ ) : ( +
+ {items.length === 0 ? ( +
+ {debouncedSearchTerm + ? t("contentManagement.noPermissionsFound") + : t("contentManagement.noPermissionsAvailable")} +
+ ) : ( +
+ {items.map((perm: PermissionDto) => ( +
+ { + if (!disabled) onPermissionChange(perm.id, !!checked); + }} + /> + +
+ ))} +
+ )} +
+ )} + + {/* Footer Info */} + {items.length > 0 && ( +
+ {t("contentManagement.showingPermissions", { + count: items.length, + total: permissions?.count || 0, + })} +
+ )} +
+ ); +}; diff --git a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionLists.tsx b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionLists.tsx index d15d82799..142b97f83 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionLists.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionLists.tsx @@ -1,276 +1,253 @@ -import { useEffect, useState, useMemo } from "react"; -import { Button } from "@/shared/common/ui/button"; -import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable"; - -import { Link } from "react-router-dom"; -import { Plus } from "lucide-react"; -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "@/shared/common/ui/card"; -import { usePositionTypes } from "@/user-management/hooks/usePositionTypes"; -import { createPositionTypeColumns } from "./PositionTypeColumnDefn"; -import { positionTypeService } from "@/user-management/services/api/positionTypesService"; -import { t } from "i18next"; -import { useUnit } from "@/user-management/hooks/useUnit"; -import { useAuth } from "@/shared/context/AuthContext"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/shared/common/ui/select"; -import { UnitDto } from "@/user-management/dto/unit/unitDto"; -import { usePositionTypeConfiguration } from "@/user-management/hooks/usePostionType"; - -export default function PositionManagement() { - const [pageIndex, setPageIndex] = useState(0); - const pageSize = 10; - const [isExporting, setIsExporting] = useState(false); - const [searchTerm, setSearchTerm] = useState(""); - const { createConfiguration } = usePositionTypeConfiguration(); - - const { user } = useAuth(); - - const { getAccessibleList } = useUnit(); - - const organizationId = user?.employee?.[0]?.organizationId; - - const { data: unitsResponse } = getAccessibleList(organizationId ?? "", { - take: 300, - skip: 0, - }); - - // Add state for selected unitId - // Default: if super_admin => "All", otherwise wait for units - const [selectedUnitId, setSelectedUnitId] = useState("All"); - - useEffect(() => { - // If there’s no selectedUnitId yet, default to first unit (if any), otherwise keep "All" - if (!selectedUnitId) { - if (unitsResponse?.data?.items?.length) { - setSelectedUnitId(unitsResponse.data.items[0].id); - } else { - setSelectedUnitId("All"); - } - } - }, [unitsResponse, selectedUnitId]); - - // Reset to first page whenever the search term or unit changes so users - // land on the first page of matches instead of an empty later page. - useEffect(() => { - setPageIndex(0); - }, [searchTerm, selectedUnitId]); - - const handlePageChange = (newPage: number) => { - setPageIndex(newPage); - }; - const { - positionTypeResponse, - isLoading, - positionTypeByUnitId, - refetch, - refetchPosition, - } = usePositionTypes({ - params: { - take: 1000, - skip: 0, - orderBy: "updatedAt:DESC", - }, - unitId: selectedUnitId === "All" ? undefined : selectedUnitId, - }); - - // Fetch position types without unitId for migration options - const { - positionTypeResponse: globalPositionTypes, - refetch: refetchGlobalPositionTypes, - } = usePositionTypes({ - params: { - take: 1000, // Get all global position types - 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( - () => - createPositionTypeColumns( - selectedUnitId === "All" ? positionTypeResponse : positionTypeByUnitId, - globalPositionTypes, - handlePositionTypeDeleted, - handlePositionTypeDeleted, - handleToggle, // ← pass toggle handler - selectedUnitId === "All", // ← isGlobal: hide toggle when "All" - ), - [ - selectedUnitId, - positionTypeResponse, - positionTypeByUnitId, - globalPositionTypes, - ], - ); - const allItems = useMemo( - () => - (selectedUnitId === "All" - ? positionTypeResponse?.items - : positionTypeByUnitId?.items) || [], - [selectedUnitId, positionTypeResponse?.items, positionTypeByUnitId?.items], - ); - - const filteredItems = useMemo(() => { - const trimmed = searchTerm.trim().toLowerCase(); - if (!trimmed) return allItems; - return allItems.filter((item: any) => { - const en = (item?.name?.en || "").toLowerCase(); - const am = (item?.name?.am || "").toLowerCase(); - const key = (item?.key || "").toLowerCase(); - return ( - en.includes(trimmed) || am.includes(trimmed) || key.includes(trimmed) - ); - }); - }, [allItems, searchTerm]); - - const paginatedItems = useMemo(() => { - const start = pageIndex * pageSize; - return filteredItems.slice(start, start + pageSize); - }, [filteredItems, pageIndex, pageSize]); - - if (isLoading) { - return
{t("contentManagement.addUser")}
; - } - - const exportTypes = () => { - setIsExporting(true); - positionTypeService - .getAll({ - take: 3000, - }) - .then((allPositionKeys) => { - // Get the position type keys - const positionTypeKeys = allPositionKeys.data?.items?.map((p) => p.key); - - if (positionTypeKeys && positionTypeKeys.length > 0) { - // Convert the array of keys into a string, with each key on a new line - const fileContent = positionTypeKeys.join("\n"); - - // 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."); - } - setIsExporting(false); - }); - }; - - return ( -
- - - - {t("contentManagement.permissionType")} - - - - {unitsResponse?.data?.items?.length > 0 && ( -
- - -
- )} - - - - - } - pageIndex={pageIndex} - onPageChange={handlePageChange} - nextFunction={() => handlePageChange(pageIndex + 1)} - prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))} - /> - -
-
- ); -} +import { useEffect, useState, useMemo, useCallback } from "react"; +import { Button } from "@/shared/common/ui/button"; +import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable"; + +import { Link } from "react-router-dom"; +import { Plus } from "lucide-react"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/shared/common/ui/card"; +import { usePositionTypes } from "@/user-management/hooks/usePositionTypes"; +import { createPositionTypeColumns } from "./PositionTypeColumnDefn"; +import { positionTypeService } from "@/user-management/services/api/positionTypesService"; +import { t } from "i18next"; +import { toast } from "sonner"; +import { useUnit } from "@/user-management/hooks/useUnit"; +import { useAuth } from "@/shared/context/AuthContext"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/common/ui/select"; +import { UnitDto } from "@/user-management/dto/unit/unitDto"; +import { PositionTypeDto } from "@/user-management/dto/positions/positionType"; + +export default function PositionManagement() { + const [pageIndex, setPageIndex] = useState(0); + const pageSize = 10; + const [isExporting, setIsExporting] = useState(false); + const [searchTerm, setSearchTerm] = useState(""); + + const { user } = useAuth(); + + const { getAccessibleList } = useUnit(); + + const organizationId = user?.employee?.[0]?.organizationId; + + const { data: unitsResponse, isError: isUnitsError } = getAccessibleList( + organizationId ?? "", + { + take: 300, + skip: 0, + }, + ); + + // Add state for selected unitId + // Default: if super_admin => "All", otherwise wait for units + const [selectedUnitId, setSelectedUnitId] = useState("All"); + + useEffect(() => { + // If there’s no selectedUnitId yet, default to first unit (if any), otherwise keep "All" + if (!selectedUnitId) { + if (unitsResponse?.data?.items?.length) { + setSelectedUnitId(unitsResponse.data.items[0].id); + } else { + setSelectedUnitId("All"); + } + } + }, [unitsResponse, selectedUnitId]); + + // Reset to first page whenever the search term or unit changes so users + // land on the first page of matches instead of an empty later page. + useEffect(() => { + setPageIndex(0); + }, [searchTerm, selectedUnitId]); + + const handlePageChange = (newPage: number) => { + setPageIndex(newPage); + }; + + const showingAllUnits = selectedUnitId === "All"; + + const { + positionTypeResponse, + isLoading, + isError, + positionTypeByUnitId, + isLoadingPosition, + isErrorPosition, + refetch, + refetchPosition, + } = usePositionTypes({ + params: { + take: 1000, + skip: 0, + orderBy: "updatedAt:DESC", + }, + unitId: showingAllUnits ? undefined : selectedUnitId, + }); + + // Refresh whichever list is on screen. `positionTypeResponse` is the + // unscoped fetch, so it doubles as the migration-target source — no second + // usePositionTypes() call needed (its cache key ignores unitId, so a second + // call returned the very same query). + const handlePositionTypeChanged = useCallback(async () => { + await (showingAllUnits ? refetch() : refetchPosition()); + }, [showingAllUnits, refetch, refetchPosition]); + + const columns = useMemo( + () => + createPositionTypeColumns( + positionTypeResponse, + handlePositionTypeChanged, + handlePositionTypeChanged, + ), + [positionTypeResponse, handlePositionTypeChanged], + ); + + const allItems = useMemo( + () => + (showingAllUnits + ? positionTypeResponse?.items + : positionTypeByUnitId?.items) || [], + [showingAllUnits, positionTypeResponse?.items, positionTypeByUnitId?.items], + ); + + const filteredItems = useMemo(() => { + const trimmed = searchTerm.trim().toLowerCase(); + if (!trimmed) return allItems; + return allItems.filter((item: PositionTypeDto) => { + const en = (item?.name?.en || "").toLowerCase(); + const am = (item?.name?.am || "").toLowerCase(); + const key = (item?.key || "").toLowerCase(); + return ( + en.includes(trimmed) || am.includes(trimmed) || key.includes(trimmed) + ); + }); + }, [allItems, searchTerm]); + + const paginatedItems = useMemo(() => { + const start = pageIndex * pageSize; + return filteredItems.slice(start, start + pageSize); + }, [filteredItems, pageIndex, pageSize]); + + // Track whichever query is actually feeding the table — picking a unit used + // 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 = () => { + setIsExporting(true); + positionTypeService + .getAll({ take: 3000 }) + .then((allPositionKeys) => { + const positionTypeKeys = allPositionKeys.data?.items?.map((p) => p.key); + + if (!positionTypeKeys?.length) { + toast.error(t("contentManagement.exportFailed")); + return; + } + + // 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); + }); + }; + + return ( +
+ + + + {t("contentManagement.permissionType")} + + + + {!!unitsResponse?.data?.items?.length && ( +
+ + +
+ )} + {isUnitsError && ( +

+ {t("organization.errorLoadingUnits")} +

+ )} + + {isLoadingList ? ( +
+ {t("common.loading")} +
+ ) : isErrorList ? ( +
+ {t("contentManagement.failedToLoadPositionTypes")} +
+ ) : ( + + + + } + pageIndex={pageIndex} + onPageChange={handlePageChange} + nextFunction={() => handlePageChange(pageIndex + 1)} + prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))} + /> + )} +
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionTypeActions.tsx b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionTypeActions.tsx index 32613a0ea..1a2b829d6 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionTypeActions.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionTypeActions.tsx @@ -1,393 +1,251 @@ -import { useState } from "react"; -import { useNavigate } from "react-router-dom"; -import { PositionTypeDto } from "@/user-management/dto/positions/positionType"; - -import { - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuLabel, - DropdownMenuItem, -} from "@/shared/common/ui/dropdown-menu"; - -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/shared/common/ui/alert-dialog"; - -import { Button } from "@/shared/common/ui/button"; -import { MoreVertical, Edit, Eye, Trash2, Pencil } from "lucide-react"; -import { t } from "i18next"; -import PositionTypeMigrationModal from "./PostionTypeMigration"; -import { CreatePositionForm } from "./CreatePositionForm"; -import { toast } from "sonner"; -import { useLocalizedName } from "@/shared/common/localizedName"; -import { positionTypeService } from "@/user-management/services/api/positionTypesService"; -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 { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/shared/common/ui/dialog"; - -interface PositionTypeResponse { - items: PositionTypeDto[]; - count: number; -} - -type ActionsCellProps = { - row: PositionTypeDto | PositionTypeConfigurationDto; - globalPositionTypes?: PositionTypeResponse; - onDelete?: () => void | Promise; - onEdit?: () => void | Promise; - onToggle?: ( - positionTypeId: string, - checked: boolean, - field: "canReceiveRecord" | "canAssignRecord" | "canCreateBankRecord", - ) => void | Promise; - isGlobal?: boolean; // true when viewing "All" units — hide toggle -}; - -const PositionTypeActionsCell: React.FC = ({ - row, - globalPositionTypes, - onDelete, - onEdit, - onToggle, - isGlobal = false, -}) => { - const navigate = useNavigate(); - const [dropdownOpen, setDropdownOpen] = useState(false); - const [showMigrateDialog, setShowMigrateDialog] = useState(false); - const [showEditDialog, setShowEditDialog] = useState(false); - const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); - const localizedName = useLocalizedName(); - const { handleError } = useErrorHandler(t); - 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 - const positionTypeOptions = - globalPositionTypes?.items - .filter((item) => !item.unitId) - .map((item) => ({ - label: localizedName(item.name), - value: item.id, - })) || []; - - // Only show migrate/delete actions if current row has a unitId - const canBeModified = !!row.unitId; - - const handleView = () => { - navigate(`/user-management/position-management/edit/${row.id}`); - }; - - const handleMigrate = (e: Event) => { - e.preventDefault(); - setDropdownOpen(false); - setShowMigrateDialog(true); - }; - - const handleEdit = (e: Event) => { - e.preventDefault(); - setDropdownOpen(false); - setShowEditDialog(true); - }; - - const handleDelete = async () => { - try { - setIsDeleting(true); - await positionTypeService.delete(row.id); - toast.success(t("common.DeletedSuccessfully")); - setShowDeleteDialog(false); - if (onDelete) { - await onDelete(); - } - } 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 ( - <> - - - - - - { - const target = e.target as HTMLElement; - if (!target.closest('[role="dialog"]')) { - setDropdownOpen(false); - } - }}> - Actions - - {canBeModified && ( - - - {t("common.Migrate")} - - )} - - {canBeModified && isPositionType && ( - - - {t("common.Edit")} - - )} - - - - {t("common.View")} - - - {canBeModified && ( - { - setDropdownOpen(false); - setShowDeleteDialog(true); - }} - className="cursor-pointer hover:!text-red-500 !bg-transparent !transition-colors duration-200"> - - {t("common.Delete")} - - )} - - {/* Toggle moved here from ToggleCell */} - {!isGlobal && ( -
-
- - {t("contentManagement.CanReceiveRecord")} - - -
-
- )} - {!isGlobal && ( -
-
- - {t("contentManagement.CanAssignRecord")} - - -
-
- )} - {!isGlobal && ( -
-
- - {t("contentManagement.CanCreateBankRecord")} - - -
-
- )} -
-
- - {showMigrateDialog && ( - { - setShowMigrateDialog(false); - }} - toId={row.id} - toName={localizedName(rowName)} - positionTypeOptions={positionTypeOptions} - /> - )} - - - - - {t("common.Edit")} - -
- {isPositionType && showEditDialog && ( - { - setShowEditDialog(false); - if (onEdit) { - await onEdit(); - } - }} - onCancel={() => setShowEditDialog(false)} - /> - )} -
-
-
- - - - - {t("common.ConfirmDelete")} - - {t("common.DeleteConfirmationMessage", { - defaultValue: `Are you sure you want to delete "${localizedName(rowName)}"? This action cannot be undone.`, - })} - - - - {t("common.Cancel")} - - {isDeleting ? t("common.Deleting") : t("common.Delete")} - - - - - - ); -}; - -export default PositionTypeActionsCell; +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { PositionTypeDto } from "@/user-management/dto/positions/positionType"; + +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuItem, +} from "@/shared/common/ui/dropdown-menu"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/common/ui/alert-dialog"; + +import { Button } from "@/shared/common/ui/button"; +import { MoreVertical, Edit, Eye, Trash2, Pencil } from "lucide-react"; +import { t } from "i18next"; +import PositionTypeMigrationModal from "./PostionTypeMigration"; +import { CreatePositionForm } from "./CreatePositionForm"; +import { useLocalizedName } from "@/shared/common/localizedName"; +import { usePositionTypes } from "@/user-management/hooks/usePositionTypes"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/shared/common/ui/dialog"; + +interface PositionTypeResponse { + items: PositionTypeDto[]; + count: number; +} + +type ActionsCellProps = { + row: PositionTypeDto; + globalPositionTypes?: PositionTypeResponse; + onDelete?: () => void | Promise; + onEdit?: () => void | Promise; +}; + +/* + * 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 = ({ + row, + globalPositionTypes, + onDelete, + onEdit, +}) => { + const navigate = useNavigate(); + const [dropdownOpen, setDropdownOpen] = useState(false); + const [showMigrateDialog, setShowMigrateDialog] = useState(false); + const [showEditDialog, setShowEditDialog] = useState(false); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const localizedName = useLocalizedName(); + const { deletePositionType } = usePositionTypes(); + + // Create position type options from globalPositionTypes - only those WITHOUT unitId + const positionTypeOptions = + globalPositionTypes?.items + .filter((item) => !item.unitId) + .map((item) => ({ + label: localizedName(item.name), + value: item.id, + })) || []; + + // Only show migrate/delete actions if current row has a unitId + const canBeModified = !!row.unitId; + + const handleView = () => { + navigate(`/user-management/position-management/edit/${row.id}`); + }; + + const handleMigrate = (e: Event) => { + e.preventDefault(); + setDropdownOpen(false); + setShowMigrateDialog(true); + }; + + const handleEdit = (e: Event) => { + e.preventDefault(); + setDropdownOpen(false); + 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 () => { + try { + await deletePositionType.mutateAsync(row.id); + setShowDeleteDialog(false); + await onDelete?.(); + } catch { + // reported by the mutation's onError + } + }; + + return ( + <> + + + + + + { + const target = e.target as HTMLElement; + if (!target.closest('[role="dialog"]')) { + setDropdownOpen(false); + } + }}> + {t("userRecord.Actions")} + + {canBeModified && ( + + + {t("common.Migrate")} + + )} + + {canBeModified && ( + + + {t("common.Edit")} + + )} + + + + {t("common.View")} + + + {canBeModified && ( + { + setDropdownOpen(false); + setShowDeleteDialog(true); + }} + className="cursor-pointer hover:!text-red-500 !bg-transparent !transition-colors duration-200"> + + {t("common.Delete")} + + )} + + + + {showMigrateDialog && ( + { + setShowMigrateDialog(false); + }} + toId={row.id} + toName={localizedName(row.name)} + positionTypeOptions={positionTypeOptions} + /> + )} + + + + + {t("common.Edit")} + +
+ {showEditDialog && ( + { + setShowEditDialog(false); + if (onEdit) { + await onEdit(); + } + }} + onCancel={() => setShowEditDialog(false)} + /> + )} +
+
+
+ + + + + {t("common.ConfirmDelete")} + + {t("common.DeleteConfirmationMessage", { + name: localizedName(row.name), + })} + + + + + {t("common.Cancel")} + + + {deletePositionType.isPending + ? t("common.Deleting") + : t("common.Delete")} + + + + + + ); +}; + +export default PositionTypeActionsCell; diff --git a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionTypeColumnDefn.tsx b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionTypeColumnDefn.tsx index d5fcb509e..bcd73ae3f 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionTypeColumnDefn.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionTypeColumnDefn.tsx @@ -16,16 +16,9 @@ const NameCell = ({ name }: { name: PositionTypeDto["name"] }) => { }; export const createPositionTypeColumns = ( - _positionTypeResponse?: PositionTypeResponse, globalPositionTypes?: PositionTypeResponse, onDelete?: () => void | Promise, onEdit?: () => void | Promise, - onToggle?: ( - positionTypeId: string, - checked: boolean, - field: "canReceiveRecord" | "canAssignRecord" | "canCreateBankRecord", - ) => void | Promise, - isGlobal?: boolean, ): ColumnDef[] => [ { accessorKey: "name", @@ -64,8 +57,6 @@ export const createPositionTypeColumns = ( globalPositionTypes={globalPositionTypes} onDelete={onDelete} onEdit={onEdit} - onToggle={onToggle} - isGlobal={isGlobal} /> ), }, diff --git a/apps/edr-freight-web/backoffice/src/user-management/dto/positions/positionType.ts b/apps/edr-freight-web/backoffice/src/user-management/dto/positions/positionType.ts index 3f76be9f7..c2e79844f 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/dto/positions/positionType.ts +++ b/apps/edr-freight-web/backoffice/src/user-management/dto/positions/positionType.ts @@ -5,10 +5,14 @@ export interface PositionTypeDto { en: string; }; key: string; - unitId: string; - canReceiveRecord: boolean; - canCreateBankRecord?: boolean; - canAssignRecord: boolean; + /** + * Null for the built-in ("common") types, which `isSystem` marks and which + * every unit can use. IAM has no organizationId on a position type — the + * 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; updatedAt: string; } diff --git a/apps/edr-freight-web/backoffice/src/user-management/hooks/usePositionTypes.ts b/apps/edr-freight-web/backoffice/src/user-management/hooks/usePositionTypes.ts index a62ef26d6..8f102532d 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/hooks/usePositionTypes.ts +++ b/apps/edr-freight-web/backoffice/src/user-management/hooks/usePositionTypes.ts @@ -1,4 +1,9 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + QueryClient, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; import { CreatePositionTypePayload, PositionRequest, @@ -23,10 +28,22 @@ interface positionParams { interface UsePositionTypeManagerProps { id?: string; unitId?: string; - organizationId?: string; params?: positionParams; // 👈 we expected query params to be passed like this } +/** + * Every cache key this hook writes under. React Query matches key prefixes + * element by element, so `["position-type"]` does NOT reach + * `["position-types-common", ...]` — each root has to be listed. Anything that + * mutates a position type should call this rather than hand-picking keys, or + * the department pickers (which read the "-common" queries) go stale. + */ +export const invalidatePositionTypeQueries = (queryClient: QueryClient) => { + ["position-types", "position-type", "position-types-common"].forEach( + (root) => queryClient.invalidateQueries({ queryKey: [root] }), + ); +}; + export const usePositionTypes = ({ id, params = { @@ -35,11 +52,11 @@ export const usePositionTypes = ({ orderBy: "createdAt:Desc", }, unitId, - organizationId, }: UsePositionTypeManagerProps = {}) => { const queryClient = useQueryClient(); const { t } = useTranslation(); const { handleError } = useErrorHandler(t); + const invalidateAll = () => invalidatePositionTypeQueries(queryClient); const { data, isLoading, isError, refetch } = useQuery({ queryKey: ["position-types", params], queryFn: () => positionTypeService.getAll(params).then((res) => res.data), @@ -70,38 +87,6 @@ export const usePositionTypes = ({ enabled: !!unitId, }); - // Position types by organization ID - const { - data: positionTypeByOrgId, - isLoading: isLoadingOrgPosition, - isError: isErrorOrgPosition, - refetch: refetchOrgPosition, - } = useQuery({ - 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({ - queryKey: ["position-types-common-org", organizationId, params], - queryFn: async () => { - if (!organizationId) return undefined; - const res = await positionTypeService.getCommonTypesByOrganizationId(organizationId, params); - return res.data as PositionTypesListResponse | undefined; - }, - enabled: !!organizationId, - }); - // Common types with unit ID (includes both unit-specific and common types) const { data: commonPositionTypes, @@ -123,15 +108,16 @@ export const usePositionTypes = ({ mutationFn: (payload: CreatePositionTypePayload) => positionTypeService.create(payload), onSuccess: () => { - toast.success("Position type created"); - queryClient.invalidateQueries({ queryKey: ["position-types"] }); + toast.success(t("contentManagement.positionTypeCreated")); + invalidateAll(); }, onError: (error) => { handleError(error); }, }); - // Update + // Update. IAM answers 403 `position_type_not_allowed_to_update` for built-in + // (isSystem) types, so the error has to reach the user. const updatePositionType = useMutation({ mutationFn: ({ id, @@ -141,11 +127,12 @@ export const usePositionTypes = ({ data: UpdatePositionTypePayload; }) => positionTypeService.update(id, data), onSuccess: () => { - toast.success("Position type updated"); - queryClient.invalidateQueries({ queryKey: ["position-types"] }); - queryClient.invalidateQueries({ queryKey: ["position-type", id] }); + toast.success(t("contentManagement.positionTypeUpdated")); + invalidateAll(); + }, + onError: (error) => { + handleError(error); }, - onError: () => {}, }); //update positon from to @@ -153,30 +140,32 @@ export const usePositionTypes = ({ mutationFn: ({ toId, fromId }: { toId: string; fromId: string }) => positionTypeService.updateFromto(toId, fromId), onSuccess: () => { - toast.success("Position type migration updated"); - queryClient.invalidateQueries({ queryKey: ["position-types-to"] }); - queryClient.invalidateQueries({ queryKey: ["position-type", id] }); + toast.success(t("contentManagement.positionTypeMigrated")); + invalidateAll(); + }, + onError: (error) => { + handleError(error); }, - onError: () => {}, }); //update all postions const migratePositionsByPositions = useMutation({ mutationFn: ({ id, data }: { id: string; data: PositionRequest }) => positionTypeService.updateByPostion(id, data), onSuccess: () => { - toast.success("Position type migration updated"); - queryClient.invalidateQueries({ queryKey: ["position-types-migration"] }); - queryClient.invalidateQueries({ queryKey: ["position-type", id] }); + toast.success(t("contentManagement.positionTypeMigrated")); + invalidateAll(); + }, + onError: (error) => { + handleError(error); }, - onError: () => {}, }); - // Delete + // Delete. Also 403s for built-in types. const deletePositionType = useMutation({ mutationFn: (id: string) => positionTypeService.delete(id), onSuccess: () => { - toast.success("Position type deleted"); - queryClient.invalidateQueries({ queryKey: ["position-types"] }); + toast.success(t("contentManagement.positionTypeDeleted")); + invalidateAll(); }, onError: (error) => { handleError(error); @@ -205,16 +194,6 @@ export const usePositionTypes = ({ refetchPosition, isErrorPosition, isLoadingPosition, - // organization-based position types - positionTypeByOrgId, - refetchOrgPosition, - isErrorOrgPosition, - isLoadingOrgPosition, - // common types with organization ID - commonPositionTypesByOrgId, - refetchCommonOrgTypes, - isErrorCommonOrgTypes, - isLoadingCommonOrgTypes, // common types with unit ID commonPositionTypes: commonPositionTypes?.items ?? [], isLoadingCommonTypes, diff --git a/apps/edr-freight-web/backoffice/src/user-management/services/api/positionService.ts b/apps/edr-freight-web/backoffice/src/user-management/services/api/positionService.ts index 9e7e4c1d4..84782b38f 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/services/api/positionService.ts +++ b/apps/edr-freight-web/backoffice/src/user-management/services/api/positionService.ts @@ -21,7 +21,7 @@ export interface PositionPayload { organizationId: string; parentPositionId?: string; projectId?: string; - positionTypeId: string; + positionTypeId?: string; } export interface PositionQueryParams { orderBy?: string; diff --git a/apps/edr-freight-web/backoffice/src/user-management/services/api/positionTypesService.ts b/apps/edr-freight-web/backoffice/src/user-management/services/api/positionTypesService.ts index b3ac43ecd..450009fa7 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/services/api/positionTypesService.ts +++ b/apps/edr-freight-web/backoffice/src/user-management/services/api/positionTypesService.ts @@ -40,38 +40,25 @@ export const positionTypeService = { getById: (id: string): Promise> => 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: ( - id: string, + unitId: string, params?: Params, ): Promise> => - axiosInstance.get(`/position-types/list/${id}`, { - headers: withHeaders(), - params, - }), - - getByOrganizationId: ( - id: string, - params?: Params, - ): Promise> => - axiosInstance.get(`/position-types/list/${id}`, { - headers: withHeaders(), - params, - }), - - getCommonTypesByOrganizationId: ( - id: string, - params?: Params, - ): Promise> => - axiosInstance.get(`/position-types/list-with-commons/${id}`, { + axiosInstance.get(`/position-types/list/${unitId}`, { headers: withHeaders(), params, }), + // WHERE isSystem = true OR unitId = :unitId — "commons" means the built-in + // types, not the ones with a null unitId. getCommonTypesById: ( - id: string, + unitId: string, params: Params, ): Promise> => - axiosInstance.get(`/position-types/list-with-commons/${id}`, { + axiosInstance.get(`/position-types/list-with-commons/${unitId}`, { headers: withHeaders(), params, }), diff --git a/apps/edr-freight-web/backoffice/src/user-management/userManagement/forms/AddDepartmentForm.tsx b/apps/edr-freight-web/backoffice/src/user-management/userManagement/forms/AddDepartmentForm.tsx index d16745327..872a82c9d 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/userManagement/forms/AddDepartmentForm.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/userManagement/forms/AddDepartmentForm.tsx @@ -48,8 +48,6 @@ export function AddDepartmentForm({ if (!nameAm.trim()) newErrors.nameAm = t("organization.amharicNameRequired"); if (!key.trim()) newErrors.key = t("contentManagement.keyRequired"); - if (!positionTypeId) - newErrors.positionTypeId = t("contentManagement.selectPosType"); setErrors(newErrors); return Object.keys(newErrors).length === 0; @@ -71,7 +69,7 @@ export function AddDepartmentForm({ key: key.trim().toLowerCase().replace(/\s+/g, "-"), unitId, organizationId, - positionTypeId, + ...(positionTypeId ? { positionTypeId } : {}), }; createPosition({ @@ -90,12 +88,11 @@ export function AddDepartmentForm({ return (
- + - {errors.positionTypeId && ( -

{errors.positionTypeId}

- )}