Merge pull request #941 from Tria-plc/freight/feat/fixes-v1

Freight/feat/fixes v1
This commit is contained in:
Nathnael Wondisha
2026-07-23 16:50:18 +03:00
committed by GitHub
22 changed files with 1681 additions and 1715 deletions

View File

@@ -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)",
})

View File

@@ -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<string, string> = {
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<CompanyStatsResponseDto> {
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<ResponseCompanyDto> {
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<void> {
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<ResponseCompanyProfileDto> {
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,

View File

@@ -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,

View File

@@ -802,8 +802,22 @@ const App = () => {
}
/>
<Route path="support" element={<SupportInboxPage />} />
<Route path="customers" element={<CustomersPage />} />
<Route path="customers/:id" element={<CustomerDetailPage />} />
<Route
path="customers"
element={
<RequirePermission permission={FREIGHT_PERMS.customers.view}>
<CustomersPage />
</RequirePermission>
}
/>
<Route
path="customers/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.customers.view}>
<CustomerDetailPage />
</RequirePermission>
}
/>
<Route
path="invoices"
element={

View File

@@ -23,6 +23,8 @@ import {
import { useState } from "react";
import { useFileViewer } from "@edr/ui-common";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { fetchViewableFile } from "@/services/files.service";
import { api } from "@/services/api";
import type { Company, CompanyChangeRequest } from "@/types/customer";
@@ -128,6 +130,8 @@ function DiffRow({
* (with note) actions, plus a short history of past decisions.
*/
export function ChangeRequestReview({ company }: { company: Company }) {
const { user } = useAuth();
const canReview = hasPermission(user, FREIGHT_PERMS.customers.verify);
const query = useQuery(
api.customers.changeRequests.queryOptions({ input: { id: company.id } }),
);
@@ -323,25 +327,30 @@ export function ChangeRequestReview({ company }: { company: Company }) {
</Stack>
)}
<Group justify="flex-end" gap="sm">
<Button
variant="light"
color="red"
onClick={() => {
setRejectId(pending.id);
setNote("");
}}
>
Reject
</Button>
<Button
color="edr-green"
loading={approve.isPending}
onClick={() => approve.mutate({ id: pending.id })}
>
Approve changes
</Button>
</Group>
{/* Reviewing the diff is `customers:view`; deciding on it is
`customers:verify`. Without it the request stays readable but
un-actionable. */}
{canReview && (
<Group justify="flex-end" gap="sm">
<Button
variant="light"
color="red"
onClick={() => {
setRejectId(pending.id);
setNote("");
}}
>
Reject
</Button>
<Button
color="edr-green"
loading={approve.isPending}
onClick={() => approve.mutate({ id: pending.id })}
>
Approve changes
</Button>
</Group>
)}
</Stack>
</Card>
)}

View File

@@ -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<ProfileStatus, string> = {
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}
<Group gap={6} wrap="nowrap">
<Button
size="xs"
variant="light"
color="edr-green"
radius="md"
loading={isPending}
onClick={() => act("active")}
>
Approve
</Button>
<Button
size="xs"
variant="light"
color="red"
radius="md"
onClick={() => openDecision("reject")}
>
Reject
</Button>
{canSet("active") && (
<Button
size="xs"
variant="light"
color="edr-green"
radius="md"
loading={isPending}
onClick={() => act("active")}
>
Approve
</Button>
)}
{canSet("rejected") && (
<Button
size="xs"
variant="light"
color="red"
radius="md"
onClick={() => openDecision("reject")}
>
Reject
</Button>
)}
</Group>
</>
);
}
if (status === "rejected") {
if (!canSet("active")) return null;
return (
<Button
size="xs"
@@ -458,6 +483,7 @@ export function ProfileApprovalActions({
}
if (status === "active") {
if (!canSet("suspended")) return null;
return (
<>
{decisionModal}
@@ -476,34 +502,40 @@ export function ProfileApprovalActions({
}
if (status === "suspended") {
if (!canSet("active") && !canSet("blacklisted")) return null;
return (
<Group gap={6} wrap="nowrap">
{decisionModal}
<Button
size="xs"
variant="light"
color="edr-green"
radius="md"
loading={isPending}
onClick={() => openDecision("reactivate")}
>
Reactivate
</Button>
<Button
size="xs"
variant="light"
color="red"
radius="md"
loading={isPending}
onClick={() => act("blacklisted")}
>
Blacklist
</Button>
{canSet("active") && (
<Button
size="xs"
variant="light"
color="edr-green"
radius="md"
loading={isPending}
onClick={() => openDecision("reactivate")}
>
Reactivate
</Button>
)}
{canSet("blacklisted") && (
<Button
size="xs"
variant="light"
color="red"
radius="md"
loading={isPending}
onClick={() => act("blacklisted")}
>
Blacklist
</Button>
)}
</Group>
);
}
if (status === "blacklisted") {
if (!canSet("pending")) return null;
return (
<Button
size="xs"

View File

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

View File

@@ -2057,6 +2057,7 @@
"setting": "Setting",
"loadingAdmins": "Loading admins...",
"errorLoadingAdmins": "Error loading admin data",
"errorLoadingUnits": "Error loading units",
"retry": "Retry",
"assignAdmin": "Assign Admin",
"addAdmin": "Add Admin",
@@ -2760,6 +2761,18 @@
"selectApplicationToLoadPermissions": "Select an application to load its permissions",
"copyPermissionsHint": "Pick an existing position type to pre-fill its permissions, then edit below.",
"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",
"perSuccess": "Permission type created and permissions assigned",
"updatePerSuccess": "Permission updated successfully",

View File

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

View File

@@ -56,6 +56,8 @@ import {
humanize,
} from "@/components/customers";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import {
downloadBookingFile,
fetchViewableFile,
@@ -108,6 +110,7 @@ export default function CustomerDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { view, viewer } = useFileViewer();
const { user } = useAuth();
const { data: company, isLoading } = useQuery(
api.customers.getById.queryOptions({
@@ -180,6 +183,11 @@ export default function CustomerDetailPage() {
// API's rule exactly, so no button is offered that the server would reject.
const stillOnboarding = company ? isOnboardingDraft(company) : false;
const canReview = company ? hasSubmittedOnboarding(company) : true;
// Workflow gate (above) AND authority: asking the customer to correct a
// document is a `customers:verify` action, so a view-only reviewer reads the
// documents but is not offered the request-change control.
const canRequestDocChange =
canReview && hasPermission(user, FREIGHT_PERMS.customers.verify);
/** Document the reviewer is asking the customer to correct; null = closed. */
const [changeRequestDoc, setChangeRequestDoc] =
@@ -446,7 +454,7 @@ export default function CustomerDetailPage() {
>
<Download size={16} />
</ActionIcon>
{canReview && (
{canRequestDocChange && (
<ActionIcon
component="button"
type="button"
@@ -468,7 +476,7 @@ export default function CustomerDetailPage() {
),
},
],
[view, canReview],
[view, canRequestDocChange],
);
const paymentColumns: ColumnDef<CustomerPayment>[] = useMemo(

View File

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

View File

@@ -1,455 +1,529 @@
import { useForm } from "react-hook-form";
import { 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<typeof formSchema>;
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<string>(
userOrganizationId ?? "",
);
const [selectedUnitId, setSelectedUnitId] = useState<string>(
initialValues?.unitId ?? "",
);
const [selectedApplicationId, setSelectedApplicationId] =
useState<string>("");
const [copyFromPositionId, setCopyFromPositionId] = useState<string>("");
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<FormValues>({
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 (
<div className="py-8 text-center text-muted-foreground">
{t("common.loading")}
</div>
);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="nameEn"
render={({ field }) => (
<FormItem>
<FormLabel>{t("contentManagement.englishName")}</FormLabel>
<FormControl>
<Input {...field} placeholder="e.g. HR Coordinator" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="nameAm"
render={({ field }) => (
<FormItem>
<FormLabel>{t("contentManagement.amharicName")}</FormLabel>
<FormControl>
<Input {...field} placeholder="e.g. ሰብል አያት" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* ✅ Organization (searchable, all orgs) */}
<div className="mb-4 w-full">
<label className="block text-sm font-medium text-gray-700 mb-1">
{t("organization.organization") || "Organization"}
</label>
<SingleSelect
options={organizationOptions}
value={selectedOrganizationId}
onValueChange={setSelectedOrganizationId}
placeholder={
isLoadingOrgs
? t("common.loading")
: t("organization.selectOrganization") ||
"Select an organization"
}
/>
</div>
{/* ✅ Unit Selector — searchable, scoped to picked org */}
<div className="mb-4 w-full">
<label className="block text-sm font-medium text-gray-700 mb-1">
{t("organization.selectUnit")}
</label>
<SingleSelect
options={unitOptions}
value={selectedUnitId}
onValueChange={setSelectedUnitId}
placeholder={
!selectedOrganizationId
? t("organization.selectOrganizationFirst") ||
"Select an organization first"
: isLoadingUnits
? t("common.loading")
: t("organization.selectUnit")
}
/>
</div>
<div className="mb-4 w-1/2">
<label className="block text-sm font-medium text-gray-700">
{t("contentManagement.selectApplication")}
</label>
<Select
value={selectedApplicationId}
onValueChange={(value) => setSelectedApplicationId(value)}
disabled={isLoadingApplications}>
<SelectTrigger className="mt-1 block w-full border-gray-300 rounded-md shadow-sm">
<SelectValue placeholder="Select an Application" />
</SelectTrigger>
<SelectContent className="max-h-60 overflow-y-auto">
{applications?.map((app) => (
<SelectItem key={app.id} value={app.id}>
{lang === "en" ? app.name.en : app.name.am}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
{t("contentManagement.copyPermissionsFrom")}
</label>
<Select
value={copyFromPositionId}
onValueChange={handleCopyFrom}
disabled={isLoadingPositionTypes || isCopying}>
<SelectTrigger className="w-full">
<SelectValue
placeholder={
isCopying
? t("common.loading")
: t("contentManagement.selectPositionToCopy")
}
/>
</SelectTrigger>
<SelectContent className="max-h-60 overflow-y-auto">
{positionTypes.map((p: PositionTypeDto) => (
<SelectItem key={p.id} value={p.id}>
{localizedName(p.name)}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("contentManagement.copyPermissionsHint")}
</p>
</div>
<FormField
control={form.control}
name="permissions"
render={({ field }) => (
<FormItem>
<FormLabel>{t("contentManagement.permission")}</FormLabel>
<PermissionSearch
selectedPermissions={field.value}
onPermissionChange={handlePermissionChange}
applicationId={selectedApplicationId}
/>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-4">
<Button
type="button"
variant="outline"
onClick={() => {
if (onCancel) {
onCancel();
} else {
window.history.back();
}
}}>
{t("common.Cancel")}
</Button>
<Button
type="submit"
disabled={
createPositionType.isPending || updatePositionType.isPending
}>
{mode === "edit" ? t("delegation.update") : t("delegation.save")}
</Button>
</div>
</form>
</Form>
);
};
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<string>("");
const [copyFromPositionId, setCopyFromPositionId] = useState<string>("");
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<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(
"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 (
<div className="py-8 text-center text-muted-foreground">
{t("common.loading")}
</div>
);
}
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 (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="nameEn"
render={({ field }) => (
<FormItem>
<FormLabel>{t("contentManagement.englishName")}</FormLabel>
<FormControl>
<Input {...field} placeholder="e.g. HR Coordinator" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="nameAm"
render={({ field }) => (
<FormItem>
<FormLabel>{t("contentManagement.amharicName")}</FormLabel>
<FormControl>
<Input {...field} placeholder="e.g. ሰብል አያት" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Organization (searchable, all orgs) */}
<FormField
control={form.control}
name="organizationId"
render={({ field }) => (
<FormItem>
<FormLabel>{t("organization.organization")}</FormLabel>
<SingleSelect
options={organizationOptions}
value={field.value}
onValueChange={field.onChange}
placeholder={
isLoadingOrgs
? t("common.loading")
: t("organization.selectOrganization")
}
/>
<FormMessage />
</FormItem>
)}
/>
{/* Unit Selector — searchable, scoped to picked org */}
<FormField
control={form.control}
name="unitId"
render={({ field }) => (
<FormItem>
<FormLabel>{t("organization.selectUnit")}</FormLabel>
<SingleSelect
options={unitOptions}
value={field.value}
onValueChange={field.onChange}
placeholder={
!selectedOrganizationId
? t("organization.selectOrganizationFirst")
: isLoadingUnits
? t("common.loading")
: t("organization.selectUnit")
}
/>
<FormMessage />
</FormItem>
)}
/>
<div className="mb-4 w-1/2">
<label className="block text-sm font-medium text-gray-700">
{t("contentManagement.selectApplication")}
</label>
<Select
value={selectedApplicationId}
onValueChange={setSelectedApplicationId}
disabled={isLoadingApplications}>
<SelectTrigger className="mt-1 block w-full border-gray-300 rounded-md shadow-sm">
<SelectValue
placeholder={
isLoadingApplications
? t("common.loading")
: t("contentManagement.selectApplication")
}
/>
</SelectTrigger>
<SelectContent className="max-h-60 overflow-y-auto">
{applications?.map((app) => (
<SelectItem key={app.id} value={app.id}>
{localizedName(app.name)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
{t("contentManagement.copyPermissionsFrom")}
</label>
<Select
value={copyFromPositionId}
onValueChange={handleCopyFrom}
disabled={
!selectedOrganizationId ||
isLoadingPositionTypes ||
isLoadingUnits ||
isCopying
}>
<SelectTrigger className="w-full">
<SelectValue placeholder={copyFromPlaceholder} />
</SelectTrigger>
<SelectContent className="max-h-60 overflow-y-auto">
{copyFromOptions.map((p: PositionTypeDto) => (
<SelectItem key={p.id} value={p.id}>
{localizedName(p.name)}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("contentManagement.copyPermissionsHint")}
</p>
</div>
<FormField
control={form.control}
name="permissions"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("contentManagement.permission")}
{selectedPermissionCount > 0 && (
<span className="ml-2 font-normal text-muted-foreground">
(
{t("contentManagement.permissionsSelected", {
count: selectedPermissionCount,
})}
)
</span>
)}
</FormLabel>
<PermissionSearch
selectedPermissions={field.value}
onPermissionChange={handlePermissionChange}
applicationId={selectedApplicationId}
/>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-4">
<Button
type="button"
variant="outline"
disabled={isBusy}
onClick={() => {
if (onCancel) {
onCancel();
} else {
window.history.back();
}
}}>
{t("common.Cancel")}
</Button>
<Button type="submit" disabled={isBusy}>
{form.formState.isSubmitting
? t("common.saving")
: mode === "edit"
? t("delegation.update")
: t("delegation.save")}
</Button>
</div>
</form>
</Form>
);
};

View File

@@ -1,160 +1,193 @@
import { useEffect, useState } from "react";
import { 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<string>("");
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 <p>Loading...</p>;
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 (
<div className="space-y-6">
<div className="space-y-2">
<Label>{t("contentManagement.englishName")}</Label>
<Input value={positionType.name.en} disabled readOnly />
</div>
<div className="space-y-2">
<Label>{t("contentManagement.amharicName")}</Label>
<Input value={positionType.name.am} disabled readOnly />
</div>
<div className="space-y-2">
<Label>Key</Label>
<Input value={positionType.key} disabled readOnly />
</div>
<div className="space-y-2">
<Label>{t("contentManagement.selectApplication")}</Label>
<Select
value={selectedApplicationId}
onValueChange={(value) => setSelectedApplicationId(value)}
disabled={isLoadingApplications}
>
<SelectTrigger className="mt-1 block w-full border-gray-300 rounded-md shadow-sm">
<SelectValue placeholder="Select an Application" />
</SelectTrigger>
<SelectContent className="max-h-60 overflow-y-auto">
{applications?.map((app: any) => (
<SelectItem key={app.id} value={app.id}>
{localizedName(app.name)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t("contentManagement.permission")}</Label>
{selectedApplicationId ? (
<PermissionSearch
selectedPermissions={assignedPermissions.map((perm) => perm.id)}
onPermissionChange={() => {
// view-only mode in edit form
}}
applicationId={selectedApplicationId}
disabled
/>
) : (
<div className="border rounded-md p-4 bg-background max-h-96 overflow-y-auto">
{isLoadingPermissions ? (
<div className="text-center py-4 text-gray-500">Loading...</div>
) : assignedPermissions.length === 0 ? (
<div className="text-center py-4 text-gray-500">
{t("contentManagement.noPermissionsAvailable")}
</div>
) : (
<ul className="grid grid-cols-1 md:grid-cols-2 gap-2">
{assignedPermissions.map((perm) => (
<li
key={perm.id}
className="capitalize text-sm py-1 px-2 rounded bg-muted/40"
>
{localizedName(perm.name)}
</li>
))}
</ul>
)}
</div>
)}
</div>
<div className="flex justify-end">
<Button
type="button"
variant="outline"
onClick={() => navigate("/user-management/position-management")}
>
{t("common.Back")}
</Button>
</div>
</div>
);
};
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<string>("");
// 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 (
<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(
(u: UnitDto) => u.id === positionType.unitId,
);
const unitName = unit ? unit.name.en || unit.name.am : positionType.unitId;
return (
<div className="space-y-6">
<div className="space-y-2">
<Label>{t("contentManagement.englishName")}</Label>
<Input value={positionType.name.en} disabled readOnly />
</div>
<div className="space-y-2">
<Label>{t("contentManagement.amharicName")}</Label>
<Input value={positionType.name.am} disabled readOnly />
</div>
<div className="space-y-2">
<Label>{t("contentManagement.key")}</Label>
<Input value={positionType.key} disabled readOnly />
</div>
<div className="space-y-2">
<Label>{t("organization.selectUnit")}</Label>
<Input value={unitName ?? ""} disabled readOnly />
</div>
<div className="space-y-2">
<Label>{t("contentManagement.selectApplication")}</Label>
<Select
value={selectedApplicationId}
onValueChange={setSelectedApplicationId}
disabled={isLoadingApplications}>
<SelectTrigger className="mt-1 block w-full border-gray-300 rounded-md shadow-sm">
<SelectValue
placeholder={
isLoadingApplications
? t("common.loading")
: t("contentManagement.selectApplication")
}
/>
</SelectTrigger>
<SelectContent className="max-h-60 overflow-y-auto">
{applications?.map((app) => (
<SelectItem key={app.id} value={app.id}>
{localizedName(app.name)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t("contentManagement.permission")}</Label>
{selectedApplicationId ? (
<PermissionSearch
selectedPermissions={assignedPermissions.map((perm) => perm.id)}
onPermissionChange={() => {
// view-only mode in edit form
}}
applicationId={selectedApplicationId}
disabled
/>
) : (
<div className="border rounded-md p-4 bg-background max-h-96 overflow-y-auto">
{isLoadingPermissions ? (
<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 ? (
<div className="text-center py-4 text-gray-500">
{t("contentManagement.noPermissionsAvailable")}
</div>
) : (
<ul className="grid grid-cols-1 md:grid-cols-2 gap-2">
{assignedPermissions.map((perm) => (
<li
key={perm.id}
className="capitalize text-sm py-1 px-2 rounded bg-muted/40">
{localizedName(perm.name)}
</li>
))}
</ul>
)}
</div>
)}
</div>
<div className="flex justify-end">
<Button
type="button"
variant="outline"
onClick={() => navigate("/user-management/position-management")}>
{t("common.Back")}
</Button>
</div>
</div>
);
};

View File

@@ -1,152 +1,120 @@
import React, { useState, useEffect, useMemo, useRef } from "react";
import { 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<PermissionSearchProps> = ({
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<HTMLDivElement>(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 (
<div className="space-y-4">
{/* Search Input */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<Input
placeholder={t("contentManagement.searchPermissions")}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
{/* Permission List Container */}
{!applicationId ? (
<div className="max-h-96 overflow-y-auto border rounded-md p-4 bg-background text-center text-gray-500">
{t("contentManagement.selectApplicationToLoadPermissions") ||
"Select an application to load permissions."}
</div>
) : isPermissionsLoading ? (
<div className="flex justify-center py-10">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
</div>
) : (
<div
ref={scrollContainerRef}
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">
{searchTerm
? t("contentManagement.noPermissionsFound")
: t("contentManagement.noPermissionsAvailable")}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{filteredPermissions.map((perm: PermissionDto) => (
<div
key={perm.id}
className="flex flex-row items-start space-x-3 space-y-0"
>
<Checkbox
checked={selectedPermissions.includes(perm.id)}
disabled={disabled}
onCheckedChange={(checked) => {
if (!disabled) onPermissionChange(perm.id, !!checked);
}}
/>
<label className="capitalize cursor-pointer font-normal text-sm">
{localizedName(perm.name)}
</label>
</div>
))}
</div>
)}
</div>
)}
{/* Footer Info */}
{filteredPermissions.length > 0 && (
<div className="text-xs text-muted-foreground px-1">
{t("contentManagement.showingPermissions", {
count: filteredPermissions.length,
total: permissions?.count || 0,
})}
</div>
)}
</div>
);
};
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<PermissionSearchProps> = ({
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 (
<div className="space-y-4">
{/* Search Input */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<Input
placeholder={t("contentManagement.searchPermissions")}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
{/* Permission List Container */}
{!applicationId ? (
<div className="max-h-96 overflow-y-auto border rounded-md p-4 bg-background text-center text-gray-500">
{t("contentManagement.selectApplicationToLoadPermissions")}
</div>
) : isPermissionsLoading ? (
<div className="flex justify-center py-10">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
</div>
) : (
<div className="max-h-96 overflow-y-auto border rounded-md p-4 bg-background">
{items.length === 0 ? (
<div className="text-center py-4 text-gray-500">
{debouncedSearchTerm
? t("contentManagement.noPermissionsFound")
: t("contentManagement.noPermissionsAvailable")}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{items.map((perm: PermissionDto) => (
<div
key={perm.id}
className="flex flex-row items-start space-x-3 space-y-0">
<Checkbox
checked={selectedPermissions.includes(perm.id)}
disabled={disabled}
onCheckedChange={(checked) => {
if (!disabled) onPermissionChange(perm.id, !!checked);
}}
/>
<label className="capitalize cursor-pointer font-normal text-sm">
{localizedName(perm.name)}
</label>
</div>
))}
</div>
)}
</div>
)}
{/* Footer Info */}
{items.length > 0 && (
<div className="text-xs text-muted-foreground px-1">
{t("contentManagement.showingPermissions", {
count: items.length,
total: permissions?.count || 0,
})}
</div>
)}
</div>
);
};

View File

@@ -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<boolean>(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<string>("All");
useEffect(() => {
// If theres 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 <div>{t("contentManagement.addUser")}</div>;
}
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 (
<div className="p-6 space-y-6">
<Card className="py-0 col-span-2 shadow-none border-none bg-transparent px-0">
<CardHeader className="flex flex-row justify-between items-center px-0">
<CardTitle className="text-xl font-semibold ">
{t("contentManagement.permissionType")}
</CardTitle>
<Button onClick={exportTypes}>
{isExporting
? t("contentManagement.exporting")
: t("contentManagement.exportTypes")}
</Button>
</CardHeader>
{unitsResponse?.data?.items?.length > 0 && (
<div className="mb-4 w-1/2">
<label className="block text-sm font-medium text-gray-700">
Select Unit
</label>
<Select
value={selectedUnitId}
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">
<SelectValue placeholder="Select a Unit" />
</SelectTrigger>
<SelectContent>
<SelectItem key="all" value="All">
All
</SelectItem>
{unitsResponse?.data.items.map((unit: UnitDto) => (
<SelectItem key={unit.id} value={unit.id}>
<span className="block truncate max-w-70">
{unit.name.en || unit.name.am}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<CardContent className="px-0">
<AdvancedTable
columns={columns}
data={paginatedItems}
tableName="Positions"
toolBarPosition="right"
itemCount={filteredItems.length}
onGlobalFilterChange={setSearchTerm}
extraToolbar={
<Link to="/user-management/position-management/new">
<Button className="px-5 py-2 rounded-md text-sm font-medium shadow-md">
<Plus className="w-4 h-4 mr-2" />
{t("contentManagement.newPermission")}
</Button>
</Link>
}
pageIndex={pageIndex}
onPageChange={handlePageChange}
nextFunction={() => handlePageChange(pageIndex + 1)}
prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))}
/>
</CardContent>
</Card>
</div>
);
}
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<boolean>(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<string>("All");
useEffect(() => {
// If theres 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 (
<div className="p-6 space-y-6">
<Card className="py-0 col-span-2 shadow-none border-none bg-transparent px-0">
<CardHeader className="flex flex-row justify-between items-center px-0">
<CardTitle className="text-xl font-semibold ">
{t("contentManagement.permissionType")}
</CardTitle>
<Button onClick={exportTypes} disabled={isExporting}>
{isExporting
? t("contentManagement.exporting")
: t("contentManagement.exportTypes")}
</Button>
</CardHeader>
{!!unitsResponse?.data?.items?.length && (
<div className="mb-4 w-1/2">
<label className="block text-sm font-medium text-gray-700">
{t("organization.selectUnit")}
</label>
<Select
value={selectedUnitId}
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">
<SelectValue placeholder={t("organization.selectUnit")} />
</SelectTrigger>
<SelectContent>
<SelectItem key="all" value="All">
{t("common.all")}
</SelectItem>
{unitsResponse.data.items.map((unit: UnitDto) => (
<SelectItem key={unit.id} value={unit.id}>
<span className="block truncate max-w-70">
{unit.name.en || unit.name.am}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{isUnitsError && (
<p className="mb-4 text-sm text-red-500">
{t("organization.errorLoadingUnits")}
</p>
)}
<CardContent className="px-0">
{isLoadingList ? (
<div className="py-10 text-center text-muted-foreground">
{t("common.loading")}
</div>
) : isErrorList ? (
<div className="py-10 text-center text-red-500">
{t("contentManagement.failedToLoadPositionTypes")}
</div>
) : (
<AdvancedTable
columns={columns}
data={paginatedItems}
tableName="Positions"
toolBarPosition="right"
itemCount={filteredItems.length}
onGlobalFilterChange={setSearchTerm}
extraToolbar={
<Link to="/user-management/position-management/new">
<Button className="px-5 py-2 rounded-md text-sm font-medium shadow-md">
<Plus className="w-4 h-4 mr-2" />
{t("contentManagement.newPermission")}
</Button>
</Link>
}
pageIndex={pageIndex}
onPageChange={handlePageChange}
nextFunction={() => handlePageChange(pageIndex + 1)}
prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))}
/>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,393 +1,251 @@
import { useState } from "react";
import { 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<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
};
const PositionTypeActionsCell: React.FC<ActionsCellProps> = ({
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 (
<>
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<MoreVertical className="h-4 w-4" />
<span className="sr-only">Open actions menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onInteractOutside={(e) => {
const target = e.target as HTMLElement;
if (!target.closest('[role="dialog"]')) {
setDropdownOpen(false);
}
}}>
<DropdownMenuLabel>Actions</DropdownMenuLabel>
{canBeModified && (
<DropdownMenuItem
onSelect={handleMigrate}
className="cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200">
<Edit className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" />
<span> {t("common.Migrate")}</span>
</DropdownMenuItem>
)}
{canBeModified && isPositionType && (
<DropdownMenuItem
onSelect={handleEdit}
className="cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200">
<Pencil className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" />
<span>{t("common.Edit")}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
onSelect={handleView}
className="cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200">
<Eye className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" />
<span>{t("common.View")}</span>
</DropdownMenuItem>
{canBeModified && (
<DropdownMenuItem
onSelect={() => {
setDropdownOpen(false);
setShowDeleteDialog(true);
}}
className="cursor-pointer hover:!text-red-500 !bg-transparent !transition-colors duration-200">
<Trash2 className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" />
<span>{t("common.Delete")}</span>
</DropdownMenuItem>
)}
{/* Toggle moved here from ToggleCell */}
{!isGlobal && (
<div className="px-2 py-2 border-t mt-1">
<div className="flex items-center justify-between">
<span className="text-sm">
{t("contentManagement.CanReceiveRecord")}
</span>
<Switch
checked={isCanReceiveRecord}
disabled={isLoadingConfigurations || isUpdatingConfiguration}
onCheckedChange={handleToggleChange}
/>
</div>
</div>
)}
{!isGlobal && (
<div className="px-2 py-2 border-t mt-1">
<div className="flex items-center justify-between">
<span className="text-sm">
{t("contentManagement.CanAssignRecord")}
</span>
<Switch
checked={isCanAssignRecord}
disabled={isLoadingConfigurations || isUpdatingConfiguration}
onCheckedChange={handleAssignToggleChange}
/>
</div>
</div>
)}
{!isGlobal && (
<div className="px-2 py-2 border-t mt-1">
<div className="flex items-center justify-between gap-4">
<span className="text-sm">
{t("contentManagement.CanCreateBankRecord")}
</span>
<Switch
checked={isCanCreateBankRecord}
disabled={isLoadingConfigurations || isUpdatingConfiguration}
onCheckedChange={handleCreateBankRecordToggleChange}
/>
</div>
</div>
)}
</DropdownMenuContent>
</DropdownMenu>
{showMigrateDialog && (
<PositionTypeMigrationModal
isOpen={showMigrateDialog}
onClose={() => {
setShowMigrateDialog(false);
}}
toId={row.id}
toName={localizedName(rowName)}
positionTypeOptions={positionTypeOptions}
/>
)}
<Dialog open={showEditDialog} onOpenChange={setShowEditDialog}>
<DialogContent className="sm:max-w-3xl max-h-[90vh] flex flex-col p-6">
<DialogHeader className="pb-4">
<DialogTitle>{t("common.Edit")}</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto pr-2 min-h-0">
{isPositionType && showEditDialog && (
<CreatePositionForm
key={row.id + "-edit"}
mode="edit"
positionTypeId={row.id}
initialValues={{
nameAm: row.name.am,
nameEn: row.name.en,
unitId: row.unitId,
key: row.key,
}}
onSuccess={async () => {
setShowEditDialog(false);
if (onEdit) {
await onEdit();
}
}}
onCancel={() => setShowEditDialog(false)}
/>
)}
</div>
</DialogContent>
</Dialog>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("common.ConfirmDelete")}</AlertDialogTitle>
<AlertDialogDescription>
{t("common.DeleteConfirmationMessage", {
defaultValue: `Are you sure you want to delete "${localizedName(rowName)}"? This action cannot be undone.`,
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.Cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
className="bg-red-500 hover:bg-red-600">
{isDeleting ? t("common.Deleting") : t("common.Delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
};
export default PositionTypeActionsCell;
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<void>;
onEdit?: () => void | Promise<void>;
};
/*
* 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> = ({
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 (
<>
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<MoreVertical className="h-4 w-4" />
<span className="sr-only">Open actions menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onInteractOutside={(e) => {
const target = e.target as HTMLElement;
if (!target.closest('[role="dialog"]')) {
setDropdownOpen(false);
}
}}>
<DropdownMenuLabel>{t("userRecord.Actions")}</DropdownMenuLabel>
{canBeModified && (
<DropdownMenuItem
onSelect={handleMigrate}
className="cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200">
<Edit className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" />
<span> {t("common.Migrate")}</span>
</DropdownMenuItem>
)}
{canBeModified && (
<DropdownMenuItem
onSelect={handleEdit}
className="cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200">
<Pencil className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" />
<span>{t("common.Edit")}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
onSelect={handleView}
className="cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200">
<Eye className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" />
<span>{t("common.View")}</span>
</DropdownMenuItem>
{canBeModified && (
<DropdownMenuItem
onSelect={() => {
setDropdownOpen(false);
setShowDeleteDialog(true);
}}
className="cursor-pointer hover:!text-red-500 !bg-transparent !transition-colors duration-200">
<Trash2 className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" />
<span>{t("common.Delete")}</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
{showMigrateDialog && (
<PositionTypeMigrationModal
isOpen={showMigrateDialog}
onClose={() => {
setShowMigrateDialog(false);
}}
toId={row.id}
toName={localizedName(row.name)}
positionTypeOptions={positionTypeOptions}
/>
)}
<Dialog open={showEditDialog} onOpenChange={setShowEditDialog}>
<DialogContent className="sm:max-w-3xl max-h-[90vh] flex flex-col p-6">
<DialogHeader className="pb-4">
<DialogTitle>{t("common.Edit")}</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto pr-2 min-h-0">
{showEditDialog && (
<CreatePositionForm
key={row.id + "-edit"}
mode="edit"
positionTypeId={row.id}
initialValues={{
nameAm: row.name.am,
nameEn: row.name.en,
unitId: row.unitId ?? "",
key: row.key,
}}
onSuccess={async () => {
setShowEditDialog(false);
if (onEdit) {
await onEdit();
}
}}
onCancel={() => setShowEditDialog(false)}
/>
)}
</div>
</DialogContent>
</Dialog>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("common.ConfirmDelete")}</AlertDialogTitle>
<AlertDialogDescription>
{t("common.DeleteConfirmationMessage", {
name: localizedName(row.name),
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deletePositionType.isPending}>
{t("common.Cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={deletePositionType.isPending}
className="bg-red-500 hover:bg-red-600">
{deletePositionType.isPending
? t("common.Deleting")
: t("common.Delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
};
export default PositionTypeActionsCell;

View File

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

View File

@@ -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;
}

View File

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

View File

@@ -21,7 +21,7 @@ export interface PositionPayload {
organizationId: string;
parentPositionId?: string;
projectId?: string;
positionTypeId: string;
positionTypeId?: string;
}
export interface PositionQueryParams {
orderBy?: string;

View File

@@ -40,38 +40,25 @@ export const positionTypeService = {
getById: (id: string): Promise<AxiosResponse<PositionTypeDto>> =>
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<AxiosResponse<PositionTypesListResponse>> =>
axiosInstance.get(`/position-types/list/${id}`, {
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}`, {
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<AxiosResponse<PositionTypesListResponse>> =>
axiosInstance.get(`/position-types/list-with-commons/${id}`, {
axiosInstance.get(`/position-types/list-with-commons/${unitId}`, {
headers: withHeaders(),
params,
}),

View File

@@ -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 (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="positionType">{t("organization.positionTypes")}</Label>
<Label htmlFor="positionType">
{t("organization.positionTypes")} ({t("common.optional")})
</Label>
<Select
onValueChange={(value) => {
setPositionTypeId(value);
setErrors((prev) => ({ ...prev, positionTypeId: "" }));
}}
onValueChange={setPositionTypeId}
value={positionTypeId}
disabled={isLoadingTypes}
>
@@ -110,9 +107,6 @@ export function AddDepartmentForm({
))}
</SelectContent>
</Select>
{errors.positionTypeId && (
<p className="text-red-500 text-sm">{errors.positionTypeId}</p>
)}
</div>
<div className="space-y-2">